feat: web UI chat render, panels, presence + analytics

This commit is contained in:
hh committed 2026-05-31 19:41:37 +02:00
1 parent 75425d1bee
commit ed469ba8dd
83 files changed
+6034 -136

No files matched your search

+77 -2
View File
@@ -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);
},
+128
View File
@@ -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();
+101
View File
@@ -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();
+27
View File
@@ -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;
},