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

This commit is contained in:
hh committed 2026-10-19 05:25:54 +00:00
1 parent 848d2aca1a
commit 171d8d922d
8 files changed
+477 -129

No files matched your search

@@ -0,0 +1,44 @@
<script lang="ts">
import { Popover } from "bits-ui";
import ChatCalendar from "#lib/components/profile/ChatCalendar.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
anchor: HTMLElement | null;
chatId: number;
month: { month: number; year: number } | null;
open: boolean;
}
let { anchor, chatId, month, open = $bindable() }: Props = $props();
</script>
<Popover.Root bind:open>
<Popover.Portal>
<Popover.Content
aria-label={t("chat.calendar")}
class="bg-menu-content calendar-popover"
customAnchor={anchor}
side="bottom"
sideOffset={6}
>
{#key month}
<ChatCalendar
{chatId}
initial={month}
onjump={() => {
open = false;
}}
/>
{/key}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<style lang="scss">
:global(.calendar-popover) {
width: min(20rem, calc(100vw - 1.5rem));
padding: 0;
background-color: var(--color-background);
}
</style>
@@ -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}
<ReplyHeader {onjump} reply={message.reply} />
{/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
{t("thread.deleted")}
</span>
{/if}
{#if message.poll}
<Poll poll={message.poll} />
{/if}
@@ -236,7 +226,13 @@
{own}
/>
{:else if message.has_media}
<MessageMedia {message} onopen={() => onmedia(0)} {own} />
<MessageMedia
items={menu}
{message}
onopen={() => onmedia(0)}
{onversions}
{own}
/>
{/if}
{/if}
{#if hasText}
@@ -266,7 +262,13 @@
{t("thread.links", { count: linkCount })}
</button>
{/if}
<MessageMeta {message} {onversions} {own} />
<MessageMeta
{message}
noted={notes.has(message)}
onnote={openAnnotations}
{onversions}
{own}
/>
{#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} />
{/if}
@@ -319,14 +321,26 @@
>{t("thread.toReply")}</ContextMenuItem
>
{/if}
<CopyItem
done={t("chat.menu.linkCopied")}
icon="link"
label={t("chat.menu.copyLink")}
text={messageLink}
/>
{#if hasText}
<ContextMenuItem icon="copy" onselect={copyText}
>{t("thread.copyText")}</ContextMenuItem
>
<CopyItem
done={t("chat.menu.copied")}
icon="copy"
label={t("thread.copyText")}
text={() => message.text ?? ""}
/>
{/if}
<ContextMenuItem icon="hashtag" onselect={copyId}
>{t("thread.copyId")}</ContextMenuItem
>
<CopyItem
done={t("chat.menu.copied")}
icon="hashtag"
label={t("thread.copyId")}
text={() => String(message.message_id)}
/>
{#if isGroupChat && !own && message.sender_id !== null}
<ContextMenuItem icon="info" onselect={openSenderProfile}
>{t("thread.senderProfile")}</ContextMenuItem
@@ -454,17 +468,6 @@
text-decoration: line-through;
}
.deleted-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-error);
}
.detail-chip {
display: inline-flex;
+125 -10
View File
@@ -1,9 +1,11 @@
<script lang="ts">
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<MessageView[]>([]);
let loading = $state(true);
@@ -45,6 +49,7 @@
let suppressAppear = $state(false);
let scrolling = $state(false);
let stuckDay = $state<string | null>(null);
let passedDays = $state.raw(new Set<string>());
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let mediaOpen = $state(false);
@@ -53,6 +58,9 @@
let versionsOpen = $state(false);
let versionsMessageId = $state<number | null>(null);
let highlightId = $state<number | null>(null);
let calendarOpen = $state(false);
let calendarAnchor = $state<HTMLElement | null>(null);
let calendarMonth = $state<{ month: number; year: number } | null>(null);
let highlightTimer: ReturnType<typeof setTimeout> | 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<Row[]>(() => {
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<HTMLElement>(".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 @@
<div class="loading-older"><Spinner /></div>
{/if}
{#each rows as row (row.message.message_id)}
{#each row.gapsBefore as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.daySeparator}
<div
class="day-separator"
data-day={row.dayKey}
class:idle={!scrolling && row.dayKey === stuckDay}
class:passed={passedDays.has(row.dayKey)}
>
<span>{row.daySeparator}</span>
<button
aria-label={t("chat.calendar")}
onclick={(event) => openCalendar(event, row.message.date)}
title={t("chat.calendar")}
type="button"
>
{row.daySeparator}
</button>
</div>
{/if}
{#each row.gapsAfter as gap (gap.start)}
{@render gapLine(gap)}
{/each}
{#if row.message.service}
<ActionMessage message={row.message} onjump={goTo} />
{:else}
@@ -625,6 +692,20 @@
{/if}
</div>
{#snippet gapLine(
gap: CaptureGap
)}
<div class="capture-gap">
<span>{t("chat.gap", { range: formatSpanRange(gap.start, gap.end) })}</span>
</div>
{/snippet}
<CalendarPopover
anchor={calendarAnchor}
{chatId}
month={calendarMonth}
bind:open={calendarOpen}
/>
<MediaViewer
{chatId}
items={mediaItems}
@@ -714,10 +795,15 @@
justify-content: center;
margin: 0.75rem 0;
span {
button {
pointer-events: auto;
cursor: pointer;
padding: 0.25rem 0.75rem;
border: 0;
border-radius: 1rem;
font: inherit;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-white);
@@ -725,10 +811,39 @@
background-color: var(--color-default-shadow);
backdrop-filter: blur(8px);
transition: opacity 0.3s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
&.idle span {
&.idle button,
&.passed button {
pointer-events: none;
opacity: 0;
}
}
.capture-gap {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.75rem 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
&::before,
&::after {
content: "";
flex: 1;
border-top: 1px dashed var(--color-borders);
}
span {
flex-shrink: 0;
}
}
</style>
+54 -34
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { ContextMenu as Menu } from "bits-ui";
import type { Snippet } from "svelte";
import { visible } from "#lib/actions/visible.js";
import { downloadMedia } from "#lib/api/download.js";
import { fetchMedia } from "#lib/api/endpoints.js";
@@ -14,24 +16,25 @@
import TgsSticker from "#lib/components/media/TgsSticker.svelte";
import VideoNote from "#lib/components/media/VideoNote.svelte";
import VoiceMessage from "#lib/components/media/VoiceMessage.svelte";
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { poster } from "#lib/media/poster.js";
import { shareUi } from "#lib/stores/shares.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
interface Props {
items?: Snippet;
message: MessageView;
onopen: () => void;
onversions?: () => void;
own: boolean;
}
let { message, onopen, own }: Props = $props();
let { items, message, onopen, onversions, own }: Props = $props();
const POLL_TRIES = 5;
const POLL_DELAY = 3000;
@@ -39,6 +42,7 @@
let loaded = $state(false);
let media = $state<InlineMedia | null>(null);
let queuing = $state(false);
let queued = $state(false);
let saving = $state(false);
const ref = $derived(message.media[0] ?? null);
@@ -71,7 +75,10 @@
const storedId = $derived(
ready?.mediaId ?? (asFile ? ref?.id : null) ?? null
);
const fileName = $derived(ref?.file_name ?? mediaKindLabel(kind) ?? "Файл");
const fileName = $derived(
ref?.file_name ?? mediaKindLabel(kind) ?? t("chat.media.file")
);
const waiting = $derived(queuing || queued);
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
@@ -101,16 +108,16 @@
}
async function queue() {
if (queuing) {
if (waiting) {
return;
}
queuing = true;
try {
await fetchMedia(message.chat_id, message.message_id);
toasts.success("Скачивание поставлено в очередь");
queued = true;
poll();
} catch {
toasts.error("Не удалось поставить в очередь");
toasts.error(t("chat.media.queueFailed"));
} finally {
queuing = false;
}
@@ -124,7 +131,7 @@
try {
await downloadMedia(storedId);
} catch {
toasts.error("Не удалось скачать файл");
toasts.error(t("chat.media.downloadFailed"));
} finally {
saving = false;
}
@@ -145,7 +152,7 @@
{#if message.is_self_destruct}
<button class="media-chip self-destruct" onclick={onopen} type="button">
<Icon name="timer" size="1.25rem" />
<span>Самоуничтожающееся медиа</span>
<span>{t("chat.media.selfDestruct")}</span>
</button>
{:else if asFile && ref}
<FileChip media={ref} {own} />
@@ -165,11 +172,11 @@
<AudioFile {own} title={fileName} url={ready.url} />
{:else if ready && isImage}
<button class="media-thumb" onclick={onopen} type="button">
<img alt="attachment" src={ready.url}>
<img alt={t("chat.media.photo")} src={ready.url}>
</button>
{:else if ready && isStaticSticker}
<button class="media-sticker" onclick={onopen} type="button">
<img alt="sticker" src={ready.url}>
<img alt={fileName} src={ready.url}>
</button>
{:else if ready && isVideoSticker}
<video
@@ -205,46 +212,59 @@
</button>
{:else if media?.state === "not-downloaded" && vk !== "other"}
<button class="media-placeholder" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
<span>{vk === "video" ? "Видео" : "Фото"}</span>
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small>
<Icon name={waiting ? "timer" : "download"} size="1.5rem" />
<span
>{vk === "video"
? t("chat.media.video")
: t("chat.media.photo")}</span
>
<small
>{waiting
? t("chat.media.queued")
: t("chat.media.tapToDownload")}</small
>
</button>
{:else if media?.state === "not-downloaded"}
<button class="media-chip" onclick={queue} type="button">
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span>
<Icon name={waiting ? "timer" : "download"} size="1.25rem" />
<span
>{waiting
? t("chat.media.queued")
: t("chat.media.download", { kind: label })}</span
>
</button>
{:else}
<button class="media-chip" onclick={onopen} type="button">
<Icon name="photo" size="1.25rem" />
<span>Медиа</span>
<span>{t("chat.media")}</span>
</button>
{/if}
</div>
{/snippet}
{#snippet menu()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
>Открыть на весь экран</ContextMenuItem
>{t("chat.media.fullscreen")}</ContextMenuItem
>
<ContextMenuItem
icon="recent"
onselect={() => ui.openMessagePanel("versions", message.message_id)}
>Версии медиа</ContextMenuItem
{#if onversions}
<ContextMenuItem icon="recent" onselect={onversions}
>{t("chat.media.versions")}</ContextMenuItem
>
{/if}
{#if storedId === null}
<ContextMenuItem icon="cloud-download" onselect={queue}>
Скачать в архив
</ContextMenuItem>
<ContextMenuItem icon="cloud-download" onselect={queue}
>{t("chat.media.queue")}</ContextMenuItem
>
{:else}
<ContextMenuItem icon="download" onselect={save}>
Скачать файл
</ContextMenuItem>
<ContextMenuItem icon="allow-share" onselect={share}>
Доступ по ссылке
</ContextMenuItem>
<ContextMenuItem icon="download" onselect={save}
>{t("chat.media.save")}</ContextMenuItem
>
<ContextMenuItem icon="allow-share" onselect={share}
>{t("chat.media.share")}</ContextMenuItem
>
{/if}
{#if items}
<Menu.Separator class="bg-menu-separator" />
{@render items()}
{/if}
{/snippet}
</ContextMenu>
+126 -19
View File
@@ -1,30 +1,86 @@
<script lang="ts">
import type { MessageView } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatTime } from "#lib/format/datetime.js";
import {
formatExact,
formatStamp,
formatTime,
} from "#lib/format/datetime.js";
import { readTitle } from "#lib/format/read.js";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
message: MessageView;
noted?: boolean;
onnote?: () => void;
onversions?: () => void;
own?: boolean;
}
let { message, onversions, own = false }: Props = $props();
let {
message,
noted = false,
onnote,
onversions,
own = false,
}: Props = $props();
const TIP_DURATION = 3000;
let tip = $state(false);
let tipTimer: ReturnType<typeof setTimeout> | undefined;
const exact = $derived(formatExact(message.date));
function toggleTip() {
tip = !tip;
clearTimeout(tipTimer);
if (tip) {
tipTimer = setTimeout(() => {
tip = false;
}, TIP_DURATION);
}
}
</script>
<span class="MessageMeta">
{#if message.edited_at}
{#if noted}
<button
class="edited"
onclick={onversions}
title="View edit history"
aria-label={t("chat.note")}
class="meta-button note"
onclick={onnote}
title={t("chat.note")}
type="button"
>
edited
<Icon name="note" size="1rem" />
</button>
{/if}
<span class="time">{formatTime(message.date)}</span>
{#if message.deleted_at}
<span class="deleted" title={formatExact(message.deleted_at)}>
{t("chat.deletedAt", { when: formatStamp(message.deleted_at) })}
</span>
{/if}
{#if message.edited_at}
<button
class="meta-button edited"
onclick={onversions}
title={t("chat.editedAt", { when: formatExact(message.edited_at) })}
type="button"
>
{t("chat.edited")}
</button>
{/if}
<button
class="meta-button time"
onclick={toggleTip}
title={exact}
type="button"
>
{formatTime(message.date)}
</button>
{#if tip}
<span class="tip" role="status">{exact}</span>
{/if}
{#if own}
<span class="ticks" title={readTitle(message)}>
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
@@ -34,6 +90,8 @@
<style lang="scss">
.MessageMeta {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.25rem;
@@ -47,21 +105,70 @@
user-select: none;
}
.time {
.meta-button {
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 0;
border: 0;
border-radius: 0.25rem;
font: inherit;
color: inherit;
white-space: nowrap;
background: transparent;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
.note {
@media (hover: hover) {
&:hover {
color: var(--color-primary);
}
}
}
.edited,
.deleted {
font-style: italic;
white-space: nowrap;
}
.deleted::after {
content: "·";
margin-inline-start: 0.25rem;
font-style: normal;
}
.tip {
pointer-events: none;
position: absolute;
right: 0;
bottom: calc(100% + 0.375rem);
z-index: 1;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-style: normal;
color: var(--color-white);
white-space: nowrap;
background-color: var(--color-toast-background);
backdrop-filter: blur(10px);
box-shadow: 0 0.25rem 0.75rem var(--color-default-shadow);
}
.ticks {
display: inline-flex;
}
.edited {
cursor: pointer;
padding: 0;
border: 0;
font-size: 0.75rem;
font-style: italic;
color: inherit;
background: transparent;
}
</style>
@@ -1,40 +1,42 @@
<script lang="ts">
import { untrack } from "svelte";
import { getChatCalendar, getMessageAt } from "#lib/api/endpoints.js";
import type { DayCount } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import Spinner from "#lib/components/ui/Spinner.svelte";
import { locale, t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
const LEVELS = 4;
const WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const MONTHS = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
import { page } from "$app/state";
interface YearMonth {
month: number;
year: number;
}
interface Props {
chatId: number;
initial?: YearMonth | null;
onjump?: () => void;
}
let { chatId, initial = null, onjump }: Props = $props();
const LEVELS = 4;
const MONDAY = Date.UTC(2024, 0, 1);
const DAY_MS = 86_400_000;
const weekdays = $derived.by(() => {
const names = new Intl.DateTimeFormat(locale.tag, {
timeZone: "UTC",
weekday: "short",
});
return Array.from({ length: 7 }, (_, offset) =>
names.format(new Date(MONDAY + offset * DAY_MS))
);
});
interface DayCell {
count: number;
day: number;
@@ -44,7 +46,7 @@
let days = $state<DayCount[]>([]);
let loading = $state(false);
let cursor = $state<YearMonth | null>(null);
let cursor = $state<YearMonth | null>(untrack(() => initial));
const cache = new Map<string, DayCount[]>();
const now = new Date();
@@ -61,6 +63,13 @@
);
const view = $derived(cursor ?? currentMonth);
const monthLabel = $derived.by(() => {
const name = new Intl.DateTimeFormat(locale.tag, {
month: "long",
timeZone: "UTC",
}).format(new Date(Date.UTC(view.year, view.month, 1)));
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${view.year}`;
});
function index(value: YearMonth): number {
return value.year * 12 + value.month;
@@ -115,9 +124,14 @@
return result;
});
let shown = untrack(() => `${chatId}:${accounts.selectedId}`);
$effect(() => {
const _id = chatId;
const _account = accounts.selectedId;
const key = `${chatId}:${accounts.selectedId}`;
if (key === shown) {
return;
}
shown = key;
cache.clear();
cursor = null;
days = [];
@@ -161,7 +175,10 @@
try {
const target = await getMessageAt(chatId, cell.key);
ui.requestJump(chatId, target.message_id);
onjump?.();
if (page.params.chatId !== String(chatId)) {
goto(`/app/${chatId}`);
}
} catch {
// no message resolved for that day
}
@@ -174,7 +191,7 @@
<div class="calendar">
<header class="nav">
<button
aria-label="Предыдущий год"
aria-label={t("chat.calendar.prevYear")}
class="step"
onclick={() => shift(-12)}
type="button"
@@ -182,16 +199,16 @@
«
</button>
<button
aria-label="Предыдущий месяц"
aria-label={t("chat.calendar.prevMonth")}
class="step"
onclick={() => shift(-1)}
type="button"
>
<Icon name="arrow-left" />
</button>
<span class="label">{MONTHS[view.month]} {view.year}</span>
<span class="label">{monthLabel}</span>
<button
aria-label="Следующий месяц"
aria-label={t("chat.calendar.nextMonth")}
class="step"
disabled={!canNext}
onclick={() => shift(1)}
@@ -200,7 +217,7 @@
<Icon name="arrow-right" />
</button>
<button
aria-label="Следующий год"
aria-label={t("chat.calendar.nextYear")}
class="step"
disabled={!canNext}
onclick={() => shift(12)}
@@ -211,7 +228,7 @@
</header>
<div class="weekdays">
{#each WEEKDAYS as weekday (weekday)}
{#each weekdays as weekday (weekday)}
<span>{weekday}</span>
{/each}
</div>
@@ -225,7 +242,7 @@
<button
class="cell day lvl-{cell.level}"
onclick={() => jump(cell)}
title={`${cell.count} сообщений`}
title={t("chat.calendar.count", { count: cell.count })}
type="button"
>
{cell.day}
@@ -1,7 +1,11 @@
<script lang="ts" module>
import { createContext } from "svelte";
type PointerHandler = (event: PointerEvent) => void;
const claimed = new WeakSet<Event>();
export const [menuCloser, provideMenuCloser] = createContext<() => void>();
</script>
<script lang="ts">
@@ -18,6 +22,10 @@
let open = $state(false);
let suppressClick = false;
provideMenuCloser(() => {
open = false;
});
function triggerProps(props: Record<string, unknown>) {
const onpointerdown = props.onpointerdown as PointerHandler;
const onpointerup = props.onpointerup as PointerHandler;
@@ -0,0 +1,34 @@
<script lang="ts">
import { ContextMenu } from "bits-ui";
import { menuCloser } from "./ContextMenu.svelte";
import Icon from "./Icon.svelte";
interface Props {
done: string;
icon: string;
label: string;
text: () => string;
}
const { done, icon, label, text }: Props = $props();
const CLOSE_DELAY = 900;
const close = menuCloser();
let copied = $state(false);
async function copy() {
try {
await navigator.clipboard.writeText(text());
} catch {
return;
}
copied = true;
setTimeout(close, CLOSE_DELAY);
}
</script>
<ContextMenu.Item class="bg-menu-item" closeOnSelect={false} onSelect={copy}>
<Icon name={copied ? "check" : icon} size="1.125rem" />
{copied ? done : label}
</ContextMenu.Item>