feat: add annotations, user profiles, watchers, stories, search and more

This commit is contained in:
hh committed 2026-06-01 17:15:09 +02:00
1 parent ed469ba8dd
commit 2465bcd184
47 files changed
+5009 -242

No files matched your search

+221 -82
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { MessageView } from "$lib/api/types";
import Contact from "$lib/components/Contact.svelte";
import EntityText from "$lib/components/EntityText.svelte";
@@ -12,11 +13,15 @@
import Reactions from "$lib/components/Reactions.svelte";
import ReplyHeader from "$lib/components/ReplyHeader.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";
import Icon from "$lib/components/ui/Icon.svelte";
import WebPage from "$lib/components/WebPage.svelte";
import { accountName, peerColorIndex, peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
import { appear } from "$lib/transitions/appear";
interface Props {
@@ -75,100 +80,210 @@
? ownId !== null && peers.get(ownId)?.has_avatar
: (sender?.has_avatar ?? false)
);
const hasCallbacks = $derived(
message.inline_buttons.some((row) =>
row.some((button) => button.kind === "callback" && button.data)
)
);
const linkCount = $derived.by(() => {
const count = message.entities.filter(
(entity) => entity.type === "url" || entity.type === "text_link"
).length;
if (count > 0) {
return count;
}
return message.web_page ? 1 : 0;
});
function openReactions() {
ui.openMessagePanel("reactions", message.message_id);
}
function openCallbacks() {
ui.openMessagePanel("callbacks", message.message_id);
}
function openLinks() {
ui.openMessagePanel("links", message.message_id);
}
function openAnnotations() {
ui.openMessagePanel("annotations", message.message_id);
}
function jumpToReply() {
if (message.reply?.message_id != null) {
onjump(message.reply.message_id);
}
}
async function copyText() {
await navigator.clipboard.writeText(message.text ?? "");
toasts.success("Текст скопирован");
}
async function copyId() {
await navigator.clipboard.writeText(String(message.message_id));
toasts.success("ID скопирован");
}
function openSenderProfile() {
if (message.sender_id !== null) {
goto(`/app/${message.sender_id}`);
ui.openPanel("profile");
}
}
</script>
<div
class="Message"
class:own
class:deleted
class:highlighted
class:first-in-group={firstInGroup}
class:last-in-group={lastInGroup}
class:with-avatar={isGroupChat}
data-message-id={message.message_id}
in:appear={{ disabled: !animate }}
>
{#if isGroupChat}
<div class="avatar-slot">
{#if lastInGroup && avatarId !== null}
<Avatar
name={senderName}
colorKey={avatarId}
size={2.125}
avatar={{ kind: "peer", id: avatarId }}
hasAvatar={avatarHas}
/>
<ContextMenu>
{#snippet children({ props })}
<div
{...props}
class="Message"
class:own
class:deleted
class:highlighted
class:first-in-group={firstInGroup}
class:last-in-group={lastInGroup}
class:with-avatar={isGroupChat}
data-message-id={message.message_id}
in:appear={{ disabled: !animate }}
>
{#if isGroupChat}
<div class="avatar-slot">
{#if lastInGroup && avatarId !== null}
<Avatar
name={senderName}
colorKey={avatarId}
size={2.125}
avatar={{ kind: "peer", id: avatarId }}
hasAvatar={avatarHas}
/>
{/if}
</div>
{/if}
</div>
{/if}
<div class="message-content" class:has-appendix={lastInGroup}>
{#if showName}
<div class="sender-name peer-color-{colorIndex}">{senderName}</div>
<div class="message-content" class:has-appendix={lastInGroup}>
{#if showName}
<div class="sender-name peer-color-{colorIndex}">{senderName}</div>
{/if}
{#if message.forward}
<ForwardHeader forward={message.forward} />
{/if}
{#if message.reply}
<ReplyHeader reply={message.reply} {onjump} />
{/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
deleted
</span>
{/if}
{#if message.poll}
<Poll poll={message.poll} />
{/if}
{#if message.contact}
<Contact contact={message.contact} />
{/if}
{#if message.location}
<Location location={message.location} />
{/if}
{#if !special}
{#if message.media.length > 1}
<MediaAlbum
media={message.media}
chatId={message.chat_id}
onopen={onmedia}
/>
{:else if message.has_media}
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
{/if}
{/if}
{#if hasText}
<div class="text">
<EntityText
text={message.text ?? ""}
entities={message.entities}
{own}
/>
</div>
{:else if !(message.has_media || deleted || special)}
<div class="text empty">(no text)</div>
{/if}
{#if message.web_page}
<WebPage {message} {own} {onmedia} />
{/if}
{#if message.reactions.length}
<Reactions
reactions={message.reactions}
{own}
onclick={openReactions}
/>
{/if}
{#if linkCount > 0}
<button type="button" class="detail-chip" onclick={openLinks}>
<Icon name="link" size="0.875rem" />
{linkCount}
{linkCount === 1 ? "ссылка" : "ссылки"}
</button>
{/if}
<MessageMeta {message} {own} {onversions} />
{#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} />
{/if}
{#if hasCallbacks}
<button type="button" class="detail-chip" onclick={openCallbacks}>
<Icon name="bot-command" size="0.875rem" />
Callback-данные
</button>
{/if}
{#if lastInGroup}
<svg aria-hidden="true" class="svg-appendix" height="20" width="9">
<path
class="corner"
d="M3 17h6V0c-.193 2.84-.876 5.767-2.05 8.782-.904 2.325-2.446 4.485-4.625 6.48A1 1 0 003 17z"
/>
</svg>
{/if}
</div>
</div>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="note" onselect={openAnnotations}
>Заметки</ContextMenuItem
>
{#if message.reactions.length}
<ContextMenuItem icon="heart" onselect={openReactions}
>Реакции</ContextMenuItem
>
{/if}
{#if message.forward}
<ForwardHeader forward={message.forward} />
{#if linkCount > 0}
<ContextMenuItem icon="link" onselect={openLinks}>Ссылки</ContextMenuItem>
{/if}
{#if message.reply}
<ReplyHeader reply={message.reply} {onjump} />
{#if hasCallbacks}
<ContextMenuItem icon="bot-command" onselect={openCallbacks}
>Callback-данные</ContextMenuItem
>
{/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
deleted
</span>
{#if message.edited_at}
<ContextMenuItem icon="edit" onselect={onversions}
>Версии</ContextMenuItem
>
{/if}
{#if message.poll}
<Poll poll={message.poll} />
{/if}
{#if message.contact}
<Contact contact={message.contact} />
{/if}
{#if message.location}
<Location location={message.location} />
{/if}
{#if !special}
{#if message.media.length > 1}
<MediaAlbum
media={message.media}
chatId={message.chat_id}
onopen={onmedia}
/>
{:else if message.has_media}
<MessageMedia {message} {own} onopen={() => onmedia(0)} />
{/if}
{#if message.reply?.message_id != null}
<ContextMenuItem icon="reply" onselect={jumpToReply}
>Перейти к ответу</ContextMenuItem
>
{/if}
{#if hasText}
<div class="text">
<EntityText
text={message.text ?? ""}
entities={message.entities}
{own}
/>
</div>
{:else if !(message.has_media || deleted || special)}
<div class="text empty">(no text)</div>
<ContextMenuItem icon="copy" onselect={copyText}
>Копировать текст</ContextMenuItem
>
{/if}
{#if message.web_page}
<WebPage {message} {own} {onmedia} />
<ContextMenuItem icon="hashtag" onselect={copyId}
>Копировать ID</ContextMenuItem
>
{#if isGroupChat && !own && message.sender_id !== null}
<ContextMenuItem icon="info" onselect={openSenderProfile}
>Профиль автора</ContextMenuItem
>
{/if}
{#if message.reactions.length}
<Reactions reactions={message.reactions} {own} />
{/if}
<MessageMeta {message} {own} {onversions} />
{#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} />
{/if}
{#if lastInGroup}
<svg aria-hidden="true" class="svg-appendix" height="20" width="9">
<path
class="corner"
d="M3 17h6V0c-.193 2.84-.876 5.767-2.05 8.782-.904 2.325-2.446 4.485-4.625 6.48A1 1 0 003 17z"
/>
</svg>
{/if}
</div>
</div>
{/snippet}
</ContextMenu>
<style lang="scss">
.Message {
@@ -278,6 +393,30 @@
color: var(--color-error);
}
.detail-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
align-self: flex-start;
margin-top: 0.25rem;
padding: 0.125rem 0.5rem 0.125rem 0.375rem;
border: 0;
border-radius: 1rem;
font-family: inherit;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
cursor: pointer;
background-color: var(--color-message-reaction);
&:hover {
color: var(--color-primary);
}
}
.svg-appendix {
position: absolute;
bottom: -0.0625rem;