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
+901
-246
No files matched your search
@@ -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<PeerView> {
|
||||
return request<PeerView>(`/peers/${peerId}`, { account: true });
|
||||
}
|
||||
|
||||
export function getPeerHistory(peerId: number): Promise<PeerHistoryView[]> {
|
||||
return request<PeerHistoryView[]>(`/peers/${peerId}/history`, {
|
||||
export function getPeerOverview(peerId: number): Promise<PeerOverview> {
|
||||
return request<PeerOverview>(`/peers/${peerId}/overview`, { account: true });
|
||||
}
|
||||
|
||||
export function getPeerGroups(peerId: number): Promise<PeerGroups> {
|
||||
return request<PeerGroups>(`/peers/${peerId}/groups`, { account: true });
|
||||
}
|
||||
|
||||
export function getChatChanges(chatId: number): Promise<ProfileChange[]> {
|
||||
return request<ProfileChange[]>(`/chats/${chatId}/changes`, {
|
||||
account: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
<section class="rows">
|
||||
{#each rows as row (row.label)}
|
||||
<div class="row">
|
||||
<dt>{row.label}</dt>
|
||||
<dd>{row.value}</dd>
|
||||
<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}
|
||||
</dl>
|
||||
</section>
|
||||
{#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}
|
||||
<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>
|
||||
{@render action(
|
||||
"play-story",
|
||||
t("profile.stories"),
|
||||
onstories,
|
||||
String(storyCount)
|
||||
)}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<AvatarHistory id={chatId} kind={avatarKind} {name} />
|
||||
<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 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 !isPerson || overview}
|
||||
<AvatarHistory
|
||||
{id}
|
||||
items={overview?.avatars}
|
||||
kind={isPerson ? "peer" : "chat"}
|
||||
{name}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#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 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;
|
||||
& + .rows {
|
||||
border-top: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
dl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
min-height: 3.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.info-tab .row-icon),
|
||||
:global(.info-tab .row-chevron) {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-icon-secondary);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
.value {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.entry-icon {
|
||||
display: flex;
|
||||
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);
|
||||
}
|
||||
|
||||
.entry-label {
|
||||
flex: 1;
|
||||
font-size: 0.9375rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.entry-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.changes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.changes li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
.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>
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -28,12 +28,18 @@ interface JumpTarget {
|
||||
messageId: number;
|
||||
}
|
||||
|
||||
interface PersonTarget {
|
||||
chatId: number;
|
||||
peerId: number;
|
||||
}
|
||||
|
||||
function createUi() {
|
||||
let rightPanel = $state<RightPanel | null>(null);
|
||||
let panelMessageId = $state<number | null>(null);
|
||||
let leftColumnOpen = $state(true);
|
||||
let leftView = $state<LeftView>("main");
|
||||
let jumpTarget = $state<JumpTarget | null>(null);
|
||||
let person = $state<PersonTarget | null>(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;
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user