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
@@ -11,6 +11,7 @@
|
||||
import Poll from "#lib/components/Poll.svelte";
|
||||
import Reactions from "#lib/components/Reactions.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 ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||
@@ -247,6 +248,9 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
{#if own}
|
||||
<MessageReadItem {message} />
|
||||
{/if}
|
||||
<ContextMenuItem icon="note" onselect={openAnnotations}
|
||||
>Заметки</ContextMenuItem
|
||||
>
|
||||
|
||||
@@ -363,13 +363,13 @@
|
||||
);
|
||||
}
|
||||
|
||||
function applyReceipt(readUpTo: number) {
|
||||
function applyReceipt(readUpTo: number, readAt: string) {
|
||||
if (ownId === null) {
|
||||
return;
|
||||
}
|
||||
messages = messages.map((m) =>
|
||||
m.sender_id === ownId && m.message_id <= readUpTo && !m.read
|
||||
? { ...m, read: true }
|
||||
? { ...m, read: true, read_at: readAt }
|
||||
: m
|
||||
);
|
||||
}
|
||||
@@ -388,7 +388,7 @@
|
||||
) {
|
||||
applyDelete(event.message_ids);
|
||||
} 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 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";
|
||||
@@ -222,6 +223,9 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet menu()}
|
||||
{#if own}
|
||||
<MessageReadItem {message} />
|
||||
{/if}
|
||||
<ContextMenuItem icon="open-in-new-tab" onselect={onopen}
|
||||
>Открыть на весь экран</ContextMenuItem
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { MessageView } from "#lib/api/types.js";
|
||||
import Icon from "#lib/components/ui/Icon.svelte";
|
||||
import { formatTime } from "#lib/format/datetime.js";
|
||||
import { readTitle } from "#lib/format/read.js";
|
||||
|
||||
interface Props {
|
||||
message: MessageView;
|
||||
@@ -25,11 +26,9 @@
|
||||
{/if}
|
||||
<span class="time">{formatTime(message.date)}</span>
|
||||
{#if own}
|
||||
<Icon
|
||||
class="ticks"
|
||||
name={message.read ? "message-read" : "check"}
|
||||
size="1rem"
|
||||
/>
|
||||
<span class="ticks" title={readTitle(message)}>
|
||||
<Icon name={message.read ? "message-read" : "check"} size="1rem" />
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
@@ -52,6 +51,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticks {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.edited {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
|
||||
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.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 SharesPanel from "#lib/components/shares/SharesPanel.svelte";
|
||||
import CallbacksPanel from "#lib/components/social/CallbacksPanel.svelte";
|
||||
@@ -18,13 +19,14 @@
|
||||
import Button from "#lib/components/ui/Button.svelte";
|
||||
import Icon from "#lib/components/ui/Icon.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 { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||
import { storageUi } from "#lib/stores/storage.svelte.js";
|
||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||
|
||||
const titles: Record<RightPanel, string> = {
|
||||
const titles: Record<RightPanel, string> = $derived({
|
||||
alerts: "Алерты",
|
||||
annotations: "Заметки",
|
||||
bots: "Боты",
|
||||
@@ -35,6 +37,7 @@
|
||||
presence: "Аналитика",
|
||||
profile: "Профиль",
|
||||
reactions: "Реакции",
|
||||
readers: t("read.readers.title"),
|
||||
search: "Поиск",
|
||||
shares: "Файлы по ссылке",
|
||||
storage: "Хранилище",
|
||||
@@ -44,7 +47,7 @@
|
||||
"stories-all": "Все сторис",
|
||||
versions: "Версии",
|
||||
watches: "Отслеживания",
|
||||
};
|
||||
});
|
||||
|
||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||
"storage-chat": "storage",
|
||||
@@ -104,6 +107,8 @@
|
||||
<ChatSearchPanel />
|
||||
{:else if ui.rightPanel === "reactions"}
|
||||
<ReactionsPanel />
|
||||
{:else if ui.rightPanel === "readers"}
|
||||
<ReadersPanel />
|
||||
{:else if ui.rightPanel === "callbacks"}
|
||||
<CallbacksPanel />
|
||||
{: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>
|
||||
Reference in new issue
Block a user