feat(frontend): feed of deleted and edited messages above the chat list and per chat in the changes panel

This commit is contained in:
hh committed 2026-10-19 03:00:12 +00:00
1 parent 010fa29ce0
commit 9f16e2aa56
23 files changed
+1915 -71

No files matched your search

+38
View File
@@ -9,6 +9,11 @@ import type {
ChatLinkView, ChatLinkView,
DayCount, DayCount,
DiscoverItem, DiscoverItem,
FeedChatKind,
FeedKind,
FeedMessagesPage,
FeedPage,
FeedUnseen,
Folder, Folder,
JobStatus, JobStatus,
JobView, JobView,
@@ -274,6 +279,39 @@ export function listDeleted(
}); });
} }
export interface FeedQuery {
chat_id?: number;
chat_kind?: FeedChatKind;
cursor?: string;
kind?: FeedKind;
limit?: number;
}
export function listFeed(query: FeedQuery = {}): Promise<FeedPage> {
return request<FeedPage>("/feed", { account: true, query: { ...query } });
}
export function getFeedUnseen(): Promise<FeedUnseen> {
return request<FeedUnseen>("/feed/unseen", { account: true });
}
export function markFeedSeen(at?: string): Promise<FeedUnseen> {
return request<FeedUnseen>("/feed/seen", {
body: { account_id: accounts.selectedId, at },
method: "POST",
});
}
export function listFeedMessages(
itemId: number,
query: { cursor?: string; limit?: number } = {}
): Promise<FeedMessagesPage> {
return request<FeedMessagesPage>(`/feed/${itemId}/messages`, {
account: true,
query: { ...query },
});
}
export function getPeer(peerId: number): Promise<PeerView> { export function getPeer(peerId: number): Promise<PeerView> {
return request<PeerView>(`/peers/${peerId}`, { account: true }); return request<PeerView>(`/peers/${peerId}`, { account: true });
} }
+46
View File
@@ -222,6 +222,52 @@ export interface MessageVersion {
text: string | null; text: string | null;
} }
export type FeedKind = "all" | "deleted" | "edited";
export type FeedChatKind = "dm" | "group" | "channel";
export interface FeedChat {
chat_id: number;
has_avatar: boolean;
kind: FeedChatKind;
title: string | null;
}
export interface FeedItem {
after: string | null;
at: string;
before: string | null;
chat: FeedChat;
edit_date: string | null;
has_before: boolean;
id: number;
kind: Exclude<FeedKind, "all">;
message: MessageView | null;
message_count: number;
message_id: number;
outgoing: boolean;
sender_id: number | null;
sender_name: string | null;
since: string;
}
export interface FeedPage {
items: FeedItem[];
next_cursor: string | null;
}
export interface FeedUnseen {
count: number;
deleted: number;
edited: number;
latest: FeedItem | null;
seen_at: string | null;
}
export interface FeedMessagesPage {
messages: MessageView[];
next_cursor: string | null;
}
export interface MediaVersion { export interface MediaVersion {
file_size: number | null; file_size: number | null;
id: number; id: number;
+39 -14
View File
@@ -8,14 +8,17 @@
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { peerName } from "#lib/format/peer.js"; import { peerName } from "#lib/format/peer.js";
import { formatPresence, isOnline } from "#lib/format/presence.js"; import { formatPresence, isOnline } from "#lib/format/presence.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { discover } from "#lib/stores/discover.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js";
import { withoutMessage } from "#lib/stores/feed.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { presence } from "#lib/stores/presence.svelte.js"; import { presence } from "#lib/stores/presence.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state";
interface Props { interface Props {
chatId: number; chatId: number;
@@ -36,14 +39,20 @@
backfilling = true; backfilling = true;
try { try {
await enqueueBackfill(chatId, true); await enqueueBackfill(chatId, true);
toasts.success("Догружаем новые сообщения"); toasts.success(t("chat.backfill.started"));
} catch { } catch {
toasts.error("Не удалось запустить бэкфилл"); toasts.error(t("chat.backfill.failed"));
} finally { } finally {
backfilling = false; backfilling = false;
} }
} }
function back() {
const target = new URL(page.url.href);
target.pathname = "/app";
goto(withoutMessage(target));
}
$effect(() => { $effect(() => {
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
return; return;
@@ -82,7 +91,9 @@
const fallbackTitle = $derived( const fallbackTitle = $derived(
chat?.title ?? chat?.title ??
discovered?.title ?? discovered?.title ??
(isDm ? "Удалённый аккаунт" : `Chat ${chatId}`) (isDm
? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: chatId }))
); );
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle); const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
const subtitle = $derived.by(() => { const subtitle = $derived.by(() => {
@@ -97,9 +108,11 @@
} }
const count = chat?.message_count ?? discovered?.message_count ?? 0; const count = chat?.message_count ?? discovered?.message_count ?? 0;
if (count > 0) { if (count > 0) {
return `${count} messages`; return t("chat.messages", { count });
} }
return discovered?.kind === "channel" ? "channel" : "group"; return discovered?.kind === "channel"
? t("chat.kind.channel")
: t("chat.kind.group");
}); });
const avatarKind = $derived(isDm ? "peer" : "chat"); const avatarKind = $derived(isDm ? "peer" : "chat");
const hasAvatar = $derived( const hasAvatar = $derived(
@@ -114,8 +127,8 @@
<header class="chat-header"> <header class="chat-header">
<div class="back"> <div class="back">
<Button <Button
aria-label="Назад" aria-label={t("common.back")}
onclick={() => goto("/app")} onclick={back}
round round
smaller smaller
variant="translucent" variant="translucent"
@@ -129,7 +142,7 @@
})} })}
<button <button
{...props} {...props}
aria-label="Открыть профиль" aria-label={t("chat.openProfile")}
class="peek" class="peek"
onclick={() => ui.openPanel("profile")} onclick={() => ui.openPanel("profile")}
type="button" type="button"
@@ -157,23 +170,26 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")} <ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")}
>Открыть профиль</ContextMenuItem >{t("chat.openProfile")}</ContextMenuItem
>
<ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")}
>{t("feed.changes")}</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="play-story" icon="play-story"
onselect={() => ui.openPanel("stories")} onselect={() => ui.openPanel("stories")}
>Сторис</ContextMenuItem >{t("settings.stories")}</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="settings" icon="settings"
onselect={() => policyUi.edit({ chatId }, title)} onselect={() => policyUi.edit({ chatId }, title)}
>Политика захвата</ContextMenuItem >{t("settings.policy")}</ContextMenuItem
> >
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
<div class="actions"> <div class="actions">
<Button <Button
aria-label="Поиск в чате" aria-label={t("chat.search")}
onclick={() => ui.openPanel("search")} onclick={() => ui.openPanel("search")}
round round
smaller smaller
@@ -182,7 +198,16 @@
<Icon name="search" /> <Icon name="search" />
</Button> </Button>
<Button <Button
aria-label="Аналитика" aria-label={t("feed.changes")}
onclick={() => ui.openPanel("changes")}
round
smaller
variant="translucent"
>
<Icon name="recent" />
</Button>
<Button
aria-label={t("chat.analytics")}
onclick={() => ui.openPanel("presence")} onclick={() => ui.openPanel("presence")}
round round
smaller smaller
@@ -191,7 +216,7 @@
<Icon name="stats" /> <Icon name="stats" />
</Button> </Button>
<Button <Button
aria-label="Догрузить новые сообщения" aria-label={t("chat.backfill")}
loading={backfilling} loading={backfilling}
onclick={backfill} onclick={backfill}
round round
+11 -4
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import ChatListItem from "#lib/components/ChatListItem.svelte"; import ChatListItem from "#lib/components/ChatListItem.svelte";
import FeedRow from "#lib/components/feed/FeedRow.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte"; import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Skeleton from "#lib/components/ui/Skeleton.svelte"; import Skeleton from "#lib/components/ui/Skeleton.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js"; import { folders } from "#lib/stores/folders.svelte.js";
@@ -46,7 +48,7 @@
return; return;
} }
untrack(() => folders.load()).catch(() => untrack(() => folders.load()).catch(() =>
toasts.error("Failed to load folders") toasts.error(t("chats.foldersFailed"))
); );
}); });
@@ -60,7 +62,7 @@
scrollTop = 0; scrollTop = 0;
} }
untrack(() => chats.load(folderId)).catch(() => untrack(() => chats.load(folderId)).catch(() =>
toasts.error("Failed to load chats") toasts.error(t("chats.loadFailed"))
); );
}); });
@@ -99,6 +101,9 @@
bind:this={viewport} bind:this={viewport}
bind:clientHeight={viewportHeight} bind:clientHeight={viewportHeight}
> >
{#if folders.selectedId === null}
<FeedRow />
{/if}
{#if chats.loading && list.length === 0} {#if chats.loading && list.length === 0}
{#each skeletonRows as index (index)} {#each skeletonRows as index (index)}
<div class="row-skeleton"> <div class="row-skeleton">
@@ -113,8 +118,10 @@
<EmptyState <EmptyState
description={folders.selectedId === null description={folders.selectedId === null
? undefined ? undefined
: "No chats match this folder yet"} : t("chats.emptyFolderHint")}
title={folders.selectedId === null ? "No chats yet" : "Empty folder"} title={folders.selectedId === null
? t("chats.empty")
: t("chats.emptyFolder")}
/> />
{:else} {:else}
<div style:padding-bottom="{padBottom}px" style:padding-top="{padTop}px"> <div style:padding-bottom="{padBottom}px" style:padding-top="{padTop}px">
@@ -5,6 +5,7 @@
import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
@@ -26,7 +27,9 @@
const title = $derived( const title = $derived(
chat.title ?? chat.title ??
(chat.chat_id > 0 ? "Удалённый аккаунт" : `Chat ${chat.chat_id}`) (chat.chat_id > 0
? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: chat.chat_id }))
); );
const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat"); const avatarKind = $derived(chat.chat_id > 0 ? "peer" : "chat");
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
@@ -45,7 +48,7 @@
return ""; return "";
} }
if (chat.last_sender_id === ownId) { if (chat.last_sender_id === ownId) {
return "You: "; return `${t("chat.you")}: `;
} }
const peer = peers.get(chat.last_sender_id as number); const peer = peers.get(chat.last_sender_id as number);
if (!peer) { if (!peer) {
@@ -55,7 +58,7 @@
}); });
const preview = $derived( const preview = $derived(
chat.last_text ?? (chat.message_count > 0 ? "Media" : "") chat.last_text ?? (chat.message_count > 0 ? t("chat.media") : "")
); );
</script> </script>
@@ -95,24 +98,27 @@
{/snippet} {/snippet}
{#snippet menu()} {#snippet menu()}
<ContextMenuItem icon="open-in-new-tab" onselect={onclick} <ContextMenuItem icon="open-in-new-tab" onselect={onclick}
>Открыть</ContextMenuItem >{t("chat.open")}</ContextMenuItem
> >
<ContextMenuItem icon="info" onselect={() => openWith("profile")} <ContextMenuItem icon="info" onselect={() => openWith("profile")}
>Профиль</ContextMenuItem >{t("chat.profile")}</ContextMenuItem
> >
<ContextMenuItem icon="search" onselect={() => openWith("search")} <ContextMenuItem icon="search" onselect={() => openWith("search")}
>Поиск в чате</ContextMenuItem >{t("chat.search")}</ContextMenuItem
>
<ContextMenuItem icon="recent" onselect={() => openWith("changes")}
>{t("feed.changes")}</ContextMenuItem
> >
<ContextMenuItem icon="stats" onselect={() => openWith("presence")} <ContextMenuItem icon="stats" onselect={() => openWith("presence")}
>Аналитика</ContextMenuItem >{t("chat.analytics")}</ContextMenuItem
> >
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")} <ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
>Сторис</ContextMenuItem >{t("settings.stories")}</ContextMenuItem
> >
<ContextMenuItem <ContextMenuItem
icon="settings" icon="settings"
onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)} onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)}
>Политика захвата</ContextMenuItem >{t("settings.policy")}</ContextMenuItem
> >
{/snippet} {/snippet}
</ContextMenu> </ContextMenu>
+33 -26
View File
@@ -4,6 +4,7 @@
import BotsPanel from "#lib/components/bots/BotsPanel.svelte"; import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
import StoragePanel from "#lib/components/disk/StoragePanel.svelte"; import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
import TrashPanel from "#lib/components/disk/TrashPanel.svelte"; import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
import ChatChanges from "#lib/components/feed/ChatChanges.svelte";
import JobsPanel from "#lib/components/jobs/JobsPanel.svelte"; import JobsPanel from "#lib/components/jobs/JobsPanel.svelte";
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte"; import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte"; import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
@@ -21,35 +22,37 @@
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte"; import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte"; import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte";
import { t } from "#lib/i18n/locale.svelte.js"; import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js"; import { policyUi } from "#lib/stores/policy.svelte.js";
import { storageUi } from "#lib/stores/storage.svelte.js"; import { storageUi } from "#lib/stores/storage.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
const titles: Record<RightPanel, string> = $derived({ const titles: Record<RightPanel, MessageKey> = {
alerts: t("settings.alerts"), alerts: "settings.alerts",
annotations: "Заметки", annotations: "panel.annotations",
bots: "Боты", bots: "settings.bots",
callbacks: "Callback-данные", callbacks: "panel.callbacks",
jobs: "Бэкфилл и задачи", changes: "feed.changes",
links: "Ссылки", jobs: "settings.jobs",
policy: "Политика захвата", links: "panel.links",
presence: "Аналитика", policy: "settings.policy",
profile: "Профиль", presence: "chat.analytics",
reactions: "Реакции", profile: "chat.profile",
readers: t("read.readers.title"), reactions: "panel.reactions",
search: "Поиск", readers: "read.readers.title",
shares: "Файлы по ссылке", search: "panel.search",
storage: "Хранилище", shares: "settings.shares",
"storage-chat": "Файлы чата", storage: "settings.storage",
"storage-trash": "Корзина", "storage-chat": "panel.chatFiles",
stories: "Сторис", "storage-trash": "panel.trash",
"stories-all": "Все сторис", stories: "settings.stories",
versions: "Версии", "stories-all": "panel.allStories",
watches: t("settings.watches"), versions: "panel.versions",
webhooks: t("watches.webhooks"), watches: "settings.watches",
}); webhooks: "watches.webhooks",
};
const parents: Partial<Record<RightPanel, RightPanel>> = { const parents: Partial<Record<RightPanel, RightPanel>> = {
"storage-chat": "storage", "storage-chat": "storage",
@@ -66,14 +69,14 @@
ui.rightPanel === "storage-chat" && storageUi.chatId !== null ui.rightPanel === "storage-chat" && storageUi.chatId !== null
? chats.byId(storageUi.chatId) ? chats.byId(storageUi.chatId)
: undefined; : undefined;
return chat?.title ?? titles[ui.rightPanel]; return chat?.title ?? t(titles[ui.rightPanel]);
}); });
</script> </script>
<header class="right-header"> <header class="right-header">
{#if parent} {#if parent}
<Button <Button
aria-label="Назад" aria-label={t("common.back")}
onclick={() => ui.openPanel(parent)} onclick={() => ui.openPanel(parent)}
round round
smaller smaller
@@ -83,7 +86,7 @@
</Button> </Button>
{:else} {:else}
<Button <Button
aria-label="Закрыть" aria-label={t("common.close")}
onclick={() => ui.closePanel()} onclick={() => ui.closePanel()}
round round
smaller smaller
@@ -98,6 +101,10 @@
<div class="right-body custom-scroll"> <div class="right-body custom-scroll">
{#if ui.rightPanel === "profile"} {#if ui.rightPanel === "profile"}
<ProfilePanel /> <ProfilePanel />
{:else if ui.rightPanel === "changes"}
{#key accounts.selectedId}
<ChatChanges />
{/key}
{:else if ui.rightPanel === "policy"} {:else if ui.rightPanel === "policy"}
{#key `${accounts.selectedId}:${policyUi.revision}`} {#key `${accounts.selectedId}:${policyUi.revision}`}
<PolicyEditor /> <PolicyEditor />
@@ -0,0 +1,35 @@
<script lang="ts">
import type { FeedKind } from "#lib/api/types.js";
import FeedFilters from "#lib/components/feed/FeedFilters.svelte";
import FeedList from "#lib/components/feed/FeedList.svelte";
import { ui } from "#lib/stores/ui.svelte.js";
import { isMobile } from "#lib/viewport.js";
import { page } from "$app/state";
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
let kind = $state<FeedKind>("all");
function pick(next: FeedKind) {
kind = next;
}
function open(messageId: number) {
if (chatId === null) {
return;
}
ui.requestJump(chatId, messageId);
if (isMobile()) {
ui.closePanel();
}
}
</script>
{#if chatId !== null}
<FeedFilters {kind} onkind={pick} />
{#key chatId}
<FeedList {chatId} {kind} onopen={(_, messageId) => open(messageId)} />
{/key}
{/if}
@@ -0,0 +1,151 @@
<script lang="ts">
import { onMount } from "svelte";
import { ripple } from "#lib/actions/ripple.js";
import { listFeedMessages } from "#lib/api/endpoints.js";
import type { FeedItem, MessageView } from "#lib/api/types.js";
import FeedText from "#lib/components/feed/FeedText.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatDayTime, formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
item: FeedItem;
onopen: (messageId: number) => void;
senderOf: (message: MessageView) => string;
}
let { item, onopen, senderOf }: Props = $props();
const PAGE = 50;
let messages = $state<MessageView[]>([]);
let cursor = $state<string | null>(null);
let loading = $state(false);
let failed = $state(false);
async function load(next: string | null) {
loading = true;
failed = false;
try {
const page = await listFeedMessages(item.id, {
cursor: next ?? undefined,
limit: PAGE,
});
messages = [...messages, ...page.messages];
cursor = page.next_cursor;
} catch {
failed = true;
} finally {
loading = false;
}
}
onMount(() => {
load(null);
});
</script>
<div class="bulk">
{#each messages as message (message.message_id)}
<button
class="member"
onclick={() => onopen(message.message_id)}
type="button"
use:ripple
>
<FeedText deleted {message} prefix={senderOf(message)} />
<time
class="date"
datetime={message.date}
title={formatFull(message.date)}
>
{formatDayTime(message.date)}
</time>
</button>
{/each}
{#if loading}
<div class="state"><Spinner /></div>
{:else if failed}
<button class="more" onclick={() => load(cursor)} type="button">
{t("feed.retry")}
</button>
{:else if cursor !== null}
<button class="more" onclick={() => load(cursor)} type="button">
{t("feed.more")}
</button>
{/if}
</div>
<style lang="scss">
.bulk {
display: flex;
flex-direction: column;
gap: 0.125rem;
margin: 0 0 0.375rem 4.125rem;
padding-inline-start: 0.625rem;
border-inline-start: 2px solid var(--color-borders);
}
.member {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
gap: 0.5rem;
align-items: baseline;
width: 100%;
padding: 0.375rem 0.5rem;
border: 0;
border-radius: var(--border-radius-default-small);
font: inherit;
text-align: start;
color: var(--color-text);
background: none;
--ripple-color: var(--color-interactive-element-hover);
:global(.feed-text) {
flex: 1;
min-width: 0;
font-size: 0.875rem;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.date {
flex-shrink: 0;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.state {
display: flex;
justify-content: center;
padding: 0.5rem 0;
}
.more {
cursor: pointer;
margin-top: 0.25rem;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
}
</style>
@@ -0,0 +1,363 @@
<script lang="ts">
import { ripple } from "#lib/actions/ripple.js";
import type { FeedItem, MessageView } from "#lib/api/types.js";
import FeedBulk from "#lib/components/feed/FeedBulk.svelte";
import FeedText from "#lib/components/feed/FeedText.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull, formatListDate } from "#lib/format/datetime.js";
import { wordDiff } from "#lib/format/diff.js";
import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
interface Props {
inChat?: boolean;
item: FeedItem;
onopen: (messageId: number) => void;
}
let { item, onopen, inChat = false }: Props = $props();
let expanded = $state(false);
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const bulk = $derived(item.kind === "deleted" && item.message_count > 1);
const isGroup = $derived(item.chat.kind === "group");
$effect(() => {
if (item.sender_id !== null && item.sender_id > 0) {
peers.ensure([item.sender_id]);
}
});
function senderName(senderId: number | null, fallback: string | null) {
if (senderId === null) {
return fallback ?? "";
}
if (senderId === ownId) {
return t("chat.you");
}
const peer = peers.get(senderId);
if (peer) {
return peerName(peer);
}
return fallback ?? String(senderId);
}
function shortName(senderId: number | null, fallback: string | null) {
if (senderId === ownId) {
return t("chat.you");
}
const peer = senderId === null ? undefined : peers.get(senderId);
return peer?.first_name || senderName(senderId, fallback);
}
function prefixOf(message: MessageView): string {
if (message.sender_id === ownId) {
return t("chat.you");
}
return isGroup ? shortName(message.sender_id, null) : "";
}
const chatTitle = $derived(
item.chat.title ??
(item.chat.chat_id > 0
? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: item.chat.chat_id }))
);
const title = $derived.by(() => {
if (!inChat) {
return chatTitle;
}
if (bulk) {
return t("feed.bulk", { count: item.message_count });
}
return senderName(item.sender_id, item.sender_name);
});
const avatar = $derived.by(() => {
if (!inChat) {
return {
has: item.chat.has_avatar,
id: item.chat.chat_id,
kind: item.chat.chat_id > 0 ? ("peer" as const) : ("chat" as const),
};
}
const id = item.sender_id ?? item.chat.chat_id;
return {
has: id > 0 ? (peers.get(id)?.has_avatar ?? false) : item.chat.has_avatar,
id,
kind: id > 0 ? ("peer" as const) : ("chat" as const),
};
});
const prefix = $derived.by(() => {
if (inChat) {
return "";
}
if (item.outgoing) {
return t("chat.you");
}
return isGroup ? shortName(item.sender_id, item.sender_name) : "";
});
const sameText = $derived(item.has_before && item.before === item.after);
const diff = $derived(
item.kind === "edited" && item.has_before && !sameText
? wordDiff(item.before ?? "", item.after ?? "")
: null
);
function open() {
if (bulk) {
expanded = !expanded;
return;
}
onopen(item.message_id);
}
</script>
<div class="feed-entry">
<button
aria-expanded={bulk ? expanded : undefined}
class="row"
onclick={open}
type="button"
use:ripple
>
{#if inChat && bulk}
<span class="glyph"><Icon name="delete" size="1.5rem" /></span>
{:else}
<Avatar
avatar={{ id: avatar.id, kind: avatar.kind }}
colorKey={avatar.id}
hasAvatar={avatar.has}
name={title}
size={3}
/>
{/if}
<span class="body">
<span class="top">
<span class="title">{title}</span>
<time datetime={item.at} title={formatFull(item.at)}>
{formatListDate(item.at)}
</time>
</span>
{#if bulk}
<span class="mark">
<Icon name="delete" size="0.875rem" />
{inChat
? t("feed.deleted")
: t("feed.bulk", { count: item.message_count })}
<span class="chevron" class:open={expanded}>
<Icon name="down" size="1rem" />
</span>
</span>
{#if !expanded}
<FeedText deleted message={item.message} {prefix} />
{/if}
{:else if item.kind === "deleted"}
<FeedText
deleted
mark={t("feed.deleted")}
message={item.message}
{prefix}
/>
{:else if sameText}
<span class="mark edited">
<Icon name="edit" size="0.875rem" />
{t("feed.mediaChanged")}
</span>
<FeedText message={item.message} {prefix} />
{:else}
<span class="change">
<span class="tag">{t("feed.before")}</span>
{#if !item.has_before}
<span class="missing">{t("feed.lostText")}</span>
{:else if !item.before}
<span class="missing">{t("feed.noText")}</span>
{:else if diff}
{#each diff.before as part, index (index)}
<span class:removed={part.changed}>{part.text}</span>
{/each}
{:else}
{item.before}
{/if}
</span>
<span class="change now">
<span class="tag">{t("feed.after")}</span>
{#if prefix}
<span class="prefix">{prefix}:</span>
{/if}
{#if !item.after}
<span class="missing">{t("feed.noText")}</span>
{:else if diff}
{#each diff.after as part, index (index)}
<span class:added={part.changed}>{part.text}</span>
{/each}
{:else}
{item.after}
{/if}
</span>
{/if}
</span>
</button>
{#if bulk && expanded}
{#key `${item.id}:${item.message_count}`}
<FeedBulk {item} {onopen} senderOf={prefixOf} />
{/key}
{/if}
</div>
<style lang="scss">
.row {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
gap: 0.625rem;
align-items: flex-start;
width: 100%;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: var(--border-radius-default-small);
font: inherit;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.glyph {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-gray);
}
.body {
display: flex;
overflow: hidden;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.top {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.title {
overflow: hidden;
flex: 1;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
time {
flex-shrink: 0;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.mark {
display: inline-flex;
gap: 0.25rem;
align-items: center;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-error);
&.edited {
color: var(--color-text-secondary);
}
}
.chevron {
display: inline-flex;
color: var(--color-text-secondary);
transition: transform 0.2s ease;
&.open {
transform: rotate(180deg);
}
}
.change {
overflow: hidden;
display: -webkit-box;
font-size: 0.9375rem;
line-height: 1.25rem;
color: var(--color-text-secondary);
overflow-wrap: anywhere;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
&.now {
color: var(--color-text);
}
}
.tag {
margin-inline-end: 0.375rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.prefix {
margin-inline-end: 0.25rem;
color: var(--color-text-secondary);
}
.removed {
text-decoration: line-through;
text-decoration-color: var(--color-error);
}
.added {
border-radius: 0.25rem;
background-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
box-decoration-break: clone;
}
.missing {
font-style: italic;
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import type { FeedChatKind, FeedKind } from "#lib/api/types.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { FEED_CHAT_KINDS, FEED_KINDS } from "#lib/stores/feed.svelte.js";
interface Props {
chats?: FeedChatKind | null;
kind: FeedKind;
onchats?: (chats: FeedChatKind | null) => void;
onkind: (kind: FeedKind) => void;
}
let { kind, chats = null, onkind, onchats }: Props = $props();
const KIND_KEYS = {
all: "feed.filter.all",
deleted: "feed.filter.deleted",
edited: "feed.filter.edited",
} as const;
const CHAT_KEYS = {
channel: "feed.chats.channel",
dm: "feed.chats.dm",
group: "feed.chats.group",
} as const;
</script>
<div class="filters">
<div class="group">
{#each FEED_KINDS as option (option)}
<button
aria-pressed={kind === option}
class="chip"
onclick={() => onkind(option)}
type="button"
class:on={kind === option}
>
{t(KIND_KEYS[option])}
</button>
{/each}
</div>
{#if onchats}
<div class="group">
{#each FEED_CHAT_KINDS as option (option)}
<button
aria-pressed={chats === option}
class="chip"
onclick={() => onchats(chats === option ? null : option)}
type="button"
class:on={chats === option}
>
{t(CHAT_KEYS[option])}
</button>
{/each}
</div>
{/if}
</div>
<style lang="scss">
.filters {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 0.375rem 0.75rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--color-borders);
}
.group {
display: inline-flex;
flex-wrap: nowrap;
gap: 0.25rem;
}
.chip {
cursor: pointer;
display: inline-flex;
align-items: center;
height: 1.75rem;
padding: 0 0.625rem;
border: 0;
border-radius: 0.875rem;
font: inherit;
font-size: 0.8125rem;
color: var(--color-text);
white-space: nowrap;
background-color: var(--color-chat-hover);
transition:
background-color 0.15s ease-out,
box-shadow 0.15s ease-out;
&.on {
background-color: color-mix(
in srgb,
var(--color-primary) 12%,
transparent
);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
@media (pointer: coarse) {
height: 2.25rem;
padding: 0 0.75rem;
border-radius: 1.125rem;
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
@media (hover: hover) {
&:not(.on):hover {
background-color: var(--color-background-secondary-accent);
}
}
}
</style>
@@ -0,0 +1,191 @@
<script lang="ts">
import { untrack } from "svelte";
import { visible } from "#lib/actions/visible.js";
import { type FeedQuery, listFeed } from "#lib/api/endpoints.js";
import type { FeedChatKind, FeedItem, FeedKind } from "#lib/api/types.js";
import FeedEntry from "#lib/components/feed/FeedEntry.svelte";
import Button from "#lib/components/ui/Button.svelte";
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { feed } from "#lib/stores/feed.svelte.js";
interface Props {
chatId?: number;
chats?: FeedChatKind | null;
kind: FeedKind;
onopen: (item: FeedItem, messageId: number) => void;
}
let { kind, chats = null, chatId, onopen }: Props = $props();
const PAGE = 30;
const EMPTY_KEYS = {
all: "feed.empty.all",
deleted: "feed.empty.deleted",
edited: "feed.empty.edited",
} as const;
let items = $state<FeedItem[]>([]);
let cursor = $state<string | null>(null);
let loading = $state(true);
let failed = $state(false);
let token = 0;
let { revision } = feed;
const filter = $derived<FeedQuery>({
chat_id: chatId,
chat_kind: chats ?? undefined,
kind,
limit: PAGE,
});
let active: FeedQuery = {};
function byTime(a: FeedItem, b: FeedItem): number {
return Date.parse(b.at) - Date.parse(a.at) || b.id - a.id;
}
async function reload() {
token += 1;
const mine = token;
loading = true;
failed = false;
try {
const page = await listFeed(active);
if (mine === token) {
({ items } = page);
cursor = page.next_cursor;
}
} catch {
if (mine === token) {
failed = true;
}
} finally {
if (mine === token) {
loading = false;
}
}
}
async function more() {
if (loading || cursor === null) {
return;
}
const mine = token;
loading = true;
failed = false;
try {
const page = await listFeed({ ...active, cursor });
if (mine === token) {
const known = new Set(items.map((item) => item.id));
items = [...items, ...page.items.filter((item) => !known.has(item.id))];
cursor = page.next_cursor;
}
} catch {
if (mine === token) {
failed = true;
}
} finally {
if (mine === token) {
loading = false;
}
}
}
async function refreshHead() {
const mine = token;
const page = await listFeed(active);
if (mine !== token) {
return;
}
const fresh = new Set(page.items.map((item) => item.id));
const rest = items.filter((item) => !fresh.has(item.id));
items = [...page.items, ...rest].sort(byTime);
if (items.length === page.items.length) {
cursor = page.next_cursor;
}
}
$effect(() => {
const next = filter;
if (accounts.selectedId === null) {
return;
}
active = next;
untrack(() => reload());
});
$effect(() => {
const next = feed.revision;
if (next === revision) {
return;
}
revision = next;
untrack(() => refreshHead()).catch(() => undefined);
});
</script>
<div class="feed-list">
{#if items.length === 0 && loading}
<div class="state"><Spinner /></div>
{:else if items.length === 0 && failed}
<div class="state">
<p>{t("feed.error")}</p>
<Button onclick={reload} variant="text">{t("feed.retry")}</Button>
</div>
{:else if items.length === 0}
<EmptyState title={t(EMPTY_KEYS[kind])} />
{:else}
{#each items as item (item.id)}
<FeedEntry
inChat={chatId !== undefined}
{item}
onopen={(messageId) => onopen(item, messageId)}
/>
{/each}
{#if cursor !== null}
{#if failed}
<div class="state">
<Button onclick={more} variant="text">{t("feed.retry")}</Button>
</div>
{:else}
{#key cursor}
<div class="state" use:visible={more}>
{#if loading}
<Spinner />
{/if}
</div>
{/key}
{/if}
{/if}
{/if}
</div>
<style lang="scss">
.feed-list {
display: flex;
flex-direction: column;
min-height: 100%;
padding: 0.25rem 0.4375rem 0.5rem;
}
.state {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
justify-content: center;
min-height: 3rem;
padding: 1rem;
color: var(--color-text-secondary);
text-align: center;
p {
margin: 0;
font-size: 0.875rem;
}
}
</style>
@@ -0,0 +1,216 @@
<script lang="ts">
import { ripple } from "#lib/actions/ripple.js";
import type { FeedItem } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatListDate } from "#lib/format/datetime.js";
import { mediaKindLabel } from "#lib/format/media.js";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { feed } from "#lib/stores/feed.svelte.js";
import { page } from "$app/state";
const BADGE_LIMIT = 10_000;
const latest = $derived(feed.unseen?.latest ?? null);
const count = $derived(feed.unseen?.count ?? 0);
const badge = $derived(
count < BADGE_LIMIT
? String(count)
: new Intl.NumberFormat(locale.tag, { notation: "compact" }).format(count)
);
$effect(() => {
if (accounts.selectedId !== null) {
feed.sync();
}
});
function chatTitle(item: FeedItem): string {
return (
item.chat.title ??
(item.chat.chat_id > 0
? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: item.chat.chat_id }))
);
}
function summary(item: FeedItem): string {
if (item.kind === "deleted" && item.message_count > 1) {
return t("feed.bulk", { count: item.message_count });
}
const text = item.kind === "edited" ? item.after : item.message?.text;
const media = item.message?.media[0]?.kind ?? null;
return text?.trim() || mediaKindLabel(media) || t("feed.noText");
}
</script>
<button
class="feed-row"
onclick={() => feed.open(page.url)}
type="button"
use:ripple
>
<span class="avatar"><Icon name="recent" /></span>
<span class="info">
<span class="info-row">
<span class="title">{t("feed.title")}</span>
{#if latest}
<span class="date">{formatListDate(latest.at)}</span>
{/if}
</span>
<span class="subtitle">
<span class="preview">
{#if latest}
<Icon
class="kind"
name={latest.kind === "deleted" ? "delete" : "edit"}
size="0.875rem"
/>
<span class="chat">{chatTitle(latest)}:</span>
<span
class:struck={latest.kind === "deleted" &&
latest.message_count === 1}
>{summary(latest)}</span
>
{:else}
{t("feed.empty.all")}
{/if}
</span>
{#if count > 0}
<span class="badge">{badge}</span>
{/if}
</span>
</span>
</button>
<style lang="scss">
.feed-row {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
gap: 0.625rem;
align-items: center;
width: 100%;
height: 4.5rem;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
font: inherit;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.avatar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 3.375rem;
height: 3.375rem;
border-radius: 50%;
font-size: 1.75rem;
color: var(--color-white);
background-color: var(--color-gray);
}
.info {
overflow: hidden;
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.info-row {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.title {
overflow: hidden;
flex: 1;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.subtitle {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.125rem;
}
.preview {
overflow: hidden;
flex: 1;
font-size: 0.875rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
:global(.kind) {
margin-inline-end: 0.125rem;
vertical-align: -0.125rem;
}
}
.chat {
color: var(--color-text);
}
.struck {
text-decoration: line-through;
}
.badge {
flex-shrink: 0;
min-width: 1.5rem;
height: 1.5rem;
padding: 0 0.4375rem;
border-radius: 0.75rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
line-height: 1.5rem;
color: var(--color-white);
text-align: center;
background-color: var(--color-gray);
}
</style>
@@ -0,0 +1,128 @@
<script lang="ts">
import { mediaUrl } from "#lib/api/client.js";
import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
deleted?: boolean;
mark?: string;
message: MessageView | null;
prefix?: string;
}
let { message, deleted = false, mark = "", prefix = "" }: Props = $props();
const media = $derived(message?.media[0] ?? null);
const thumb = $derived(
media?.kind === "photo" && media.downloaded && media.id !== null
? mediaUrl(media.id)
: null
);
const kind = $derived.by(() => {
if (message?.poll) {
return "poll";
}
if (message?.contact) {
return "contact";
}
if (message?.location) {
return "location";
}
if (message?.sticker) {
return "sticker";
}
return media?.kind ?? (message?.has_media ? "other" : null);
});
const label = $derived(
message?.sticker?.emoji
? `${message.sticker.emoji} ${mediaKindLabel(kind)}`
: mediaKindLabel(kind)
);
const text = $derived(message?.text?.trim() || message?.poll?.question || "");
const lead = $derived.by(() => {
if (!label || (thumb && text)) {
return "";
}
return text ? `${label}, ` : label;
});
</script>
<span class="feed-text" class:deleted>
{#if mark}
<span class="mark"><Icon name="delete" size="0.875rem" /> {mark}</span>
{/if}
{#if prefix}
<span class="prefix">{prefix}: </span>
{/if}
{#if thumb}
<img alt="" class="thumb" decoding="async" loading="lazy" src={thumb}>
{/if}
{#if lead}
<span class="label">{lead}</span>
{/if}
{#if text}
<span class="body">{text}</span>
{:else if !lead}
<span class="empty">{t("feed.noText")}</span>
{/if}
</span>
<style lang="scss">
.feed-text {
overflow: hidden;
display: -webkit-box;
font-size: 0.9375rem;
line-height: 1.25rem;
color: var(--color-text-secondary);
overflow-wrap: anywhere;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.prefix {
color: var(--color-text);
}
.mark {
margin-inline-end: 0.375rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-error);
:global(.icon) {
vertical-align: -0.125rem;
}
}
.deleted .body,
.deleted .label {
text-decoration: line-through;
}
.thumb {
display: inline-block;
width: 1.25rem;
height: 1.25rem;
margin-inline-end: 0.25rem;
border-radius: 0.25rem;
vertical-align: -0.25rem;
object-fit: cover;
}
.label {
color: var(--color-text);
}
.empty {
font-style: italic;
}
</style>
@@ -0,0 +1,93 @@
<script lang="ts">
import { onMount } from "svelte";
import type { FeedItem } from "#lib/api/types.js";
import FeedFilters from "#lib/components/feed/FeedFilters.svelte";
import FeedList from "#lib/components/feed/FeedList.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import {
type FeedFilter,
feed,
messageHref,
} from "#lib/stores/feed.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
interface Props {
filter: FeedFilter;
}
let { filter }: Props = $props();
function open(item: FeedItem, messageId: number) {
goto(messageHref(page.url, item.chat.chat_id, messageId));
}
onMount(() => {
feed.refresh().catch(() => undefined);
});
$effect(() => {
const { unseen } = feed;
if (unseen && unseen.count > 0) {
feed.markSeen(unseen.latest?.at).catch(() => undefined);
}
});
</script>
<div class="feed-view">
<header class="feed-header">
<Button
aria-label={t("common.back")}
onclick={() => feed.close(page.url)}
round
smaller
variant="translucent"
>
<Icon name="arrow-left" />
</Button>
<h2>{t("feed.title")}</h2>
</header>
<FeedFilters
chats={filter.chats}
kind={filter.kind}
onchats={(chats) => feed.filter(page.url, { ...filter, chats })}
onkind={(kind) => feed.filter(page.url, { ...filter, kind })}
/>
<div class="feed-body custom-scroll">
<FeedList chats={filter.chats} kind={filter.kind} onopen={open} />
</div>
</div>
<style lang="scss">
.feed-view {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.feed-header {
display: flex;
flex-shrink: 0;
gap: 0.75rem;
align-items: center;
height: var(--header-height);
padding: 0 0.625rem;
border-bottom: 1px solid var(--color-borders);
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: var(--font-weight-medium);
}
}
.feed-body {
overflow-y: auto;
flex: 1;
}
</style>
+5
View File
@@ -79,3 +79,8 @@ export function timeZoneLabel(): string {
.find((part) => part.type === "timeZoneName")?.value ?? browserTimeZone() .find((part) => part.type === "timeZoneName")?.value ?? browserTimeZone()
); );
} }
export function formatDayTime(iso: string): string {
const date = new Date(iso);
return `${format("day", date)}, ${format("time", date)}`;
}
+74
View File
@@ -0,0 +1,74 @@
export interface DiffPart {
changed: boolean;
text: string;
}
export interface WordDiff {
after: DiffPart[];
before: DiffPart[];
}
const TOKEN = /(\s+)/;
const MAX_TOKENS = 400;
function tokens(text: string): string[] {
return text.split(TOKEN).filter(Boolean);
}
function push(parts: DiffPart[], text: string, changed: boolean) {
const last = parts.at(-1);
if (last && last.changed === changed) {
last.text += text;
} else {
parts.push({ changed, text });
}
}
function lcsTable(a: string[], b: string[]): Uint16Array {
const width = b.length + 1;
const table = new Uint16Array((a.length + 1) * width);
for (let i = a.length - 1; i >= 0; i -= 1) {
for (let j = b.length - 1; j >= 0; j -= 1) {
table[i * width + j] =
a[i] === b[j]
? table[(i + 1) * width + j + 1] + 1
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
}
}
return table;
}
export function wordDiff(before: string, after: string): WordDiff | null {
const a = tokens(before);
const b = tokens(after);
if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) {
return null;
}
const table = lcsTable(a, b);
const width = b.length + 1;
const left: DiffPart[] = [];
const right: DiffPart[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
push(left, a[i], false);
push(right, b[j], false);
i += 1;
j += 1;
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
push(left, a[i], true);
i += 1;
} else {
push(right, b[j], true);
j += 1;
}
}
for (const token of a.slice(i)) {
push(left, token, true);
}
for (const token of b.slice(j)) {
push(right, token, true);
}
return { after: right, before: left };
}
+21 -18
View File
@@ -1,27 +1,30 @@
const MEDIA_KIND_LABELS: Record<string, string> = { import { t } from "#lib/i18n/locale.svelte.js";
animation: "GIF", import type { MessageKey } from "#lib/i18n/messages.js";
audio: "Аудио",
contact: "Контакт", const MEDIA_KIND_KEYS: Record<string, MessageKey> = {
dice: "Кубик", animation: "media.kind.gif",
document: "Файл", audio: "media.kind.audio",
game: "Игра", contact: "media.kind.contact",
gif: "GIF", dice: "media.kind.dice",
location: "Геопозиция", document: "media.kind.document",
photo: "Фото", game: "media.kind.game",
poll: "Опрос", gif: "media.kind.gif",
sticker: "Стикер", location: "media.kind.location",
story: "Сторис", photo: "media.kind.photo",
venue: "Геопозиция", poll: "media.kind.poll",
video: "Видео", sticker: "media.kind.sticker",
video_note: "Кружок", story: "media.kind.story",
voice: "Голосовое", venue: "media.kind.location",
video: "media.kind.video",
video_note: "media.kind.videoNote",
voice: "media.kind.voice",
}; };
export function mediaKindLabel(kind: string | null): string | null { export function mediaKindLabel(kind: string | null): string | null {
if (!kind) { if (!kind) {
return null; return null;
} }
return MEDIA_KIND_LABELS[kind] ?? "Медиа"; return t(MEDIA_KIND_KEYS[kind] ?? "media.kind.other");
} }
const BYTE_UNITS = ["B", "KB", "MB", "GB"]; const BYTE_UNITS = ["B", "KB", "MB", "GB"];
+2
View File
@@ -1,6 +1,7 @@
import { account } from "#lib/i18n/messages/account.js"; import { account } from "#lib/i18n/messages/account.js";
import { alerts } from "#lib/i18n/messages/alerts.js"; import { alerts } from "#lib/i18n/messages/alerts.js";
import { common } from "#lib/i18n/messages/common.js"; import { common } from "#lib/i18n/messages/common.js";
import { feed } from "#lib/i18n/messages/feed.js";
import { menu } from "#lib/i18n/messages/menu.js"; import { menu } from "#lib/i18n/messages/menu.js";
import { profile } from "#lib/i18n/messages/profile.js"; import { profile } from "#lib/i18n/messages/profile.js";
import { read } from "#lib/i18n/messages/read.js"; import { read } from "#lib/i18n/messages/read.js";
@@ -11,6 +12,7 @@ export const messages = {
...account, ...account,
...alerts, ...alerts,
...common, ...common,
...feed,
...menu, ...menu,
...profile, ...profile,
...read, ...read,
+139
View File
@@ -0,0 +1,139 @@
import type { Messages } from "#lib/i18n/types.js";
export const feed = {
"chat.analytics": { en: "Analytics", ru: "Аналитика" },
"chat.backfill": {
en: "Fetch new messages",
ru: "Догрузить новые сообщения",
},
"chat.backfill.failed": {
en: "Couldn't start fetching new messages",
ru: "Не удалось начать догрузку",
},
"chat.backfill.started": {
en: "Fetching new messages",
ru: "Догружаем новые сообщения",
},
"chat.deletedAccount": { en: "Deleted account", ru: "Удалённый аккаунт" },
"chat.fallbackTitle": { en: "Chat {id}", ru: "Чат {id}" },
"chat.kind.channel": { en: "channel", ru: "канал" },
"chat.kind.group": { en: "group", ru: "группа" },
"chat.media": { en: "Media", ru: "Медиа" },
"chat.messages": {
en: { one: "{count} message", other: "{count} messages" },
ru: {
few: "{count} сообщения",
many: "{count} сообщений",
one: "{count} сообщение",
other: "{count} сообщения",
},
},
"chat.open": { en: "Open", ru: "Открыть" },
"chat.openProfile": { en: "Open profile", ru: "Открыть профиль" },
"chat.profile": { en: "Profile", ru: "Профиль" },
"chat.search": { en: "Search in chat", ru: "Поиск в чате" },
"chat.you": { en: "You", ru: "Вы" },
"chats.empty": { en: "No chats yet", ru: "Чатов пока нет" },
"chats.emptyFolder": { en: "Empty folder", ru: "Папка пуста" },
"chats.emptyFolderHint": {
en: "No chats match this folder yet",
ru: "В этой папке пока нет чатов",
},
"chats.foldersFailed": {
en: "Couldn't load folders",
ru: "Не удалось загрузить папки",
},
"chats.loadFailed": {
en: "Couldn't load chats",
ru: "Не удалось загрузить чаты",
},
"feed.after": { en: "Now", ru: "Стало" },
"feed.before": { en: "Was", ru: "Было" },
"feed.bulk": {
en: { one: "{count} message deleted", other: "{count} messages deleted" },
ru: {
few: "Удалено {count} сообщения",
many: "Удалено {count} сообщений",
one: "Удалено {count} сообщение",
other: "Удалено {count} сообщения",
},
},
"feed.changes": { en: "Changes", ru: "Изменения" },
"feed.chats.channel": { en: "Channels", ru: "Каналы" },
"feed.chats.dm": { en: "Private", ru: "Личка" },
"feed.chats.group": { en: "Groups", ru: "Группы" },
"feed.deleted": { en: "deleted", ru: "удалено" },
"feed.edited": { en: "edited", ru: "изменено" },
"feed.empty.all": {
en: "No deleted or edited messages yet",
ru: "Удалённых и изменённых пока нет",
},
"feed.empty.deleted": {
en: "No deleted messages yet",
ru: "Удалённых пока нет",
},
"feed.empty.edited": {
en: "No edited messages yet",
ru: "Изменённых пока нет",
},
"feed.error": {
en: "Couldn't load the feed",
ru: "Не удалось загрузить ленту",
},
"feed.filter.all": { en: "All", ru: "Все" },
"feed.filter.deleted": { en: "Deleted", ru: "Удалённые" },
"feed.filter.edited": { en: "Edited", ru: "Изменённые" },
"feed.lostText": {
en: "The earlier text wasn't saved",
ru: "Прежний текст не сохранился",
},
"feed.markSeen": { en: "Mark as seen", ru: "Отметить просмотренным" },
"feed.mediaChanged": { en: "Media replaced", ru: "Медиа заменено" },
"feed.more": { en: "Show more", ru: "Показать ещё" },
"feed.noText": { en: "No text", ru: "Без текста" },
"feed.retry": { en: "Retry", ru: "Повторить" },
"feed.title": { en: "Feed", ru: "Лента" },
"feed.unseen": {
en: { one: "{count} new", other: "{count} new" },
ru: {
few: "{count} новых",
many: "{count} новых",
one: "{count} новое",
other: "{count} новых",
},
},
"feed.versions": { en: "All versions", ru: "Все версии" },
"media.kind.audio": { en: "Audio", ru: "Аудио" },
"media.kind.contact": { en: "Contact", ru: "Контакт" },
"media.kind.dice": { en: "Dice", ru: "Кубик" },
"media.kind.document": { en: "File", ru: "Файл" },
"media.kind.game": { en: "Game", ru: "Игра" },
"media.kind.gif": { en: "GIF", ru: "GIF" },
"media.kind.location": { en: "Location", ru: "Геопозиция" },
"media.kind.other": { en: "Media", ru: "Медиа" },
"media.kind.photo": { en: "Photo", ru: "Фото" },
"media.kind.poll": { en: "Poll", ru: "Опрос" },
"media.kind.sticker": { en: "Sticker", ru: "Стикер" },
"media.kind.story": { en: "Story", ru: "Сторис" },
"media.kind.video": { en: "Video", ru: "Видео" },
"media.kind.videoNote": { en: "Video message", ru: "Кружок" },
"media.kind.voice": { en: "Voice message", ru: "Голосовое" },
"panel.allStories": { en: "All stories", ru: "Все сторис" },
"panel.annotations": { en: "Notes", ru: "Заметки" },
"panel.callbacks": { en: "Callback data", ru: "Callback-данные" },
"panel.chatFiles": { en: "Chat files", ru: "Файлы чата" },
"panel.links": { en: "Links", ru: "Ссылки" },
"panel.reactions": { en: "Reactions", ru: "Реакции" },
"panel.search": { en: "Search", ru: "Поиск" },
"panel.trash": { en: "Trash", ru: "Корзина" },
"panel.versions": { en: "Versions", ru: "Версии" },
"thread.loadFailed": {
en: "Couldn't load messages",
ru: "Не удалось загрузить сообщения",
},
"thread.notFound": {
en: "This message isn't in the archive",
ru: "Этого сообщения нет в архиве",
},
"thread.toLatest": { en: "Jump to latest", ru: "К последним сообщениям" },
} satisfies Messages;
+162
View File
@@ -0,0 +1,162 @@
import { getFeedUnseen, markFeedSeen } from "#lib/api/endpoints.js";
import type { FeedChatKind, FeedKind, FeedUnseen } from "#lib/api/types.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { events } from "#lib/stores/events.svelte.js";
import { browser } from "$app/env";
import { goto } from "$app/navigation";
const LIVE_DELAY = 600;
const KIND_PARAM = "feed";
const CHATS_PARAM = "chats";
const MESSAGE_PARAM = "message";
export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited"];
export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"];
export interface FeedFilter {
chats: FeedChatKind | null;
kind: FeedKind;
}
interface UrlLike {
readonly href: string;
}
export function feedFilterOf(url: UrlLike): FeedFilter | null {
const { searchParams } = new URL(url.href);
const kind = searchParams.get(KIND_PARAM);
if (!FEED_KINDS.includes(kind as FeedKind)) {
return null;
}
const chats = searchParams.get(CHATS_PARAM);
return {
chats: FEED_CHAT_KINDS.includes(chats as FeedChatKind)
? (chats as FeedChatKind)
: null,
kind: kind as FeedKind,
};
}
function withFilter(url: UrlLike, filter: FeedFilter | null): URL {
const next = new URL(url.href);
next.searchParams.delete(KIND_PARAM);
next.searchParams.delete(CHATS_PARAM);
next.searchParams.delete(MESSAGE_PARAM);
if (filter) {
next.searchParams.set(KIND_PARAM, filter.kind);
if (filter.chats) {
next.searchParams.set(CHATS_PARAM, filter.chats);
}
}
return next;
}
function href(url: UrlLike): string {
const { pathname, search } = new URL(url.href);
return `${pathname}${search}`;
}
export function messageHref(
url: UrlLike,
chatId: number,
messageId: number
): string {
const next = withFilter(url, feedFilterOf(url));
next.pathname = `/app/${chatId}`;
next.searchParams.set(MESSAGE_PARAM, String(messageId));
return href(next);
}
export function withoutMessage(url: UrlLike): string {
const next = new URL(url.href);
next.searchParams.delete(MESSAGE_PARAM);
return href(next);
}
function createFeed() {
let unseen = $state<FeedUnseen | null>(null);
let revision = $state(0);
let account: number | null = null;
let openedHref: string | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
async function refresh() {
const id = accounts.selectedId;
if (id === null) {
unseen = null;
return;
}
const next = await getFeedUnseen();
if (accounts.selectedId === id) {
unseen = next;
}
}
function schedule() {
if (timer !== null) {
return;
}
timer = setTimeout(() => {
timer = null;
revision += 1;
refresh().catch(() => undefined);
}, LIVE_DELAY);
}
if (browser) {
events.subscribe((event) => {
if (event.type === "delete" || event.type === "edit") {
schedule();
}
});
events.onReconnect(schedule);
}
return {
async close(url: UrlLike) {
if (openedHref === href(url)) {
openedHref = null;
history.back();
return;
}
await goto(href(withFilter(url, null)), { replace: true, reset: false });
},
async filter(url: UrlLike, filter: FeedFilter) {
const wasOpened = openedHref === href(url);
const target = href(withFilter(url, filter));
if (wasOpened) {
openedHref = target;
}
await goto(target, { replace: true, reset: false });
},
async markSeen(at?: string) {
const id = accounts.selectedId;
const next = await markFeedSeen(at);
if (accounts.selectedId === id) {
unseen = next;
}
},
async open(url: UrlLike) {
const target = href(withFilter(url, { chats: null, kind: "all" }));
openedHref = target;
await goto(target, { reset: false });
},
refresh,
get revision() {
return revision;
},
sync() {
if (accounts.selectedId === account) {
return;
}
account = accounts.selectedId;
unseen = null;
refresh().catch(() => undefined);
},
get unseen() {
return unseen;
},
};
}
export const feed = createFeed();
+1
View File
@@ -1,4 +1,5 @@
export type RightPanel = export type RightPanel =
| "changes"
| "profile" | "profile"
| "search" | "search"
| "versions" | "versions"
+10
View File
@@ -4,6 +4,7 @@
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import ChatList from "#lib/components/ChatList.svelte"; import ChatList from "#lib/components/ChatList.svelte";
import FolderTabs from "#lib/components/FolderTabs.svelte"; import FolderTabs from "#lib/components/FolderTabs.svelte";
import FeedView from "#lib/components/feed/FeedView.svelte";
import MainMenu from "#lib/components/MainMenu.svelte"; import MainMenu from "#lib/components/MainMenu.svelte";
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte"; import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
import RightColumn from "#lib/components/RightColumn.svelte"; import RightColumn from "#lib/components/RightColumn.svelte";
@@ -18,6 +19,7 @@
import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js"; import { ALERTS_PARAM } from "#lib/stores/alerts.svelte.js";
import { auth } from "#lib/stores/auth.svelte.js"; import { auth } from "#lib/stores/auth.svelte.js";
import { events } from "#lib/stores/events.svelte.js"; import { events } from "#lib/stores/events.svelte.js";
import { feedFilterOf } from "#lib/stores/feed.svelte.js";
import { search } from "#lib/stores/search.svelte.js"; import { search } from "#lib/stores/search.svelte.js";
import { shareUi } from "#lib/stores/shares.svelte.js"; import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js"; import { toasts } from "#lib/stores/toasts.svelte.js";
@@ -28,6 +30,7 @@
// biome-ignore lint/suspicious/noEqualsToNull: route params are undefined when absent // biome-ignore lint/suspicious/noEqualsToNull: route params are undefined when absent
const chatOpen = $derived(page.params.chatId != null); const chatOpen = $derived(page.params.chatId != null);
const feedFilter = $derived(feedFilterOf(page.url));
$effect(() => { $effect(() => {
if (!accounts.loaded) { if (!accounts.loaded) {
@@ -81,6 +84,13 @@
> >
<Settings /> <Settings />
</div> </div>
{:else if feedFilter}
<div
class="left-view"
in:fly={{ duration: 200, easing: cubicOut, x: 64 }}
>
<FeedView filter={feedFilter} />
</div>
{:else} {:else}
<div <div
class="left-view" class="left-view"
@@ -1,11 +1,16 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import ChatHeader from "#lib/components/ChatHeader.svelte"; import ChatHeader from "#lib/components/ChatHeader.svelte";
import MessageList from "#lib/components/MessageList.svelte"; import MessageList from "#lib/components/MessageList.svelte";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js";
import { withoutMessage } from "#lib/stores/feed.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
const chatId = $derived(Number(page.params.chatId)); const chatId = $derived(Number(page.params.chatId));
const linkedMessage = $derived(page.url.searchParams.get("message"));
$effect(() => { $effect(() => {
if (accounts.selectedId === null) { if (accounts.selectedId === null) {
@@ -14,6 +19,19 @@
chats.ensure(chatId); chats.ensure(chatId);
chats.enrich(chatId); chats.enrich(chatId);
}); });
$effect(() => {
if (linkedMessage === null) {
return;
}
const messageId = Number(linkedMessage);
untrack(() => {
if (Number.isSafeInteger(messageId)) {
ui.requestJump(chatId, messageId);
}
goto(withoutMessage(page.url), { replace: true, reset: false });
});
});
</script> </script>
<div class="chat-view"> <div class="chat-view">