feat(frontend): my message menu shows when it was read or listened, group readers open in a right panel
This commit is contained in:
1 parent
f03d7c2890
commit
bb38e23f29
14 files changed
+543
-10
No files matched your search
@@ -31,6 +31,7 @@ import type {
|
|||||||
PresenceHourly,
|
PresenceHourly,
|
||||||
PresenceSample,
|
PresenceSample,
|
||||||
ReactionView,
|
ReactionView,
|
||||||
|
ReadInfo,
|
||||||
ResponseStats,
|
ResponseStats,
|
||||||
ScopeTarget,
|
ScopeTarget,
|
||||||
SearchHit,
|
SearchHit,
|
||||||
@@ -304,6 +305,25 @@ export function getChatLinks(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getReadInfo(
|
||||||
|
chatId: number,
|
||||||
|
messageId: number
|
||||||
|
): Promise<ReadInfo> {
|
||||||
|
return request<ReadInfo>(`/messages/${chatId}/${messageId}/read`, {
|
||||||
|
account: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshReadInfo(
|
||||||
|
chatId: number,
|
||||||
|
messageId: number
|
||||||
|
): Promise<{ job_id: number | null }> {
|
||||||
|
return request<{ job_id: number | null }>(
|
||||||
|
`/messages/${chatId}/${messageId}/read/refresh`,
|
||||||
|
{ body: { account_id: accounts.selectedId }, method: "POST" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getMessageReactions(
|
export function getMessageReactions(
|
||||||
chatId: number,
|
chatId: number,
|
||||||
messageId: number
|
messageId: number
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export interface MessageView {
|
|||||||
quote: string | null;
|
quote: string | null;
|
||||||
reactions: ReactionCount[];
|
reactions: ReactionCount[];
|
||||||
read: boolean;
|
read: boolean;
|
||||||
|
read_at: string | null;
|
||||||
reply: ReplyView | null;
|
reply: ReplyView | null;
|
||||||
sender_id: number | null;
|
sender_id: number | null;
|
||||||
service: ServiceView | null;
|
service: ServiceView | null;
|
||||||
@@ -369,6 +370,40 @@ export interface PeerView {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReaderView {
|
||||||
|
peer: PeerView | null;
|
||||||
|
peer_id: number;
|
||||||
|
read_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReadOutcome =
|
||||||
|
| "ok"
|
||||||
|
| "not_read_yet"
|
||||||
|
| "user_privacy"
|
||||||
|
| "your_privacy"
|
||||||
|
| "chat_too_big"
|
||||||
|
| "too_old"
|
||||||
|
| "unavailable"
|
||||||
|
| "flood"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface ReadInfo {
|
||||||
|
can_fetch: boolean;
|
||||||
|
chat_id: number;
|
||||||
|
chat_kind: "dm" | "group" | "channel";
|
||||||
|
exact_at: string | null;
|
||||||
|
fetched_at: string | null;
|
||||||
|
listened_at: string | null;
|
||||||
|
message_id: number;
|
||||||
|
observed_at: string | null;
|
||||||
|
outcome: ReadOutcome | null;
|
||||||
|
own: boolean;
|
||||||
|
read: boolean;
|
||||||
|
readers: ReaderView[];
|
||||||
|
unread: boolean;
|
||||||
|
voice: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PeerHistoryView {
|
export interface PeerHistoryView {
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
is_deleted_account: boolean;
|
is_deleted_account: boolean;
|
||||||
@@ -518,6 +553,7 @@ export interface LivePresenceEvent {
|
|||||||
|
|
||||||
export interface LiveReceiptEvent {
|
export interface LiveReceiptEvent {
|
||||||
chat_id: number;
|
chat_id: number;
|
||||||
|
read_at: string;
|
||||||
read_up_to: number;
|
read_up_to: number;
|
||||||
type: "receipt";
|
type: "receipt";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import Poll from "#lib/components/Poll.svelte";
|
import Poll from "#lib/components/Poll.svelte";
|
||||||
import Reactions from "#lib/components/Reactions.svelte";
|
import Reactions from "#lib/components/Reactions.svelte";
|
||||||
import ReplyHeader from "#lib/components/ReplyHeader.svelte";
|
import ReplyHeader from "#lib/components/ReplyHeader.svelte";
|
||||||
|
import MessageReadItem from "#lib/components/read/MessageReadItem.svelte";
|
||||||
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";
|
||||||
@@ -247,6 +248,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
|
{#if own}
|
||||||
|
<MessageReadItem {message} />
|
||||||
|
{/if}
|
||||||
<ContextMenuItem icon="note" onselect={openAnnotations}
|
<ContextMenuItem icon="note" onselect={openAnnotations}
|
||||||
>Заметки</ContextMenuItem
|
>Заметки</ContextMenuItem
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -363,13 +363,13 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyReceipt(readUpTo: number) {
|
function applyReceipt(readUpTo: number, readAt: string) {
|
||||||
if (ownId === null) {
|
if (ownId === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
messages = messages.map((m) =>
|
messages = messages.map((m) =>
|
||||||
m.sender_id === ownId && m.message_id <= readUpTo && !m.read
|
m.sender_id === ownId && m.message_id <= readUpTo && !m.read
|
||||||
? { ...m, read: true }
|
? { ...m, read: true, read_at: readAt }
|
||||||
: m
|
: m
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -388,7 +388,7 @@
|
|||||||
) {
|
) {
|
||||||
applyDelete(event.message_ids);
|
applyDelete(event.message_ids);
|
||||||
} else if (event.type === "receipt" && event.chat_id === chatId) {
|
} else if (event.type === "receipt" && event.chat_id === chatId) {
|
||||||
applyReceipt(event.read_up_to);
|
applyReceipt(event.read_up_to, event.read_at);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
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";
|
||||||
@@ -222,6 +223,9 @@
|
|||||||
</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
|
>Открыть на весь экран</ContextMenuItem
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
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 { formatTime } from "#lib/format/datetime.js";
|
||||||
|
import { readTitle } from "#lib/format/read.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: MessageView;
|
message: MessageView;
|
||||||
@@ -25,11 +26,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class="time">{formatTime(message.date)}</span>
|
<span class="time">{formatTime(message.date)}</span>
|
||||||
{#if own}
|
{#if own}
|
||||||
<Icon
|
<span class="ticks" title={readTitle(message)}>
|
||||||
class="ticks"
|
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
|
||||||
name={message.read ? "message-read" : "check"}
|
</span>
|
||||||
size="1rem"
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -52,6 +51,10 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.edited {
|
.edited {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
|
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
|
||||||
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
|
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
|
||||||
import ProfilePanel from "#lib/components/profile/ProfilePanel.svelte";
|
import ProfilePanel from "#lib/components/profile/ProfilePanel.svelte";
|
||||||
|
import ReadersPanel from "#lib/components/read/ReadersPanel.svelte";
|
||||||
import ChatSearchPanel from "#lib/components/search/ChatSearchPanel.svelte";
|
import ChatSearchPanel from "#lib/components/search/ChatSearchPanel.svelte";
|
||||||
import SharesPanel from "#lib/components/shares/SharesPanel.svelte";
|
import SharesPanel from "#lib/components/shares/SharesPanel.svelte";
|
||||||
import CallbacksPanel from "#lib/components/social/CallbacksPanel.svelte";
|
import CallbacksPanel from "#lib/components/social/CallbacksPanel.svelte";
|
||||||
@@ -18,13 +19,14 @@
|
|||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
|
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
|
||||||
|
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 { policyUi } from "#lib/stores/policy.svelte.js";
|
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||||
import { storageUi } from "#lib/stores/storage.svelte.js";
|
import { storageUi } from "#lib/stores/storage.svelte.js";
|
||||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
const titles: Record<RightPanel, string> = {
|
const titles: Record<RightPanel, string> = $derived({
|
||||||
alerts: "Алерты",
|
alerts: "Алерты",
|
||||||
annotations: "Заметки",
|
annotations: "Заметки",
|
||||||
bots: "Боты",
|
bots: "Боты",
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
presence: "Аналитика",
|
presence: "Аналитика",
|
||||||
profile: "Профиль",
|
profile: "Профиль",
|
||||||
reactions: "Реакции",
|
reactions: "Реакции",
|
||||||
|
readers: t("read.readers.title"),
|
||||||
search: "Поиск",
|
search: "Поиск",
|
||||||
shares: "Файлы по ссылке",
|
shares: "Файлы по ссылке",
|
||||||
storage: "Хранилище",
|
storage: "Хранилище",
|
||||||
@@ -44,7 +47,7 @@
|
|||||||
"stories-all": "Все сторис",
|
"stories-all": "Все сторис",
|
||||||
versions: "Версии",
|
versions: "Версии",
|
||||||
watches: "Отслеживания",
|
watches: "Отслеживания",
|
||||||
};
|
});
|
||||||
|
|
||||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||||
"storage-chat": "storage",
|
"storage-chat": "storage",
|
||||||
@@ -104,6 +107,8 @@
|
|||||||
<ChatSearchPanel />
|
<ChatSearchPanel />
|
||||||
{:else if ui.rightPanel === "reactions"}
|
{:else if ui.rightPanel === "reactions"}
|
||||||
<ReactionsPanel />
|
<ReactionsPanel />
|
||||||
|
{:else if ui.rightPanel === "readers"}
|
||||||
|
<ReadersPanel />
|
||||||
{:else if ui.rightPanel === "callbacks"}
|
{:else if ui.rightPanel === "callbacks"}
|
||||||
<CallbacksPanel />
|
<CallbacksPanel />
|
||||||
{:else if ui.rightPanel === "links"}
|
{:else if ui.rightPanel === "links"}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ContextMenu } from "bits-ui";
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import type { MessageView } from "#lib/api/types.js";
|
||||||
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
import { peerColorIndex, peerName } from "#lib/format/peer.js";
|
||||||
|
import { readLine } from "#lib/format/read.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import { reads } from "#lib/stores/reads.svelte.js";
|
||||||
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: MessageView;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVATARS = 3;
|
||||||
|
|
||||||
|
let { message }: Props = $props();
|
||||||
|
|
||||||
|
const entry = $derived(reads.get(message.chat_id, message.message_id));
|
||||||
|
const fetching = $derived(entry?.fetching ?? false);
|
||||||
|
const shown = $derived(readLine(message, entry?.info ?? null, fetching));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const chatId = message.chat_id;
|
||||||
|
const messageId = message.message_id;
|
||||||
|
untrack(() => reads.open(chatId, messageId));
|
||||||
|
});
|
||||||
|
|
||||||
|
function openReaders() {
|
||||||
|
ui.openMessagePanel("readers", message.message_id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if shown?.kind === "readers"}
|
||||||
|
<ContextMenu.Item class="bg-menu-item read-item" onSelect={openReaders}>
|
||||||
|
<Icon name="group" size="1.125rem" />
|
||||||
|
<span class="label"
|
||||||
|
>{t("read.readers", { count: shown.readers.length })}</span
|
||||||
|
>
|
||||||
|
{#if fetching}
|
||||||
|
<Spinner color="gray" size="1rem" />
|
||||||
|
{/if}
|
||||||
|
<span class="avatars">
|
||||||
|
{#each shown.readers.slice(0, AVATARS) as reader (reader.peer_id)}
|
||||||
|
<Avatar
|
||||||
|
avatar={{ id: reader.peer_id, kind: "peer" }}
|
||||||
|
colorKey={peerColorIndex(reader.peer_id)}
|
||||||
|
hasAvatar={reader.peer?.has_avatar ?? false}
|
||||||
|
name={peerName(reader.peer) || String(reader.peer_id)}
|
||||||
|
size={1.5}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
<ContextMenu.Separator class="bg-menu-separator" />
|
||||||
|
{:else if shown}
|
||||||
|
<div class="bg-menu-item read-item static" class:muted={shown.muted}>
|
||||||
|
<Icon name={shown.icon} size="1.125rem" />
|
||||||
|
<span class="label">{shown.text}</span>
|
||||||
|
{#if fetching}
|
||||||
|
<Spinner color="gray" size="1rem" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<ContextMenu.Separator class="bg-menu-separator" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global(.bg-menu-item.read-item) {
|
||||||
|
min-height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.static {
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatars {
|
||||||
|
display: flex;
|
||||||
|
margin-inline-start: 0.25rem;
|
||||||
|
|
||||||
|
& > :global(.Avatar) {
|
||||||
|
border: 2px solid var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > :global(.Avatar + .Avatar) {
|
||||||
|
margin-inline-start: -0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
import { peerColorIndex, peerName } from "#lib/format/peer.js";
|
||||||
|
import { readWhen } from "#lib/format/read.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import { reads } from "#lib/stores/reads.svelte.js";
|
||||||
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const chatId = $derived(
|
||||||
|
page.params.chatId ? Number(page.params.chatId) : null
|
||||||
|
);
|
||||||
|
const messageId = $derived(ui.panelMessageId);
|
||||||
|
const entry = $derived(
|
||||||
|
chatId === null || messageId === null
|
||||||
|
? undefined
|
||||||
|
: reads.get(chatId, messageId)
|
||||||
|
);
|
||||||
|
const readers = $derived(entry?.info.readers ?? []);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = chatId;
|
||||||
|
const mid = messageId;
|
||||||
|
if (cid !== null && mid !== null) {
|
||||||
|
untrack(() => reads.open(cid, mid));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openProfile(peerId: number) {
|
||||||
|
goto(`/app/${peerId}`);
|
||||||
|
ui.openPanel("profile");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if entry === undefined}
|
||||||
|
<div class="center"><Spinner /></div>
|
||||||
|
{:else if readers.length === 0}
|
||||||
|
<EmptyState title={t("read.readers.empty")} />
|
||||||
|
{:else}
|
||||||
|
<ul class="readers">
|
||||||
|
{#each readers as reader (reader.peer_id)}
|
||||||
|
{@const name = peerName(reader.peer) || String(reader.peer_id)}
|
||||||
|
<li>
|
||||||
|
<button onclick={() => openProfile(reader.peer_id)} type="button">
|
||||||
|
<Avatar
|
||||||
|
avatar={{ id: reader.peer_id, kind: "peer" }}
|
||||||
|
colorKey={peerColorIndex(reader.peer_id)}
|
||||||
|
hasAvatar={reader.peer?.has_avatar ?? false}
|
||||||
|
{name}
|
||||||
|
size={2.625}
|
||||||
|
/>
|
||||||
|
<span class="info">
|
||||||
|
<span class="name">{name}</span>
|
||||||
|
<span class="time">{readWhen(reader.read_at)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4375rem 0.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--border-radius-default-small);
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: start;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type {
|
||||||
|
MessageView,
|
||||||
|
ReaderView,
|
||||||
|
ReadInfo,
|
||||||
|
ReadOutcome,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import { formatTime } from "#lib/format/datetime.js";
|
||||||
|
import { locale, t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
|
|
||||||
|
export type ReadLine =
|
||||||
|
| { kind: "readers"; readers: ReaderView[] }
|
||||||
|
| { icon: string; kind: "text"; muted: boolean; text: string };
|
||||||
|
|
||||||
|
const REASONS: Partial<Record<ReadOutcome, MessageKey>> = {
|
||||||
|
chat_too_big: "read.reason.chat_too_big",
|
||||||
|
error: "read.reason.error",
|
||||||
|
flood: "read.reason.flood",
|
||||||
|
too_old: "read.reason.too_old",
|
||||||
|
unavailable: "read.reason.unavailable",
|
||||||
|
user_privacy: "read.reason.user_privacy",
|
||||||
|
your_privacy: "read.reason.your_privacy",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||||
|
|
||||||
|
function dayLabel(date: Date, withYear: boolean): string {
|
||||||
|
const formatKey = `${locale.tag}:${withYear}`;
|
||||||
|
let formatter = dayFormatters.get(formatKey);
|
||||||
|
if (!formatter) {
|
||||||
|
formatter = new Intl.DateTimeFormat(locale.tag, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: withYear ? "numeric" : undefined,
|
||||||
|
});
|
||||||
|
dayFormatters.set(formatKey, formatter);
|
||||||
|
}
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const pick = (type: Intl.DateTimeFormatPartTypes) =>
|
||||||
|
parts.find((part) => part.type === type)?.value.replace(".", "");
|
||||||
|
return [pick("day"), pick("month"), pick("year")].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readWhen(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const time = formatTime(iso);
|
||||||
|
if (date.toDateString() === now.toDateString()) {
|
||||||
|
return t("read.when.today", { time });
|
||||||
|
}
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(now.getDate() - 1);
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return t("read.when.yesterday", { time });
|
||||||
|
}
|
||||||
|
const withYear = date.getFullYear() !== now.getFullYear();
|
||||||
|
return t("read.when.day", { day: dayLabel(date, withYear), time });
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(text: string, icon = "message-read", muted = false): ReadLine {
|
||||||
|
return { icon, kind: "text", muted, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTitle(message: MessageView): string | undefined {
|
||||||
|
if (message.read_at) {
|
||||||
|
return t("read.at", { when: readWhen(message.read_at) });
|
||||||
|
}
|
||||||
|
return message.read ? t("read.done") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLine(
|
||||||
|
message: MessageView,
|
||||||
|
info: ReadInfo | null,
|
||||||
|
fetching: boolean
|
||||||
|
): ReadLine | null {
|
||||||
|
if (info === null) {
|
||||||
|
const title = readTitle(message);
|
||||||
|
return title ? line(title) : null;
|
||||||
|
}
|
||||||
|
if (info.chat_kind === "group" && info.readers.length > 0) {
|
||||||
|
return { kind: "readers", readers: info.readers };
|
||||||
|
}
|
||||||
|
if (info.voice && info.listened_at) {
|
||||||
|
return line(
|
||||||
|
t("read.listened", { when: readWhen(info.listened_at) }),
|
||||||
|
"play-story"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const at = info.exact_at ?? info.observed_at;
|
||||||
|
if (at) {
|
||||||
|
return line(t("read.at", { when: readWhen(at) }));
|
||||||
|
}
|
||||||
|
if (info.read) {
|
||||||
|
return line(t("read.done"));
|
||||||
|
}
|
||||||
|
if (info.unread) {
|
||||||
|
return line(t("read.unread"), "check");
|
||||||
|
}
|
||||||
|
const reason = info.outcome ? REASONS[info.outcome] : undefined;
|
||||||
|
if (reason) {
|
||||||
|
return line(t(reason), "eye-crossed-outline", true);
|
||||||
|
}
|
||||||
|
return fetching ? line(t("read.pending"), "message-read", true) : null;
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { account } from "#lib/i18n/messages/account.js";
|
import { account } from "#lib/i18n/messages/account.js";
|
||||||
import { common } from "#lib/i18n/messages/common.js";
|
import { common } from "#lib/i18n/messages/common.js";
|
||||||
import { menu } from "#lib/i18n/messages/menu.js";
|
import { menu } from "#lib/i18n/messages/menu.js";
|
||||||
|
import { read } from "#lib/i18n/messages/read.js";
|
||||||
import { settings } from "#lib/i18n/messages/settings.js";
|
import { settings } from "#lib/i18n/messages/settings.js";
|
||||||
|
|
||||||
export const messages = {
|
export const messages = {
|
||||||
...account,
|
...account,
|
||||||
...common,
|
...common,
|
||||||
...menu,
|
...menu,
|
||||||
|
...read,
|
||||||
...settings,
|
...settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Messages } from "#lib/i18n/types.js";
|
||||||
|
|
||||||
|
export const read = {
|
||||||
|
"read.at": { en: "Read {when}", ru: "Прочитано {when}" },
|
||||||
|
"read.done": { en: "Read", ru: "Прочитано" },
|
||||||
|
"read.listened": { en: "Listened {when}", ru: "Прослушано {when}" },
|
||||||
|
"read.pending": { en: "Read time", ru: "Время прочтения" },
|
||||||
|
"read.readers": { en: "Read by {count}", ru: "Прочитали: {count}" },
|
||||||
|
"read.readers.empty": {
|
||||||
|
en: "Nobody has read it yet",
|
||||||
|
ru: "Пока никто не прочитал",
|
||||||
|
},
|
||||||
|
"read.readers.title": { en: "Read by", ru: "Прочитали" },
|
||||||
|
"read.reason.chat_too_big": {
|
||||||
|
en: "The group has over 100 members",
|
||||||
|
ru: "В группе больше 100 участников",
|
||||||
|
},
|
||||||
|
"read.reason.error": {
|
||||||
|
en: "Telegram did not answer",
|
||||||
|
ru: "Telegram не ответил",
|
||||||
|
},
|
||||||
|
"read.reason.flood": {
|
||||||
|
en: "Telegram asked to wait",
|
||||||
|
ru: "Telegram попросил подождать",
|
||||||
|
},
|
||||||
|
"read.reason.too_old": {
|
||||||
|
en: "Telegram no longer keeps the time",
|
||||||
|
ru: "Telegram уже не хранит время",
|
||||||
|
},
|
||||||
|
"read.reason.unavailable": {
|
||||||
|
en: "Telegram cannot find the chat",
|
||||||
|
ru: "Telegram не находит чат",
|
||||||
|
},
|
||||||
|
"read.reason.user_privacy": {
|
||||||
|
en: "They hide their read time",
|
||||||
|
ru: "Собеседник скрывает время прочтения",
|
||||||
|
},
|
||||||
|
"read.reason.your_privacy": {
|
||||||
|
en: "You hide your read time",
|
||||||
|
ru: "Вы скрываете своё время прочтения",
|
||||||
|
},
|
||||||
|
"read.unread": { en: "Not read yet", ru: "Не прочитано" },
|
||||||
|
"read.when.day": { en: "{day} at {time}", ru: "{day} в {time}" },
|
||||||
|
"read.when.today": { en: "today at {time}", ru: "сегодня в {time}" },
|
||||||
|
"read.when.yesterday": {
|
||||||
|
en: "yesterday at {time}",
|
||||||
|
ru: "вчера в {time}",
|
||||||
|
},
|
||||||
|
} satisfies Messages;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { getJob, getReadInfo, refreshReadInfo } from "#lib/api/endpoints.js";
|
||||||
|
import type { ReadInfo } from "#lib/api/types.js";
|
||||||
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 1000;
|
||||||
|
const POLL_MAX = 20;
|
||||||
|
|
||||||
|
export interface ReadEntry {
|
||||||
|
fetching: boolean;
|
||||||
|
info: ReadInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForJob(jobId: number) {
|
||||||
|
for (let attempt = 0; attempt < POLL_MAX; attempt += 1) {
|
||||||
|
// biome-ignore lint/performance/noAwaitInLoops: polling waits between tries on purpose
|
||||||
|
await delay(POLL_INTERVAL);
|
||||||
|
const job = await getJob(jobId);
|
||||||
|
if (job.status !== "pending" && job.status !== "running") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReads() {
|
||||||
|
let entries = $state<Record<string, ReadEntry>>({});
|
||||||
|
const inflight = new Set<string>();
|
||||||
|
|
||||||
|
function key(chatId: number, messageId: number): string {
|
||||||
|
return `${accounts.selectedId}:${chatId}:${messageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(entryKey: string, chatId: number, messageId: number) {
|
||||||
|
const info = await getReadInfo(chatId, messageId);
|
||||||
|
entries[entryKey] = { fetching: info.can_fetch, info };
|
||||||
|
if (!info.can_fetch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { job_id } = await refreshReadInfo(chatId, messageId);
|
||||||
|
if (job_id !== null) {
|
||||||
|
await waitForJob(job_id);
|
||||||
|
}
|
||||||
|
entries[entryKey] = {
|
||||||
|
fetching: false,
|
||||||
|
info: await getReadInfo(chatId, messageId),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
entries[entryKey].fetching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(chatId: number, messageId: number): ReadEntry | undefined {
|
||||||
|
return entries[key(chatId, messageId)];
|
||||||
|
},
|
||||||
|
open(chatId: number, messageId: number) {
|
||||||
|
const entryKey = key(chatId, messageId);
|
||||||
|
if (accounts.selectedId === null || inflight.has(entryKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inflight.add(entryKey);
|
||||||
|
load(entryKey, chatId, messageId)
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(entryKey);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reads = createReads();
|
||||||
@@ -3,6 +3,7 @@ export type RightPanel =
|
|||||||
| "search"
|
| "search"
|
||||||
| "versions"
|
| "versions"
|
||||||
| "reactions"
|
| "reactions"
|
||||||
|
| "readers"
|
||||||
| "callbacks"
|
| "callbacks"
|
||||||
| "links"
|
| "links"
|
||||||
| "annotations"
|
| "annotations"
|
||||||
|
|||||||
Reference in new issue
Block a user