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

@@ -0,0 +1,183 @@
<script lang="ts">
import { untrack } from "svelte";
import { page } from "$app/state";
import { getMessageCallbacks } from "$lib/api/endpoints";
import type { CallbackView } from "$lib/api/types";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
const HEX_PAIR = /.{1,2}/g;
const MIN_PRINTABLE = 32;
const TAB = 9;
const LF = 10;
const CR = 13;
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const messageId = $derived(ui.panelMessageId);
let items = $state<CallbackView[]>([]);
let loading = $state(false);
let token = 0;
let copied = $state<number | null>(null);
let resetTimer: ReturnType<typeof setTimeout> | undefined;
function isPrintable(text: string): boolean {
for (const char of text) {
const code = char.codePointAt(0) ?? 0;
if (code < MIN_PRINTABLE && code !== TAB && code !== LF && code !== CR) {
return false;
}
}
return true;
}
function decode(hex: string | null): string {
if (!hex) {
return "";
}
const pairs = hex.match(HEX_PAIR);
if (!pairs) {
return hex;
}
try {
const bytes = Uint8Array.from(pairs, (pair) => Number.parseInt(pair, 16));
const text = new TextDecoder().decode(bytes);
return isPrintable(text) ? text : hex;
} catch {
return hex;
}
}
async function copy(position: number, value: string): Promise<void> {
await navigator.clipboard.writeText(value);
copied = position;
clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
copied = null;
}, 1500);
}
$effect(() => {
const cid = chatId;
const mid = messageId;
if (cid === null || mid === null || accounts.selectedId === null) {
return;
}
untrack(() => {
token++;
const current = token;
loading = true;
items = [];
getMessageCallbacks(cid, mid)
.then((rows) => {
if (current === token) {
items = rows;
}
})
.finally(() => {
if (current === token) {
loading = false;
}
});
});
});
</script>
{#if chatId === null || messageId === null}
<EmptyState title="Callback-данные" description="Сообщение не выбрано" />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет callback-кнопок" />
{:else}
<ul class="list">
{#each items as item (item.position)}
{@const value = decode(item.data)}
<li>
<div class="meta">
<span class="label">{item.label ?? "—"}</span>
<span class="data">{value || "—"}</span>
</div>
{#if value}
<button
type="button"
class="copy"
onclick={() => copy(item.position, value)}
aria-label="Скопировать"
>
<Icon name={copied === item.position ? "check" : "copy"} />
</button>
{/if}
</li>
{/each}
</ul>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0.5rem;
gap: 0.25rem;
list-style: none;
}
.list > li {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.5rem;
background-color: var(--color-background-secondary);
}
.meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.label {
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.data {
overflow-wrap: anywhere;
font-family: var(--font-family-monospace, monospace);
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.copy {
display: flex;
flex-shrink: 0;
padding: 0.375rem;
border: 0;
border-radius: 0.5rem;
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
&:hover {
color: var(--color-primary);
background-color: var(--color-chat-hover);
}
}
</style>
@@ -0,0 +1,151 @@
<script lang="ts">
import { untrack } from "svelte";
import { page } from "$app/state";
import { getMessageLinks } from "$lib/api/endpoints";
import type { LinkView } from "$lib/api/types";
import EmptyState from "$lib/components/ui/EmptyState.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { ui } from "$lib/stores/ui.svelte";
const WWW_PREFIX = /^www\./;
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const messageId = $derived(ui.panelMessageId);
let items = $state<LinkView[]>([]);
let loading = $state(false);
let token = 0;
function host(url: string): string {
try {
return new URL(url).hostname.replace(WWW_PREFIX, "");
} catch {
return url;
}
}
$effect(() => {
const cid = chatId;
const mid = messageId;
if (cid === null || mid === null || accounts.selectedId === null) {
return;
}
untrack(() => {
token++;
const current = token;
loading = true;
items = [];
getMessageLinks(cid, mid)
.then((rows) => {
if (current === token) {
items = rows;
}
})
.finally(() => {
if (current === token) {
loading = false;
}
});
});
});
</script>
{#if chatId === null || messageId === null}
<EmptyState title="Ссылки" description="Сообщение не выбрано" />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет ссылок" />
{:else}
<ul class="list">
{#each items as item (item.position)}
<li>
<span class="link-icon"><Icon name="link" /></span>
<span class="meta">
{#if item.web_title}
<span class="title">{item.web_title}</span>
{/if}
<a class="url" href={item.url} target="_blank" rel="noopener">
{item.url}
</a>
{#if item.web_site_name || item.web_description}
<span class="sub">
{item.web_site_name ?? host(item.url)}
{#if item.web_description}
· {item.web_description}
{/if}
</span>
{/if}
</span>
</li>
{/each}
</ul>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.list {
display: flex;
flex-direction: column;
margin: 0;
padding: 0.5rem;
list-style: none;
}
.list > li {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.5rem;
border-radius: 0.5rem;
}
.link-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
}
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.title {
overflow: hidden;
font-size: 0.9375rem;
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.url {
overflow-wrap: anywhere;
font-size: 0.8125rem;
color: var(--color-primary);
}
.sub {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,193 @@
<script lang="ts">
import { untrack } from "svelte";
import { page } from "$app/state";
import { getMessageReactions } from "$lib/api/endpoints";
import type { ReactionView } from "$lib/api/types";
import CustomEmoji from "$lib/components/CustomEmoji.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 { formatFull } from "$lib/format/datetime";
import { peerColorIndex, peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
import { ui } from "$lib/stores/ui.svelte";
const CUSTOM_PREFIX = "custom:";
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const messageId = $derived(ui.panelMessageId);
let items = $state<ReactionView[]>([]);
let loading = $state(false);
let token = 0;
const groups = $derived.by(() => {
const map = new Map<string, ReactionView[]>();
for (const item of items) {
const list = map.get(item.reaction);
if (list) {
list.push(item);
} else {
map.set(item.reaction, [item]);
}
}
return [...map.entries()];
});
$effect(() => {
const cid = chatId;
const mid = messageId;
if (cid === null || mid === null || accounts.selectedId === null) {
return;
}
untrack(() => {
token++;
const current = token;
loading = true;
items = [];
getMessageReactions(cid, mid)
.then((rows) => {
if (current === token) {
items = rows;
peers.ensure(rows.map((row) => row.peer_id));
}
})
.finally(() => {
if (current === token) {
loading = false;
}
});
});
});
</script>
{#if chatId === null || messageId === null}
<EmptyState title="Реакции" description="Сообщение не выбрано" />
{:else if loading && items.length === 0}
<div class="center"><Spinner /></div>
{:else if items.length === 0}
<EmptyState title="Нет реакций" />
{:else}
<div class="groups">
{#each groups as [ reaction, reactors ] (reaction)}
<div class="group">
<div class="group-head">
<span class="emoji">
{#if reaction.startsWith(CUSTOM_PREFIX)}
<CustomEmoji
id={reaction.slice(CUSTOM_PREFIX.length)}
size={1.5}
/>
{:else}
{reaction}
{/if}
</span>
<span class="count">{reactors.length}</span>
</div>
<ul class="reactors">
{#each reactors as reactor (`${reactor.peer_id}:${reactor.added_at}`)}
{@const peer = peers.get(reactor.peer_id)}
<li class:removed={reactor.removed_at !== null}>
<Avatar
name={peer ? peerName(peer) : String(reactor.peer_id)}
colorKey={peerColorIndex(reactor.peer_id)}
size={2.25}
avatar={{ kind: "peer", id: reactor.peer_id }}
hasAvatar={peer?.has_avatar ?? false}
/>
<span class="info">
<span class="name"
>{peer ? peerName(peer) : String(reactor.peer_id)}</span
>
<span class="time">
{#if reactor.removed_at}
снято · {formatFull(reactor.removed_at)}
{:else}
{formatFull(reactor.added_at)}
{/if}
</span>
</span>
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
<style lang="scss">
.center {
display: flex;
justify-content: center;
padding: 2rem 0;
}
.groups {
display: flex;
flex-direction: column;
padding: 0.5rem;
gap: 0.5rem;
}
.group-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
font-weight: var(--font-weight-medium);
}
.emoji {
display: inline-flex;
align-items: center;
font-size: 1.25rem;
line-height: 1;
}
.count {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.reactors {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
list-style: none;
}
.reactors > li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.375rem 0.5rem;
border-radius: 0.5rem;
&.removed {
opacity: 0.55;
}
}
.info {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
overflow: hidden;
color: var(--color-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
</style>