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:
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 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 CopyItem from "#lib/components/ui/CopyItem.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import WebPage from "#lib/components/WebPage.svelte";
|
import WebPage from "#lib/components/WebPage.svelte";
|
||||||
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
|
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.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 { chats } from "#lib/stores/chats.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 { peers } from "#lib/stores/peers.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { appear } from "#lib/transitions/appear.js";
|
import { appear } from "#lib/transitions/appear.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -137,13 +138,8 @@
|
|||||||
onjump(message.reply.message_id);
|
onjump(message.reply.message_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function copyText() {
|
function messageLink(): string {
|
||||||
await navigator.clipboard.writeText(message.text ?? "");
|
return `${location.origin}/app/${message.chat_id}?message=${message.message_id}`;
|
||||||
toasts.success(t("thread.copiedText"));
|
|
||||||
}
|
|
||||||
async function copyId() {
|
|
||||||
await navigator.clipboard.writeText(String(message.message_id));
|
|
||||||
toasts.success(t("thread.copiedId"));
|
|
||||||
}
|
}
|
||||||
function openSenderProfile() {
|
function openSenderProfile() {
|
||||||
if (senderChat === message.chat_id) {
|
if (senderChat === message.chat_id) {
|
||||||
@@ -212,12 +208,6 @@
|
|||||||
{#if message.reply}
|
{#if message.reply}
|
||||||
<ReplyHeader {onjump} reply={message.reply} />
|
<ReplyHeader {onjump} reply={message.reply} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if deleted}
|
|
||||||
<span class="deleted-tag">
|
|
||||||
<Icon name="delete" size="0.875rem" />
|
|
||||||
{t("thread.deleted")}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if message.poll}
|
{#if message.poll}
|
||||||
<Poll poll={message.poll} />
|
<Poll poll={message.poll} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -236,7 +226,13 @@
|
|||||||
{own}
|
{own}
|
||||||
/>
|
/>
|
||||||
{:else if message.has_media}
|
{:else if message.has_media}
|
||||||
<MessageMedia {message} onopen={() => onmedia(0)} {own} />
|
<MessageMedia
|
||||||
|
items={menu}
|
||||||
|
{message}
|
||||||
|
onopen={() => onmedia(0)}
|
||||||
|
{onversions}
|
||||||
|
{own}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasText}
|
{#if hasText}
|
||||||
@@ -266,7 +262,13 @@
|
|||||||
{t("thread.links", { count: linkCount })}
|
{t("thread.links", { count: linkCount })}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<MessageMeta {message} {onversions} {own} />
|
<MessageMeta
|
||||||
|
{message}
|
||||||
|
noted={notes.has(message)}
|
||||||
|
onnote={openAnnotations}
|
||||||
|
{onversions}
|
||||||
|
{own}
|
||||||
|
/>
|
||||||
{#if message.inline_buttons.length}
|
{#if message.inline_buttons.length}
|
||||||
<InlineButtons rows={message.inline_buttons} />
|
<InlineButtons rows={message.inline_buttons} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -319,14 +321,26 @@
|
|||||||
>{t("thread.toReply")}</ContextMenuItem
|
>{t("thread.toReply")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
<CopyItem
|
||||||
|
done={t("chat.menu.linkCopied")}
|
||||||
|
icon="link"
|
||||||
|
label={t("chat.menu.copyLink")}
|
||||||
|
text={messageLink}
|
||||||
|
/>
|
||||||
{#if hasText}
|
{#if hasText}
|
||||||
<ContextMenuItem icon="copy" onselect={copyText}
|
<CopyItem
|
||||||
>{t("thread.copyText")}</ContextMenuItem
|
done={t("chat.menu.copied")}
|
||||||
>
|
icon="copy"
|
||||||
|
label={t("thread.copyText")}
|
||||||
|
text={() => message.text ?? ""}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenuItem icon="hashtag" onselect={copyId}
|
<CopyItem
|
||||||
>{t("thread.copyId")}</ContextMenuItem
|
done={t("chat.menu.copied")}
|
||||||
>
|
icon="hashtag"
|
||||||
|
label={t("thread.copyId")}
|
||||||
|
text={() => String(message.message_id)}
|
||||||
|
/>
|
||||||
{#if isGroupChat && !own && message.sender_id !== null}
|
{#if isGroupChat && !own && message.sender_id !== null}
|
||||||
<ContextMenuItem icon="info" onselect={openSenderProfile}
|
<ContextMenuItem icon="info" onselect={openSenderProfile}
|
||||||
>{t("thread.senderProfile")}</ContextMenuItem
|
>{t("thread.senderProfile")}</ContextMenuItem
|
||||||
@@ -454,17 +468,6 @@
|
|||||||
text-decoration: line-through;
|
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 {
|
.detail-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick, untrack } from "svelte";
|
import { tick, untrack } from "svelte";
|
||||||
|
import type { CaptureGap } from "#lib/api/capture.js";
|
||||||
import { listMessages } from "#lib/api/endpoints.js";
|
import { listMessages } from "#lib/api/endpoints.js";
|
||||||
import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js";
|
import { type ViewerItem, viewerItemsFrom } from "#lib/api/media.js";
|
||||||
import type { LiveEvent, MessageView } from "#lib/api/types.js";
|
import type { LiveEvent, MessageView } from "#lib/api/types.js";
|
||||||
import ActionMessage from "#lib/components/ActionMessage.svelte";
|
import ActionMessage from "#lib/components/ActionMessage.svelte";
|
||||||
|
import CalendarPopover from "#lib/components/CalendarPopover.svelte";
|
||||||
import MediaViewer from "#lib/components/MediaViewer.svelte";
|
import MediaViewer from "#lib/components/MediaViewer.svelte";
|
||||||
import MessageBubble from "#lib/components/MessageBubble.svelte";
|
import MessageBubble from "#lib/components/MessageBubble.svelte";
|
||||||
import MessageVersions from "#lib/components/MessageVersions.svelte";
|
import MessageVersions from "#lib/components/MessageVersions.svelte";
|
||||||
@@ -11,9 +13,10 @@
|
|||||||
import TrackChat from "#lib/components/TrackChat.svelte";
|
import TrackChat from "#lib/components/TrackChat.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.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 { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.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 { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { discover } from "#lib/stores/discover.svelte.js";
|
import { discover } from "#lib/stores/discover.svelte.js";
|
||||||
import { events } from "#lib/stores/events.svelte.js";
|
import { events } from "#lib/stores/events.svelte.js";
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
const STICK_OFFSET = 9;
|
const STICK_OFFSET = 9;
|
||||||
const IDLE_DELAY = 1500;
|
const IDLE_DELAY = 1500;
|
||||||
const MAX_MESSAGES = 360;
|
const MAX_MESSAGES = 360;
|
||||||
|
const SETTLE_DELAY = 400;
|
||||||
|
|
||||||
let messages = $state<MessageView[]>([]);
|
let messages = $state<MessageView[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -45,6 +49,7 @@
|
|||||||
let suppressAppear = $state(false);
|
let suppressAppear = $state(false);
|
||||||
let scrolling = $state(false);
|
let scrolling = $state(false);
|
||||||
let stuckDay = $state<string | null>(null);
|
let stuckDay = $state<string | null>(null);
|
||||||
|
let passedDays = $state.raw(new Set<string>());
|
||||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
let mediaOpen = $state(false);
|
let mediaOpen = $state(false);
|
||||||
@@ -53,6 +58,9 @@
|
|||||||
let versionsOpen = $state(false);
|
let versionsOpen = $state(false);
|
||||||
let versionsMessageId = $state<number | null>(null);
|
let versionsMessageId = $state<number | null>(null);
|
||||||
let highlightId = $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;
|
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
@@ -66,6 +74,8 @@
|
|||||||
dayKey: string;
|
dayKey: string;
|
||||||
daySeparator: string | null;
|
daySeparator: string | null;
|
||||||
firstInGroup: boolean;
|
firstInGroup: boolean;
|
||||||
|
gapsAfter: CaptureGap[];
|
||||||
|
gapsBefore: CaptureGap[];
|
||||||
lastInGroup: boolean;
|
lastInGroup: boolean;
|
||||||
message: MessageView;
|
message: MessageView;
|
||||||
own: boolean;
|
own: boolean;
|
||||||
@@ -75,6 +85,27 @@
|
|||||||
return new Date(iso).toDateString();
|
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 rows = $derived.by<Row[]>(() => {
|
||||||
const out: Row[] = [];
|
const out: Row[] = [];
|
||||||
for (let i = 0; i < messages.length; i += 1) {
|
for (let i = 0; i < messages.length; i += 1) {
|
||||||
@@ -82,15 +113,28 @@
|
|||||||
const prev = messages[i - 1];
|
const prev = messages[i - 1];
|
||||||
const next = messages[i + 1];
|
const next = messages[i + 1];
|
||||||
const day = dayKey(message.date);
|
const day = dayKey(message.date);
|
||||||
|
const gaps = gapsBetween(prev, message);
|
||||||
const samePrevDay = prev ? dayKey(prev.date) === day : false;
|
const samePrevDay = prev ? dayKey(prev.date) === day : false;
|
||||||
const sameNextDay = next ? dayKey(next.date) === day : false;
|
const sameNextDay = next ? dayKey(next.date) === day : false;
|
||||||
out.push({
|
out.push({
|
||||||
dayKey: day,
|
dayKey: day,
|
||||||
daySeparator: samePrevDay ? null : formatDay(message.date),
|
daySeparator: samePrevDay ? null : formatDay(message.date),
|
||||||
firstInGroup:
|
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:
|
lastInGroup:
|
||||||
!next || next.sender_id !== message.sender_id || !sameNextDay,
|
!next ||
|
||||||
|
next.sender_id !== message.sender_id ||
|
||||||
|
!sameNextDay ||
|
||||||
|
gapsBetween(message, next).length > 0,
|
||||||
message,
|
message,
|
||||||
own: ownId !== null && message.sender_id === ownId,
|
own: ownId !== null && message.sender_id === ownId,
|
||||||
});
|
});
|
||||||
@@ -219,13 +263,14 @@
|
|||||||
}
|
}
|
||||||
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
|
const limit = container.getBoundingClientRect().top + STICK_OFFSET + 1;
|
||||||
const seps = container.querySelectorAll<HTMLElement>(".day-separator");
|
const seps = container.querySelectorAll<HTMLElement>(".day-separator");
|
||||||
let day: string | null = null;
|
const stuck: string[] = [];
|
||||||
for (const sep of seps) {
|
for (const sep of seps) {
|
||||||
if (sep.getBoundingClientRect().top <= limit) {
|
if (sep.getBoundingClientRect().top <= limit && sep.dataset.day) {
|
||||||
day = sep.dataset.day ?? null;
|
stuck.push(sep.dataset.day);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stuckDay = day;
|
stuckDay = stuck.pop() ?? null;
|
||||||
|
passedDays = new Set(stuck);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadInitial() {
|
async function loadInitial() {
|
||||||
@@ -252,6 +297,7 @@
|
|||||||
await tick();
|
await tick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
updateStuck();
|
updateStuck();
|
||||||
|
setTimeout(updateStuck, SETTLE_DELAY);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error(t("thread.loadFailed"));
|
toasts.error(t("thread.loadFailed"));
|
||||||
} finally {
|
} 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) {
|
function openMedia(message: MessageView, index: number) {
|
||||||
mediaItems = viewerItemsFrom(message.message_id, message.media);
|
mediaItems = viewerItemsFrom(message.message_id, message.media);
|
||||||
mediaIndex = index;
|
mediaIndex = index;
|
||||||
@@ -581,15 +634,29 @@
|
|||||||
<div class="loading-older"><Spinner /></div>
|
<div class="loading-older"><Spinner /></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each rows as row (row.message.message_id)}
|
{#each rows as row (row.message.message_id)}
|
||||||
|
{#each row.gapsBefore as gap (gap.start)}
|
||||||
|
{@render gapLine(gap)}
|
||||||
|
{/each}
|
||||||
{#if row.daySeparator}
|
{#if row.daySeparator}
|
||||||
<div
|
<div
|
||||||
class="day-separator"
|
class="day-separator"
|
||||||
data-day={row.dayKey}
|
data-day={row.dayKey}
|
||||||
class:idle={!scrolling && row.dayKey === stuckDay}
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#each row.gapsAfter as gap (gap.start)}
|
||||||
|
{@render gapLine(gap)}
|
||||||
|
{/each}
|
||||||
{#if row.message.service}
|
{#if row.message.service}
|
||||||
<ActionMessage message={row.message} onjump={goTo} />
|
<ActionMessage message={row.message} onjump={goTo} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -625,6 +692,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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
|
<MediaViewer
|
||||||
{chatId}
|
{chatId}
|
||||||
items={mediaItems}
|
items={mediaItems}
|
||||||
@@ -714,10 +795,15 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
|
|
||||||
span {
|
button {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
|
border: 0;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
color: var(--color-white);
|
color: var(--color-white);
|
||||||
@@ -725,10 +811,39 @@
|
|||||||
background-color: var(--color-default-shadow);
|
background-color: var(--color-default-shadow);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
transition: opacity 0.3s ease;
|
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;
|
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>
|
</style>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { ContextMenu as Menu } from "bits-ui";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
import { visible } from "#lib/actions/visible.js";
|
import { visible } from "#lib/actions/visible.js";
|
||||||
import { downloadMedia } from "#lib/api/download.js";
|
import { downloadMedia } from "#lib/api/download.js";
|
||||||
import { fetchMedia } from "#lib/api/endpoints.js";
|
import { fetchMedia } from "#lib/api/endpoints.js";
|
||||||
@@ -14,24 +16,25 @@
|
|||||||
import TgsSticker from "#lib/components/media/TgsSticker.svelte";
|
import TgsSticker from "#lib/components/media/TgsSticker.svelte";
|
||||||
import VideoNote from "#lib/components/media/VideoNote.svelte";
|
import VideoNote from "#lib/components/media/VideoNote.svelte";
|
||||||
import VoiceMessage from "#lib/components/media/VoiceMessage.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 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 Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
|
import { formatBytes, mediaKindLabel } from "#lib/format/media.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { poster } from "#lib/media/poster.js";
|
import { poster } from "#lib/media/poster.js";
|
||||||
import { shareUi } from "#lib/stores/shares.svelte.js";
|
import { shareUi } from "#lib/stores/shares.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
items?: Snippet;
|
||||||
message: MessageView;
|
message: MessageView;
|
||||||
onopen: () => void;
|
onopen: () => void;
|
||||||
|
onversions?: () => void;
|
||||||
own: boolean;
|
own: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { message, onopen, own }: Props = $props();
|
let { items, message, onopen, onversions, own }: Props = $props();
|
||||||
|
|
||||||
const POLL_TRIES = 5;
|
const POLL_TRIES = 5;
|
||||||
const POLL_DELAY = 3000;
|
const POLL_DELAY = 3000;
|
||||||
@@ -39,6 +42,7 @@
|
|||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let media = $state<InlineMedia | null>(null);
|
let media = $state<InlineMedia | null>(null);
|
||||||
let queuing = $state(false);
|
let queuing = $state(false);
|
||||||
|
let queued = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
const ref = $derived(message.media[0] ?? null);
|
const ref = $derived(message.media[0] ?? null);
|
||||||
@@ -71,7 +75,10 @@
|
|||||||
const storedId = $derived(
|
const storedId = $derived(
|
||||||
ready?.mediaId ?? (asFile ? ref?.id : null) ?? null
|
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> {
|
function delay(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -101,16 +108,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function queue() {
|
async function queue() {
|
||||||
if (queuing) {
|
if (waiting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queuing = true;
|
queuing = true;
|
||||||
try {
|
try {
|
||||||
await fetchMedia(message.chat_id, message.message_id);
|
await fetchMedia(message.chat_id, message.message_id);
|
||||||
toasts.success("Скачивание поставлено в очередь");
|
queued = true;
|
||||||
poll();
|
poll();
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось поставить в очередь");
|
toasts.error(t("chat.media.queueFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
queuing = false;
|
queuing = false;
|
||||||
}
|
}
|
||||||
@@ -124,7 +131,7 @@
|
|||||||
try {
|
try {
|
||||||
await downloadMedia(storedId);
|
await downloadMedia(storedId);
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось скачать файл");
|
toasts.error(t("chat.media.downloadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -145,7 +152,7 @@
|
|||||||
{#if message.is_self_destruct}
|
{#if message.is_self_destruct}
|
||||||
<button class="media-chip self-destruct" onclick={onopen} type="button">
|
<button class="media-chip self-destruct" onclick={onopen} type="button">
|
||||||
<Icon name="timer" size="1.25rem" />
|
<Icon name="timer" size="1.25rem" />
|
||||||
<span>Самоуничтожающееся медиа</span>
|
<span>{t("chat.media.selfDestruct")}</span>
|
||||||
</button>
|
</button>
|
||||||
{:else if asFile && ref}
|
{:else if asFile && ref}
|
||||||
<FileChip media={ref} {own} />
|
<FileChip media={ref} {own} />
|
||||||
@@ -165,11 +172,11 @@
|
|||||||
<AudioFile {own} title={fileName} url={ready.url} />
|
<AudioFile {own} title={fileName} url={ready.url} />
|
||||||
{:else if ready && isImage}
|
{:else if ready && isImage}
|
||||||
<button class="media-thumb" onclick={onopen} type="button">
|
<button class="media-thumb" onclick={onopen} type="button">
|
||||||
<img alt="attachment" src={ready.url}>
|
<img alt={t("chat.media.photo")} src={ready.url}>
|
||||||
</button>
|
</button>
|
||||||
{:else if ready && isStaticSticker}
|
{:else if ready && isStaticSticker}
|
||||||
<button class="media-sticker" onclick={onopen} type="button">
|
<button class="media-sticker" onclick={onopen} type="button">
|
||||||
<img alt="sticker" src={ready.url}>
|
<img alt={fileName} src={ready.url}>
|
||||||
</button>
|
</button>
|
||||||
{:else if ready && isVideoSticker}
|
{:else if ready && isVideoSticker}
|
||||||
<video
|
<video
|
||||||
@@ -205,46 +212,59 @@
|
|||||||
</button>
|
</button>
|
||||||
{:else if media?.state === "not-downloaded" && vk !== "other"}
|
{:else if media?.state === "not-downloaded" && vk !== "other"}
|
||||||
<button class="media-placeholder" onclick={queue} type="button">
|
<button class="media-placeholder" onclick={queue} type="button">
|
||||||
<Icon name={queuing ? "timer" : "download"} size="1.5rem" />
|
<Icon name={waiting ? "timer" : "download"} size="1.5rem" />
|
||||||
<span>{vk === "video" ? "Видео" : "Фото"}</span>
|
<span
|
||||||
<small>{queuing ? "В очереди" : "Нажмите, чтобы скачать"}</small>
|
>{vk === "video"
|
||||||
|
? t("chat.media.video")
|
||||||
|
: t("chat.media.photo")}</span
|
||||||
|
>
|
||||||
|
<small
|
||||||
|
>{waiting
|
||||||
|
? t("chat.media.queued")
|
||||||
|
: t("chat.media.tapToDownload")}</small
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{:else if media?.state === "not-downloaded"}
|
{:else if media?.state === "not-downloaded"}
|
||||||
<button class="media-chip" onclick={queue} type="button">
|
<button class="media-chip" onclick={queue} type="button">
|
||||||
<Icon name={queuing ? "timer" : "download"} size="1.25rem" />
|
<Icon name={waiting ? "timer" : "download"} size="1.25rem" />
|
||||||
<span>{queuing ? "В очереди" : `Скачать ${label}`}</span>
|
<span
|
||||||
|
>{waiting
|
||||||
|
? t("chat.media.queued")
|
||||||
|
: t("chat.media.download", { kind: label })}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="media-chip" onclick={onopen} type="button">
|
<button class="media-chip" onclick={onopen} type="button">
|
||||||
<Icon name="photo" size="1.25rem" />
|
<Icon name="photo" size="1.25rem" />
|
||||||
<span>Медиа</span>
|
<span>{t("chat.media")}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
{#if own}
|
|
||||||
<MessageReadItem {message} />
|
|
||||||
{/if}
|
|
||||||
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
|
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
|
||||||
>Открыть на весь экран</ContextMenuItem
|
>{t("chat.media.fullscreen")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem
|
{#if onversions}
|
||||||
icon="recent"
|
<ContextMenuItem icon="recent" onselect={onversions}
|
||||||
onselect={() => ui.openMessagePanel("versions", message.message_id)}
|
>{t("chat.media.versions")}</ContextMenuItem
|
||||||
>Версии медиа</ContextMenuItem
|
|
||||||
>
|
>
|
||||||
|
{/if}
|
||||||
{#if storedId === null}
|
{#if storedId === null}
|
||||||
<ContextMenuItem icon="cloud-download" onselect={queue}>
|
<ContextMenuItem icon="cloud-download" onselect={queue}
|
||||||
Скачать в архив
|
>{t("chat.media.queue")}</ContextMenuItem
|
||||||
</ContextMenuItem>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<ContextMenuItem icon="download" onselect={save}>
|
<ContextMenuItem icon="download" onselect={save}
|
||||||
Скачать файл
|
>{t("chat.media.save")}</ContextMenuItem
|
||||||
</ContextMenuItem>
|
>
|
||||||
<ContextMenuItem icon="allow-share" onselect={share}>
|
<ContextMenuItem icon="allow-share" onselect={share}
|
||||||
Доступ по ссылке
|
>{t("chat.media.share")}</ContextMenuItem
|
||||||
</ContextMenuItem>
|
>
|
||||||
|
{/if}
|
||||||
|
{#if items}
|
||||||
|
<Menu.Separator class="bg-menu-separator" />
|
||||||
|
{@render items()}
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|||||||
@@ -1,30 +1,86 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MessageView } from "#lib/api/types.js";
|
import type { MessageView } from "#lib/api/types.js";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
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 { readTitle } from "#lib/format/read.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: MessageView;
|
message: MessageView;
|
||||||
|
noted?: boolean;
|
||||||
|
onnote?: () => void;
|
||||||
onversions?: () => void;
|
onversions?: () => void;
|
||||||
own?: boolean;
|
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>
|
</script>
|
||||||
|
|
||||||
<span class="MessageMeta">
|
<span class="MessageMeta">
|
||||||
{#if message.edited_at}
|
{#if noted}
|
||||||
<button
|
<button
|
||||||
class="edited"
|
aria-label={t("chat.note")}
|
||||||
onclick={onversions}
|
class="meta-button note"
|
||||||
title="View edit history"
|
onclick={onnote}
|
||||||
|
title={t("chat.note")}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
edited
|
<Icon name="note" size="1rem" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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}
|
{#if own}
|
||||||
<span class="ticks" title={readTitle(message)}>
|
<span class="ticks" title={readTitle(message)}>
|
||||||
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
|
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
|
||||||
@@ -34,6 +90,8 @@
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.MessageMeta {
|
.MessageMeta {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -47,21 +105,70 @@
|
|||||||
user-select: none;
|
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;
|
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 {
|
.ticks {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edited {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: inherit;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,40 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
import { getChatCalendar, getMessageAt } from "#lib/api/endpoints.js";
|
import { getChatCalendar, getMessageAt } from "#lib/api/endpoints.js";
|
||||||
import type { DayCount } from "#lib/api/types.js";
|
import type { DayCount } from "#lib/api/types.js";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.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 { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
interface Props {
|
|
||||||
chatId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { chatId }: Props = $props();
|
|
||||||
|
|
||||||
const LEVELS = 4;
|
|
||||||
const WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
|
|
||||||
const MONTHS = [
|
|
||||||
"Январь",
|
|
||||||
"Февраль",
|
|
||||||
"Март",
|
|
||||||
"Апрель",
|
|
||||||
"Май",
|
|
||||||
"Июнь",
|
|
||||||
"Июль",
|
|
||||||
"Август",
|
|
||||||
"Сентябрь",
|
|
||||||
"Октябрь",
|
|
||||||
"Ноябрь",
|
|
||||||
"Декабрь",
|
|
||||||
];
|
|
||||||
|
|
||||||
interface YearMonth {
|
interface YearMonth {
|
||||||
month: number;
|
month: number;
|
||||||
year: 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 {
|
interface DayCell {
|
||||||
count: number;
|
count: number;
|
||||||
day: number;
|
day: number;
|
||||||
@@ -44,7 +46,7 @@
|
|||||||
|
|
||||||
let days = $state<DayCount[]>([]);
|
let days = $state<DayCount[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let cursor = $state<YearMonth | null>(null);
|
let cursor = $state<YearMonth | null>(untrack(() => initial));
|
||||||
const cache = new Map<string, DayCount[]>();
|
const cache = new Map<string, DayCount[]>();
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -61,6 +63,13 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const view = $derived(cursor ?? currentMonth);
|
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 {
|
function index(value: YearMonth): number {
|
||||||
return value.year * 12 + value.month;
|
return value.year * 12 + value.month;
|
||||||
@@ -115,9 +124,14 @@
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let shown = untrack(() => `${chatId}:${accounts.selectedId}`);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const _id = chatId;
|
const key = `${chatId}:${accounts.selectedId}`;
|
||||||
const _account = accounts.selectedId;
|
if (key === shown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shown = key;
|
||||||
cache.clear();
|
cache.clear();
|
||||||
cursor = null;
|
cursor = null;
|
||||||
days = [];
|
days = [];
|
||||||
@@ -161,7 +175,10 @@
|
|||||||
try {
|
try {
|
||||||
const target = await getMessageAt(chatId, cell.key);
|
const target = await getMessageAt(chatId, cell.key);
|
||||||
ui.requestJump(chatId, target.message_id);
|
ui.requestJump(chatId, target.message_id);
|
||||||
|
onjump?.();
|
||||||
|
if (page.params.chatId !== String(chatId)) {
|
||||||
goto(`/app/${chatId}`);
|
goto(`/app/${chatId}`);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// no message resolved for that day
|
// no message resolved for that day
|
||||||
}
|
}
|
||||||
@@ -174,7 +191,7 @@
|
|||||||
<div class="calendar">
|
<div class="calendar">
|
||||||
<header class="nav">
|
<header class="nav">
|
||||||
<button
|
<button
|
||||||
aria-label="Предыдущий год"
|
aria-label={t("chat.calendar.prevYear")}
|
||||||
class="step"
|
class="step"
|
||||||
onclick={() => shift(-12)}
|
onclick={() => shift(-12)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -182,16 +199,16 @@
|
|||||||
«
|
«
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Предыдущий месяц"
|
aria-label={t("chat.calendar.prevMonth")}
|
||||||
class="step"
|
class="step"
|
||||||
onclick={() => shift(-1)}
|
onclick={() => shift(-1)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="arrow-left" />
|
<Icon name="arrow-left" />
|
||||||
</button>
|
</button>
|
||||||
<span class="label">{MONTHS[view.month]} {view.year}</span>
|
<span class="label">{monthLabel}</span>
|
||||||
<button
|
<button
|
||||||
aria-label="Следующий месяц"
|
aria-label={t("chat.calendar.nextMonth")}
|
||||||
class="step"
|
class="step"
|
||||||
disabled={!canNext}
|
disabled={!canNext}
|
||||||
onclick={() => shift(1)}
|
onclick={() => shift(1)}
|
||||||
@@ -200,7 +217,7 @@
|
|||||||
<Icon name="arrow-right" />
|
<Icon name="arrow-right" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="Следующий год"
|
aria-label={t("chat.calendar.nextYear")}
|
||||||
class="step"
|
class="step"
|
||||||
disabled={!canNext}
|
disabled={!canNext}
|
||||||
onclick={() => shift(12)}
|
onclick={() => shift(12)}
|
||||||
@@ -211,7 +228,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="weekdays">
|
<div class="weekdays">
|
||||||
{#each WEEKDAYS as weekday (weekday)}
|
{#each weekdays as weekday (weekday)}
|
||||||
<span>{weekday}</span>
|
<span>{weekday}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +242,7 @@
|
|||||||
<button
|
<button
|
||||||
class="cell day lvl-{cell.level}"
|
class="cell day lvl-{cell.level}"
|
||||||
onclick={() => jump(cell)}
|
onclick={() => jump(cell)}
|
||||||
title={`${cell.count} сообщений`}
|
title={t("chat.calendar.count", { count: cell.count })}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{cell.day}
|
{cell.day}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
|
import { createContext } from "svelte";
|
||||||
|
|
||||||
type PointerHandler = (event: PointerEvent) => void;
|
type PointerHandler = (event: PointerEvent) => void;
|
||||||
|
|
||||||
const claimed = new WeakSet<Event>();
|
const claimed = new WeakSet<Event>();
|
||||||
|
|
||||||
|
export const [menuCloser, provideMenuCloser] = createContext<() => void>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -18,6 +22,10 @@
|
|||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let suppressClick = false;
|
let suppressClick = false;
|
||||||
|
|
||||||
|
provideMenuCloser(() => {
|
||||||
|
open = false;
|
||||||
|
});
|
||||||
|
|
||||||
function triggerProps(props: Record<string, unknown>) {
|
function triggerProps(props: Record<string, unknown>) {
|
||||||
const onpointerdown = props.onpointerdown as PointerHandler;
|
const onpointerdown = props.onpointerdown as PointerHandler;
|
||||||
const onpointerup = props.onpointerup 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>
|
||||||
Reference in new issue
Block a user