feat: web UI chat render, panels, presence + analytics
This commit is contained in:
1 parent
75425d1bee
commit
ed469ba8dd
83 files changed
+6034
-136
No files matched your search
@@ -1,17 +1,21 @@
|
||||
import { enrichChat, getJob, listChats } from "$lib/api/endpoints";
|
||||
import type { Chat } from "$lib/api/types";
|
||||
import type { Chat, LiveEvent } from "$lib/api/types";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { events } from "$lib/stores/events.svelte";
|
||||
import { peers } from "$lib/stores/peers.svelte";
|
||||
|
||||
const POLL_INTERVAL = 1500;
|
||||
const POLL_MAX = 12;
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
function createChats() {
|
||||
let list = $state<Chat[]>([]);
|
||||
let loaded = $state(false);
|
||||
let loading = $state(false);
|
||||
let hasMore = $state(false);
|
||||
let revision = $state(0);
|
||||
let account: number | null = null;
|
||||
let filling = false;
|
||||
const enriched = new Set<number>();
|
||||
|
||||
function syncAccount() {
|
||||
@@ -30,11 +34,71 @@ function createChats() {
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
list = await listChats({ limit: 200 });
|
||||
const page = await listChats({ limit: PAGE_SIZE });
|
||||
list = page;
|
||||
hasMore = page.length === PAGE_SIZE;
|
||||
loaded = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
loadAll();
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
if (filling) {
|
||||
return;
|
||||
}
|
||||
filling = true;
|
||||
try {
|
||||
while (hasMore) {
|
||||
if (loading) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const before = list.length;
|
||||
await loadMore();
|
||||
if (list.length === before) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
filling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
syncAccount();
|
||||
if (account === null || loading || !loaded || !hasMore) {
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const page = await listChats({ limit: PAGE_SIZE, offset: list.length });
|
||||
const seen = new Set(list.map((chat) => chat.chat_id));
|
||||
list = [...list, ...page.filter((chat) => !seen.has(chat.chat_id))];
|
||||
hasMore = page.length === PAGE_SIZE;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyEvent(event: LiveEvent) {
|
||||
if (event.type !== "message" || !loaded) {
|
||||
return;
|
||||
}
|
||||
const message = event.message;
|
||||
const existing = list.find((chat) => chat.chat_id === message.chat_id);
|
||||
if (!existing) {
|
||||
load(true);
|
||||
return;
|
||||
}
|
||||
existing.last_date = message.date;
|
||||
existing.last_sender_id = message.sender_id;
|
||||
existing.last_text = message.text;
|
||||
existing.message_count++;
|
||||
list = [existing, ...list.filter((chat) => chat !== existing)];
|
||||
}
|
||||
|
||||
async function waitForJob(jobId: number) {
|
||||
@@ -49,6 +113,13 @@ function createChats() {
|
||||
}
|
||||
}
|
||||
|
||||
events.subscribe(applyEvent);
|
||||
events.onReconnect(() => {
|
||||
if (loaded) {
|
||||
load(true);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get list(): Chat[] {
|
||||
return list;
|
||||
@@ -59,9 +130,13 @@ function createChats() {
|
||||
get loading(): boolean {
|
||||
return loading;
|
||||
},
|
||||
get hasMore(): boolean {
|
||||
return hasMore;
|
||||
},
|
||||
get revision(): number {
|
||||
return revision;
|
||||
},
|
||||
loadMore,
|
||||
byId(id: number): Chat | undefined {
|
||||
return list.find((chat) => chat.chat_id === id);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { LiveEvent } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth.svelte";
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
|
||||
const RECONNECT_DELAY = 2000;
|
||||
|
||||
type Listener = (event: LiveEvent) => void;
|
||||
|
||||
function parseFrame(block: string): LiveEvent | null {
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("data:")) {
|
||||
try {
|
||||
return JSON.parse(line.slice(5).trim()) as LiveEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createEvents() {
|
||||
const listeners = new Set<Listener>();
|
||||
const reconnectListeners = new Set<() => void>();
|
||||
let epoch = $state(0);
|
||||
let account: number | null = null;
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
function emit(event: LiveEvent) {
|
||||
for (const listener of listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
function drain(input: string): string {
|
||||
let rest = input;
|
||||
let split = rest.indexOf("\n\n");
|
||||
while (split !== -1) {
|
||||
const block = rest.slice(0, split);
|
||||
if (!block.startsWith(":")) {
|
||||
const event = parseFrame(block);
|
||||
if (event) {
|
||||
emit(event);
|
||||
}
|
||||
}
|
||||
rest = rest.slice(split + 2);
|
||||
split = rest.indexOf("\n\n");
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
async function consume(response: Response, signal: AbortSignal) {
|
||||
if (!response.body) {
|
||||
return;
|
||||
}
|
||||
epoch++;
|
||||
for (const listener of reconnectListeners) {
|
||||
listener();
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (!signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
buffer = drain(buffer + decoder.decode(value, { stream: true }));
|
||||
}
|
||||
}
|
||||
|
||||
async function run(accountId: number, signal: AbortSignal) {
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const response = await fetch(`${BASE}/events?account_id=${accountId}`, {
|
||||
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {},
|
||||
signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
await consume(response, signal);
|
||||
}
|
||||
} catch {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, RECONNECT_DELAY);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
}
|
||||
|
||||
return {
|
||||
get epoch(): number {
|
||||
return epoch;
|
||||
},
|
||||
subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
onReconnect(listener: () => void): () => void {
|
||||
reconnectListeners.add(listener);
|
||||
return () => reconnectListeners.delete(listener);
|
||||
},
|
||||
open(accountId: number | null) {
|
||||
if (accountId === account) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
account = accountId;
|
||||
if (accountId === null || !auth.token) {
|
||||
return;
|
||||
}
|
||||
controller = new AbortController();
|
||||
run(accountId, controller.signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const events = createEvents();
|
||||
@@ -0,0 +1,101 @@
|
||||
import { searchMessages } from "$lib/api/endpoints";
|
||||
import type { SearchHit } from "$lib/api/types";
|
||||
import { chats } from "$lib/stores/chats.svelte";
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
const MIN_LENGTH = 1;
|
||||
|
||||
function createSearch() {
|
||||
let active = $state(false);
|
||||
let query = $state("");
|
||||
let messageHits = $state<SearchHit[]>([]);
|
||||
let loading = $state(false);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let seq = 0;
|
||||
|
||||
const trimmed = $derived(query.trim());
|
||||
const chatHits = $derived.by(() => {
|
||||
const needle = trimmed.toLowerCase();
|
||||
if (needle.length < MIN_LENGTH) {
|
||||
return [];
|
||||
}
|
||||
return chats.list.filter((chat) =>
|
||||
(chat.title ?? "").toLowerCase().includes(needle)
|
||||
);
|
||||
});
|
||||
|
||||
async function run(value: string) {
|
||||
const current = ++seq;
|
||||
try {
|
||||
const hits = await searchMessages(value);
|
||||
if (current === seq) {
|
||||
messageHits = hits;
|
||||
}
|
||||
} catch {
|
||||
if (current === seq) {
|
||||
messageHits = [];
|
||||
}
|
||||
} finally {
|
||||
if (current === seq) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const value = trimmed;
|
||||
if (value.length < MIN_LENGTH) {
|
||||
seq++;
|
||||
messageHits = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
timer = setTimeout(() => {
|
||||
run(value).catch(() => undefined);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
return {
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get query() {
|
||||
return query;
|
||||
},
|
||||
get trimmed() {
|
||||
return trimmed;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get messageHits() {
|
||||
return messageHits;
|
||||
},
|
||||
get chatHits() {
|
||||
return chatHits;
|
||||
},
|
||||
open() {
|
||||
active = true;
|
||||
},
|
||||
setQuery(value: string) {
|
||||
query = value;
|
||||
schedule();
|
||||
},
|
||||
close() {
|
||||
active = false;
|
||||
query = "";
|
||||
messageHits = [];
|
||||
loading = false;
|
||||
seq++;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const search = createSearch();
|
||||
@@ -10,9 +10,18 @@ export type RightPanel =
|
||||
| "stories"
|
||||
| "policy";
|
||||
|
||||
export type LeftView = "main" | "settings";
|
||||
|
||||
interface JumpTarget {
|
||||
chatId: number;
|
||||
messageId: number;
|
||||
}
|
||||
|
||||
function createUi() {
|
||||
let rightPanel = $state<RightPanel | null>(null);
|
||||
let leftColumnOpen = $state(true);
|
||||
let leftView = $state<LeftView>("main");
|
||||
let jumpTarget = $state<JumpTarget | null>(null);
|
||||
|
||||
return {
|
||||
get rightPanel() {
|
||||
@@ -21,6 +30,24 @@ function createUi() {
|
||||
get leftColumnOpen() {
|
||||
return leftColumnOpen;
|
||||
},
|
||||
get leftView() {
|
||||
return leftView;
|
||||
},
|
||||
get jumpTarget() {
|
||||
return jumpTarget;
|
||||
},
|
||||
openSettings() {
|
||||
leftView = "settings";
|
||||
},
|
||||
closeSettings() {
|
||||
leftView = "main";
|
||||
},
|
||||
requestJump(chatId: number, messageId: number) {
|
||||
jumpTarget = { chatId, messageId };
|
||||
},
|
||||
clearJump() {
|
||||
jumpTarget = null;
|
||||
},
|
||||
openPanel(panel: RightPanel) {
|
||||
rightPanel = panel;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user