feat(frontend): main menu lists accounts with their avatars, switching opens the last chat of that account

This commit is contained in:
hh committed 2026-10-19 02:02:31 +00:00
1 parent 3460246749
commit a1d3552519
10 files changed
+338 -55

No files matched your search

+2 -2
View File
@@ -66,9 +66,9 @@ async function fetchAvatar(
export function loadAvatar(
kind: AvatarKind,
id: number
id: number,
account: number | null = accounts.selectedId
): Promise<string | null> {
const account = accounts.selectedId;
if (account === null) {
return Promise.resolve(null);
}
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { DropdownMenu } from "bits-ui";
import AccountAvatar from "#lib/components/settings/AccountAvatar.svelte";
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { accountName } 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 { theme } from "#lib/stores/theme.svelte.js";
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
interface Entry {
icon: string;
label: MessageKey;
panel: RightPanel;
}
const DIGIT = /^Digit([1-9])$/;
const entries: Entry[] = [
{ icon: "eye", label: "settings.watches", panel: "watches" },
{ icon: "unmute", label: "settings.alerts", panel: "alerts" },
{ icon: "data", label: "settings.storage", panel: "storage" },
{ icon: "play-story", label: "settings.stories", panel: "stories-all" },
{ icon: "cloud-download", label: "settings.jobs", panel: "jobs" },
{ icon: "permissions", label: "settings.policy", panel: "policy" },
];
let adding = $state(false);
function onkeydown(event: KeyboardEvent) {
const match = DIGIT.exec(event.code);
if (!(event.altKey && match)) {
return;
}
const account = accounts.list[Number(match[1]) - 1];
if (account) {
event.preventDefault();
accounts.switchTo(account.account_id);
}
}
</script>
<svelte:window {onkeydown} />
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({
props,
})}
<Button
{...props}
aria-label={t("menu.open")}
round
smaller
variant="translucent"
>
<Icon name="menu" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="start"
class="bg-menu-content main-menu"
sideOffset={6}
>
{#each accounts.list as account (account.account_id)}
<DropdownMenu.Item
class="bg-menu-item account-item"
onSelect={() => accounts.switchTo(account.account_id)}
>
<AccountAvatar {account} size={1.75} />
<span class="account-name">{accountName(account)}</span>
{#if !account.is_active}
<span class="account-tag">{t("menu.archived")}</span>
{/if}
{#if account.account_id === accounts.selectedId}
<Icon class="account-check" name="check" size="1.125rem" />
{/if}
</DropdownMenu.Item>
{/each}
<DropdownMenu.Item
class="bg-menu-item"
onSelect={() => {
adding = true;
}}
>
<Icon name="add" size="1.125rem" />
{t("menu.addAccount")}
</DropdownMenu.Item>
<DropdownMenu.Separator class="bg-menu-separator" />
<DropdownMenu.Item
class="bg-menu-item"
onSelect={() => ui.openSettings()}
>
<Icon name="settings" size="1.125rem" />
{t("settings.title")}
</DropdownMenu.Item>
{#each entries as entry (entry.panel)}
<DropdownMenu.Item
class="bg-menu-item"
onSelect={() => ui.openPanel(entry.panel)}
>
<Icon name={entry.icon} size="1.125rem" />
{t(entry.label)}
</DropdownMenu.Item>
{/each}
<DropdownMenu.Separator class="bg-menu-separator" />
<DropdownMenu.Item
class="bg-menu-item"
closeOnSelect={false}
onSelect={() => theme.toggle()}
>
<Icon name="darkmode" size="1.125rem" />
<span class="account-name">{t("menu.nightMode")}</span>
{#if theme.resolved === "dark"}
<Icon class="account-check" name="check" size="1.125rem" />
{/if}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<AddAccountDialog bind:open={adding} />
<style lang="scss">
:global(.main-menu) {
width: 17rem;
max-height: calc(100dvh - 5rem);
overflow-y: auto;
}
:global(.main-menu .account-item) {
padding-block: 0.375rem;
}
.account-name {
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-tag {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
:global(.main-menu .account-check) {
flex-shrink: 0;
color: var(--color-primary);
}
</style>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Account } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import { accountName } from "#lib/format/peer.js";
interface Props {
account: Account;
size?: number;
}
let { account, size }: Props = $props();
const avatar = $derived(
account.tg_user_id === null
? null
: {
account: account.account_id,
id: account.tg_user_id,
kind: "peer" as const,
}
);
</script>
<Avatar
{avatar}
colorKey={account.account_id}
hasAvatar={avatar !== null}
name={accountName(account)}
{size}
/>
@@ -2,14 +2,14 @@
import { Dialog } from "bits-ui";
import { ripple } from "#lib/actions/ripple.js";
import { ApiError } from "#lib/api/client.js";
import AccountAvatar from "#lib/components/settings/AccountAvatar.svelte";
import AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
import SettingsItem from "#lib/components/settings/SettingsItem.svelte";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { accountName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
const current = $derived(accounts.selected);
const others = $derived(
@@ -26,11 +26,13 @@
let renaming = $state(false);
let deviceName = $state("");
let busy = $state(false);
let failure = $state<string | null>(null);
const deviceLabel = $derived(current?.device_model ?? DEFAULT_DEVICE);
function openRename() {
deviceName = deviceLabel;
failure = null;
renaming = true;
}
@@ -41,14 +43,13 @@
return;
}
busy = true;
failure = null;
try {
await accounts.renameDevice(current.account_id, value);
renaming = false;
toasts.success("Имя устройства обновлено");
} catch (error) {
toasts.error(
error instanceof ApiError ? error.detail : "Не удалось переименовать"
);
failure =
error instanceof ApiError ? error.detail : t("account.renameFailed");
} finally {
busy = false;
}
@@ -59,12 +60,12 @@
return;
}
busy = true;
failure = null;
try {
await accounts.logout(current.account_id);
confirming = false;
toasts.success("Сессия завершена");
} catch {
toasts.error("Не удалось выйти из аккаунта");
failure = t("account.logoutFailed");
} finally {
busy = false;
}
@@ -74,17 +75,13 @@
<div class="my-account">
{#if current}
<div class="profile">
<Avatar
colorKey={current.account_id}
name={accountName(current)}
size={5}
/>
<AccountAvatar account={current} size={5} />
<div class="name">{accountName(current)}</div>
{#if current.phone}
<div class="phone">+{current.phone}</div>
{/if}
{#if !current.is_active}
<div class="inactive">Сессия завершена, доступен только архив</div>
<div class="inactive">{t("account.inactive")}</div>
{/if}
</div>
{/if}
@@ -94,18 +91,14 @@
{#each others as account (account.account_id)}
<button
class="switch-row"
onclick={() => accounts.select(account.account_id)}
onclick={() => accounts.switchTo(account.account_id)}
type="button"
use:ripple
>
<Avatar
colorKey={account.account_id}
name={accountName(account)}
size={2.25}
/>
<AccountAvatar {account} size={2.25} />
<span>{accountName(account)}</span>
{#if !account.is_active}
<span class="tag">архив</span>
<span class="tag">{t("menu.archived")}</span>
{/if}
<Icon class="chevron" name="arrow-right" size="1rem" />
</button>
@@ -117,14 +110,14 @@
{#if current?.is_active}
<SettingsItem
icon="active-sessions"
label="Имя устройства"
label={t("account.device")}
onclick={openRename}
value={deviceLabel}
/>
{/if}
<SettingsItem
icon="add-user"
label="Добавить аккаунт"
label={t("menu.addAccount")}
onclick={() => {
adding = true;
}}
@@ -132,8 +125,9 @@
{#if current?.is_active}
<SettingsItem
icon="logout"
label="Выйти из аккаунта"
label={t("account.logout")}
onclick={() => {
failure = null;
confirming = true;
}}
/>
@@ -148,18 +142,14 @@
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Имя устройства</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Dialog.Title class="dialog-title">{t("account.device")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<form onsubmit={rename}>
<div class="dialog-body">
<p class="confirm-text">
Так сессия называется в списке устройств Telegram. Чтобы имя
применилось, соединение переподключится — сбор сообщений прервётся
на пару секунд.
</p>
<p class="confirm-text">{t("account.deviceHint")}</p>
<div class="input-group">
<input
class="form-control"
@@ -169,8 +159,11 @@
type="text"
bind:value={deviceName}
>
<label for="device-name">Название</label>
<label for="device-name">{t("account.deviceName")}</label>
</div>
{#if failure}
<p class="failure">{failure}</p>
{/if}
</div>
<div class="dialog-actions">
<Button
@@ -179,7 +172,7 @@
pill
type="submit"
>
Сохранить
{t("common.save")}
</Button>
</div>
</form>
@@ -192,17 +185,22 @@
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Выйти из аккаунта?</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close">
<Dialog.Title class="dialog-title">
{t("account.logoutTitle")}
</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
<p class="confirm-text">
Сессия {current ? accountName(current) : ""} будет завершена в
Telegram, новые сообщения перестанут собираться. Уже собранный архив
останется доступным.
{t("account.logoutText", {
name: current ? accountName(current) : "",
})}
</p>
{#if failure}
<p class="failure">{failure}</p>
{/if}
</div>
<div class="dialog-actions">
<Button
@@ -212,10 +210,10 @@
pill
variant="text"
>
Отмена
{t("common.cancel")}
</Button>
<Button loading={busy} onclick={logout} pill variant="danger">
Выйти
{t("account.logoutAction")}
</Button>
</div>
</Dialog.Content>
@@ -292,6 +290,12 @@
color: var(--color-text-secondary);
}
.failure {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: var(--color-error);
}
:global(.switch-row .chevron) {
flex-shrink: 0;
color: var(--color-icon-secondary);
+2 -2
View File
@@ -3,7 +3,7 @@
import { initials as toInitials } from "#lib/format/peer.js";
interface Props {
avatar?: { kind: AvatarKind; id: number } | null;
avatar?: { account?: number; kind: AvatarKind; id: number } | null;
colorKey?: number;
deleted?: boolean;
hasAvatar?: boolean;
@@ -41,7 +41,7 @@
return;
}
let active = true;
loadAvatar(avatar.kind, avatar.id).then((resolved) => {
loadAvatar(avatar.kind, avatar.id, avatar.account).then((resolved) => {
if (active) {
url = resolved;
}
+4
View File
@@ -1,8 +1,12 @@
import { account } from "#lib/i18n/messages/account.js";
import { common } from "#lib/i18n/messages/common.js";
import { menu } from "#lib/i18n/messages/menu.js";
import { settings } from "#lib/i18n/messages/settings.js";
export const messages = {
...account,
...common,
...menu,
...settings,
};
+29
View File
@@ -0,0 +1,29 @@
import type { Messages } from "#lib/i18n/types.js";
export const account = {
"account.device": { en: "Device name", ru: "Имя устройства" },
"account.deviceHint": {
en: "This is how the session is called in the Telegram device list. To apply the name the connection restarts, capture pauses for a couple of seconds.",
ru: "Так сессия называется в списке устройств Telegram. Чтобы имя применилось, соединение переподключится, сбор сообщений прервётся на пару секунд.",
},
"account.deviceName": { en: "Name", ru: "Название" },
"account.inactive": {
en: "Session ended, only the archive is available",
ru: "Сессия завершена, доступен только архив",
},
"account.logout": { en: "Log out", ru: "Выйти из аккаунта" },
"account.logoutAction": { en: "Log out", ru: "Выйти" },
"account.logoutFailed": {
en: "Could not log out",
ru: "Не удалось выйти из аккаунта",
},
"account.logoutText": {
en: "The {name} session will be ended in Telegram and new messages will stop being captured. The archive collected so far stays available.",
ru: "Сессия {name} будет завершена в Telegram, новые сообщения перестанут собираться. Уже собранный архив останется доступным.",
},
"account.logoutTitle": { en: "Log out?", ru: "Выйти из аккаунта?" },
"account.renameFailed": {
en: "Could not rename",
ru: "Не удалось переименовать",
},
} satisfies Messages;
+12
View File
@@ -0,0 +1,12 @@
import type { Messages } from "#lib/i18n/types.js";
export const menu = {
"menu.accountsFailed": {
en: "Could not load accounts",
ru: "Не удалось загрузить аккаунты",
},
"menu.addAccount": { en: "Add account", ru: "Добавить аккаунт" },
"menu.archived": { en: "archive", ru: "архив" },
"menu.nightMode": { en: "Night mode", ru: "Ночной режим" },
"menu.open": { en: "Menu", ru: "Меню" },
} satisfies Messages;
@@ -5,8 +5,21 @@ import {
} from "#lib/api/endpoints.js";
import type { Account } from "#lib/api/types.js";
import { browser } from "$app/env";
import { goto } from "$app/navigation";
const STORAGE_KEY = "bg.account";
const LAST_CHAT_KEY = "bg.lastChat";
function readLastChats(): Record<string, number> {
if (!browser) {
return {};
}
try {
return JSON.parse(localStorage.getItem(LAST_CHAT_KEY) ?? "{}");
} catch {
return {};
}
}
function readSelected(): number | null {
if (!browser) {
@@ -20,6 +33,7 @@ function createAccounts() {
let list = $state<Account[]>([]);
let selectedId = $state<number | null>(readSelected());
let loaded = $state(false);
const lastChats = readLastChats();
const selected = $derived(
list.find((account) => account.account_id === selectedId) ?? null
@@ -67,6 +81,17 @@ function createAccounts() {
}
await load();
},
rememberChat(chatId: number | null) {
if (selectedId === null || !browser) {
return;
}
if (chatId === null) {
delete lastChats[selectedId];
} else {
lastChats[selectedId] = chatId;
}
localStorage.setItem(LAST_CHAT_KEY, JSON.stringify(lastChats));
},
async renameDevice(id: number, deviceModel: string) {
const updated = await renameAccountDevice(id, deviceModel);
list = list.map((account) =>
@@ -80,6 +105,14 @@ function createAccounts() {
get selectedId() {
return selectedId;
},
switchTo(id: number) {
if (id === selectedId) {
return;
}
select(id);
const chatId = lastChats[id];
goto(chatId === undefined ? "/app" : `/app/${chatId}`);
},
};
}
+25 -11
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { untrack } from "svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import ChatList from "#lib/components/ChatList.svelte";
import FolderTabs from "#lib/components/FolderTabs.svelte";
import MainMenu from "#lib/components/MainMenu.svelte";
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
import RightColumn from "#lib/components/RightColumn.svelte";
import SearchInput from "#lib/components/search/SearchInput.svelte";
@@ -11,6 +13,7 @@
import ShareDialog from "#lib/components/shares/ShareDialog.svelte";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { auth } from "#lib/stores/auth.svelte.js";
import { events } from "#lib/stores/events.svelte.js";
@@ -27,13 +30,32 @@
$effect(() => {
if (!accounts.loaded) {
accounts.load().catch(() => toasts.error("Failed to load accounts"));
accounts.load().catch(() => toasts.error(t("menu.accountsFailed")));
}
});
$effect(() => {
events.open(auth.token === null ? null : accounts.selectedId);
});
$effect(() => {
const chatId = page.params.chatId ? Number(page.params.chatId) : null;
untrack(() => accounts.rememberChat(chatId));
});
let shownAccount = accounts.selectedId;
$effect(() => {
const id = accounts.selectedId;
if (id === shownAccount) {
return;
}
shownAccount = id;
untrack(() => {
ui.closePanel();
search.close();
});
});
</script>
<div
@@ -57,7 +79,7 @@
<header class="left-header">
{#if search.active}
<Button
aria-label="Назад"
aria-label={t("common.back")}
onclick={() => search.close()}
round
smaller
@@ -66,15 +88,7 @@
<Icon name="arrow-left" />
</Button>
{:else}
<Button
aria-label="Меню"
onclick={() => ui.openSettings()}
round
smaller
variant="translucent"
>
<Icon name="menu" />
</Button>
<MainMenu />
{/if}
<SearchInput />
</header>