feat(frontend): profiles chip in the feed shows name, username, photo and title changes

This commit is contained in:
hh committed 2026-10-19 03:25:48 +00:00
1 parent 8e0f828dd4
commit 98dda99c5c
9 files changed
+181 -14

No files matched your search

+2 -1
View File
@@ -222,7 +222,7 @@ export interface MessageVersion {
text: string | null; text: string | null;
} }
export type FeedKind = "all" | "deleted" | "edited"; export type FeedKind = "all" | "deleted" | "edited" | "profile";
export type FeedChatKind = "dm" | "group" | "channel"; export type FeedChatKind = "dm" | "group" | "channel";
export interface FeedChat { export interface FeedChat {
@@ -237,6 +237,7 @@ export interface FeedItem {
at: string; at: string;
before: string | null; before: string | null;
chat: FeedChat; chat: FeedChat;
detail: Record<string, [string | null, string | null]> | null;
edit_date: string | null; edit_date: string | null;
has_before: boolean; has_before: boolean;
id: number; id: number;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { FeedKind } from "#lib/api/types.js"; import type { FeedItem, FeedKind } from "#lib/api/types.js";
import FeedFilters from "#lib/components/feed/FeedFilters.svelte"; import FeedFilters from "#lib/components/feed/FeedFilters.svelte";
import FeedList from "#lib/components/feed/FeedList.svelte"; import FeedList from "#lib/components/feed/FeedList.svelte";
import { ui } from "#lib/stores/ui.svelte.js"; import { ui } from "#lib/stores/ui.svelte.js";
@@ -16,10 +16,14 @@
kind = next; kind = next;
} }
function open(messageId: number) { function open(item: FeedItem, messageId: number) {
if (chatId === null) { if (chatId === null) {
return; return;
} }
if (item.kind === "profile" && !messageId) {
ui.openPanel("profile");
return;
}
ui.requestJump(chatId, messageId); ui.requestJump(chatId, messageId);
if (isMobile()) { if (isMobile()) {
ui.closePanel(); ui.closePanel();
@@ -30,6 +34,6 @@
{#if chatId !== null} {#if chatId !== null}
<FeedFilters {kind} onkind={pick} /> <FeedFilters {kind} onkind={pick} />
{#key chatId} {#key chatId}
<FeedList {chatId} {kind} onopen={(_, messageId) => open(messageId)} /> <FeedList {chatId} {kind} onopen={open} />
{/key} {/key}
{/if} {/if}
@@ -2,6 +2,7 @@
import { ripple } from "#lib/actions/ripple.js"; import { ripple } from "#lib/actions/ripple.js";
import type { FeedItem, MessageView } from "#lib/api/types.js"; import type { FeedItem, MessageView } from "#lib/api/types.js";
import FeedBulk from "#lib/components/feed/FeedBulk.svelte"; import FeedBulk from "#lib/components/feed/FeedBulk.svelte";
import FeedPhoto from "#lib/components/feed/FeedPhoto.svelte";
import FeedText from "#lib/components/feed/FeedText.svelte"; import FeedText from "#lib/components/feed/FeedText.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte"; import Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
@@ -20,6 +21,14 @@
let { item, onopen, inChat = false }: Props = $props(); let { item, onopen, inChat = false }: Props = $props();
const FIELD_KEYS = {
name: "feed.field.name",
photo: "feed.field.photo",
title: "feed.field.title",
username: "feed.field.username",
} as const;
const FIELD_ORDER = ["name", "title", "username", "photo"] as const;
let expanded = $state(false); let expanded = $state(false);
const ownId = $derived(accounts.selected?.tg_user_id ?? null); const ownId = $derived(accounts.selected?.tg_user_id ?? null);
@@ -67,8 +76,14 @@
? t("chat.deletedAccount") ? t("chat.deletedAccount")
: t("chat.fallbackTitle", { id: item.chat.chat_id })) : t("chat.fallbackTitle", { id: item.chat.chat_id }))
); );
const profile = $derived(item.kind === "profile");
const fields = $derived(
FIELD_ORDER.filter((field) => item.detail?.[field]).map(
(field) => [field, item.detail?.[field] ?? [null, null]] as const
)
);
const title = $derived.by(() => { const title = $derived.by(() => {
if (!inChat) { if (!inChat || profile) {
return chatTitle; return chatTitle;
} }
if (bulk) { if (bulk) {
@@ -77,7 +92,7 @@
return senderName(item.sender_id, item.sender_name); return senderName(item.sender_id, item.sender_name);
}); });
const avatar = $derived.by(() => { const avatar = $derived.by(() => {
if (!inChat) { if (!inChat || profile) {
return { return {
has: item.chat.has_avatar, has: item.chat.has_avatar,
id: item.chat.chat_id, id: item.chat.chat_id,
@@ -92,7 +107,7 @@
}; };
}); });
const prefix = $derived.by(() => { const prefix = $derived.by(() => {
if (inChat) { if (inChat || profile) {
return ""; return "";
} }
if (item.outgoing) { if (item.outgoing) {
@@ -107,6 +122,13 @@
: null : null
); );
function shown(field: string, value: string | null): string {
if (!value) {
return t("feed.noText");
}
return field === "username" ? `@${value}` : value;
}
function open() { function open() {
if (bulk) { if (bulk) {
expanded = !expanded; expanded = !expanded;
@@ -142,7 +164,27 @@
{formatListDate(item.at)} {formatListDate(item.at)}
</time> </time>
</span> </span>
{#if bulk} {#if profile}
{#each fields as [field, pair] (field)}
<span class="change now">
<span class="tag">{t(FIELD_KEYS[field])}</span>
{#if field === "photo"}
<FeedPhoto ownerId={item.chat.chat_id} uniqueId={pair[0]} />
<span class="arrow">→</span>
<FeedPhoto ownerId={item.chat.chat_id} uniqueId={pair[1]} />
{:else}
<span class="was">{shown(field, pair[0])}</span>
<span class="arrow">→</span>
{shown(field, pair[1])}
{/if}
</span>
{/each}
{#if item.message_count > fields.length}
<span class="count">
{t("feed.profileCount", { count: item.message_count })}
</span>
{/if}
{:else if bulk}
<span class="mark"> <span class="mark">
<Icon name="delete" size="0.875rem" /> <Icon name="delete" size="0.875rem" />
{inChat {inChat
@@ -360,4 +402,18 @@
.missing { .missing {
font-style: italic; font-style: italic;
} }
.was {
color: var(--color-text-secondary);
}
.arrow {
margin-inline: 0.25rem;
color: var(--color-text-secondary);
}
.count {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
</style> </style>
@@ -16,6 +16,7 @@
all: "feed.filter.all", all: "feed.filter.all",
deleted: "feed.filter.deleted", deleted: "feed.filter.deleted",
edited: "feed.filter.edited", edited: "feed.filter.edited",
profile: "feed.filter.profile",
} as const; } as const;
const CHAT_KEYS = { const CHAT_KEYS = {
@@ -69,7 +70,7 @@
.group { .group {
display: inline-flex; display: inline-flex;
flex-wrap: nowrap; flex-wrap: wrap;
gap: 0.25rem; gap: 0.25rem;
} }
@@ -25,6 +25,7 @@
all: "feed.empty.all", all: "feed.empty.all",
deleted: "feed.empty.deleted", deleted: "feed.empty.deleted",
edited: "feed.empty.edited", edited: "feed.empty.edited",
profile: "feed.empty.profile",
} as const; } as const;
let items = $state<FeedItem[]>([]); let items = $state<FeedItem[]>([]);
@@ -0,0 +1,64 @@
<script lang="ts">
import { loadAvatarVariant } from "#lib/api/avatars.js";
import Icon from "#lib/components/ui/Icon.svelte";
interface Props {
ownerId: number;
uniqueId: string | null;
}
let { ownerId, uniqueId }: Props = $props();
let url = $state<string | null>(null);
$effect(() => {
url = null;
if (uniqueId === null) {
return;
}
let active = true;
loadAvatarVariant(ownerId > 0 ? "peer" : "chat", ownerId, uniqueId).then(
(resolved) => {
if (active) {
url = resolved;
}
}
);
return () => {
active = false;
};
});
</script>
<span class="feed-photo">
{#if url}
<img alt="" src={url}>
{:else}
<Icon name="photo" size="0.875rem" />
{/if}
</span>
<style lang="scss">
.feed-photo {
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
vertical-align: middle;
color: var(--color-white);
background-color: var(--color-gray);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
@@ -11,6 +11,7 @@
feed, feed,
messageHref, messageHref,
} from "#lib/stores/feed.svelte.js"; } from "#lib/stores/feed.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -20,8 +21,11 @@
let { filter }: Props = $props(); let { filter }: Props = $props();
function open(item: FeedItem, messageId: number) { async function open(item: FeedItem, messageId: number) {
goto(messageHref(page.url, item.chat.chat_id, messageId)); await goto(messageHref(page.url, item.chat.chat_id, messageId));
if (item.kind === "profile" && !messageId) {
ui.openPanel("profile");
}
} }
onMount(() => { onMount(() => {
+34
View File
@@ -76,13 +76,22 @@ export const feed = {
en: "No edited messages yet", en: "No edited messages yet",
ru: "Изменённых пока нет", ru: "Изменённых пока нет",
}, },
"feed.empty.profile": {
en: "No profile changes yet",
ru: "Профили пока не менялись",
},
"feed.error": { "feed.error": {
en: "Couldn't load the feed", en: "Couldn't load the feed",
ru: "Не удалось загрузить ленту", ru: "Не удалось загрузить ленту",
}, },
"feed.field.name": { en: "Name", ru: "Имя" },
"feed.field.photo": { en: "Photo", ru: "Фото" },
"feed.field.title": { en: "Title", ru: "Название" },
"feed.field.username": { en: "Username", ru: "Юзернейм" },
"feed.filter.all": { en: "All", ru: "Все" }, "feed.filter.all": { en: "All", ru: "Все" },
"feed.filter.deleted": { en: "Deleted", ru: "Удалённые" }, "feed.filter.deleted": { en: "Deleted", ru: "Удалённые" },
"feed.filter.edited": { en: "Edited", ru: "Изменённые" }, "feed.filter.edited": { en: "Edited", ru: "Изменённые" },
"feed.filter.profile": { en: "Profiles", ru: "Профили" },
"feed.lostText": { "feed.lostText": {
en: "The earlier text wasn't saved", en: "The earlier text wasn't saved",
ru: "Прежний текст не сохранился", ru: "Прежний текст не сохранился",
@@ -91,6 +100,15 @@ export const feed = {
"feed.mediaChanged": { en: "Media replaced", ru: "Медиа заменено" }, "feed.mediaChanged": { en: "Media replaced", ru: "Медиа заменено" },
"feed.more": { en: "Show more", ru: "Показать ещё" }, "feed.more": { en: "Show more", ru: "Показать ещё" },
"feed.noText": { en: "No text", ru: "Без текста" }, "feed.noText": { en: "No text", ru: "Без текста" },
"feed.profileCount": {
en: { one: "{count} change", other: "{count} changes" },
ru: {
few: "{count} изменения",
many: "{count} изменений",
one: "{count} изменение",
other: "{count} изменения",
},
},
"feed.retry": { en: "Retry", ru: "Повторить" }, "feed.retry": { en: "Retry", ru: "Повторить" },
"feed.title": { en: "Feed", ru: "Лента" }, "feed.title": { en: "Feed", ru: "Лента" },
"feed.unseen": { "feed.unseen": {
@@ -127,6 +145,20 @@ export const feed = {
"panel.search": { en: "Search", ru: "Поиск" }, "panel.search": { en: "Search", ru: "Поиск" },
"panel.trash": { en: "Trash", ru: "Корзина" }, "panel.trash": { en: "Trash", ru: "Корзина" },
"panel.versions": { en: "Versions", ru: "Версии" }, "panel.versions": { en: "Versions", ru: "Версии" },
"thread.copiedId": { en: "ID copied", ru: "ID скопирован" },
"thread.copiedText": { en: "Text copied", ru: "Текст скопирован" },
"thread.copyId": { en: "Copy ID", ru: "Копировать ID" },
"thread.copyText": { en: "Copy text", ru: "Копировать текст" },
"thread.deleted": { en: "deleted", ru: "удалено" },
"thread.links": {
en: { one: "{count} link", other: "{count} links" },
ru: {
few: "{count} ссылки",
many: "{count} ссылок",
one: "{count} ссылка",
other: "{count} ссылки",
},
},
"thread.loadFailed": { "thread.loadFailed": {
en: "Couldn't load messages", en: "Couldn't load messages",
ru: "Не удалось загрузить сообщения", ru: "Не удалось загрузить сообщения",
@@ -135,5 +167,7 @@ export const feed = {
en: "This message isn't in the archive", en: "This message isn't in the archive",
ru: "Этого сообщения нет в архиве", ru: "Этого сообщения нет в архиве",
}, },
"thread.senderProfile": { en: "Sender's profile", ru: "Профиль автора" },
"thread.toLatest": { en: "Jump to latest", ru: "К последним сообщениям" }, "thread.toLatest": { en: "Jump to latest", ru: "К последним сообщениям" },
"thread.toReply": { en: "Go to the reply", ru: "Перейти к ответу" },
} satisfies Messages; } satisfies Messages;
+5 -3
View File
@@ -10,7 +10,7 @@ const KIND_PARAM = "feed";
const CHATS_PARAM = "chats"; const CHATS_PARAM = "chats";
const MESSAGE_PARAM = "message"; const MESSAGE_PARAM = "message";
export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited"]; export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited", "profile"];
export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"]; export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"];
export interface FeedFilter { export interface FeedFilter {
@@ -59,11 +59,13 @@ function href(url: UrlLike): string {
export function messageHref( export function messageHref(
url: UrlLike, url: UrlLike,
chatId: number, chatId: number,
messageId: number messageId?: number
): string { ): string {
const next = withFilter(url, feedFilterOf(url)); const next = withFilter(url, feedFilterOf(url));
next.pathname = `/app/${chatId}`; next.pathname = `/app/${chatId}`;
next.searchParams.set(MESSAGE_PARAM, String(messageId)); if (messageId) {
next.searchParams.set(MESSAGE_PARAM, String(messageId));
}
return href(next); return href(next);
} }