feat(frontend): profile panel about one person with real changes, shared groups, joins and leaves and deleted messages
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user