feat: 1-to-1 message render + web data-lake backend

This commit is contained in:
hh committed 2026-05-31 01:45:05 +02:00
1 parent f0afb7ec5b
commit 75425d1bee
110 files changed
+10199 -54

No files matched your search

@@ -0,0 +1,66 @@
import { browser } from "$app/environment";
import { listAccounts } from "$lib/api/endpoints";
import type { Account } from "$lib/api/types";
const STORAGE_KEY = "bg.account";
function readSelected(): number | null {
if (!browser) {
return null;
}
const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? null : Number(stored);
}
function createAccounts() {
let list = $state<Account[]>([]);
let selectedId = $state<number | null>(readSelected());
let loaded = $state(false);
const selected = $derived(
list.find((account) => account.account_id === selectedId) ?? null
);
function persist(id: number | null) {
if (!browser) {
return;
}
if (id === null) {
localStorage.removeItem(STORAGE_KEY);
} else {
localStorage.setItem(STORAGE_KEY, String(id));
}
}
return {
get list() {
return list;
},
get selectedId() {
return selectedId;
},
get selected() {
return selected;
},
get loaded() {
return loaded;
},
async load() {
list = await listAccounts();
loaded = true;
const exists = list.some((account) => account.account_id === selectedId);
if (!exists) {
const fallback =
list.find((account) => account.is_active) ?? list.at(0) ?? null;
selectedId = fallback ? fallback.account_id : null;
persist(selectedId);
}
},
select(id: number) {
selectedId = id;
persist(id);
},
};
}
export const accounts = createAccounts();
+37
View File
@@ -0,0 +1,37 @@
import { browser } from "$app/environment";
const STORAGE_KEY = "bg.token";
function readToken(): string | null {
if (!browser) {
return null;
}
return localStorage.getItem(STORAGE_KEY);
}
function createAuth() {
let token = $state<string | null>(readToken());
return {
get token() {
return token;
},
get isAuthenticated() {
return token !== null && token.length > 0;
},
login(value: string) {
token = value;
if (browser) {
localStorage.setItem(STORAGE_KEY, value);
}
},
logout() {
token = null;
if (browser) {
localStorage.removeItem(STORAGE_KEY);
}
},
};
}
export const auth = createAuth();
+93
View File
@@ -0,0 +1,93 @@
import { enrichChat, getJob, listChats } from "$lib/api/endpoints";
import type { Chat } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
const POLL_INTERVAL = 1500;
const POLL_MAX = 12;
function createChats() {
let list = $state<Chat[]>([]);
let loaded = $state(false);
let loading = $state(false);
let revision = $state(0);
let account: number | null = null;
const enriched = new Set<number>();
function syncAccount() {
if (accounts.selectedId !== account) {
account = accounts.selectedId;
list = [];
loaded = false;
enriched.clear();
}
}
async function load(force: boolean) {
syncAccount();
if (account === null || (loaded && !force)) {
return;
}
loading = true;
try {
list = await listChats({ limit: 200 });
loaded = true;
} finally {
loading = false;
}
}
async function waitForJob(jobId: number) {
for (let attempt = 0; attempt < POLL_MAX; attempt++) {
await new Promise((resolve) => {
setTimeout(resolve, POLL_INTERVAL);
});
const job = await getJob(jobId);
if (job.status === "done" || job.status === "failed") {
return;
}
}
}
return {
get list(): Chat[] {
return list;
},
get loaded(): boolean {
return loaded;
},
get loading(): boolean {
return loading;
},
get revision(): number {
return revision;
},
byId(id: number): Chat | undefined {
return list.find((chat) => chat.chat_id === id);
},
load() {
return load(false);
},
refresh() {
return load(true);
},
async enrich(chatId: number) {
syncAccount();
if (account === null || enriched.has(chatId)) {
return;
}
enriched.add(chatId);
try {
const { job_id } = await enrichChat(chatId);
await waitForJob(job_id);
peers.reset();
await load(true);
revision++;
} catch {
enriched.delete(chatId);
}
},
};
}
export const chats = createChats();
+74
View File
@@ -0,0 +1,74 @@
import { listFolders } from "$lib/api/endpoints";
import type { Folder } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
function createFolders() {
let list = $state<Folder[]>([]);
let loaded = $state(false);
let loading = $state(false);
let selectedId = $state<number | null>(null);
let direction = $state(1);
let account: number | null = null;
function syncAccount() {
if (accounts.selectedId !== account) {
account = accounts.selectedId;
list = [];
loaded = false;
selectedId = null;
}
}
function indexOf(id: number | null): number {
if (id === null) {
return 0;
}
const index = list.findIndex((folder) => folder.folder_id === id);
return index === -1 ? 0 : index + 1;
}
async function load() {
syncAccount();
if (account === null || loaded) {
return;
}
loading = true;
try {
const folders = await listFolders();
list = folders.sort((a, b) => a.order_index - b.order_index);
loaded = true;
} finally {
loading = false;
}
}
return {
get list(): Folder[] {
return list;
},
get loading(): boolean {
return loading;
},
get selectedId(): number | null {
return selectedId;
},
get selected(): Folder | null {
return list.find((folder) => folder.folder_id === selectedId) ?? null;
},
get activeIndex(): number {
return indexOf(selectedId);
},
get direction(): number {
return direction;
},
load() {
return load();
},
select(id: number | null) {
direction = indexOf(id) >= indexOf(selectedId) ? 1 : -1;
selectedId = id;
},
};
}
export const folders = createFolders();
+70
View File
@@ -0,0 +1,70 @@
import { getPeers } from "$lib/api/endpoints";
import type { PeerView } from "$lib/api/types";
import { accounts } from "$lib/stores/accounts.svelte";
const BATCH_DELAY = 40;
function createPeers() {
let map = $state<Record<number, PeerView>>({});
const requested = new Set<number>();
const pending = new Set<number>();
let account: number | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
function reset() {
map = {};
requested.clear();
pending.clear();
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
}
function flush() {
timer = null;
const ids = [...pending];
pending.clear();
if (ids.length === 0) {
return;
}
getPeers(ids)
.then((peers) => {
const next = { ...map };
for (const peer of peers) {
next[peer.peer_id] = peer;
}
map = next;
})
.catch(() => {
for (const id of ids) {
requested.delete(id);
}
});
}
return {
reset,
get(id: number): PeerView | undefined {
return map[id];
},
ensure(ids: Iterable<number>) {
if (accounts.selectedId !== account) {
account = accounts.selectedId;
reset();
}
for (const id of ids) {
if (map[id] || requested.has(id)) {
continue;
}
requested.add(id);
pending.add(id);
}
if (pending.size > 0 && timer === null) {
timer = setTimeout(flush, BATCH_DELAY);
}
},
};
}
export const peers = createPeers();
+67
View File
@@ -0,0 +1,67 @@
import { browser } from "$app/environment";
type Preference = "light" | "dark" | "system";
type Resolved = "light" | "dark";
const STORAGE_KEY = "bg.theme";
function readPreference(): Preference {
if (!browser) {
return "system";
}
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
return "system";
}
function prefersDark(): boolean {
return browser && window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function createTheme() {
let preference = $state<Preference>(readPreference());
let systemDark = $state(prefersDark());
function systemResolved(): Resolved {
return systemDark ? "dark" : "light";
}
const resolved = $derived<Resolved>(
preference === "system" ? systemResolved() : preference
);
function watchSystem() {
if (!browser) {
return () => undefined;
}
const media = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = (event: MediaQueryListEvent) => {
systemDark = event.matches;
};
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}
return {
get preference() {
return preference;
},
get resolved() {
return resolved;
},
set(value: Preference) {
preference = value;
if (browser) {
localStorage.setItem(STORAGE_KEY, value);
}
},
toggle() {
this.set(this.resolved === "dark" ? "light" : "dark");
},
watchSystem,
};
}
export const theme = createTheme();
+47
View File
@@ -0,0 +1,47 @@
type ToastType = "info" | "error" | "success";
interface Toast {
id: number;
message: string;
type: ToastType;
}
const DEFAULT_TIMEOUT = 4000;
function createToasts() {
let items = $state<Toast[]>([]);
let nextId = 0;
function dismiss(id: number) {
items = items.filter((toast) => toast.id !== id);
}
function show(
message: string,
type: ToastType = "info",
timeout = DEFAULT_TIMEOUT
) {
const id = nextId++;
items = [...items, { id, type, message }];
if (timeout > 0) {
setTimeout(() => dismiss(id), timeout);
}
return id;
}
return {
get items() {
return items;
},
show,
error(message: string) {
return show(message, "error");
},
success(message: string) {
return show(message, "success");
},
dismiss,
};
}
export const toasts = createToasts();
+39
View File
@@ -0,0 +1,39 @@
export type RightPanel =
| "profile"
| "search"
| "versions"
| "reactions"
| "links"
| "annotations"
| "jobs"
| "presence"
| "stories"
| "policy";
function createUi() {
let rightPanel = $state<RightPanel | null>(null);
let leftColumnOpen = $state(true);
return {
get rightPanel() {
return rightPanel;
},
get leftColumnOpen() {
return leftColumnOpen;
},
openPanel(panel: RightPanel) {
rightPanel = panel;
},
closePanel() {
rightPanel = null;
},
toggleLeftColumn() {
leftColumnOpen = !leftColumnOpen;
},
setLeftColumn(open: boolean) {
leftColumnOpen = open;
},
};
}
export const ui = createUi();