feat: 1-to-1 message render + web data-lake backend
This commit is contained in:
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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user