feat(frontend): main menu lists accounts with their avatars, switching opens the last chat of that account
This commit is contained in:
1 parent
3460246749
commit
a1d3552519
10 files changed
+338
-55
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user