Files
beavergram/frontend/src/lib/components/ChatHeader.svelte
T

292 lines
6.6 KiB
Svelte

<script lang="ts">
import { goto } from "$app/navigation";
import {
enqueueBackfill,
getCurrentPresence,
getPeer,
} from "$lib/api/endpoints";
import type { PeerView, PresenceSample } from "$lib/api/types";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Button from "$lib/components/ui/Button.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 { peerName } from "$lib/format/peer";
import { formatPresence } from "$lib/format/presence";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { discover } from "$lib/stores/discover.svelte";
import { events } from "$lib/stores/events.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
interface Props {
chatId: number;
}
let { chatId }: Props = $props();
const isDm = $derived(chatId > 0);
const chat = $derived(chats.byId(chatId));
const discovered = $derived(discover.get(chatId));
let peer = $state<PeerView | null>(null);
let presence = $state<PresenceSample | null>(null);
let backfilling = $state(false);
async function backfill() {
if (backfilling) {
return;
}
backfilling = true;
try {
await enqueueBackfill(chatId, true);
toasts.success("Бэкфилл запущен");
} catch {
toasts.error("Не удалось запустить бэкфилл");
} finally {
backfilling = false;
}
}
$effect(() => {
if (accounts.selectedId === null) {
return;
}
discover.ensure(chatId).catch(() => undefined);
});
$effect(() => {
if (accounts.selectedId === null || !isDm) {
peer = null;
return;
}
let active = true;
peer = null;
getPeer(chatId)
.then((result) => {
if (active) {
peer = result;
}
})
.catch(() => {
if (active) {
peer = null;
}
});
return () => {
active = false;
};
});
$effect(() => {
if (accounts.selectedId === null || !isDm) {
presence = null;
return;
}
let active = true;
presence = null;
getCurrentPresence(chatId)
.then((result) => {
if (active) {
presence = result;
}
})
.catch(() => {
if (active) {
presence = null;
}
});
const unsub = events.subscribe((event) => {
if (
event.type === "presence" &&
event.peer_id === chatId &&
event.sample
) {
presence = event.sample;
}
});
return () => {
active = false;
unsub();
};
});
const fallbackTitle = $derived(
chat?.title ??
discovered?.title ??
(isDm ? "Удалённый аккаунт" : `Chat ${chatId}`)
);
const title = $derived(isDm && peer ? peerName(peer) : fallbackTitle);
const subtitle = $derived.by(() => {
if (isDm) {
if (presence) {
return formatPresence(presence);
}
if (peer?.username) {
return `@${peer.username}`;
}
return peer?.phone ?? `ID ${chatId}`;
}
const count = chat?.message_count ?? discovered?.message_count ?? 0;
if (count > 0) {
return `${count} messages`;
}
return discovered?.kind === "channel" ? "channel" : "group";
});
const avatarKind = $derived(isDm ? "peer" : "chat");
const hasAvatar = $derived(
chat?.has_avatar ?? Boolean(peer?.has_avatar || discovered?.has_avatar)
);
</script>
<header class="chat-header">
<div class="back">
<Button
variant="translucent"
round
smaller
onclick={() => goto("/app")}
aria-label="Назад"
>
<Icon name="arrow-left" />
</Button>
</div>
<ContextMenu>
{#snippet children({ props })}
<button
{...props}
type="button"
class="peek"
onclick={() => ui.openPanel("profile")}
aria-label="Открыть профиль"
>
<Avatar
name={title}
colorKey={chatId}
size={2.5}
avatar={{ kind: avatarKind, id: chatId }}
{hasAvatar}
deleted={peer?.is_deleted_account ?? false}
/>
<div class="info">
<h2 class="title">{title}</h2>
<span
class="subtitle"
class:online={isDm && presence?.status === "online"}
>
{subtitle}
</span>
</div>
</button>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="info" onselect={() => ui.openPanel("profile")}
>Открыть профиль</ContextMenuItem
>
<ContextMenuItem
icon="play-story"
onselect={() => ui.openPanel("stories")}
>Сторис</ContextMenuItem
>
{/snippet}
</ContextMenu>
<div class="actions">
<Button
variant="translucent"
round
smaller
onclick={() => ui.openPanel("search")}
aria-label="Поиск в чате"
>
<Icon name="search" />
</Button>
<Button
variant="translucent"
round
smaller
onclick={() => ui.openPanel("presence")}
aria-label="Аналитика"
>
<Icon name="stats" />
</Button>
<Button
variant="translucent"
round
smaller
loading={backfilling}
onclick={backfill}
aria-label="Скачать историю"
>
<Icon name="cloud-download" />
</Button>
</div>
</header>
<style lang="scss">
.chat-header {
display: flex;
align-items: center;
gap: 0.625rem;
height: var(--header-height);
padding: 0 1rem;
border-bottom: 1px solid var(--color-borders);
background-color: var(--color-background);
}
.back {
display: none;
margin-left: -0.25rem;
@media (max-width: 600px) {
display: block;
}
}
.peek {
display: flex;
flex: 1;
align-items: center;
gap: 0.625rem;
min-width: 0;
padding: 0;
border: 0;
text-align: left;
cursor: pointer;
background: transparent;
}
.info {
overflow: hidden;
min-width: 0;
}
.actions {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
}
.title {
overflow: hidden;
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
font-size: 0.8125rem;
color: var(--color-text-secondary);
&.online {
color: var(--color-primary);
}
}
</style>