From 171d8d922d158aa6757a6bdfe63092ae9c02fa54 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 19 Oct 2026 03:49:58 +0000 Subject: [PATCH] feat(frontend): exact time on the message time, deletion and edit times in the meta, note marker, copy link answered in the menu, media menu keeps the message items, date pill opens the calendar, capture gaps between messages --- .../src/lib/components/CalendarPopover.svelte | 44 ++++++ .../src/lib/components/MessageBubble.svelte | 69 +++++---- .../src/lib/components/MessageList.svelte | 135 ++++++++++++++-- .../src/lib/components/MessageMedia.svelte | 90 ++++++----- .../src/lib/components/MessageMeta.svelte | 145 +++++++++++++++--- .../components/profile/ChatCalendar.svelte | 85 ++++++---- .../src/lib/components/ui/ContextMenu.svelte | 8 + .../src/lib/components/ui/CopyItem.svelte | 34 ++++ 8 files changed, 479 insertions(+), 131 deletions(-) create mode 100644 frontend/src/lib/components/CalendarPopover.svelte create mode 100644 frontend/src/lib/components/ui/CopyItem.svelte diff --git a/frontend/src/lib/components/CalendarPopover.svelte b/frontend/src/lib/components/CalendarPopover.svelte new file mode 100644 index 0000000..3aa8b02 --- /dev/null +++ b/frontend/src/lib/components/CalendarPopover.svelte @@ -0,0 +1,44 @@ + + + + + + {#key month} + { + open = false; + }} + /> + {/key} + + + + + diff --git a/frontend/src/lib/components/MessageBubble.svelte b/frontend/src/lib/components/MessageBubble.svelte index 808af2c..d520d68 100644 --- a/frontend/src/lib/components/MessageBubble.svelte +++ b/frontend/src/lib/components/MessageBubble.svelte @@ -15,14 +15,15 @@ import Avatar from "#lib/components/ui/Avatar.svelte"; import ContextMenu from "#lib/components/ui/ContextMenu.svelte"; import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte"; + import CopyItem from "#lib/components/ui/CopyItem.svelte"; import Icon from "#lib/components/ui/Icon.svelte"; import WebPage from "#lib/components/WebPage.svelte"; import { accountName, peerColorIndex, peerName } from "#lib/format/peer.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 { notes } from "#lib/stores/notes.svelte.js"; import { peers } from "#lib/stores/peers.svelte.js"; - import { toasts } from "#lib/stores/toasts.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js"; import { appear } from "#lib/transitions/appear.js"; import { goto } from "$app/navigation"; @@ -137,13 +138,8 @@ onjump(message.reply.message_id); } } - async function copyText() { - await navigator.clipboard.writeText(message.text ?? ""); - toasts.success(t("thread.copiedText")); - } - async function copyId() { - await navigator.clipboard.writeText(String(message.message_id)); - toasts.success(t("thread.copiedId")); + function messageLink(): string { + return `${location.origin}/app/${message.chat_id}?message=${message.message_id}`; } function openSenderProfile() { if (senderChat === message.chat_id) { @@ -212,12 +208,6 @@ {#if message.reply} {/if} - {#if deleted} - - - {t("thread.deleted")} - - {/if} {#if message.poll} {/if} @@ -236,7 +226,13 @@ {own} /> {:else if message.has_media} - onmedia(0)} {own} /> + onmedia(0)} + {onversions} + {own} + /> {/if} {/if} {#if hasText} @@ -266,7 +262,13 @@ {t("thread.links", { count: linkCount })} {/if} - + {#if message.inline_buttons.length} {/if} @@ -319,14 +321,26 @@ >{t("thread.toReply")} {/if} + {#if hasText} - {t("thread.copyText")} + message.text ?? ""} + /> {/if} - {t("thread.copyId")} + String(message.message_id)} + /> {#if isGroupChat && !own && message.sender_id !== null} {t("thread.senderProfile")} import { tick, untrack } from "svelte"; + import type { CaptureGap } from "#lib/api/capture.js"; import { listMessages } from "#lib/api/endpoints.js"; import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js"; import type { LiveEvent, MessageView } from "#lib/api/types.js"; import ActionMessage from "#lib/components/ActionMessage.svelte"; + import CalendarPopover from "#lib/components/CalendarPopover.svelte"; import MediaViewer from "#lib/components/MediaViewer.svelte"; import MessageBubble from "#lib/components/MessageBubble.svelte"; import MessageVersions from "#lib/components/MessageVersions.svelte"; @@ -11,9 +13,10 @@ import TrackChat from "#lib/components/TrackChat.svelte"; import Icon from "#lib/components/ui/Icon.svelte"; import Spinner from "#lib/components/ui/Spinner.svelte"; - import { formatDay } from "#lib/format/datetime.js"; + import { formatDay, formatSpanRange } from "#lib/format/datetime.js"; import { t } from "#lib/i18n/locale.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js"; + import { capture } from "#lib/stores/capture.svelte.js"; import { chats } from "#lib/stores/chats.svelte.js"; import { discover } from "#lib/stores/discover.svelte.js"; import { events } from "#lib/stores/events.svelte.js"; @@ -34,6 +37,7 @@ const STICK_OFFSET = 9; const IDLE_DELAY = 1500; const MAX_MESSAGES = 360; + const SETTLE_DELAY = 400; let messages = $state([]); let loading = $state(true); @@ -45,6 +49,7 @@ let suppressAppear = $state(false); let scrolling = $state(false); let stuckDay = $state(null); + let passedDays = $state.raw(new Set()); let idleTimer: ReturnType | null = null; let mediaOpen = $state(false); @@ -53,6 +58,9 @@ let versionsOpen = $state(false); let versionsMessageId = $state(null); let highlightId = $state(null); + let calendarOpen = $state(false); + let calendarAnchor = $state(null); + let calendarMonth = $state<{ month: number; year: number } | null>(null); let highlightTimer: ReturnType | null = null; const ownId = $derived(accounts.selected?.tg_user_id ?? null); @@ -66,6 +74,8 @@ dayKey: string; daySeparator: string | null; firstInGroup: boolean; + gapsAfter: CaptureGap[]; + gapsBefore: CaptureGap[]; lastInGroup: boolean; message: MessageView; own: boolean; @@ -75,6 +85,27 @@ return new Date(iso).toDateString(); } + const gapSpans = $derived( + capture.gaps.map((gap) => ({ + day: dayKey(gap.start), + end: Date.parse(gap.end), + gap, + start: Date.parse(gap.start), + })) + ); + + function gapsBetween( + prev: MessageView | undefined, + message: MessageView + ): typeof gapSpans { + if (!prev || gapSpans.length === 0) { + return []; + } + const from = Date.parse(prev.date); + const to = Date.parse(message.date); + return gapSpans.filter((span) => span.start >= from && span.end <= to); + } + const rows = $derived.by(() => { const out: Row[] = []; for (let i = 0; i < messages.length; i += 1) { @@ -82,15 +113,28 @@ const prev = messages[i - 1]; const next = messages[i + 1]; const day = dayKey(message.date); + const gaps = gapsBetween(prev, message); const samePrevDay = prev ? dayKey(prev.date) === day : false; const sameNextDay = next ? dayKey(next.date) === day : false; out.push({ dayKey: day, daySeparator: samePrevDay ? null : formatDay(message.date), firstInGroup: - !prev || prev.sender_id !== message.sender_id || !samePrevDay, + !prev || + prev.sender_id !== message.sender_id || + !samePrevDay || + gaps.length > 0, + gapsAfter: gaps + .filter((span) => span.day === day) + .map((span) => span.gap), + gapsBefore: gaps + .filter((span) => span.day !== day) + .map((span) => span.gap), lastInGroup: - !next || next.sender_id !== message.sender_id || !sameNextDay, + !next || + next.sender_id !== message.sender_id || + !sameNextDay || + gapsBetween(message, next).length > 0, message, own: ownId !== null && message.sender_id === ownId, }); @@ -219,13 +263,14 @@ } const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1; const seps = container.querySelectorAll(".day-separator"); - let day: string | null = null; + const stuck: string[] = []; for (const sep of seps) { - if (sep.getBoundingClientRect().top <= limit) { - day = sep.dataset.day ?? null; + if (sep.getBoundingClientRect().top <= limit && sep.dataset.day) { + stuck.push(sep.dataset.day); } } - stuckDay = day; + stuckDay = stuck.pop() ?? null; + passedDays = new Set(stuck); } async function loadInitial() { @@ -252,6 +297,7 @@ await tick(); scrollToBottom(); updateStuck(); + setTimeout(updateStuck, SETTLE_DELAY); } catch { toasts.error(t("thread.loadFailed")); } finally { @@ -394,6 +440,13 @@ } } + function openCalendar(event: MouseEvent, iso: string) { + const date = new Date(iso); + calendarAnchor = event.currentTarget as HTMLElement; + calendarMonth = { month: date.getUTCMonth(), year: date.getUTCFullYear() }; + calendarOpen = true; + } + function openMedia(message: MessageView, index: number) { mediaItems = viewerItemsFrom(message.message_id, message.media); mediaIndex = index; @@ -581,15 +634,29 @@
{/if} {#each rows as row (row.message.message_id)} + {#each row.gapsBefore as gap (gap.start)} + {@render gapLine(gap)} + {/each} {#if row.daySeparator}
- {row.daySeparator} +
{/if} + {#each row.gapsAfter as gap (gap.start)} + {@render gapLine(gap)} + {/each} {#if row.message.service} {:else} @@ -625,6 +692,20 @@ {/if} +{#snippet gapLine( + gap: CaptureGap +)} +
+ {t("chat.gap", { range: formatSpanRange(gap.start, gap.end) })} +
+{/snippet} + + diff --git a/frontend/src/lib/components/MessageMedia.svelte b/frontend/src/lib/components/MessageMedia.svelte index d18adaf..9c68474 100644 --- a/frontend/src/lib/components/MessageMedia.svelte +++ b/frontend/src/lib/components/MessageMedia.svelte @@ -1,4 +1,6 @@ - {#if message.edited_at} + {#if noted} {/if} - {formatTime(message.date)} + {#if message.deleted_at} + + {t("chat.deletedAt", { when: formatStamp(message.deleted_at) })} + + {/if} + {#if message.edited_at} + + {/if} + + {#if tip} + {exact} + {/if} {#if own} @@ -34,6 +90,8 @@ diff --git a/frontend/src/lib/components/profile/ChatCalendar.svelte b/frontend/src/lib/components/profile/ChatCalendar.svelte index 0801963..587a78c 100644 --- a/frontend/src/lib/components/profile/ChatCalendar.svelte +++ b/frontend/src/lib/components/profile/ChatCalendar.svelte @@ -1,40 +1,42 @@ + + + + {copied ? done : label} +