feat(frontend): online dots on private chats, deleted last message struck through, back from a chat goes through history

This commit is contained in:
hh committed 2026-10-19 05:26:11 +00:00
1 parent 9c556ff8b9
commit 4a32e32114
4 files changed
+113 -3

No files matched your search

+19 -3
View File
@@ -29,8 +29,11 @@
const isDm = $derived(chatId > 0); const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId)); const chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId)); const discovered = $derived(discover.get(chatId));
const DONE_DURATION = 2500;
let peer = $state<PeerView | null>(null); let peer = $state<PeerView | null>(null);
let backfilling = $state(false); let backfilling = $state(false);
let backfilled = $state(false);
async function backfill() { async function backfill() {
if (backfilling) { if (backfilling) {
@@ -39,7 +42,10 @@
backfilling = true; backfilling = true;
try { try {
await enqueueBackfill(chatId, true); await enqueueBackfill(chatId, true);
toasts.success(t("chat.backfill.started")); backfilled = true;
setTimeout(() => {
backfilled = false;
}, DONE_DURATION);
} catch { } catch {
toasts.error(t("chat.backfill.failed")); toasts.error(t("chat.backfill.failed"));
} finally { } finally {
@@ -48,6 +54,10 @@
} }
function back() { function back() {
if (page.state.inApp) {
history.back();
return;
}
const target = new URL(page.url.href); const target = new URL(page.url.href);
target.pathname = "/app"; target.pathname = "/app";
goto(withoutMessage(target)); goto(withoutMessage(target));
@@ -216,14 +226,15 @@
<Icon name="stats" /> <Icon name="stats" />
</Button> </Button>
<Button <Button
aria-label={t("chat.backfill")} aria-label={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
loading={backfilling} loading={backfilling}
onclick={backfill} onclick={backfill}
round round
smaller smaller
title={backfilled ? t("chat.backfill.started") : t("chat.backfill")}
variant="translucent" variant="translucent"
> >
<Icon name="cloud-download" /> <Icon name={backfilled ? "check" : "cloud-download"} />
</Button> </Button>
</div> </div>
</header> </header>
@@ -287,8 +298,13 @@
} }
.subtitle { .subtitle {
overflow: hidden;
display: block;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
&.online { &.online {
color: var(--color-primary); color: var(--color-primary);
@@ -76,6 +76,22 @@
} }
}); });
$effect(() => {
const id = activeChatId;
untrack(() => {
const index = list.findIndex((chat) => chat.chat_id === id);
if (index === -1 || viewport === null) {
return;
}
const top = index * rowHeight;
if (top < viewport.scrollTop) {
viewport.scrollTop = top;
} else if (top + rowHeight > viewport.scrollTop + viewport.clientHeight) {
viewport.scrollTop = top + rowHeight - viewport.clientHeight;
}
});
});
function measure(el: HTMLElement) { function measure(el: HTMLElement) {
({ scrollTop } = el); ({ scrollTop } = el);
if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) { if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_THRESHOLD) {
@@ -4,9 +4,11 @@
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
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 Icon from "#lib/components/ui/Icon.svelte";
import { formatListDate } from "#lib/format/datetime.js"; import { formatListDate } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.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 { online } from "#lib/stores/online.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";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js"; import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
@@ -31,6 +33,14 @@
? t("chat.deletedAccount") ? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: chat.chat_id })) : t("chat.fallbackTitle", { id: chat.chat_id }))
); );
const person = $derived(chat.chat_id > 0 && !chat.is_bot);
const isOnline = $derived(person && online.isOnline(chat.chat_id));
$effect(() => {
if (person) {
online.watch(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);
const showSender = $derived( const showSender = $derived(
@@ -74,12 +84,14 @@
class:selected class:selected
use:ripple use:ripple
> >
<span class="avatar" class:online={isOnline}>
<Avatar <Avatar
avatar={{ id: chat.chat_id, kind: avatarKind }} avatar={{ id: chat.chat_id, kind: avatarKind }}
colorKey={chat.chat_id} colorKey={chat.chat_id}
hasAvatar={chat.has_avatar} hasAvatar={chat.has_avatar}
name={title} name={title}
/> />
</span>
<div class="info"> <div class="info">
<div class="info-row"> <div class="info-row">
<h3 class="title">{title}</h3> <h3 class="title">{title}</h3>
@@ -90,7 +102,13 @@
{#if senderPrefix} {#if senderPrefix}
<span class="sender">{senderPrefix}</span> <span class="sender">{senderPrefix}</span>
{/if} {/if}
{#if chat.last_deleted}
<span class="deleted-mark" title={t("chat.deletedPreview")}
><Icon name="delete" size="1rem" /></span
><s class="deleted-text">{preview}</s>
{:else}
{preview} {preview}
{/if}
</span> </span>
</div> </div>
</div> </div>
@@ -147,11 +165,15 @@
@media (hover: hover) { @media (hover: hover) {
&:hover { &:hover {
--online-ring: var(--color-chat-hover);
background-color: var(--color-chat-hover); background-color: var(--color-chat-hover);
} }
} }
&.selected { &.selected {
--online-ring: var(--color-chat-active);
background-color: var(--color-chat-active); background-color: var(--color-chat-active);
.title, .title,
@@ -163,6 +185,37 @@
} }
} }
.avatar {
position: relative;
display: flex;
flex-shrink: 0;
&.online::after {
content: "";
position: absolute;
right: 0.0625rem;
bottom: 0.0625rem;
width: 0.875rem;
height: 0.875rem;
border: 2px solid var(--online-ring, var(--color-background));
border-radius: 50%;
background-color: var(--color-green);
}
}
.deleted-mark {
display: inline-flex;
margin-inline-end: 0.1875rem;
vertical-align: -0.1875rem;
}
.deleted-text {
text-decoration: line-through;
}
.info { .info {
overflow: hidden; overflow: hidden;
flex: 1; flex: 1;
+25
View File
@@ -14,6 +14,7 @@ import { folders } from "#lib/stores/folders.svelte.js";
const POLL_INTERVAL = 1500; const POLL_INTERVAL = 1500;
const POLL_MAX = 12; const POLL_MAX = 12;
const PAGE_SIZE = 40; const PAGE_SIZE = 40;
const CHANNEL_IDS = -1_000_000_000_000;
const ALL = "all"; const ALL = "all";
const EMPTY: Chat[] = []; const EMPTY: Chat[] = [];
@@ -159,7 +160,29 @@ function createChats() {
} }
} }
function applyDelete(chatId: number | null, ids: number[]) {
for (const bucket of Object.values(buckets)) {
for (const chat of bucket.list) {
const inScope =
chatId === null
? chat.chat_id > CHANNEL_IDS
: chat.chat_id === chatId;
if (
inScope &&
chat.last_message_id !== null &&
ids.includes(chat.last_message_id)
) {
chat.last_deleted = true;
}
}
}
}
function applyEvent(event: LiveEvent) { function applyEvent(event: LiveEvent) {
if (event.type === "delete") {
applyDelete(event.chat_id, event.message_ids);
return;
}
if (event.type !== "message") { if (event.type !== "message") {
return; return;
} }
@@ -174,6 +197,8 @@ function createChats() {
} }
seenChat = true; seenChat = true;
existing.last_date = message.date; existing.last_date = message.date;
existing.last_deleted = message.deleted_at !== null;
existing.last_message_id = message.message_id;
existing.last_sender_id = message.sender_id; existing.last_sender_id = message.sender_id;
existing.last_text = message.text; existing.last_text = message.text;
existing.message_count += 1; existing.message_count += 1;