feat: avatar by link, loaded by the browser; Files nests workspaces under owners' homes

- POST /api/me/avatar/url stores the https link (users.avatar_link) instead
  of downloading it; GET /api/me/avatar and /api/users/:id/avatar answer 302
  to it (no referrer). Link rules: https, no userinfo, <= 2048 chars, no
  localhost or private/reserved IP literal. Upload and link replace each other.
- Settings -> Account first loads the link as an embedded image and refuses
  one this browser cannot show: a picture behind a login (Mattermost's
  /api/v4/users/<id>/image) gets no cookie cross-site and 401s.
- Files sidebar: each workspace under its owner's home, chevrons remembered
  per browser (agenthub.files.homesOpen); ownerless ones stay flat.
This commit is contained in:
2026-10-22 14:34:50 +07:00
parent 649f0599f8
commit 26952ae0d9
19 changed files with 463 additions and 289 deletions
+159 -54
View File
@@ -22,6 +22,9 @@
* another user's. An admin's Trash opens on one folder per user (with its
* item count); each folder is THAT user's Trash, to restore from or purge
* without waiting the 7 days.
* The sidebar nests each workspace under its owner's home (a chevron drops
* them down, remembered per browser); one with no owner home stays in a flat
* Workspaces list.
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
* (server/lib/file-explorer.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -93,6 +96,7 @@ import { copyText } from "../../lib/clipboard";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { CloneHereDialog } from "./CloneHereDialog";
import { isSubmitEnter } from "../../lib/keys";
import { useAuth } from "../AuthGate";
/** One upload batch at most (the server caps one file the same). */
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
@@ -137,6 +141,11 @@ export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
const MIME = "application/x-agenthub-fx";
const VIEW_KEY = "agenthub.files.view";
const HIDDEN_KEY = "agenthub.files.hidden";
/** The sidebar homes whose workspaces are dropped down (a JSON list of home ids). */
const HOMES_OPEN_KEY = "agenthub.files.homesOpen";
/** A sidebar user row; `noHome`: it only groups an owner's workspaces, the
* viewer may not open that home (an analyst's view of other users). */
type UserRow = FsHome & { noHome?: boolean };
type Clip = { place: FsPlace; paths: string[]; cut: boolean };
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
@@ -319,6 +328,16 @@ export function FileBrowser({
bytes: number;
} | null>(null);
const [lanes, setLanes] = useState<Lane[]>([]);
const { state: auth } = useAuth();
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
const [openHomes, setOpenHomes] = useState<Set<string>>(() => {
try {
const v: unknown = JSON.parse(readPref(HOMES_OPEN_KEY, '["me"]'));
return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
} catch {
return new Set(["me"]);
}
});
const [clip, setClip] = useState<Clip | null>(clipboard);
const [dropping, setDropping] = useState(false);
const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null);
@@ -892,6 +911,52 @@ export function FileBrowser({
onCancel={() => setRenaming(null)}
/>
) : null;
// Each workspace drops down under its owner: the caller's own under My
// home, another user's under theirs (`owner_name` = that home's id). An
// owner whose home this viewer may not open (an analyst's view of other
// users) still gets a row, one that only groups. A workspace with no owner
// stays in the flat Workspaces list. Which rows are open is per browser.
const { lanesOfHome, userRows } = useMemo(() => {
const ids = new Set(homes.map((h) => h.id));
const map = new Map<string, Lane[]>();
const extra = new Map<string, UserRow>();
for (const l of lanes) {
const home =
myId != null && l.owner_id === myId && ids.has("me")
? "me"
: l.owner_name && l.owner_name !== "me"
? l.owner_name
: null;
if (!home) continue;
map.set(home, [...(map.get(home) ?? []), l]);
if (!ids.has(home) && !extra.has(home)) {
extra.set(home, {
id: home,
label: l.owner_display_name || home,
readOnly: true,
noHome: true,
});
}
}
const rows: UserRow[] = [
...homes.filter((h) => h.id !== "me"),
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
];
return { lanesOfHome: map, userRows: rows };
}, [homes, lanes, myId]);
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
const looseLanes = lanes.filter((l) => !grouped.has(l.id));
// A home holding the open workspace shows it, whatever was stored.
const homeIsOpen = (id: string, own: Lane[]) =>
openHomes.has(id) ||
(view === "files" && !isHomePlace(target) && own.some((l) => l.id === laneOf(target)));
const toggleHome = (id: string, own: Lane[]) => {
const next = new Set(openHomes);
if (homeIsOpen(id, own)) next.delete(id);
else next.add(id);
setOpenHomes(next);
writePref(HOMES_OPEN_KEY, JSON.stringify([...next]));
};
const placeClass = (active: boolean) =>
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${
active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2"
@@ -920,64 +985,104 @@ export function FileBrowser({
{/* ── Places ── */}
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
{homes.some((h) => h.id === "me") && (
<div className="pt-3">
{(() => {
const laneRow = (l: Lane, nested: boolean) => (
<button
key={l.id}
type="button"
onClick={() => go("", { home: "me" })}
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
className={placeClass(
view === "files" && isHomePlace(target) && target.home === "me"
)}
title={l.cwd}
onClick={() => go("", l.id)}
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
className={`${placeClass(
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
)}${nested ? " pl-7" : ""}`}
>
<House className="h-4 w-4 shrink-0" />
<span className="truncate">{t("fx.myHome")}</span>
<FolderGit2 className="h-4 w-4 shrink-0" />
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
</button>
</div>
)}
{homes.some((h) => h.id !== "me") && (
<>
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("fx.userHomes")}
</p>
{homes
.filter((h) => h.id !== "me")
.map((h) => (
<button
key={h.id}
type="button"
title={t("fx.readOnlyHome")}
onClick={() => go("", { home: h.id })}
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
className={placeClass(
view === "files" && isHomePlace(target) && target.home === h.id
)}
>
<UserRound className="h-4 w-4 shrink-0" />
<span className="truncate">{h.label}</span>
<Eye className="ml-auto h-3 w-3 shrink-0 opacity-60" />
</button>
))}
</>
)}
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("fx.places")}
</p>
{lanes.map((l) => (
<button
key={l.id}
type="button"
title={l.cwd}
onClick={() => go("", l.id)}
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
className={placeClass(
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
)}
>
<FolderGit2 className="h-4 w-4 shrink-0" />
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
</button>
))}
);
// A home row with a chevron: its user's workspaces drop down under it.
const homeRow = (h: UserRow) => {
const own = lanesOfHome.get(h.id) ?? [];
const open = own.length > 0 && homeIsOpen(h.id, own);
const mine = h.id === "me";
// No home to open: the name drops the list down too.
const browse = !h.noHome;
return (
<div key={h.id} data-testid={`fx-home-${h.id}`}>
<div className="flex items-center">
<button
type="button"
aria-label={t("fx.toggleWorkspaces", {
name: mine ? t("fx.myHome") : h.label,
})}
aria-expanded={open}
disabled={!own.length}
onClick={() => toggleHome(h.id, own)}
className="flex h-6 w-5 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-2 disabled:opacity-0"
>
{open ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
title={mine || !browse ? undefined : t("fx.readOnlyHome")}
aria-expanded={browse ? undefined : open}
onClick={() => (browse ? go("", { home: h.id }) : toggleHome(h.id, own))}
onContextMenu={browse ? (e) => placeCtx.open(e, { home: h.id }) : undefined}
className={placeClass(
browse && view === "files" && isHomePlace(target) && target.home === h.id
)}
>
{mine ? (
<House className="h-4 w-4 shrink-0" />
) : (
<UserRound className="h-4 w-4 shrink-0" />
)}
<span className="truncate">{mine ? t("fx.myHome") : h.label}</span>
{own.length > 0 && (
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
{own.length}
</span>
)}
{!mine && browse && (
<Eye
className={`${own.length ? "" : "ml-auto "}h-3 w-3 shrink-0 opacity-60`}
/>
)}
</button>
</div>
{open && own.map((l) => laneRow(l, true))}
</div>
);
};
const me = homes.find((h) => h.id === "me");
const others = userRows;
return (
<>
{me && <div className="pt-3">{homeRow(me)}</div>}
{others.length > 0 && (
<>
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{others.some((h) => !h.noHome) ? t("fx.userHomes") : t("fx.users")}
</p>
{others.map(homeRow)}
</>
)}
{looseLanes.length > 0 && (
<>
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("fx.places")}
</p>
{looseLanes.map((l) => laneRow(l, false))}
</>
)}
</>
);
})()}
</nav>
<div className="border-t border-border p-2" data-testid="fx-trashes">
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
@@ -7,7 +7,9 @@
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
* one saying why), opens each user's Trash from its folder (asking before a
* purge there, sending the item's store) and
* shows a Trash that could not be read as an error, never as empty.
* shows a Trash that could not be read as an error, never as empty; nests a
* user's workspaces under their home in the sidebar (under a fold-only row
* when the viewer may not open that home).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -470,6 +472,59 @@ describe("FileExplorer", () => {
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
expect(api.files.remove).not.toHaveBeenCalled();
});
it("drops a user's workspaces down under their home; an unowned one stays in the list", async () => {
localStorage.removeItem("agenthub.files.homesOpen");
vi.mocked(api.lanes.list).mockResolvedValue({
lanes: [
{ id: 1, title: "one", cwd: "/w/one" },
{ id: 2, title: "two", cwd: "/w/two", owner_id: 7, owner_name: "bob" },
],
counts: {},
} as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const toggle = await screen.findByRole("button", { name: "Show or hide Bob’s workspaces" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
const places = screen.getByRole("navigation", { name: "Workspaces" });
expect(within(places).queryByRole("button", { name: "two" })).toBeNull();
expect(within(places).getByRole("button", { name: "one" })).toBeInTheDocument();
await user.click(toggle);
await user.click(within(places).getByRole("button", { name: "two" }));
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
expect(localStorage.getItem("agenthub.files.homesOpen")).toContain("bob");
});
it("groups an owner whose home the viewer cannot open (an analyst) under a row that only folds", async () => {
localStorage.removeItem("agenthub.files.homesOpen");
vi.mocked(api.files.homes).mockResolvedValue({
homes: [{ id: "me", label: "An", readOnly: false }],
});
vi.mocked(api.lanes.list).mockResolvedValue({
lanes: [
{ id: 1, title: "one", cwd: "/w/one" },
{
id: 3,
title: "three",
cwd: "/w/three",
owner_id: 9,
owner_name: "cara",
owner_display_name: "Cara",
},
],
counts: {},
} as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const places = screen.getByRole("navigation", { name: "Workspaces" });
expect(within(places).getByText("Users")).toBeInTheDocument();
expect(within(places).queryByRole("button", { name: "three" })).toBeNull();
await user.click(within(places).getByRole("button", { name: /^Cara/ }));
expect(api.files.list).not.toHaveBeenCalledWith({ home: "cara" }, "");
expect(within(places).getByRole("button", { name: "three" })).toBeInTheDocument();
});
});
describe("FileExplorer — New → Clone repository", () => {
@@ -4,7 +4,7 @@
* account's own information (`GET /api/me/account` — username, role, member
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB — or
* an https link the server downloads, `POST /api/me/avatar/url`; the sidebar
* an https link the browser loads itself, `POST /api/me/avatar/url`; the sidebar
* shows it at once), its display name
* and email (`PATCH /api/me/account`), a password change that asks for the
* current one and passes every rule of `lib/passwordRules.ts`, ticked as it is
@@ -48,6 +48,24 @@ const errText = (t: (k: string, o?: Record<string, unknown>) => string, e: unkno
: err.message;
};
/** Whether `url` loads as an image embedded in this page — the way every
* avatar is shown: no referrer, the cookies a cross-site image gets. */
export function loadsAsImage(url: string, timeoutMs = 10_000): Promise<boolean> {
return new Promise((resolve) => {
const img = new Image();
const done = (ok: boolean) => {
window.clearTimeout(timer);
img.onload = img.onerror = null;
resolve(ok);
};
const timer = window.setTimeout(() => done(false), timeoutMs);
img.referrerPolicy = "no-referrer";
img.onload = () => done(img.naturalWidth > 0);
img.onerror = () => done(false);
img.src = url;
});
}
export function AccountSettings() {
const { t } = useTranslation("settings");
const [account, setAccount] = useState<ServerAccount | null>(null);
@@ -138,6 +156,14 @@ function ProfileCard({
const applyLink = async () => {
const url = link?.trim();
if (!url) return;
// The avatar routes only redirect to the link, so it shows only if THIS
// browser can load it as an embedded image: a link behind a login (a
// Mattermost /api/v4/users/<id>/image) gets no cookie from another site
// and fails — say so now instead of saving a picture nobody sees.
setBusy(true);
const ok = await loadsAsImage(url);
setBusy(false);
if (!ok) return void toast.error(t("account.avatar.linkUnreachable"));
if (await run(() => api.me.setAvatarFromUrl(url), t("account.avatar.saved"))) setLink(null);
};
const pick = (e: ChangeEvent<HTMLInputElement>) => {
@@ -78,8 +78,40 @@ describe("AccountSettings", () => {
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
});
/** jsdom never loads an image: play the browser's answer to `src`. */
const stubImage = (loads: boolean) =>
vi.stubGlobal(
"Image",
class {
naturalWidth = loads ? 64 : 0;
referrerPolicy = "";
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_v: string) {
setTimeout(() => (loads ? this.onload?.() : this.onerror?.()), 0);
}
}
);
it("refuses a link this browser cannot load as an image, and saves nothing", async () => {
const ue = userEvent.setup();
stubImage(false);
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
await ue.type(
screen.getByTestId("account-avatar-url-input"),
"https://chat.example/api/v4/users/x/image{Enter}"
);
await waitFor(() => expect(screen.getByTestId("account-avatar-url-input")).toBeTruthy());
await new Promise((r) => setTimeout(r, 20));
expect(me.setAvatarFromUrl).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("sets the avatar from a link and closes the field", async () => {
const ue = userEvent.setup();
stubImage(true);
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-21T00:00:00Z" } };
me.setAvatarFromUrl.mockResolvedValue(withAvatar);
render(<AccountSettings />);
@@ -92,6 +124,7 @@ describe("AccountSettings", () => {
"/api/me/avatar?v=2026-10-21T00:00:00Z"
);
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
vi.unstubAllGlobals();
});
it("shows the account's own information", async () => {
+2
View File
@@ -650,6 +650,8 @@
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
"fx.myHome": "Home",
"fx.userHomes": "Users' homes",
"fx.users": "Users",
"fx.toggleWorkspaces": "Show or hide {{name}}’s workspaces",
"fx.readOnlyHome": "Another user's home — view and download only; their Trash can be restored or emptied",
"fx.readOnly": "Read-only",
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
+11 -46
View File
@@ -77,21 +77,13 @@
"run_done_other": "{{count}} runs finished 🐾"
},
"greet": {
"morning": [
"good morning! ☀️",
"morning! coffee first? ☕",
"rise and shine 🌤️"
],
"morning": ["good morning! ☀️", "morning! coffee first? ☕", "rise and shine 🌤️"],
"afternoon": [
"good afternoon! 🐾",
"hey, welcome back 😺",
"afternoon! ready when you are ✨"
],
"evening": [
"good evening! 🌆",
"evening shift? I'm in 😼",
"hey there, night owl-to-be 🌙"
],
"evening": ["good evening! 🌆", "evening shift? I'm in 😼", "hey there, night owl-to-be 🌙"],
"night": [
"burning the midnight oil? 🌙",
"late one, huh? I'll keep watch 🦉",
@@ -114,11 +106,7 @@
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
"hello": "hi there! 😺 {{summary}}.",
"thanks": [
"anytime! 💜",
"happy to help 😸",
"you got it 🐾"
],
"thanks": ["anytime! 💜", "happy to help 😸", "you got it 🐾"],
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
"errorsNone": "no errors - all clean 🐾",
@@ -175,41 +163,17 @@
"can't reach the server 📡",
"no signal - taking a nap 💤"
],
"worried": [
"that didn't look right 😟",
"keeping an eye out 👀",
"hmm, something's off 🫣"
],
"worried": ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
"stuck": [
"a session's been quiet a while… 🤔",
"is something stuck? ⏳",
"still chewing on it… 😾"
],
"happy": [
"great run! 😻",
"love a tidy finish ✨",
"purrfect 💜"
],
"thinking": [
"hmm, let me look… 🤔",
"sniffing around… 🐾",
"one sec, checking 🔍"
],
"watching": [
"on the prowl 👀",
"watching your sessions 😼",
"eyes peeled 🐾"
],
"sleeping": [
"zzz… 💤",
"wake me if something happens 😴",
"curled up, all calm 🐈"
],
"idle": [
"all quiet 😺",
"ready when you are 🐾",
"just vibing ✨"
]
"happy": ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
"thinking": ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
},
"chat": {
"label": "Chat with {{name}}",
@@ -1357,7 +1321,8 @@
"tooBig": "The picture must be at most 1 MB",
"fromUrl": "Use a picture from a link",
"urlPlaceholder": "https://… image link",
"urlApply": "Use"
"urlApply": "Use",
"linkUnreachable": "That link did not load as an image in your browser — it may need a login (a Mattermost or other chat picture does). Download the picture and upload it instead."
},
"loadFailed": "Could not load your account: {{message}}",
"memberSince": "Member since",
+2
View File
@@ -650,6 +650,8 @@
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
"fx.myHome": "Thư mục home",
"fx.userHomes": "Home của các user",
"fx.users": "Người dùng",
"fx.toggleWorkspaces": "Hiện/ẩn workspace của {{name}}",
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về; Thùng rác của họ thì khôi phục hoặc xoá được",
"fx.readOnly": "Chỉ đọc",
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
+14 -61
View File
@@ -77,26 +77,10 @@
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
},
"greet": {
"morning": [
"chào buổi sáng! ☀️",
"sáng rồi! cà phê chưa? ☕",
"ngày mới tốt lành nha 🌤️"
],
"afternoon": [
"chào buổi chiều! 🐾",
"bạn quay lại rồi 😺",
"chiều nay làm gì đây? ✨"
],
"evening": [
"chào buổi tối! 🌆",
"ca tối hả? có mình đây 😼",
"tối rồi, cố lên nhé 🌙"
],
"night": [
"khuya rồi mà vẫn làm à? 🌙",
"để mình canh giúp cho 🦉",
"nhớ nghỉ ngơi nha 💤"
],
"morning": ["chào buổi sáng! ☀️", "sáng rồi! cà phê chưa? ☕", "ngày mới tốt lành nha 🌤️"],
"afternoon": ["chào buổi chiều! 🐾", "bạn quay lại rồi 😺", "chiều nay làm gì đây? ✨"],
"evening": ["chào buổi tối! 🌆", "ca tối hả? có mình đây 😼", "tối rồi, cố lên nhé 🌙"],
"night": ["khuya rồi mà vẫn làm à? 🌙", "để mình canh giúp cho 🦉", "nhớ nghỉ ngơi nha 💤"],
"summary_one": "{{count}} phiên đang chạy",
"summary_other": "{{count}} phiên đang chạy",
"summaryWaiting": "{{count}} phiên chờ bạn",
@@ -114,11 +98,7 @@
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
"hello": "chào bạn! 😺 {{summary}}.",
"thanks": [
"có gì đâu! 💜",
"rất vui được giúp 😸",
"chuyện nhỏ 🐾"
],
"thanks": ["có gì đâu! 💜", "rất vui được giúp 😸", "chuyện nhỏ 🐾"],
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
@@ -175,41 +155,13 @@
"không gọi được máy chủ 📡",
"mất sóng - đi ngủ chút 💤"
],
"worried": [
"có vẻ không ổn 😟",
"mình đang để mắt đây 👀",
"hừm, có gì đó lạ lạ 🫣"
],
"stuck": [
"một phiên im lặng lâu rồi… 🤔",
"có gì bị kẹt không? ⏳",
"vẫn đang nhai… 😾"
],
"happy": [
"tuyệt vời! 😻",
"kết thúc gọn ghê ✨",
"hoàn hảo 💜"
],
"thinking": [
"để mình xem… 🤔",
"đang đánh hơi… 🐾",
"chờ chút, đang kiểm tra 🔍"
],
"watching": [
"đang tuần tra 👀",
"đang canh các phiên của bạn 😼",
"mắt mở to 🐾"
],
"sleeping": [
"zzz… 💤",
"có gì thì gọi mình dậy nhé 😴",
"cuộn tròn, mọi thứ yên ổn 🐈"
],
"idle": [
"yên ắng quá 😺",
"sẵn sàng khi bạn cần 🐾",
"chill thôi ✨"
]
"worried": ["có vẻ không ổn 😟", "mình đang để mắt đây 👀", "hừm, có gì đó lạ lạ 🫣"],
"stuck": ["một phiên im lặng lâu rồi… 🤔", "có gì bị kẹt không? ⏳", "vẫn đang nhai… 😾"],
"happy": ["tuyệt vời! 😻", "kết thúc gọn ghê ✨", "hoàn hảo 💜"],
"thinking": ["để mình xem… 🤔", "đang đánh hơi… 🐾", "chờ chút, đang kiểm tra 🔍"],
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
},
"chat": {
"label": "Trò chuyện với {{name}}",
@@ -1357,7 +1309,8 @@
"tooBig": "Ảnh tối đa 1 MB",
"fromUrl": "Dùng ảnh từ đường dẫn",
"urlPlaceholder": "https://… đường dẫn ảnh",
"urlApply": "Dùng"
"urlApply": "Dùng",
"linkUnreachable": "Trình duyệt không tải được ảnh từ link này — có thể link cần đăng nhập (ảnh Mattermost hay app chat khác là vậy). Hãy tải ảnh về rồi upload lên."
},
"loadFailed": "Không tải được tài khoản: {{message}}",
"memberSince": "Tham gia từ",
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.18 (rebuild client to match)
UI build v2.8.19 (rebuild client to match)
</span>
</p>
</div>