perf(*): bound list queries, batch fetches, harden media downloads
This commit is contained in:
@@ -9,10 +9,27 @@ export interface CustomEmojiAsset {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const MAX_CACHED = 500;
|
||||
|
||||
const ready = new Map<string, CustomEmojiAsset>();
|
||||
const missing = new Set<string>();
|
||||
const inflight = new Map<string, Promise<CustomEmojiAsset | null>>();
|
||||
|
||||
function remember(key: string, asset: CustomEmojiAsset) {
|
||||
ready.set(key, asset);
|
||||
while (ready.size > MAX_CACHED) {
|
||||
const oldest = ready.keys().next();
|
||||
if (oldest.done) {
|
||||
return;
|
||||
}
|
||||
const stale = ready.get(oldest.value);
|
||||
ready.delete(oldest.value);
|
||||
if (stale) {
|
||||
URL.revokeObjectURL(stale.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
|
||||
}
|
||||
@@ -34,7 +51,7 @@ async function fetchEmoji(
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const asset = { url: URL.createObjectURL(blob), mime: blob.type };
|
||||
ready.set(key, asset);
|
||||
remember(key, asset);
|
||||
return asset;
|
||||
}
|
||||
if (response.status === 409 && retry) {
|
||||
|
||||
@@ -303,8 +303,14 @@ export function getMessageLinks(
|
||||
});
|
||||
}
|
||||
|
||||
export function getChatCalendar(chatId: number): Promise<DayCount[]> {
|
||||
return request<DayCount[]>(`/chats/${chatId}/calendar`, { account: true });
|
||||
export function getChatCalendar(
|
||||
chatId: number,
|
||||
range: { date_from?: string; date_to?: string } = {}
|
||||
): Promise<DayCount[]> {
|
||||
return request<DayCount[]>(`/chats/${chatId}/calendar`, {
|
||||
account: true,
|
||||
query: { ...range },
|
||||
});
|
||||
}
|
||||
|
||||
export function getMessageAt(chatId: number, date: string): Promise<MessageAt> {
|
||||
@@ -334,6 +340,16 @@ export function getPeers(ids: number[]): Promise<PeerView[]> {
|
||||
});
|
||||
}
|
||||
|
||||
export function getChatsBatch(ids: number[]): Promise<Chat[]> {
|
||||
if (ids.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return request<Chat[]>("/chats/batch", {
|
||||
account: true,
|
||||
query: { ids: ids.join(",") },
|
||||
});
|
||||
}
|
||||
|
||||
export function enrichChat(chatId: number): Promise<{ job_id: number }> {
|
||||
return request<{ job_id: number }>(`/chats/${chatId}/enrich`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -102,9 +102,30 @@ export function visualKind(kind: string): VisualKind {
|
||||
return "other";
|
||||
}
|
||||
|
||||
const MAX_CACHED = 200;
|
||||
|
||||
const ready = new Map<string, InlineMedia>();
|
||||
const inflight = new Map<string, Promise<InlineMedia>>();
|
||||
|
||||
function remember(
|
||||
cache: Map<string, InlineMedia>,
|
||||
key: string,
|
||||
item: InlineMedia
|
||||
) {
|
||||
cache.set(key, item);
|
||||
while (cache.size > MAX_CACHED) {
|
||||
const oldest = cache.keys().next();
|
||||
if (oldest.done) {
|
||||
return;
|
||||
}
|
||||
const stale = cache.get(oldest.value);
|
||||
cache.delete(oldest.value);
|
||||
if (stale?.state === "ready") {
|
||||
URL.revokeObjectURL(stale.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKey(account: number, chatId: number, messageId: number): string {
|
||||
return `${account}:${chatId}:${messageId}`;
|
||||
}
|
||||
@@ -183,7 +204,7 @@ export function loadMediaItem(media: MediaRef): Promise<InlineMedia> {
|
||||
const promise = resolveById(media)
|
||||
.then((result) => {
|
||||
if (result.state === "ready") {
|
||||
byId.set(key, result);
|
||||
remember(byId, key, result);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
@@ -251,7 +272,7 @@ export function loadInlineMedia(
|
||||
const promise = resolve(chatId, messageId)
|
||||
.then((result) => {
|
||||
if (result.state === "ready") {
|
||||
ready.set(key, result);
|
||||
remember(ready, key, result);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
|
||||
@@ -3,10 +3,27 @@ import { auth } from "$lib/stores/auth.svelte";
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? "/api";
|
||||
|
||||
const MAX_CACHED = 100;
|
||||
|
||||
const ready = new Map<string, string>();
|
||||
const missing = new Set<string>();
|
||||
const inflight = new Map<string, Promise<string | null>>();
|
||||
|
||||
function remember(key: string, url: string) {
|
||||
ready.set(key, url);
|
||||
while (ready.size > MAX_CACHED) {
|
||||
const oldest = ready.keys().next();
|
||||
if (oldest.done) {
|
||||
return;
|
||||
}
|
||||
const stale = ready.get(oldest.value);
|
||||
ready.delete(oldest.value);
|
||||
if (stale) {
|
||||
URL.revokeObjectURL(stale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
|
||||
}
|
||||
@@ -21,7 +38,7 @@ async function fetchStoryMedia(
|
||||
const response = await fetch(url, { headers: authHeaders() });
|
||||
if (response.ok) {
|
||||
const objectUrl = URL.createObjectURL(await response.blob());
|
||||
ready.set(key, objectUrl);
|
||||
remember(key, objectUrl);
|
||||
return objectUrl;
|
||||
}
|
||||
missing.add(key);
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
const SCROLL_THRESHOLD = 160;
|
||||
const STICK_OFFSET = 9;
|
||||
const IDLE_DELAY = 1500;
|
||||
const MAX_MESSAGES = 360;
|
||||
|
||||
let messages = $state<MessageView[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -176,6 +177,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function trimTail() {
|
||||
if (messages.length <= MAX_MESSAGES) {
|
||||
return;
|
||||
}
|
||||
messages = messages.slice(0, MAX_MESSAGES);
|
||||
hasNewer = true;
|
||||
}
|
||||
|
||||
async function trimHead() {
|
||||
if (messages.length <= MAX_MESSAGES || container === null) {
|
||||
return;
|
||||
}
|
||||
const el = container;
|
||||
const prevHeight = el.scrollHeight;
|
||||
const prevTop = el.scrollTop;
|
||||
messages = messages.slice(messages.length - MAX_MESSAGES);
|
||||
hasMore = true;
|
||||
await tick();
|
||||
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (
|
||||
loadingOlder ||
|
||||
@@ -204,6 +226,7 @@
|
||||
ensurePeers(fresh);
|
||||
await tick();
|
||||
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||||
trimTail();
|
||||
}
|
||||
} finally {
|
||||
loadingOlder = false;
|
||||
@@ -230,6 +253,7 @@
|
||||
messages = [...messages, ...fresh];
|
||||
ensurePeers(fresh);
|
||||
await tick();
|
||||
await trimHead();
|
||||
}
|
||||
} finally {
|
||||
loadingNewer = false;
|
||||
@@ -321,6 +345,7 @@
|
||||
const stick = isNearBottom();
|
||||
messages = [...messages, message];
|
||||
ensurePeers([message]);
|
||||
await trimHead();
|
||||
if (stick) {
|
||||
await tick();
|
||||
scrollToBottom();
|
||||
@@ -391,6 +416,7 @@
|
||||
...appended,
|
||||
];
|
||||
ensurePeers(fresh);
|
||||
await trimHead();
|
||||
if (appended.length > 0 && stick) {
|
||||
await tick();
|
||||
scrollToBottom();
|
||||
|
||||
@@ -92,11 +92,11 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
for (const policy of chatPolicies) {
|
||||
if (policy.scope_id !== null) {
|
||||
chats.ensure(policy.scope_id);
|
||||
}
|
||||
}
|
||||
chats.ensureMany(
|
||||
chatPolicies
|
||||
.map((policy) => policy.scope_id)
|
||||
.filter((id): id is number => id !== null)
|
||||
);
|
||||
});
|
||||
|
||||
function folderTitle(id: number | null): string {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { getChatCalendar, getMessageAt } from "$lib/api/endpoints";
|
||||
import type { DayCount } from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/ui/EmptyState.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
@@ -46,6 +45,13 @@
|
||||
let days = $state<DayCount[]>([]);
|
||||
let loading = $state(false);
|
||||
let cursor = $state<YearMonth | null>(null);
|
||||
const cache = new Map<string, DayCount[]>();
|
||||
|
||||
const now = new Date();
|
||||
const currentMonth: YearMonth = {
|
||||
year: now.getUTCFullYear(),
|
||||
month: now.getUTCMonth(),
|
||||
};
|
||||
|
||||
const byKey = $derived(
|
||||
new Map(days.map((day) => [day.day.slice(0, 10), day.count]))
|
||||
@@ -54,33 +60,27 @@
|
||||
days.reduce((max, day) => Math.max(max, day.count), 0)
|
||||
);
|
||||
|
||||
const minMonth = $derived.by<YearMonth | null>(() => {
|
||||
if (days.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(days[0].day);
|
||||
return { year: date.getUTCFullYear(), month: date.getUTCMonth() };
|
||||
});
|
||||
const maxMonth = $derived.by<YearMonth | null>(() => {
|
||||
if (days.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(days.at(-1)?.day ?? days[0].day);
|
||||
return { year: date.getUTCFullYear(), month: date.getUTCMonth() };
|
||||
});
|
||||
|
||||
const view = $derived(cursor ?? maxMonth);
|
||||
const view = $derived(cursor ?? currentMonth);
|
||||
|
||||
function index(value: YearMonth): number {
|
||||
return value.year * 12 + value.month;
|
||||
}
|
||||
|
||||
const canPrev = $derived(
|
||||
Boolean(view && minMonth && index(view) > index(minMonth))
|
||||
);
|
||||
const canNext = $derived(
|
||||
Boolean(view && maxMonth && index(view) < index(maxMonth))
|
||||
);
|
||||
const canNext = $derived(index(view) < index(currentMonth));
|
||||
|
||||
function monthKey(value: YearMonth): string {
|
||||
return `${value.year}-${value.month}`;
|
||||
}
|
||||
|
||||
function monthRange(value: YearMonth): {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
} {
|
||||
return {
|
||||
date_from: new Date(Date.UTC(value.year, value.month, 1)).toISOString(),
|
||||
date_to: new Date(Date.UTC(value.year, value.month + 1, 1)).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function level(count: number): number {
|
||||
if (count <= 0 || maxCount <= 0) {
|
||||
@@ -92,35 +92,17 @@
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value: YearMonth): YearMonth {
|
||||
if (minMonth && index(value) < index(minMonth)) {
|
||||
return minMonth;
|
||||
}
|
||||
if (maxMonth && index(value) > index(maxMonth)) {
|
||||
return maxMonth;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function shift(months: number) {
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
const total = index(view) + months;
|
||||
cursor = clamp({ year: Math.floor(total / 12), month: total % 12 });
|
||||
const total = Math.min(index(view) + months, index(currentMonth));
|
||||
cursor = { year: Math.floor(total / 12), month: total % 12 };
|
||||
}
|
||||
|
||||
const lead = $derived(
|
||||
view
|
||||
? (new Date(Date.UTC(view.year, view.month, 1)).getUTCDay() + 6) % 7
|
||||
: 0
|
||||
(new Date(Date.UTC(view.year, view.month, 1)).getUTCDay() + 6) % 7
|
||||
);
|
||||
const blanks = $derived(Array.from({ length: lead }, (_, i) => i));
|
||||
|
||||
const cells = $derived.by<DayCell[]>(() => {
|
||||
if (!view) {
|
||||
return [];
|
||||
}
|
||||
const count = new Date(Date.UTC(view.year, view.month + 1, 0)).getUTCDate();
|
||||
const result: DayCell[] = [];
|
||||
for (let day = 1; day <= count; day++) {
|
||||
@@ -133,17 +115,30 @@
|
||||
return result;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const _id = chatId;
|
||||
const _account = accounts.selectedId;
|
||||
cache.clear();
|
||||
cursor = null;
|
||||
days = [];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const _id = chatId;
|
||||
if (accounts.selectedId === null) {
|
||||
return;
|
||||
}
|
||||
const target = view;
|
||||
const cached = cache.get(monthKey(target));
|
||||
if (cached !== undefined) {
|
||||
days = cached;
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
loading = true;
|
||||
days = [];
|
||||
cursor = null;
|
||||
getChatCalendar(chatId)
|
||||
getChatCalendar(chatId, monthRange(target))
|
||||
.then((result) => {
|
||||
cache.set(monthKey(target), result);
|
||||
if (active) {
|
||||
days = result;
|
||||
}
|
||||
@@ -175,8 +170,6 @@
|
||||
|
||||
{#if loading && days.length === 0}
|
||||
<div class="center"><Spinner /></div>
|
||||
{:else if !view}
|
||||
<EmptyState title="Нет сообщений" />
|
||||
{:else}
|
||||
<div class="calendar">
|
||||
<header class="nav">
|
||||
@@ -184,7 +177,6 @@
|
||||
type="button"
|
||||
class="step"
|
||||
onclick={() => shift(-12)}
|
||||
disabled={!canPrev}
|
||||
aria-label="Предыдущий год"
|
||||
>
|
||||
«
|
||||
@@ -193,7 +185,6 @@
|
||||
type="button"
|
||||
class="step"
|
||||
onclick={() => shift(-1)}
|
||||
disabled={!canPrev}
|
||||
aria-label="Предыдущий месяц"
|
||||
>
|
||||
<Icon name="arrow-left" />
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
let items = $state<FileShare[]>([]);
|
||||
let loading = $state(false);
|
||||
let filter = $state<Filter>("active");
|
||||
let fetchedAll = $state(false);
|
||||
let expanded = $state<number | null>(null);
|
||||
let token = 0;
|
||||
|
||||
@@ -44,16 +45,20 @@
|
||||
: items
|
||||
);
|
||||
const activeCount = $derived(
|
||||
items.filter((item) => shareState(item) === "active").length
|
||||
fetchedAll
|
||||
? items.filter((item) => shareState(item) === "active").length
|
||||
: items.length
|
||||
);
|
||||
|
||||
async function load() {
|
||||
const current = ++token;
|
||||
const activeOnly = filter === "active";
|
||||
loading = true;
|
||||
try {
|
||||
const rows = await listShares();
|
||||
const rows = await listShares(activeOnly);
|
||||
if (current === token) {
|
||||
items = rows;
|
||||
fetchedAll = !activeOnly;
|
||||
}
|
||||
} catch {
|
||||
if (current === token) {
|
||||
@@ -118,7 +123,7 @@
|
||||
let loadedKey = "";
|
||||
|
||||
$effect(() => {
|
||||
const key = `${accounts.selectedId}:${shareUi.revision}`;
|
||||
const key = `${accounts.selectedId}:${shareUi.revision}:${filter}`;
|
||||
if (accounts.selectedId !== null && key !== loadedKey) {
|
||||
loadedKey = key;
|
||||
load();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
enqueueStoriesBackfill,
|
||||
getChat,
|
||||
getChatsBatch,
|
||||
getPeers,
|
||||
getStories,
|
||||
} from "$lib/api/endpoints";
|
||||
@@ -63,17 +63,13 @@
|
||||
const chatIds = [...byPeer.keys()].filter((id) => id < 0);
|
||||
const [peers, fetched] = await Promise.all([
|
||||
getPeers(peerIds),
|
||||
Promise.all(chatIds.map((id) => getChat(id))),
|
||||
getChatsBatch(chatIds),
|
||||
]);
|
||||
if (current !== token) {
|
||||
return;
|
||||
}
|
||||
const peerById = new Map(peers.map((peer) => [peer.peer_id, peer]));
|
||||
const chatById = new Map(
|
||||
fetched
|
||||
.filter((item) => item !== null)
|
||||
.map((item) => [item.chat_id, item])
|
||||
);
|
||||
const chatById = new Map(fetched.map((item) => [item.chat_id, item]));
|
||||
groups = [...byPeer.entries()].map(([peerId, stories]) => {
|
||||
if (peerId > 0) {
|
||||
const peer = peerById.get(peerId) ?? null;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { enrichChat, getChat, getJob, listChats } from "$lib/api/endpoints";
|
||||
import {
|
||||
enrichChat,
|
||||
getChat,
|
||||
getChatsBatch,
|
||||
getJob,
|
||||
listChats,
|
||||
} from "$lib/api/endpoints";
|
||||
import type { Chat, LiveEvent } from "$lib/api/types";
|
||||
import { folderContains } from "$lib/format/folders";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
@@ -245,6 +251,32 @@ function createChats() {
|
||||
})
|
||||
.catch(() => resolving.delete(id));
|
||||
},
|
||||
ensureMany(ids: number[]) {
|
||||
syncAccount();
|
||||
if (account === null) {
|
||||
return;
|
||||
}
|
||||
const missing = [
|
||||
...new Set(ids.filter((id) => !(resolving.has(id) || known(id)))),
|
||||
];
|
||||
if (missing.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const id of missing) {
|
||||
resolving.add(id);
|
||||
}
|
||||
getChatsBatch(missing)
|
||||
.then((fetched) => {
|
||||
for (const chat of fetched) {
|
||||
extra[chat.chat_id] = chat;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
for (const id of missing) {
|
||||
resolving.delete(id);
|
||||
}
|
||||
});
|
||||
},
|
||||
load(folderId: number | null = folders.selectedId) {
|
||||
return load(folderId, false);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user