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
@@ -0,0 +1,70 @@
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
import { auth } from "$lib/stores/auth.svelte";
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
|
||||
const RETRY_DELAY = 2500;
|
||||
|
||||
export interface CustomEmojiAsset {
|
||||
mime: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const ready = new Map<string, CustomEmojiAsset>();
|
||||
const missing = new Set<string>();
|
||||
const inflight = new Map<string, Promise<CustomEmojiAsset | null>>();
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchEmoji(
|
||||
account: number,
|
||||
id: string,
|
||||
key: string,
|
||||
retry: boolean
|
||||
): Promise<CustomEmojiAsset | null> {
|
||||
const url = `${BASE}/custom-emoji/${id}?account_id=${account}`;
|
||||
const response = await fetch(url, { headers: authHeaders() });
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const asset = { url: URL.createObjectURL(blob), mime: blob.type };
|
||||
ready.set(key, asset);
|
||||
return asset;
|
||||
}
|
||||
if (response.status === 409 && retry) {
|
||||
await delay(RETRY_DELAY);
|
||||
return fetchEmoji(account, id, key, false);
|
||||
}
|
||||
missing.add(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadCustomEmoji(id: string): Promise<CustomEmojiAsset | null> {
|
||||
const account = accounts.selectedId;
|
||||
if (account === null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const key = `${account}:${id}`;
|
||||
const cached = ready.get(key);
|
||||
if (cached) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
if (missing.has(key)) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const existing = inflight.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const promise = fetchEmoji(account, id, key, true).finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
import { request } from "$lib/api/client";
|
||||
import type {
|
||||
Account,
|
||||
CaptureToggles,
|
||||
Chat,
|
||||
Folder,
|
||||
JobStatus,
|
||||
JobView,
|
||||
MediaVersion,
|
||||
MediaView,
|
||||
MessageVersion,
|
||||
MessageView,
|
||||
PeerView,
|
||||
PinnedView,
|
||||
PolicyChatKind,
|
||||
PolicyCreate,
|
||||
PolicyRecord,
|
||||
PresenceHourly,
|
||||
PresenceSample,
|
||||
ResponseStats,
|
||||
SearchHit,
|
||||
VolumeBucket,
|
||||
} from "$lib/api/types";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
|
||||
@@ -29,6 +40,43 @@ export function listFolders(): Promise<Folder[]> {
|
||||
return request<Folder[]>("/folders", { account: true });
|
||||
}
|
||||
|
||||
export function listPolicies(): Promise<PolicyRecord[]> {
|
||||
return request<PolicyRecord[]>("/policy", { account: true });
|
||||
}
|
||||
|
||||
export function createPolicy(body: PolicyCreate): Promise<PolicyRecord> {
|
||||
return request<PolicyRecord>("/policy", {
|
||||
method: "POST",
|
||||
body: { ...body, account_id: accounts.selectedId },
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePolicy(
|
||||
id: number,
|
||||
toggles: CaptureToggles
|
||||
): Promise<PolicyRecord> {
|
||||
return request<PolicyRecord>(`/policy/${id}`, {
|
||||
method: "PUT",
|
||||
body: toggles,
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePolicy(id: number): Promise<void> {
|
||||
return request<void>(`/policy/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function effectivePolicy(query: {
|
||||
chat_id: number;
|
||||
is_bot?: boolean;
|
||||
is_contact?: boolean | null;
|
||||
kind: PolicyChatKind;
|
||||
}): Promise<CaptureToggles> {
|
||||
return request<CaptureToggles>("/policy/effective", {
|
||||
account: true,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function listMessages(
|
||||
chatId: number,
|
||||
options: Page & { include_deleted?: boolean } = {}
|
||||
@@ -39,6 +87,55 @@ export function listMessages(
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentPresence(
|
||||
peerId: number
|
||||
): Promise<PresenceSample | null> {
|
||||
return request<PresenceSample | null>("/presence/current", {
|
||||
account: true,
|
||||
query: { peer_id: peerId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getPresenceHistory(
|
||||
peerId: number,
|
||||
page: Page = {}
|
||||
): Promise<PresenceSample[]> {
|
||||
return request<PresenceSample[]>("/presence", {
|
||||
account: true,
|
||||
query: { peer_id: peerId, ...page },
|
||||
});
|
||||
}
|
||||
|
||||
export function getPresenceHourly(peerId: number): Promise<PresenceHourly[]> {
|
||||
return request<PresenceHourly[]>("/presence/hourly", {
|
||||
account: true,
|
||||
query: { peer_id: peerId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getMessageVolume(
|
||||
chatId: number,
|
||||
days = 90
|
||||
): Promise<VolumeBucket[]> {
|
||||
return request<VolumeBucket[]>("/analytics/volume", {
|
||||
account: true,
|
||||
query: { chat_id: chatId, days },
|
||||
});
|
||||
}
|
||||
|
||||
export function getResponseStats(chatId: number): Promise<ResponseStats> {
|
||||
return request<ResponseStats>("/analytics/response-time", {
|
||||
account: true,
|
||||
query: { chat_id: chatId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getPinned(chatId: number): Promise<PinnedView | null> {
|
||||
return request<PinnedView | null>(`/chats/${chatId}/pinned`, {
|
||||
account: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function listMessageVersions(
|
||||
chatId: number,
|
||||
messageId: number
|
||||
@@ -83,6 +180,30 @@ export function getJob(jobId: number): Promise<JobView> {
|
||||
return request<JobView>(`/jobs/${jobId}`, { account: true });
|
||||
}
|
||||
|
||||
export function listJobs(status?: JobStatus): Promise<JobView[]> {
|
||||
return request<JobView[]>("/jobs", {
|
||||
account: true,
|
||||
query: status ? { status } : {},
|
||||
});
|
||||
}
|
||||
|
||||
export function enqueueBackfill(
|
||||
chatId: number,
|
||||
media: boolean
|
||||
): Promise<{ job_id: number }> {
|
||||
return request<{ job_id: number }>("/backfill", {
|
||||
method: "POST",
|
||||
body: { account_id: accounts.selectedId, chat_id: chatId, media },
|
||||
});
|
||||
}
|
||||
|
||||
export function syncDialogs(): Promise<{ job_id: number }> {
|
||||
return request<{ job_id: number }>("/dialogs/sync", {
|
||||
method: "POST",
|
||||
body: { account_id: accounts.selectedId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getMediaVersions(
|
||||
chatId: number,
|
||||
messageId: number
|
||||
@@ -105,6 +226,16 @@ export function getMessageMedia(
|
||||
});
|
||||
}
|
||||
|
||||
export function searchMessages(
|
||||
query: string,
|
||||
options: Page & { chat_id?: number } = {}
|
||||
): Promise<SearchHit[]> {
|
||||
return request<SearchHit[]>("/search", {
|
||||
account: true,
|
||||
query: { query, ...options },
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchMedia(
|
||||
chatId: number,
|
||||
messageId: number
|
||||
|
||||
@@ -139,6 +139,13 @@ export interface ServiceView {
|
||||
pinned_message_id: number | null;
|
||||
}
|
||||
|
||||
export interface PinnedView {
|
||||
media_kind: string | null;
|
||||
message_id: number;
|
||||
sender_name: string | null;
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
export interface StickerView {
|
||||
emoji: string | null;
|
||||
height: number | null;
|
||||
@@ -169,6 +176,7 @@ export interface MessageView {
|
||||
poll: PollView | null;
|
||||
quote: string | null;
|
||||
reactions: ReactionCount[];
|
||||
read: boolean;
|
||||
reply: ReplyView | null;
|
||||
sender_id: number | null;
|
||||
service: ServiceView | null;
|
||||
@@ -257,6 +265,20 @@ export interface PresenceHourly {
|
||||
samples: number;
|
||||
}
|
||||
|
||||
export interface VolumeBucket {
|
||||
bucket: string;
|
||||
incoming: number;
|
||||
outgoing: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ResponseStats {
|
||||
mine_count: number;
|
||||
mine_median_seconds: number | null;
|
||||
their_count: number;
|
||||
their_median_seconds: number | null;
|
||||
}
|
||||
|
||||
export interface PeerView {
|
||||
first_name: string | null;
|
||||
has_avatar: boolean;
|
||||
@@ -323,6 +345,13 @@ export interface PolicyRecord extends CaptureToggles {
|
||||
scope_type: PolicyScopeType;
|
||||
}
|
||||
|
||||
export type PolicyChatKind = "dm" | "group" | "channel";
|
||||
|
||||
export interface PolicyCreate extends CaptureToggles {
|
||||
scope_id?: number | null;
|
||||
scope_type: PolicyScopeType;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
bots: boolean;
|
||||
broadcasts: boolean;
|
||||
@@ -373,3 +402,32 @@ export interface JobView {
|
||||
started_at: string | null;
|
||||
status: JobStatus;
|
||||
}
|
||||
|
||||
export interface LiveMessageEvent {
|
||||
message: MessageView;
|
||||
type: "message" | "edit" | "reaction";
|
||||
}
|
||||
|
||||
export interface LiveDeleteEvent {
|
||||
chat_id: number | null;
|
||||
message_ids: number[];
|
||||
type: "delete";
|
||||
}
|
||||
|
||||
export interface LivePresenceEvent {
|
||||
peer_id: number;
|
||||
sample: PresenceSample | null;
|
||||
type: "presence";
|
||||
}
|
||||
|
||||
export interface LiveReceiptEvent {
|
||||
chat_id: number;
|
||||
read_up_to: number;
|
||||
type: "receipt";
|
||||
}
|
||||
|
||||
export type LiveEvent =
|
||||
| LiveMessageEvent
|
||||
| LiveDeleteEvent
|
||||
| LivePresenceEvent
|
||||
| LiveReceiptEvent;
|
||||
Reference in new issue
Block a user