feat: add annotations, user profiles, watchers, stories, search and more
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user