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} +