From 143408cd7f8759eeb85ff762ddb5e590a288d56c Mon Sep 17 00:00:00 2001 From: h Date: Mon, 19 Oct 2026 02:46:04 +0000 Subject: [PATCH] feat(frontend): profile panel about one person with real changes, shared groups, joins and leaves and deleted messages --- frontend/src/lib/api/endpoints.ts | 16 +- frontend/src/lib/api/types.ts | 56 ++- .../components/profile/AvatarHistory.svelte | 42 +- .../lib/components/profile/ChangeRow.svelte | 142 ++++++ .../src/lib/components/profile/ChatRow.svelte | 103 ++++ .../lib/components/profile/ProfileInfo.svelte | 473 +++++++++++------- .../components/profile/ProfilePanel.svelte | 142 ++++-- .../components/profile/ProfileSection.svelte | 86 ++++ frontend/src/lib/format/datetime.ts | 5 + frontend/src/lib/i18n/messages.ts | 2 + frontend/src/lib/i18n/messages/profile.ts | 55 ++ frontend/src/lib/stores/ui.svelte.ts | 21 + 12 files changed, 899 insertions(+), 244 deletions(-) create mode 100644 frontend/src/lib/components/profile/ChangeRow.svelte create mode 100644 frontend/src/lib/components/profile/ChatRow.svelte create mode 100644 frontend/src/lib/components/profile/ProfileSection.svelte create mode 100644 frontend/src/lib/i18n/messages/profile.ts diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts index 6a684f2..12ce3e1 100644 --- a/frontend/src/lib/api/endpoints.ts +++ b/frontend/src/lib/api/endpoints.ts @@ -19,7 +19,8 @@ import type { MessageAt, MessageVersion, MessageView, - PeerHistoryView, + PeerGroups, + PeerOverview, PeerView, PinnedView, PolicyChatKind, @@ -30,6 +31,7 @@ import type { PresenceSample, PresenceSession, PresenceStats, + ProfileChange, ReactionView, ReadInfo, ResponseStats, @@ -276,8 +278,16 @@ export function getPeer(peerId: number): Promise { return request(`/peers/${peerId}`, { account: true }); } -export function getPeerHistory(peerId: number): Promise { - return request(`/peers/${peerId}/history`, { +export function getPeerOverview(peerId: number): Promise { + return request(`/peers/${peerId}/overview`, { account: true }); +} + +export function getPeerGroups(peerId: number): Promise { + return request(`/peers/${peerId}/groups`, { account: true }); +} + +export function getChatChanges(chatId: number): Promise { + return request(`/chats/${chatId}/changes`, { account: true, }); } diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 7a47e4f..69aa3e1 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -451,14 +451,54 @@ export interface ReadInfo { voice: boolean; } -export interface PeerHistoryView { - first_name: string | null; - is_deleted_account: boolean; - last_name: string | null; - observed_at: string; - phone: string | null; - photo_unique_id: string | null; - username: string | null; +export type ProfileField = "name" | "username" | "phone" | "deleted" | "title"; + +export interface ProfileChange { + actor_id: number | null; + changed_at: string; + field: ProfileField; + message_id: number | null; + new: string | null; + old: string | null; +} + +export interface MembershipView { + actor_id: number | null; + actor_name: string | null; + chat_id: number; + chat_title: string | null; + event: "join" | "leave"; + has_avatar: boolean; + message_id: number; + ts: string; +} + +export interface DirectChatView { + chat_id: number; + last_date: string | null; + message_count: number; +} + +export interface PeerOverview { + avatars: AvatarHistoryView[]; + changes: ProfileChange[]; + dm: DirectChatView | null; + memberships: MembershipView[]; + peer: PeerView | null; +} + +export interface SharedGroupView { + chat_id: number; + has_avatar: boolean; + last_date: string; + last_message_id: number; + message_count: number; + title: string | null; +} + +export interface PeerGroups { + deleted_count: number; + groups: SharedGroupView[]; } export interface StoryView { diff --git a/frontend/src/lib/components/profile/AvatarHistory.svelte b/frontend/src/lib/components/profile/AvatarHistory.svelte index 2372538..1b9656b 100644 --- a/frontend/src/lib/components/profile/AvatarHistory.svelte +++ b/frontend/src/lib/components/profile/AvatarHistory.svelte @@ -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([]); + let fetched = $state([]); + const items = $derived(given ?? fetched); const urls = $state>({}); $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}
-

История аватарок ({items.length})

+

+ {t("profile.avatars")} + {items.length} +

{#each items as item (item.unique_id)}
@@ -76,24 +82,34 @@ diff --git a/frontend/src/lib/components/profile/ChatRow.svelte b/frontend/src/lib/components/profile/ChatRow.svelte new file mode 100644 index 0000000..edf431c --- /dev/null +++ b/frontend/src/lib/components/profile/ChatRow.svelte @@ -0,0 +1,103 @@ + + + + {#snippet children({ + props, + })} + + + + {title} + {subtitle} + + + {/snippet} + {#snippet menu()} + goto(`/app/${chatId}`)} + > + {t("profile.open")} + + goto(`/app/${chatId}?message=${messageId}`)} + > + {t("profile.goToMessage")} + + {/snippet} + + + diff --git a/frontend/src/lib/components/profile/ProfileInfo.svelte b/frontend/src/lib/components/profile/ProfileInfo.svelte index cb25eb1..44b2e74 100644 --- a/frontend/src/lib/components/profile/ProfileInfo.svelte +++ b/frontend/src/lib/components/profile/ProfileInfo.svelte @@ -1,93 +1,101 @@ +{#snippet action( + icon: string, + label: string, + onclick: () => void, + value = "" +)} + +{/snippet} +
- {#if rows.length > 0} -
-
- {#each rows as row (row.label)} -
-
{row.label}
-
{row.value}
-
- {/each} -
+
+ {#each rows as row (row.label)} +
+ + + {row.value} + {row.label} + +
+ {/each} + {#if isPerson} +
+ + {t("profile.deletedMessages")} + {groups?.deleted_count ?? ""} +
+ {/if} +
+ + {#if (isPerson && overview) || storyCount > 0} +
+ {#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} +
+ + {t("profile.noChat")} +
+ {/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}
{/if} - {#if storyCount > 0} -
- -
+ `${change.field}:${change.changed_at}`} + title={isPerson ? t("profile.changes") : t("profile.titleChanges")} + > + {#snippet row( + change + )} + + {/snippet} + + + {#if !isPerson || overview} + {/if} - + {#if groups} + group.chat_id} + title={t("profile.groups")} + > + {#snippet row( + group + )} + + {/snippet} + + {/if} - {#if changes.length > 1} -
-

История профиля

-
    - {#each changes as entry (entry.observed_at)} -
  • - {entryName(entry)} - {formatFull(entry.observed_at)} -
  • - {/each} -
-
+ {#if overview} + `${item.chat_id}:${item.message_id}`} + title={t("profile.memberships")} + > + {#snippet row( + item + )} + + {/snippet} + {/if}
@@ -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); } diff --git a/frontend/src/lib/components/profile/ProfilePanel.svelte b/frontend/src/lib/components/profile/ProfilePanel.svelte index a717546..f695113 100644 --- a/frontend/src/lib/components/profile/ProfilePanel.svelte +++ b/frontend/src/lib/components/profile/ProfilePanel.svelte @@ -1,6 +1,6 @@ -{#if chatId === null} - +{#if chatId === null || targetId === null} + {:else}
@@ -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; } diff --git a/frontend/src/lib/components/profile/ProfileSection.svelte b/frontend/src/lib/components/profile/ProfileSection.svelte new file mode 100644 index 0000000..64d9837 --- /dev/null +++ b/frontend/src/lib/components/profile/ProfileSection.svelte @@ -0,0 +1,86 @@ + + +{#if items.length > 0} +
+

+ {title} + {items.length} +

+ {#each items.slice(0, shown) as item (key(item))} + {@render row(item)} + {/each} + {#if items.length > shown} + + {/if} +
+{/if} + + diff --git a/frontend/src/lib/format/datetime.ts b/frontend/src/lib/format/datetime.ts index ba5cb9f..f3dabde 100644 --- a/frontend/src/lib/format/datetime.ts +++ b/frontend/src/lib/format/datetime.ts @@ -1,6 +1,7 @@ import { locale } from "#lib/i18n/locale.svelte.js"; const OPTIONS = { + date: { day: "numeric", month: "short", year: "numeric" }, day: { day: "numeric", month: "short" }, full: { day: "numeric", @@ -38,6 +39,10 @@ export function formatDay(iso: string): string { return format("day", new Date(iso)); } +export function formatDate(iso: string): string { + return format("date", new Date(iso)); +} + export function formatFull(iso: string): string { return format("full", new Date(iso)); } diff --git a/frontend/src/lib/i18n/messages.ts b/frontend/src/lib/i18n/messages.ts index 2939b7a..4c66e91 100644 --- a/frontend/src/lib/i18n/messages.ts +++ b/frontend/src/lib/i18n/messages.ts @@ -2,6 +2,7 @@ import { account } from "#lib/i18n/messages/account.js"; import { alerts } from "#lib/i18n/messages/alerts.js"; import { common } from "#lib/i18n/messages/common.js"; import { menu } from "#lib/i18n/messages/menu.js"; +import { profile } from "#lib/i18n/messages/profile.js"; import { read } from "#lib/i18n/messages/read.js"; import { settings } from "#lib/i18n/messages/settings.js"; import { watches } from "#lib/i18n/messages/watches.js"; @@ -11,6 +12,7 @@ export const messages = { ...alerts, ...common, ...menu, + ...profile, ...read, ...settings, ...watches, diff --git a/frontend/src/lib/i18n/messages/profile.ts b/frontend/src/lib/i18n/messages/profile.ts new file mode 100644 index 0000000..fcdd39a --- /dev/null +++ b/frontend/src/lib/i18n/messages/profile.ts @@ -0,0 +1,55 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const profile = { + "profile.accountDeleted": { en: "Account deleted", ru: "Аккаунт удалён" }, + "profile.addedBy": { en: "Added by {name}", ru: "{name} добавил(а)" }, + "profile.addedByYou": { en: "Added by you", ru: "Вы добавили" }, + "profile.analytics": { en: "Analytics", ru: "Аналитика" }, + "profile.avatars": { en: "Profile photos", ru: "Фотографии профиля" }, + "profile.changes": { en: "Profile changes", ru: "Изменения профиля" }, + "profile.chat": { en: "Chat {id}", ru: "Чат {id}" }, + "profile.deletedAccount": { en: "Deleted account", ru: "Удалённый аккаунт" }, + "profile.deletedMessages": { + en: "Deleted messages", + ru: "Удалено сообщений", + }, + "profile.empty": { en: "Open a chat", ru: "Откройте чат" }, + "profile.fieldName": { en: "Name", ru: "Имя" }, + "profile.fieldPhone": { en: "Phone", ru: "Телефон" }, + "profile.fieldTitle": { en: "Title", ru: "Название" }, + "profile.fieldUsername": { en: "Username", ru: "Имя пользователя" }, + "profile.goToMessage": { en: "Go to message", ru: "Перейти к сообщению" }, + "profile.group": { en: "group", ru: "группа" }, + "profile.groups": { en: "Groups in common", ru: "Общие группы" }, + "profile.joined": { en: "Joined", ru: "Вступил(а) в группу" }, + "profile.left": { en: "Left", ru: "Вышел(а) из группы" }, + "profile.memberships": { en: "Joins and leaves", ru: "Вступления и выходы" }, + "profile.messages": { + en: { one: "{count} message", other: "{count} messages" }, + ru: { + few: "{count} сообщения", + many: "{count} сообщений", + one: "{count} сообщение", + other: "{count} сообщения", + }, + }, + "profile.noChat": { en: "No private chat", ru: "Личной переписки нет" }, + "profile.none": { en: "none", ru: "нет" }, + "profile.open": { en: "Open", ru: "Открыть" }, + "profile.openChat": { en: "Open chat", ru: "Открыть чат" }, + "profile.phone": { en: "Phone", ru: "Телефон" }, + "profile.removedBy": { en: "Removed by {name}", ru: "{name} удалил(а)" }, + "profile.removedByYou": { en: "Removed by you", ru: "Вы удалили" }, + "profile.showMore": { en: "Show more", ru: "Показать ещё" }, + "profile.status": { en: "Status", ru: "Статус" }, + "profile.stories": { en: "Stories", ru: "Сторис" }, + "profile.tabCalendar": { en: "Calendar", ru: "Календарь" }, + "profile.tabFiles": { en: "Files", ru: "Файлы" }, + "profile.tabInfo": { en: "Info", ru: "Инфо" }, + "profile.tabLinks": { en: "Links", ru: "Ссылки" }, + "profile.tabMedia": { en: "Media", ru: "Медиа" }, + "profile.title": { en: "Profile", ru: "Профиль" }, + "profile.titleChanges": { en: "Title changes", ru: "Изменения названия" }, + "profile.updated": { en: "Updated", ru: "Обновлено" }, + "profile.username": { en: "Username", ru: "Имя пользователя" }, +} satisfies Messages; diff --git a/frontend/src/lib/stores/ui.svelte.ts b/frontend/src/lib/stores/ui.svelte.ts index c0ec9fe..99c7074 100644 --- a/frontend/src/lib/stores/ui.svelte.ts +++ b/frontend/src/lib/stores/ui.svelte.ts @@ -28,12 +28,18 @@ interface JumpTarget { messageId: number; } +interface PersonTarget { + chatId: number; + peerId: number; +} + function createUi() { let rightPanel = $state(null); let panelMessageId = $state(null); let leftColumnOpen = $state(true); let leftView = $state("main"); let jumpTarget = $state(null); + let person = $state(null); return { clearJump() { @@ -42,6 +48,7 @@ function createUi() { closePanel() { rightPanel = null; panelMessageId = null; + person = null; }, closeSettings() { leftView = "main"; @@ -62,6 +69,12 @@ function createUi() { openPanel(panel: RightPanel) { rightPanel = panel; panelMessageId = null; + person = null; + }, + openPerson(chatId: number, peerId: number) { + rightPanel = "profile"; + panelMessageId = null; + person = { chatId, peerId }; }, openSettings() { leftView = "settings"; @@ -69,12 +82,20 @@ function createUi() { get panelMessageId() { return panelMessageId; }, + personIn(chatId: number | null): number | null { + return person !== null && person.chatId === chatId ? person.peerId : null; + }, requestJump(chatId: number, messageId: number) { jumpTarget = { chatId, messageId }; }, get rightPanel() { return rightPanel; }, + setChat(chatId: number | null) { + if (person !== null && person.chatId !== chatId) { + person = null; + } + }, setLeftColumn(open: boolean) { leftColumnOpen = open; },