feat(frontend): feed of deleted and edited messages above the chat list and per chat in the changes panel
This commit is contained in:
1 parent
010fa29ce0
commit
9f16e2aa56
23 files changed
+1915
-71
No files matched your search
@@ -8,14 +8,17 @@
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { peerName } from "#lib/format/peer.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 { chats } from "#lib/stores/chats.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 { presence } from "#lib/stores/presence.svelte.js";
|
||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||
import { ui } from "#lib/stores/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
interface Props {
|
||||
chatId: number;
|
||||
@@ -36,14 +39,20 @@
|
||||
backfilling = true;
|
||||
try {
|
||||
await enqueueBackfill(chatId, true);
|
||||
toasts.success("Догружаем новые сообщения");
|
||||
toasts.success(t("chat.backfill.started"));
|
||||
} catch {
|
||||
toasts.error("Не удалось запустить бэкфилл");
|
||||
toasts.error(t("chat.backfill.failed"));
|
||||
} finally {
|
||||
backfilling = false;
|
||||
}
|
||||
}
|
||||
|
||||
function back() {
|
||||
const target = new URL(page.url.href);
|
||||
target.pathname = "/app";
|
||||
goto(withoutMessage(target));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (accounts.selectedId === null) {
|
||||
return;
|
||||
@@ -82,7 +91,9 @@
|
||||
const fallbackTitle = $derived(
|
||||
chat?.title ??
|
||||
discovered?.title ??
|
||||
(isDm ? "Удалённый аккаунт" : `Chat ${chatId}`)
|
||||
(isDm
|
||||
? t("chat.deletedAccount")
|
||||
: t("chat.fallbackTitle", { id: chatId }))
|
||||
);
|
||||
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
|
||||
const subtitle = $derived.by(() => {
|
||||
@@ -97,9 +108,11 @@
|
||||
}
|
||||
const count = chat?.message_count ?? discovered?.message_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 hasAvatar = $derived(
|
||||
@@ -114,8 +127,8 @@
|
||||
<header class="chat-header">
|
||||
<div class="back">
|
||||
<Button
|
||||
aria-label="Назад"
|
||||
onclick={() => goto("/app")}
|
||||
aria-label={t("common.back")}
|
||||
onclick={back}
|
||||
round
|
||||
smaller
|
||||
variant="translucent"
|
||||
@@ -129,7 +142,7 @@
|
||||
})}
|
||||
<button
|
||||
{...props}
|
||||
aria-label="Открыть профиль"
|
||||
aria-label={t("chat.openProfile")}
|
||||
class="peek"
|
||||
onclick={() => ui.openPanel("profile")}
|
||||
type="button"
|
||||
@@ -157,23 +170,26 @@
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")}
|
||||
>Открыть профиль</ContextMenuItem
|
||||
>{t("chat.openProfile")}</ContextMenuItem
|
||||
>
|
||||
<ContextMenuItem icon="recent" onselect={() => ui.openPanel("changes")}
|
||||
>{t("feed.changes")}</ContextMenuItem
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon="play-story"
|
||||
onselect={() => ui.openPanel("stories")}
|
||||
>Сторис</ContextMenuItem
|
||||
>{t("settings.stories")}</ContextMenuItem
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon="settings"
|
||||
onselect={() => policyUi.edit({ chatId }, title)}
|
||||
>Политика захвата</ContextMenuItem
|
||||
>{t("settings.policy")}</ContextMenuItem
|
||||
>
|
||||
{/snippet}
|
||||
</ContextMenu>
|
||||
<div class="actions">
|
||||
<Button
|
||||
aria-label="Поиск в чате"
|
||||
aria-label={t("chat.search")}
|
||||
onclick={() => ui.openPanel("search")}
|
||||
round
|
||||
smaller
|
||||
@@ -182,7 +198,16 @@
|
||||
<Icon name="search" />
|
||||
</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")}
|
||||
round
|
||||
smaller
|
||||
@@ -191,7 +216,7 @@
|
||||
<Icon name="stats" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Догрузить новые сообщения"
|
||||
aria-label={t("chat.backfill")}
|
||||
loading={backfilling}
|
||||
onclick={backfill}
|
||||
round
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "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 Skeleton from "#lib/components/ui/Skeleton.svelte";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { folders } from "#lib/stores/folders.svelte.js";
|
||||
@@ -46,7 +48,7 @@
|
||||
return;
|
||||
}
|
||||
untrack(() => folders.load()).catch(() =>
|
||||
toasts.error("Failed to load folders")
|
||||
toasts.error(t("chats.foldersFailed"))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,7 +62,7 @@
|
||||
scrollTop = 0;
|
||||
}
|
||||
untrack(() => chats.load(folderId)).catch(() =>
|
||||
toasts.error("Failed to load chats")
|
||||
toasts.error(t("chats.loadFailed"))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -99,6 +101,9 @@
|
||||
bind:this={viewport}
|
||||
bind:clientHeight={viewportHeight}
|
||||
>
|
||||
{#if folders.selectedId === null}
|
||||
<FeedRow />
|
||||
{/if}
|
||||
{#if chats.loading && list.length === 0}
|
||||
{#each skeletonRows as index (index)}
|
||||
<div class="row-skeleton">
|
||||
@@ -113,8 +118,10 @@
|
||||
<EmptyState
|
||||
description={folders.selectedId === null
|
||||
? undefined
|
||||
: "No chats match this folder yet"}
|
||||
title={folders.selectedId === null ? "No chats yet" : "Empty folder"}
|
||||
: t("chats.emptyFolderHint")}
|
||||
title={folders.selectedId === null
|
||||
? t("chats.empty")
|
||||
: t("chats.emptyFolder")}
|
||||
/>
|
||||
{:else}
|
||||
<div style:padding-bottom="{padBottom}px" style:padding-top="{padTop}px">
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||
import { formatListDate } from "#lib/format/datetime.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";
|
||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||
@@ -26,7 +27,9 @@
|
||||
|
||||
const title = $derived(
|
||||
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 ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||
@@ -45,7 +48,7 @@
|
||||
return "";
|
||||
}
|
||||
if (chat.last_sender_id === ownId) {
|
||||
return "You: ";
|
||||
return `${t("chat.you")}: `;
|
||||
}
|
||||
const peer = peers.get(chat.last_sender_id as number);
|
||||
if (!peer) {
|
||||
@@ -55,7 +58,7 @@
|
||||
});
|
||||
|
||||
const preview = $derived(
|
||||
chat.last_text ?? (chat.message_count > 0 ? "Media" : "")
|
||||
chat.last_text ?? (chat.message_count > 0 ? t("chat.media") : "")
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -95,24 +98,27 @@
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
<ContextMenuItem icon="open-in-new-tab" onselect={onclick}
|
||||
>Открыть</ContextMenuItem
|
||||
>{t("chat.open")}</ContextMenuItem
|
||||
>
|
||||
<ContextMenuItem icon="info" onselect={() => openWith("profile")}
|
||||
>Профиль</ContextMenuItem
|
||||
>{t("chat.profile")}</ContextMenuItem
|
||||
>
|
||||
<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
|
||||
>{t("chat.analytics")}</ContextMenuItem
|
||||
>
|
||||
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
|
||||
>Сторис</ContextMenuItem
|
||||
>{t("settings.stories")}</ContextMenuItem
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon="settings"
|
||||
onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)}
|
||||
>Политика захвата</ContextMenuItem
|
||||
>{t("settings.policy")}</ContextMenuItem
|
||||
>
|
||||
{/snippet}
|
||||
</ContextMenu>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
|
||||
import StoragePanel from "#lib/components/disk/StoragePanel.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 PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
|
||||
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
|
||||
@@ -21,35 +22,37 @@
|
||||
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
|
||||
import WebhooksPanel from "#lib/components/watches/WebhooksPanel.svelte";
|
||||
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 { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||
import { storageUi } from "#lib/stores/storage.svelte.js";
|
||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||
|
||||
const titles: Record<RightPanel, string> = $derived({
|
||||
alerts: t("settings.alerts"),
|
||||
annotations: "Заметки",
|
||||
bots: "Боты",
|
||||
callbacks: "Callback-данные",
|
||||
jobs: "Бэкфилл и задачи",
|
||||
links: "Ссылки",
|
||||
policy: "Политика захвата",
|
||||
presence: "Аналитика",
|
||||
profile: "Профиль",
|
||||
reactions: "Реакции",
|
||||
readers: t("read.readers.title"),
|
||||
search: "Поиск",
|
||||
shares: "Файлы по ссылке",
|
||||
storage: "Хранилище",
|
||||
"storage-chat": "Файлы чата",
|
||||
"storage-trash": "Корзина",
|
||||
stories: "Сторис",
|
||||
"stories-all": "Все сторис",
|
||||
versions: "Версии",
|
||||
watches: t("settings.watches"),
|
||||
webhooks: t("watches.webhooks"),
|
||||
});
|
||||
const titles: Record<RightPanel, MessageKey> = {
|
||||
alerts: "settings.alerts",
|
||||
annotations: "panel.annotations",
|
||||
bots: "settings.bots",
|
||||
callbacks: "panel.callbacks",
|
||||
changes: "feed.changes",
|
||||
jobs: "settings.jobs",
|
||||
links: "panel.links",
|
||||
policy: "settings.policy",
|
||||
presence: "chat.analytics",
|
||||
profile: "chat.profile",
|
||||
reactions: "panel.reactions",
|
||||
readers: "read.readers.title",
|
||||
search: "panel.search",
|
||||
shares: "settings.shares",
|
||||
storage: "settings.storage",
|
||||
"storage-chat": "panel.chatFiles",
|
||||
"storage-trash": "panel.trash",
|
||||
stories: "settings.stories",
|
||||
"stories-all": "panel.allStories",
|
||||
versions: "panel.versions",
|
||||
watches: "settings.watches",
|
||||
webhooks: "watches.webhooks",
|
||||
};
|
||||
|
||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||
"storage-chat": "storage",
|
||||
@@ -66,14 +69,14 @@
|
||||
ui.rightPanel === "storage-chat" && storageUi.chatId !== null
|
||||
? chats.byId(storageUi.chatId)
|
||||
: undefined;
|
||||
return chat?.title ?? titles[ui.rightPanel];
|
||||
return chat?.title ?? t(titles[ui.rightPanel]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class="right-header">
|
||||
{#if parent}
|
||||
<Button
|
||||
aria-label="Назад"
|
||||
aria-label={t("common.back")}
|
||||
onclick={() => ui.openPanel(parent)}
|
||||
round
|
||||
smaller
|
||||
@@ -83,7 +86,7 @@
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
aria-label="Закрыть"
|
||||
aria-label={t("common.close")}
|
||||
onclick={() => ui.closePanel()}
|
||||
round
|
||||
smaller
|
||||
@@ -98,6 +101,10 @@
|
||||
<div class="right-body custom-scroll">
|
||||
{#if ui.rightPanel === "profile"}
|
||||
<ProfilePanel />
|
||||
{:else if ui.rightPanel === "changes"}
|
||||
{#key accounts.selectedId}
|
||||
<ChatChanges />
|
||||
{/key}
|
||||
{:else if ui.rightPanel === "policy"}
|
||||
{#key `${accounts.selectedId}:${policyUi.revision}`}
|
||||
<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>
|
||||
Reference in new issue
Block a user