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( export function loadAvatar(
kind: AvatarKind, kind: AvatarKind,
id: number id: number,
account: number | null = accounts.selectedId
): Promise<string | null> { ): Promise<string | null> {
const account = accounts.selectedId;
if (account === null) { if (account === null) {
return Promise.resolve(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 { Dialog } from "bits-ui";
import { ripple } from "#lib/actions/ripple.js"; import { ripple } from "#lib/actions/ripple.js";
import { ApiError } from "#lib/api/client.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 AddAccountDialog from "#lib/components/settings/AddAccountDialog.svelte";
import SettingsItem from "#lib/components/settings/SettingsItem.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 Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte"; import Icon from "#lib/components/ui/Icon.svelte";
import { accountName } from "#lib/format/peer.js"; import { accountName } from "#lib/format/peer.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js"; import { accounts } from "#lib/stores/accounts.svelte.js";
import { toasts } from "#lib/stores/toasts.svelte.js";
const current = $derived(accounts.selected); const current = $derived(accounts.selected);
const others = $derived( const others = $derived(
@@ -26,11 +26,13 @@
let renaming = $state(false); let renaming = $state(false);
let deviceName = $state(""); let deviceName = $state("");
let busy = $state(false); let busy = $state(false);
let failure = $state<string | null>(null);
const deviceLabel = $derived(current?.device_model ?? DEFAULT_DEVICE); const deviceLabel = $derived(current?.device_model ?? DEFAULT_DEVICE);
function openRename() { function openRename() {
deviceName = deviceLabel; deviceName = deviceLabel;
failure = null;
renaming = true; renaming = true;
} }
@@ -41,14 +43,13 @@
return; return;
} }
busy = true; busy = true;
failure = null;
try { try {
await accounts.renameDevice(current.account_id, value); await accounts.renameDevice(current.account_id, value);
renaming = false; renaming = false;
toasts.success("Имя устройства обновлено");
} catch (error) { } catch (error) {
toasts.error( failure =
error instanceof ApiError ? error.detail : "Не удалось переименовать" error instanceof ApiError ? error.detail : t("account.renameFailed");
);
} finally { } finally {
busy = false; busy = false;
} }
@@ -59,12 +60,12 @@
return; return;
} }
busy = true; busy = true;
failure = null;
try { try {
await accounts.logout(current.account_id); await accounts.logout(current.account_id);
confirming = false; confirming = false;
toasts.success("Сессия завершена");
} catch { } catch {
toasts.error("Не удалось выйти из аккаунта"); failure = t("account.logoutFailed");
} finally { } finally {
busy = false; busy = false;
} }
@@ -74,17 +75,13 @@
<div class="my-account"> <div class="my-account">
{#if current} {#if current}
<div class="profile"> <div class="profile">
<Avatar <AccountAvatar account={current} size={5} />
colorKey={current.account_id}
name={accountName(current)}
size={5}
/>
<div class="name">{accountName(current)}</div> <div class="name">{accountName(current)}</div>
{#if current.phone} {#if current.phone}
<div class="phone">+{current.phone}</div> <div class="phone">+{current.phone}</div>
{/if} {/if}
{#if !current.is_active} {#if !current.is_active}
<div class="inactive">Сессия завершена, доступен только архив</div> <div class="inactive">{t("account.inactive")}</div>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -94,18 +91,14 @@
{#each others as account (account.account_id)} {#each others as account (account.account_id)}
<button <button
class="switch-row" class="switch-row"
onclick={() => accounts.select(account.account_id)} onclick={() => accounts.switchTo(account.account_id)}
type="button" type="button"
use:ripple use:ripple
> >
<Avatar <AccountAvatar {account} size={2.25} />
colorKey={account.account_id}
name={accountName(account)}
size={2.25}
/>
<span>{accountName(account)}</span> <span>{accountName(account)}</span>
{#if !account.is_active} {#if !account.is_active}
<span class="tag">архив</span> <span class="tag">{t("menu.archived")}</span>
{/if} {/if}
<Icon class="chevron" name="arrow-right" size="1rem" /> <Icon class="chevron" name="arrow-right" size="1rem" />
</button> </button>
@@ -117,14 +110,14 @@
{#if current?.is_active} {#if current?.is_active}
<SettingsItem <SettingsItem
icon="active-sessions" icon="active-sessions"
label="Имя устройства" label={t("account.device")}
onclick={openRename} onclick={openRename}
value={deviceLabel} value={deviceLabel}
/> />
{/if} {/if}
<SettingsItem <SettingsItem
icon="add-user" icon="add-user"
label="Добавить аккаунт" label={t("menu.addAccount")}
onclick={() => { onclick={() => {
adding = true; adding = true;
}} }}
@@ -132,8 +125,9 @@
{#if current?.is_active} {#if current?.is_active}
<SettingsItem <SettingsItem
icon="logout" icon="logout"
label="Выйти из аккаунта" label={t("account.logout")}
onclick={() => { onclick={() => {
failure = null;
confirming = true; confirming = true;
}} }}
/> />
@@ -148,18 +142,14 @@
<Dialog.Overlay class="dialog-overlay" /> <Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Имя устройства</Dialog.Title> <Dialog.Title class="dialog-title">{t("account.device")}</Dialog.Title>
<Dialog.Close aria-label="Закрыть" class="dialog-close"> <Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
<form onsubmit={rename}> <form onsubmit={rename}>
<div class="dialog-body"> <div class="dialog-body">
<p class="confirm-text"> <p class="confirm-text">{t("account.deviceHint")}</p>
Так сессия называется в списке устройств Telegram. Чтобы имя
применилось, соединение переподключится — сбор сообщений прервётся
на пару секунд.
</p>
<div class="input-group"> <div class="input-group">
<input <input
class="form-control" class="form-control"
@@ -169,8 +159,11 @@
type="text" type="text"
bind:value={deviceName} bind:value={deviceName}
> >
<label for="device-name">Название</label> <label for="device-name">{t("account.deviceName")}</label>
</div> </div>
{#if failure}
<p class="failure">{failure}</p>
{/if}
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
<Button <Button
@@ -179,7 +172,7 @@
pill pill
type="submit" type="submit"
> >
Сохранить {t("common.save")}
</Button> </Button>
</div> </div>
</form> </form>
@@ -192,17 +185,22 @@
<Dialog.Overlay class="dialog-overlay" /> <Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content"> <Dialog.Content class="dialog-content">
<header class="dialog-head"> <header class="dialog-head">
<Dialog.Title class="dialog-title">Выйти из аккаунта?</Dialog.Title> <Dialog.Title class="dialog-title">
<Dialog.Close aria-label="Закрыть" class="dialog-close"> {t("account.logoutTitle")}
</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" /> <Icon name="close" size="1.25rem" />
</Dialog.Close> </Dialog.Close>
</header> </header>
<div class="dialog-body"> <div class="dialog-body">
<p class="confirm-text"> <p class="confirm-text">
Сессия {current ? accountName(current) : ""} будет завершена в {t("account.logoutText", {
Telegram, новые сообщения перестанут собираться. Уже собранный архив name: current ? accountName(current) : "",
останется доступным. })}
</p> </p>
{#if failure}
<p class="failure">{failure}</p>
{/if}
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
<Button <Button
@@ -212,10 +210,10 @@
pill pill
variant="text" variant="text"
> >
Отмена {t("common.cancel")}
</Button> </Button>
<Button loading={busy} onclick={logout} pill variant="danger"> <Button loading={busy} onclick={logout} pill variant="danger">
Выйти {t("account.logoutAction")}
</Button> </Button>
</div> </div>
</Dialog.Content> </Dialog.Content>
@@ -292,6 +290,12 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.failure {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: var(--color-error);
}
:global(.switch-row .chevron) { :global(.switch-row .chevron) {
flex-shrink: 0; flex-shrink: 0;
color: var(--color-icon-secondary); color: var(--color-icon-secondary);
+2 -2
View File
@@ -3,7 +3,7 @@
import { initials as toInitials } from "#lib/format/peer.js"; import { initials as toInitials } from "#lib/format/peer.js";
interface Props { interface Props {
avatar?: { kind: AvatarKind; id: number } | null; avatar?: { account?: number; kind: AvatarKind; id: number } | null;
colorKey?: number; colorKey?: number;
deleted?: boolean; deleted?: boolean;
hasAvatar?: boolean; hasAvatar?: boolean;
@@ -41,7 +41,7 @@
return; return;
} }
let active = true; let active = true;
loadAvatar(avatar.kind, avatar.id).then((resolved) => { loadAvatar(avatar.kind, avatar.id, avatar.account).then((resolved) => {
if (active) { if (active) {
url = resolved; 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 { common } from "#lib/i18n/messages/common.js";
import { menu } from "#lib/i18n/messages/menu.js";
import { settings } from "#lib/i18n/messages/settings.js"; import { settings } from "#lib/i18n/messages/settings.js";
export const messages = { export const messages = {
...account,
...common, ...common,
...menu,
...settings, ...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"; } from "#lib/api/endpoints.js";
import type { Account } from "#lib/api/types.js"; import type { Account } from "#lib/api/types.js";
import { browser } from "$app/env"; import { browser } from "$app/env";
import { goto } from "$app/navigation";
const STORAGE_KEY = "bg.account"; 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 { function readSelected(): number | null {
if (!browser) { if (!browser) {
@@ -20,6 +33,7 @@ function createAccounts() {
let list = $state<Account[]>([]); let list = $state<Account[]>([]);
let selectedId = $state<number | null>(readSelected()); let selectedId = $state<number | null>(readSelected());
let loaded = $state(false); let loaded = $state(false);
const lastChats = readLastChats();
const selected = $derived( const selected = $derived(
list.find((account) => account.account_id === selectedId) ?? null list.find((account) => account.account_id === selectedId) ?? null
@@ -67,6 +81,17 @@ function createAccounts() {
} }
await load(); 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) { async renameDevice(id: number, deviceModel: string) {
const updated = await renameAccountDevice(id, deviceModel); const updated = await renameAccountDevice(id, deviceModel);
list = list.map((account) => list = list.map((account) =>
@@ -80,6 +105,14 @@ function createAccounts() {
get selectedId() { get selectedId() {
return 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"> <script lang="ts">
import { untrack } from "svelte";
import { cubicOut } from "svelte/easing"; import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import ChatList from "#lib/components/ChatList.svelte"; import ChatList from "#lib/components/ChatList.svelte";
import FolderTabs from "#lib/components/FolderTabs.svelte"; import FolderTabs from "#lib/components/FolderTabs.svelte";
import MainMenu from "#lib/components/MainMenu.svelte";
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte"; import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
import RightColumn from "#lib/components/RightColumn.svelte"; import RightColumn from "#lib/components/RightColumn.svelte";
import SearchInput from "#lib/components/search/SearchInput.svelte"; import SearchInput from "#lib/components/search/SearchInput.svelte";
@@ -11,6 +13,7 @@
import ShareDialog from "#lib/components/shares/ShareDialog.svelte"; import ShareDialog from "#lib/components/shares/ShareDialog.svelte";
import Button from "#lib/components/ui/Button.svelte"; import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.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 { accounts } from "#lib/stores/accounts.svelte.js";
import { auth } from "#lib/stores/auth.svelte.js"; import { auth } from "#lib/stores/auth.svelte.js";
import { events } from "#lib/stores/events.svelte.js"; import { events } from "#lib/stores/events.svelte.js";
@@ -27,13 +30,32 @@
$effect(() => { $effect(() => {
if (!accounts.loaded) { if (!accounts.loaded) {
accounts.load().catch(() => toasts.error("Failed to load accounts")); accounts.load().catch(() => toasts.error(t("menu.accountsFailed")));
} }
}); });
$effect(() => { $effect(() => {
events.open(auth.token === null ? null : accounts.selectedId); 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> </script>
<div <div
@@ -57,7 +79,7 @@
<header class="left-header"> <header class="left-header">
{#if search.active} {#if search.active}
<Button <Button
aria-label="Назад" aria-label={t("common.back")}
onclick={() => search.close()} onclick={() => search.close()}
round round
smaller smaller
@@ -66,15 +88,7 @@
<Icon name="arrow-left" /> <Icon name="arrow-left" />
</Button> </Button>
{:else} {:else}
<Button <MainMenu />
aria-label="Меню"
onclick={() => ui.openSettings()}
round
smaller
variant="translucent"
>
<Icon name="menu" />
</Button>
{/if} {/if}
<SearchInput /> <SearchInput />
</header> </header>