feat(frontend): profile panel about one person with real changes, shared groups, joins and leaves and deleted messages

This commit is contained in:
hh committed 2026-10-19 02:57:49 +00:00
1 parent c8d485fe0e
commit 143408cd7f
12 files changed
+899 -244

No files matched your search

@@ -4,29 +4,32 @@
import { getAvatarHistory } from "#lib/api/endpoints.js";
import type { AvatarHistoryView } from "#lib/api/types.js";
import { formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
interface Props {
id: number;
items?: AvatarHistoryView[];
kind: AvatarKind;
name: string;
}
let { kind, id, name }: Props = $props();
let { kind, id, name, items: given }: Props = $props();
let items = $state<AvatarHistoryView[]>([]);
let fetched = $state<AvatarHistoryView[]>([]);
const items = $derived(given ?? fetched);
const urls = $state<Record<string, string | null>>({});
$effect(() => {
if (accounts.selectedId === null) {
if (given || accounts.selectedId === null) {
return;
}
let active = true;
items = [];
fetched = [];
getAvatarHistory(kind, id)
.then((result) => {
if (active) {
items = result;
fetched = result;
}
})
.catch(() => undefined);
@@ -59,7 +62,10 @@
{#if items.length > 0}
<section>
<h3>История аватарок ({items.length})</h3>
<h3 class="section-title">
<span>{t("profile.avatars")}</span>
<span class="count">{items.length}</span>
</h3>
<div class="grid">
{#each items as item (item.unique_id)}
<figure title={formatFull(item.first_seen_at)}>
@@ -76,24 +82,34 @@
<style lang="scss">
section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 0 1rem;
border-top: 1px solid var(--color-borders);
}
h3 {
.section-title {
display: flex;
align-items: baseline;
justify-content: space-between;
margin: 0;
font-size: 0.75rem;
padding: 0.5rem 1rem 0.625rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.count {
font-weight: var(--font-weight-normal);
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(4rem, 1fr));
gap: 0.375rem;
padding: 0 1rem;
}
figure {
@@ -0,0 +1,142 @@
<script lang="ts">
import type { ProfileChange, ProfileField } from "#lib/api/types.js";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { peers } from "#lib/stores/peers.svelte.js";
interface Props {
change: ProfileChange;
chatId: number;
}
let { change, chatId }: Props = $props();
const ICONS: Record<ProfileField, string> = {
deleted: "delete-user",
name: "user",
phone: "phone",
title: "edit",
username: "mention",
};
const LABELS: Record<ProfileField, MessageKey> = {
deleted: "profile.accountDeleted",
name: "profile.fieldName",
phone: "profile.fieldPhone",
title: "profile.fieldTitle",
username: "profile.fieldUsername",
};
function shown(value: string | null): string {
if (value === null) {
return t("profile.none");
}
if (change.field === "username") {
return `@${value}`;
}
return change.field === "phone" ? `+${value}` : value;
}
const actor = $derived(
change.actor_id === null ? undefined : peers.get(change.actor_id)
);
const meta = $derived(
[
change.field === "deleted" ? null : t(LABELS[change.field]),
formatFull(change.changed_at),
actor ? peerName(actor) : null,
]
.filter(Boolean)
.join(" · ")
);
const href = $derived(
change.message_id === null
? undefined
: `/app/${chatId}?message=${change.message_id}`
);
</script>
<svelte:element
class="change"
{href}
this={href ? "a" : "div"}
class:link={href !== undefined}
>
<Icon class="change-icon" name={ICONS[change.field]} size="1.5rem" />
<span class="text">
{#if change.field === "deleted"}
<span class="value">{t(LABELS.deleted)}</span>
{:else}
<span class="value">
<span class="old">{shown(change.old)}</span>
<span class="arrow">→</span>
{shown(change.new)}
</span>
{/if}
<span class="meta">{meta}</span>
</span>
</svelte:element>
<style lang="scss">
.change {
display: flex;
gap: 1.5rem;
align-items: center;
min-height: 3.5rem;
padding: 0.5rem 1rem;
color: var(--color-text);
text-decoration: none;
}
.link {
cursor: pointer;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
:global(.change .change-icon) {
flex-shrink: 0;
color: var(--color-icon-secondary);
}
.text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.value {
font-size: 0.9375rem;
overflow-wrap: anywhere;
}
.old {
color: var(--color-text-secondary);
}
.arrow {
color: var(--color-text-secondary);
}
.meta {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,103 @@
<script lang="ts">
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 { t } from "#lib/i18n/locale.svelte.js";
import { goto } from "$app/navigation";
interface Props {
chatId: number;
hasAvatar: boolean;
href: string;
messageId: number;
subtitle: string;
title: string;
}
let { chatId, hasAvatar, href, messageId, subtitle, title }: Props = $props();
</script>
<ContextMenu>
{#snippet children({
props,
})}
<a {...props} class="chat-row" {href}>
<Avatar
avatar={{ id: chatId, kind: "chat" }}
colorKey={chatId}
{hasAvatar}
name={title}
size={2.75}
/>
<span class="text">
<span class="title">{title}</span>
<span class="subtitle">{subtitle}</span>
</span>
</a>
{/snippet}
{#snippet menu()}
<ContextMenuItem
icon="open-in-new-tab"
onselect={() => goto(`/app/${chatId}`)}
>
{t("profile.open")}
</ContextMenuItem>
<ContextMenuItem
icon="show-message"
onselect={() => goto(`/app/${chatId}?message=${messageId}`)}
>
{t("profile.goToMessage")}
</ContextMenuItem>
{/snippet}
</ContextMenu>
<style lang="scss">
.chat-row {
display: flex;
gap: 1rem;
align-items: center;
min-height: 3.5rem;
padding: 0.375rem 1rem;
color: var(--color-text);
text-decoration: none;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.title {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitle {
overflow: hidden;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -1,93 +1,101 @@
<script lang="ts">
import type { AvatarKind } from "#lib/api/avatars.js";
import { getPeerHistory, getStories } from "#lib/api/endpoints.js";
import type { PeerHistoryView, PeerView } from "#lib/api/types.js";
import { ripple } from "#lib/actions/ripple.js";
import {
getChatChanges,
getPeerGroups,
getStories,
} from "#lib/api/endpoints.js";
import type {
MembershipView,
PeerGroups,
PeerOverview,
ProfileChange,
SharedGroupView,
} from "#lib/api/types.js";
import AvatarHistory from "#lib/components/profile/AvatarHistory.svelte";
import ChangeRow from "#lib/components/profile/ChangeRow.svelte";
import ChatRow from "#lib/components/profile/ChatRow.svelte";
import ProfileSection from "#lib/components/profile/ProfileSection.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull } from "#lib/format/datetime.js";
import { formatDate, formatFull } from "#lib/format/datetime.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation";
interface Props {
avatarKind: AvatarKind;
chatId: number;
isDm: boolean;
id: number;
name: string;
peer: PeerView | null;
onstories: () => void;
overview: PeerOverview | null;
}
let { chatId, isDm, peer, name, avatarKind }: Props = $props();
let { chatId, id, name, onstories, overview }: Props = $props();
interface InfoRow {
icon: string;
label: string;
value: string;
}
const isPerson = $derived(id > 0);
const peer = $derived(overview?.peer ?? null);
const dm = $derived(overview?.dm ?? null);
let groups = $state<PeerGroups | null>(null);
let titleChanges = $state<ProfileChange[]>([]);
let storyCount = $state(0);
const changes = $derived(isPerson ? (overview?.changes ?? []) : titleChanges);
const rows = $derived.by(() => {
const result: InfoRow[] = [];
if (peer?.username) {
result.push({ label: "Имя пользователя", value: `@${peer.username}` });
}
if (peer?.phone) {
result.push({ label: "Телефон", value: `+${peer.phone}` });
result.push({
icon: "phone",
label: t("profile.phone"),
value: `+${peer.phone}`,
});
}
result.push({ label: "ID", value: String(chatId) });
if (peer?.username) {
result.push({
icon: "mention",
label: t("profile.username"),
value: `@${peer.username}`,
});
}
result.push({ icon: "info", label: "ID", value: String(id) });
if (peer?.is_deleted_account) {
result.push({ label: "Статус", value: "Удалённый аккаунт" });
result.push({
icon: "delete-user",
label: t("profile.status"),
value: t("profile.deletedAccount"),
});
}
if (peer?.updated_at) {
result.push({ label: "Обновлено", value: formatFull(peer.updated_at) });
result.push({
icon: "recent",
label: t("profile.updated"),
value: formatFull(peer.updated_at),
});
}
return result;
});
let history = $state<PeerHistoryView[]>([]);
function fingerprint(entry: PeerHistoryView): string {
return [
entry.first_name,
entry.last_name,
entry.username,
entry.phone,
entry.is_deleted_account,
].join("|");
}
const changes = $derived.by(() => {
const result: PeerHistoryView[] = [];
let last: string | null = null;
for (const entry of history) {
const print = fingerprint(entry);
if (print !== last) {
result.push(entry);
last = print;
}
}
return result;
});
function entryName(entry: PeerHistoryView): string {
const parts = [entry.first_name, entry.last_name].filter(Boolean);
if (parts.length > 0) {
return parts.join(" ");
}
if (entry.username) {
return `@${entry.username}`;
}
return "—";
}
$effect(() => {
if (accounts.selectedId === null || !isDm) {
history = [];
if (accounts.selectedId === null || !isPerson) {
groups = null;
return;
}
const peerId = id;
let active = true;
history = [];
getPeerHistory(chatId)
groups = null;
getPeerGroups(peerId)
.then((result) => {
if (active) {
history = result;
groups = result;
}
})
.catch(() => undefined);
@@ -96,17 +104,44 @@
};
});
let storyCount = $state(0);
$effect(() => {
if (accounts.selectedId === null || isPerson) {
titleChanges = [];
return;
}
const groupId = id;
let active = true;
titleChanges = [];
getChatChanges(groupId)
.then((result) => {
if (active) {
titleChanges = result;
}
})
.catch(() => undefined);
return () => {
active = false;
};
});
$effect(() => {
const actors = titleChanges
.map((change) => change.actor_id)
.filter((actor) => actor !== null);
if (actors.length > 0) {
peers.ensure(actors);
}
});
$effect(() => {
if (accounts.selectedId === null) {
storyCount = 0;
return;
}
const id = chatId;
const storyOwner = id;
let active = true;
storyCount = 0;
getStories(id, { limit: 100 })
getStories(storyOwner, { limit: 100 })
.then((result) => {
if (active) {
storyCount = result.length;
@@ -117,51 +152,164 @@
active = false;
};
});
function membershipAction(item: MembershipView): string {
const join = item.event === "join";
if (item.actor_id === null || item.actor_id === id) {
return join ? t("profile.joined") : t("profile.left");
}
if (item.actor_id === accounts.selected?.tg_user_id) {
return join ? t("profile.addedByYou") : t("profile.removedByYou");
}
const actor = item.actor_name ?? String(item.actor_id);
return join
? t("profile.addedBy", { name: actor })
: t("profile.removedBy", { name: actor });
}
function membershipLine(item: MembershipView): string {
return `${membershipAction(item)} · ${formatDate(item.ts)}`;
}
function groupLine(group: SharedGroupView): string {
const count = t("profile.messages", { count: group.message_count });
return `${count} · ${formatDate(group.last_date)}`;
}
async function openAnalytics() {
if (id !== chatId) {
await goto(`/app/${id}`);
}
ui.openPanel("presence");
}
</script>
{#snippet action(
icon: string,
label: string,
onclick: () => void,
value = ""
)}
<button class="row action" {onclick} type="button" use:ripple>
<Icon class="row-icon" name={icon} size="1.5rem" />
<span class="label">{label}</span>
{#if value}
<span class="end">{value}</span>
{/if}
<Icon class="row-chevron" name="next" size="1rem" />
</button>
{/snippet}
<div class="info-tab">
{#if rows.length > 0}
<section>
<dl>
{#each rows as row (row.label)}
<div class="row">
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
{/each}
</dl>
<section class="rows">
{#each rows as row (row.label)}
<div class="row">
<Icon class="row-icon" name={row.icon} size="1.5rem" />
<span class="text">
<span class="value">{row.value}</span>
<span class="caption">{row.label}</span>
</span>
</div>
{/each}
{#if isPerson}
<div class="row">
<Icon class="row-icon" name="delete" size="1.5rem" />
<span class="label">{t("profile.deletedMessages")}</span>
<span class="end">{groups?.deleted_count ?? ""}</span>
</div>
{/if}
</section>
{#if (isPerson && overview) || storyCount > 0}
<section class="rows">
{#if isPerson && dm && id !== chatId}
{@render action(
"message",
t("profile.openChat"),
() => goto(`/app/${id}`),
t("profile.messages", { count: dm.message_count })
)}
{:else if isPerson && !dm}
<div class="row">
<Icon class="row-icon" name="message" size="1.5rem" />
<span class="label muted">{t("profile.noChat")}</span>
</div>
{/if}
{#if isPerson && dm}
{@render action("stats", t("profile.analytics"), openAnalytics)}
{/if}
{#if storyCount > 0}
{@render action(
"play-story",
t("profile.stories"),
onstories,
String(storyCount)
)}
{/if}
</section>
{/if}
{#if storyCount > 0}
<section>
<button
class="entry"
onclick={() => ui.openPanel("stories")}
type="button"
>
<span class="entry-icon"><Icon name="play-story" /></span>
<span class="entry-label">Сторис</span>
<span class="entry-count">{storyCount}</span>
<Icon name="next" size="1rem" />
</button>
</section>
<ProfileSection
items={changes}
key={(change) => `${change.field}:${change.changed_at}`}
title={isPerson ? t("profile.changes") : t("profile.titleChanges")}
>
{#snippet row(
change
)}
<ChangeRow {change} chatId={id} />
{/snippet}
</ProfileSection>
{#if !isPerson || overview}
<AvatarHistory
{id}
items={overview?.avatars}
kind={isPerson ? "peer" : "chat"}
{name}
/>
{/if}
<AvatarHistory id={chatId} kind={avatarKind} {name} />
{#if groups}
<ProfileSection
items={groups.groups}
key={(group) => group.chat_id}
title={t("profile.groups")}
>
{#snippet row(
group
)}
<ChatRow
chatId={group.chat_id}
hasAvatar={group.has_avatar}
href="/app/{group.chat_id}"
messageId={group.last_message_id}
subtitle={groupLine(group)}
title={group.title ?? t("profile.chat", { id: group.chat_id })}
/>
{/snippet}
</ProfileSection>
{/if}
{#if changes.length > 1}
<section>
<h3>История профиля</h3>
<ul class="changes">
{#each changes as entry (entry.observed_at)}
<li>
<span class="label">{entryName(entry)}</span>
<span class="time">{formatFull(entry.observed_at)}</span>
</li>
{/each}
</ul>
</section>
{#if overview}
<ProfileSection
items={overview.memberships}
key={(item) => `${item.chat_id}:${item.message_id}`}
title={t("profile.memberships")}
>
{#snippet row(
item
)}
<ChatRow
chatId={item.chat_id}
hasAvatar={item.has_avatar}
href="/app/{item.chat_id}?message={item.message_id}"
messageId={item.message_id}
subtitle={membershipLine(item)}
title={item.chat_title ?? t("profile.chat", { id: item.chat_id })}
/>
{/snippet}
</ProfileSection>
{/if}
</div>
@@ -169,119 +317,94 @@
.info-tab {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
padding-bottom: 1rem;
}
section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rows {
padding: 0.5rem 0;
h3 {
margin: 0;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
dl {
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0;
& + .rows {
border-top: 1px solid var(--color-borders);
}
}
.row {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
dt {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
dd {
margin: 0;
font-size: 0.9375rem;
color: var(--color-text);
overflow-wrap: anywhere;
}
.entry {
display: flex;
gap: 1.5rem;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.5rem;
border: 0;
border-radius: 0.5rem;
min-height: 3.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
color: var(--color-text);
background: transparent;
}
&:hover {
background-color: var(--color-background-secondary);
.action {
cursor: pointer;
position: relative;
overflow: hidden;
border: 0;
font: inherit;
text-align: start;
background-color: transparent;
transition: background-color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.entry-icon {
display: flex;
:global(.info-tab .row-icon),
:global(.info-tab .row-chevron) {
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
color: var(--color-icon-secondary);
}
.entry-label {
flex: 1;
font-size: 0.9375rem;
text-align: left;
}
.entry-count {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.changes {
.text {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
gap: 0.125rem;
min-width: 0;
}
.changes li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
.value {
font-size: 0.9375rem;
overflow-wrap: anywhere;
}
.caption {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.label {
overflow: hidden;
flex: 1;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
.muted {
color: var(--color-text-secondary);
}
.end {
flex-shrink: 0;
font-size: 0.9375rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>
@@ -1,6 +1,6 @@
<script lang="ts">
import { getPeer } from "#lib/api/endpoints.js";
import type { PeerView } from "#lib/api/types.js";
import { getPeerOverview } from "#lib/api/endpoints.js";
import type { PeerOverview } from "#lib/api/types.js";
import ChatCalendar from "#lib/components/profile/ChatCalendar.svelte";
import ProfileInfo from "#lib/components/profile/ProfileInfo.svelte";
import SharedLinks from "#lib/components/profile/SharedLinks.svelte";
@@ -10,18 +10,23 @@
import EmptyState from "#lib/components/ui/EmptyState.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { peerName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import type { MessageKey } from "#lib/i18n/messages.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { page } from "$app/state";
type Tab = "info" | "media" | "files" | "links" | "stories" | "calendar";
const TABS: { id: Tab; icon: string; label: string }[] = [
{ icon: "info", id: "info", label: "Инфо" },
{ icon: "photo", id: "media", label: "Медиа" },
{ icon: "document", id: "files", label: "Файлы" },
{ icon: "link", id: "links", label: "Ссылки" },
{ icon: "play-story", id: "stories", label: "Сторис" },
{ icon: "calendar", id: "calendar", label: "Календарь" },
const TABS: { id: Tab; icon: string; label: MessageKey }[] = [
{ icon: "info", id: "info", label: "profile.tabInfo" },
{ icon: "photo", id: "media", label: "profile.tabMedia" },
{ icon: "document", id: "files", label: "profile.tabFiles" },
{ icon: "link", id: "links", label: "profile.tabLinks" },
{ icon: "play-story", id: "stories", label: "profile.stories" },
{ icon: "calendar", id: "calendar", label: "profile.tabCalendar" },
];
const MEDIA_KINDS = ["photo", "video", "animation", "video_note"];
@@ -30,41 +35,65 @@
const chatId = $derived(
page.params.chatId ? Number(page.params.chatId) : null
);
const isDm = $derived(chatId !== null && chatId > 0);
const chat = $derived(chatId === null ? null : chats.byId(chatId));
const avatarKind = $derived(isDm ? "peer" : "chat");
let peer = $state<PeerView | null>(null);
let tab = $state<Tab>("info");
const title = $derived(
isDm && peer
? peerName(peer)
: (chat?.title ?? (chatId === null ? "" : `Chat ${chatId}`))
const targetId = $derived(
chatId === null ? null : (ui.personIn(chatId) ?? chatId)
);
const isPerson = $derived(targetId !== null && targetId > 0);
const chat = $derived(targetId === null ? undefined : chats.byId(targetId));
let overview = $state<PeerOverview | null>(null);
let picked = $state<{ target: number | null; tab: Tab }>({
tab: "info",
target: null,
});
const peer = $derived(
overview?.peer ?? (targetId === null ? undefined : peers.get(targetId))
);
const hasChat = $derived(targetId === chatId || Boolean(overview?.dm));
const tabs = $derived(
TABS.filter(
(item) => hasChat || item.id === "info" || item.id === "stories"
)
);
const tab = $derived(
picked.target === targetId && tabs.some((item) => item.id === picked.tab)
? picked.tab
: "info"
);
const title = $derived.by(() => {
if (isPerson && peer) {
return peerName(peer);
}
return chat?.title ?? t("profile.chat", { id: targetId ?? "" });
});
const subtitle = $derived.by(() => {
if (isDm) {
if (isPerson) {
if (peer?.username) {
return `@${peer.username}`;
}
return peer?.phone ? `+${peer.phone}` : `ID ${chatId}`;
return peer?.phone ? `+${peer.phone}` : `ID ${targetId}`;
}
const count = chat?.message_count ?? 0;
return count > 0 ? `${count} сообщений` : "группа";
return count > 0 ? t("profile.messages", { count }) : t("profile.group");
});
const hasAvatar = $derived(chat?.has_avatar ?? Boolean(peer?.has_avatar));
const hasAvatar = $derived(
isPerson ? Boolean(peer?.has_avatar) : (chat?.has_avatar ?? false)
);
$effect(() => {
if (chatId === null || !isDm) {
peer = null;
if (accounts.selectedId === null || targetId === null || !isPerson) {
overview = null;
return;
}
const peerId = targetId;
let active = true;
peer = null;
getPeer(chatId)
overview = null;
getPeerOverview(peerId)
.then((result) => {
if (active) {
peer = result;
overview = result;
}
})
.catch(() => undefined);
@@ -74,14 +103,14 @@
});
</script>
{#if chatId === null}
<EmptyState description="Откройте чат" title="Профиль" />
{#if chatId === null || targetId === null}
<EmptyState description={t("profile.empty")} title={t("profile.title")} />
{:else}
<div class="profile">
<header class="hero">
<Avatar
avatar={{ id: chatId, kind: avatarKind }}
colorKey={chatId}
avatar={{ id: targetId, kind: isPerson ? "peer" : "chat" }}
colorKey={targetId}
deleted={peer?.is_deleted_account ?? false}
{hasAvatar}
name={title}
@@ -92,13 +121,15 @@
</header>
<nav class="tabs">
{#each TABS as item (item.id)}
{#each tabs as item (item.id)}
<button
aria-label={item.label}
aria-label={t(item.label)}
aria-pressed={tab === item.id}
class="tab"
onclick={() => {
tab = item.id;
picked = { tab: item.id, target: targetId };
}}
title={t(item.label)}
type="button"
class:active={tab === item.id}
>
@@ -109,17 +140,25 @@
<div class="tab-body">
{#if tab === "info"}
<ProfileInfo {avatarKind} {chatId} {isDm} name={title} {peer} />
<ProfileInfo
{chatId}
id={targetId}
name={title}
onstories={() => {
picked = { tab: "stories", target: targetId };
}}
{overview}
/>
{:else if tab === "media"}
<SharedMedia {chatId} kinds={MEDIA_KINDS} layout="grid" />
<SharedMedia chatId={targetId} kinds={MEDIA_KINDS} layout="grid" />
{:else if tab === "files"}
<SharedMedia {chatId} kinds={FILE_KINDS} layout="list" />
<SharedMedia chatId={targetId} kinds={FILE_KINDS} layout="list" />
{:else if tab === "links"}
<SharedLinks {chatId} />
<SharedLinks chatId={targetId} />
{:else if tab === "stories"}
<StoriesArchive {chatId} />
<StoriesArchive chatId={targetId} />
{:else if tab === "calendar"}
<ChatCalendar {chatId} />
<ChatCalendar chatId={targetId} />
{/if}
</div>
</div>
@@ -129,7 +168,7 @@
.profile {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
}
.hero {
@@ -155,12 +194,21 @@
}
.tabs {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-shrink: 0;
border-bottom: 1px solid var(--color-borders);
background-color: var(--color-background);
}
.tab {
cursor: pointer;
display: flex;
flex: 1;
align-items: center;
@@ -171,17 +219,21 @@
border-bottom: 2px solid transparent;
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
&.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.tab-body {
overflow-y: auto;
flex: 1;
}
</style>
@@ -0,0 +1,86 @@
<script generics="T" lang="ts">
import type { Snippet } from "svelte";
import { t } from "#lib/i18n/locale.svelte.js";
interface Props {
items: T[];
key: (item: T) => string | number;
row: Snippet<[T]>;
title: string;
}
let { items, key, row, title }: Props = $props();
const STEP = 8;
let shown = $state(STEP);
</script>
{#if items.length > 0}
<section class="profile-section">
<h3 class="section-title">
<span>{title}</span>
<span class="count">{items.length}</span>
</h3>
{#each items.slice(0, shown) as item (key(item))}
{@render row(item)}
{/each}
{#if items.length > shown}
<button
class="more"
onclick={() => {
shown += STEP * 4;
}}
type="button"
>
{t("profile.showMore")}
</button>
{/if}
</section>
{/if}
<style lang="scss">
.profile-section {
padding: 0.5rem 0;
border-top: 1px solid var(--color-borders);
}
.section-title {
display: flex;
align-items: baseline;
justify-content: space-between;
margin: 0;
padding: 0.5rem 1rem 0.375rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-primary);
}
.count {
font-weight: var(--font-weight-normal);
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.more {
cursor: pointer;
width: calc(100% - 2rem);
margin: 0.5rem 1rem 0;
padding: 0.5rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-size: 0.875rem;
color: var(--color-primary);
background-color: var(--color-background-secondary);
&:focus-visible {
outline: 2px solid var(--color-primary);
}
}
</style>