feat(FN-3403): add shell multi-profile support with connection manager onbo
Merges shell multi-profile support for desktop (FN-3403) — spanning new connection-manager flows, shell onboarding interoperability, mobile connection profiles, desktop IPC/shell-settings plumbing, and tokenized titlebar styles — with a secondary migration of the WhatsApp plugin to the Baileys pairi Fusion-Task-Id: FN-3403
This commit is contained in:
@@ -66,7 +66,8 @@ Desktop boots through a shell-owned mode chooser before mounting the dashboard a
|
||||
- **First run choice:** users choose **Local Fusion (bundled runtime)** or **Remote connection path**.
|
||||
- **Mode contract:** `desktopMode` is `"local" | "remote" | null` and `hasCompletedModeSelection` determines whether the renderer treats startup as first-run. IPC also exposes a renderer-safe `{ isFirstRun, desktopMode }` shape via `shell:getDesktopModeState`.
|
||||
- **Desktop mode restore:** after selection, mode is persisted and reused on relaunch.
|
||||
- **Remote profiles:** multiple saved profiles are supported (`name`, `serverUrl`, optional `authToken`) and can be managed/switched later from the dashboard header connection UI.
|
||||
- **Remote profiles:** multiple saved profiles are supported (`name`, `serverUrl`, optional `authToken`) and can be created/edited/switched/deleted from the dashboard connection manager.
|
||||
- **Delete fallback:** if the active profile is deleted, desktop shell settings automatically select the first remaining profile; deleting the final profile leaves a valid empty payload (`activeProfileId: null`, `profiles: []`).
|
||||
- **Storage boundary:** shell connection state is stored only in desktop-local app data at `app.getPath("userData")/shell-connections.json` and is not written to `.fusion/config.json` or dashboard project storage keys.
|
||||
|
||||
### Production vs dev bootstrap behavior
|
||||
@@ -157,6 +158,7 @@ Desktop local mode uses an in-process runtime manager (`src/local-server.ts`) th
|
||||
- `getState()`, `listProfiles()`, `saveProfile()`, `deleteProfile()`
|
||||
- `setActiveProfile()`, `setDesktopMode()`
|
||||
- `startQrScan()`, `openConnectionManager()`, `subscribe(listener)`
|
||||
- Together these cover create/delete/switch operations for shell-owned remote profiles without writing to project/global Fusion settings
|
||||
- `window.fusionAPI` remains as a backward-compatible alias of `window.electronAPI`.
|
||||
|
||||
All preload typings are declared in `src/types.d.ts`.
|
||||
|
||||
@@ -56,6 +56,27 @@ vi.mock("../native.js", () => ({
|
||||
vi.mock("../shell-settings.js", () => ({
|
||||
readShellSettings: mocks.readShellSettings,
|
||||
writeShellSettings: mocks.writeShellSettings,
|
||||
buildSavedProfile: (settings: { profiles: Array<{ id: string }>; }, profile: { id?: string; name: string; serverUrl: string }) => ({
|
||||
id: profile.id ?? "generated-id",
|
||||
name: profile.name.trim() || "Remote Server",
|
||||
serverUrl: profile.serverUrl,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
authToken: null,
|
||||
lastUsedAt: null,
|
||||
}),
|
||||
applyDeleteProfile: (settings: { activeProfileId: string | null; profiles: Array<{ id: string }> }, profileId: string) => {
|
||||
const profiles = settings.profiles.filter((item) => item.id !== profileId);
|
||||
return {
|
||||
...settings,
|
||||
profiles,
|
||||
activeProfileId: settings.activeProfileId === profileId ? (profiles[0]?.id ?? null) : settings.activeProfileId,
|
||||
};
|
||||
},
|
||||
applySetActiveProfile: (settings: { profiles: Array<{ id: string }> }, profileId: string | null) => ({
|
||||
...settings,
|
||||
activeProfileId: profileId && settings.profiles.some((item) => item.id === profileId) ? profileId : null,
|
||||
}),
|
||||
getDesktopShellModeState: (settings: { hasCompletedModeSelection?: boolean; desktopMode?: "local" | "remote" | null }) => ({
|
||||
isFirstRun: !settings.hasCompletedModeSelection || !settings.desktopMode,
|
||||
desktopMode: settings.desktopMode ?? null,
|
||||
@@ -128,15 +149,27 @@ describe("ipc handlers", () => {
|
||||
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
|
||||
});
|
||||
|
||||
it("shell:saveProfile rejects invalid URLs", async () => {
|
||||
it("shell:saveProfile persists the helper-generated profile", async () => {
|
||||
await registerHandlers();
|
||||
const handler = mocks.ipcHandlers.get("shell:saveProfile");
|
||||
const result = await handler?.({}, { name: " Prod ", serverUrl: "https://fusion.example.com" });
|
||||
|
||||
await expect(handler?.({}, { name: "Prod", serverUrl: "not-a-url" })).rejects.toThrow(
|
||||
"Server URL must be a valid absolute URL",
|
||||
);
|
||||
await expect(handler?.({}, { name: "Prod", serverUrl: "ftp://fusion.example.com" })).rejects.toThrow(
|
||||
"Server URL must use http or https",
|
||||
);
|
||||
expect(result).toMatchObject({ id: "generated-id", name: "Prod" });
|
||||
expect(mocks.writeShellSettings).toHaveBeenCalledWith(expect.objectContaining({ profiles: [expect.objectContaining({ id: "generated-id" })] }));
|
||||
});
|
||||
|
||||
it("shell:deleteProfile falls back to first remaining profile when deleting active", async () => {
|
||||
mocks.readShellSettings.mockResolvedValueOnce({
|
||||
desktopMode: "remote",
|
||||
hasCompletedModeSelection: true,
|
||||
activeProfileId: "p2",
|
||||
profiles: [{ id: "p1" }, { id: "p2" }],
|
||||
});
|
||||
await registerHandlers();
|
||||
|
||||
const handler = mocks.ipcHandlers.get("shell:deleteProfile");
|
||||
await handler?.({}, "p2");
|
||||
|
||||
expect(mocks.writeShellSettings).toHaveBeenCalledWith(expect.objectContaining({ activeProfileId: "p1" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,4 +101,49 @@ describe("shell-settings", () => {
|
||||
desktopMode: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes invalid profiles, duplicate names, and invalid active id", async () => {
|
||||
mockState.content.set(
|
||||
"/tmp/fusion/shell-connections.json",
|
||||
JSON.stringify({
|
||||
activeProfileId: "missing",
|
||||
profiles: [
|
||||
{ id: "p1", name: "", serverUrl: "https://fusion.example.com" },
|
||||
{ id: "p2", name: "Remote Server", serverUrl: "https://staging.example.com" },
|
||||
{ id: "p3", name: "Bad", serverUrl: "not-a-url" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { readShellSettings } = await import("../shell-settings.ts");
|
||||
const settings = await readShellSettings();
|
||||
expect(settings.activeProfileId).toBeNull();
|
||||
expect(settings.profiles).toHaveLength(2);
|
||||
expect(settings.profiles[0]?.name).toBe("Remote Server");
|
||||
expect(settings.profiles[1]?.name).toBe("Remote Server (2)");
|
||||
});
|
||||
|
||||
it("deleting active profile picks fallback and deleting last clears state", async () => {
|
||||
const { applyDeleteProfile } = await import("../shell-settings.ts");
|
||||
const first = { id: "p1", name: "Prod", serverUrl: "https://fusion.example.com", authToken: null, createdAt: "", updatedAt: "" };
|
||||
const second = { id: "p2", name: "Staging", serverUrl: "https://staging.example.com", authToken: null, createdAt: "", updatedAt: "" };
|
||||
|
||||
const withFallback = applyDeleteProfile({
|
||||
desktopMode: "remote",
|
||||
hasCompletedModeSelection: true,
|
||||
activeProfileId: "p2",
|
||||
profiles: [first, second],
|
||||
}, "p2");
|
||||
|
||||
expect(withFallback.activeProfileId).toBe("p1");
|
||||
|
||||
const empty = applyDeleteProfile({
|
||||
desktopMode: "remote",
|
||||
hasCompletedModeSelection: true,
|
||||
activeProfileId: "p1",
|
||||
profiles: [first],
|
||||
}, "p1");
|
||||
|
||||
expect(empty).toMatchObject({ activeProfileId: null, profiles: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@ import { app, type BrowserWindow, ipcMain, type Tray } from "electron";
|
||||
import { setupAutoUpdater, showExportSettingsDialog, showImportSettingsDialog } from "./native.js";
|
||||
import { type EngineStatus, updateTrayStatus } from "./tray.js";
|
||||
import {
|
||||
applyDeleteProfile,
|
||||
applySetActiveProfile,
|
||||
buildSavedProfile,
|
||||
getDesktopShellModeState,
|
||||
readShellSettings,
|
||||
writeShellSettings,
|
||||
@@ -35,27 +38,6 @@ interface RegisterIpcOptions {
|
||||
getServerPort?: () => number | undefined;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function createProfileId(): string {
|
||||
return `profile_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function normalizeServerUrl(serverUrl: string): string {
|
||||
const normalized = serverUrl.trim().replace(/\/$/, "");
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw new Error("Server URL must be a valid absolute URL");
|
||||
}
|
||||
if (!parsed.protocol || !/^https?:$/.test(parsed.protocol)) {
|
||||
throw new Error("Server URL must use http or https");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function toShellState(
|
||||
settings: Awaited<ReturnType<typeof readShellSettings>>,
|
||||
@@ -122,38 +104,25 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
|
||||
|
||||
ipcMain.handle("shell:saveProfile", async (_event, profile: ShellConnectionProfileInput) => {
|
||||
const settings = await readShellSettings();
|
||||
const existing = profile.id ? settings.profiles.find((item) => item.id === profile.id) : undefined;
|
||||
const timestamp = nowIso();
|
||||
const nextProfile: ShellConnectionProfile = {
|
||||
id: existing?.id ?? profile.id ?? createProfileId(),
|
||||
name: profile.name.trim(),
|
||||
serverUrl: normalizeServerUrl(profile.serverUrl),
|
||||
authToken: profile.authToken ?? null,
|
||||
createdAt: existing?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp,
|
||||
lastUsedAt: existing?.lastUsedAt ?? null,
|
||||
};
|
||||
const nextProfile = buildSavedProfile(settings, profile);
|
||||
const existing = settings.profiles.find((item) => item.id === nextProfile.id);
|
||||
|
||||
settings.profiles = existing ? settings.profiles.map((item) => (item.id === existing.id ? nextProfile : item)) : [...settings.profiles, nextProfile];
|
||||
settings.profiles = existing
|
||||
? settings.profiles.map((item) => (item.id === existing.id ? nextProfile : item))
|
||||
: [...settings.profiles, nextProfile];
|
||||
await writeShellSettings(settings);
|
||||
await emitShellState(mainWindow, options.getLocalServerState);
|
||||
return nextProfile;
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:deleteProfile", async (_event, profileId: string) => {
|
||||
const settings = await readShellSettings();
|
||||
settings.profiles = settings.profiles.filter((item) => item.id !== profileId);
|
||||
if (settings.activeProfileId === profileId) settings.activeProfileId = null;
|
||||
const settings = applyDeleteProfile(await readShellSettings(), profileId);
|
||||
await writeShellSettings(settings);
|
||||
await emitShellState(mainWindow, options.getLocalServerState);
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:setActiveProfile", async (_event, profileId: string | null) => {
|
||||
const settings = await readShellSettings();
|
||||
settings.activeProfileId = profileId && settings.profiles.some((item) => item.id === profileId) ? profileId : null;
|
||||
settings.profiles = settings.profiles.map((item) =>
|
||||
item.id === settings.activeProfileId ? { ...item, lastUsedAt: nowIso(), updatedAt: nowIso() } : item,
|
||||
);
|
||||
const settings = applySetActiveProfile(await readShellSettings(), profileId);
|
||||
await writeShellSettings(settings);
|
||||
return emitShellState(mainWindow, options.getLocalServerState);
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
gap: var(--space-md);
|
||||
padding: 0 var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
@@ -24,19 +24,19 @@
|
||||
.desktop-titlebar__brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.desktop-titlebar__logo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: var(--space-lg);
|
||||
height: var(--space-lg);
|
||||
color: var(--logo-accent, var(--todo));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.desktop-titlebar__title {
|
||||
font-size: 12px;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text);
|
||||
@@ -45,7 +45,7 @@
|
||||
.desktop-titlebar__controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.desktop-titlebar__controls--no-drag,
|
||||
@@ -54,19 +54,19 @@
|
||||
}
|
||||
|
||||
.desktop-titlebar__control {
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
width: calc(var(--space-lg) + var(--space-lg));
|
||||
height: var(--space-xl);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
line-height: 1;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.desktop-titlebar__control:hover {
|
||||
@@ -75,8 +75,8 @@
|
||||
}
|
||||
|
||||
.desktop-titlebar__control:focus-visible {
|
||||
outline: 2px solid var(--todo);
|
||||
outline-offset: 1px;
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.desktop-titlebar__control--close:hover {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { app } from "electron";
|
||||
|
||||
@@ -37,11 +38,97 @@ function getSettingsPath(): string {
|
||||
return join(app.getPath("userData"), "shell-connections.json");
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeDesktopMode(value: unknown): DesktopShellMode | null {
|
||||
if (value === "local" || value === "remote") {
|
||||
return value;
|
||||
return value === "local" || value === "remote" ? value : null;
|
||||
}
|
||||
|
||||
function normalizeServerUrl(serverUrl: string): string {
|
||||
const normalized = serverUrl.trim().replace(/\/$/, "");
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw new Error("Server URL must be a valid absolute URL");
|
||||
}
|
||||
return null;
|
||||
if (!/^https?:$/.test(parsed.protocol)) {
|
||||
throw new Error("Server URL must use http or https");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeProfileName(name: string): string {
|
||||
const normalized = name.trim().replace(/\s+/g, " ");
|
||||
return normalized.length > 0 ? normalized : "Remote Server";
|
||||
}
|
||||
|
||||
function profileBaseId(name: string, serverUrl: string): string {
|
||||
const hash = createHash("sha1").update(`${name}|${serverUrl}`).digest("hex").slice(0, 10);
|
||||
return `profile_${hash}`;
|
||||
}
|
||||
|
||||
function ensureUniqueProfileName(name: string, profiles: ShellConnectionProfile[], skipId?: string): string {
|
||||
const used = new Set(
|
||||
profiles.filter((profile) => profile.id !== skipId).map((profile) => profile.name.toLocaleLowerCase()),
|
||||
);
|
||||
if (!used.has(name.toLocaleLowerCase())) {
|
||||
return name;
|
||||
}
|
||||
let suffix = 2;
|
||||
let candidate = `${name} (${suffix})`;
|
||||
while (used.has(candidate.toLocaleLowerCase())) {
|
||||
suffix += 1;
|
||||
candidate = `${name} (${suffix})`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function createDeterministicProfileId(name: string, serverUrl: string, profiles: ShellConnectionProfile[], skipId?: string): string {
|
||||
const used = new Set(profiles.filter((profile) => profile.id !== skipId).map((profile) => profile.id));
|
||||
const base = profileBaseId(name, serverUrl);
|
||||
if (!used.has(base)) {
|
||||
return base;
|
||||
}
|
||||
let suffix = 2;
|
||||
let candidate = `${base}_${suffix}`;
|
||||
while (used.has(candidate)) {
|
||||
suffix += 1;
|
||||
candidate = `${base}_${suffix}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizeProfileRecord(input: unknown, fallbackIndex: number): ShellConnectionProfile | null {
|
||||
if (!input || typeof input !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = input as Partial<ShellConnectionProfile>;
|
||||
if (typeof candidate.serverUrl !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let serverUrl: string;
|
||||
try {
|
||||
serverUrl = normalizeServerUrl(candidate.serverUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = normalizeProfileName(typeof candidate.name === "string" ? candidate.name : "");
|
||||
const createdAt = typeof candidate.createdAt === "string" && candidate.createdAt.length > 0 ? candidate.createdAt : nowIso();
|
||||
const updatedAt = typeof candidate.updatedAt === "string" && candidate.updatedAt.length > 0 ? candidate.updatedAt : createdAt;
|
||||
return {
|
||||
id: typeof candidate.id === "string" && candidate.id.length > 0 ? candidate.id : `profile_imported_${fallbackIndex}`,
|
||||
name,
|
||||
serverUrl,
|
||||
authToken: typeof candidate.authToken === "string" ? candidate.authToken : null,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
lastUsedAt: typeof candidate.lastUsedAt === "string" ? candidate.lastUsedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(input: unknown): DesktopShellSettings {
|
||||
@@ -52,13 +139,80 @@ function normalize(input: unknown): DesktopShellSettings {
|
||||
const candidate = input as Partial<DesktopShellSettings>;
|
||||
const desktopMode = normalizeDesktopMode(candidate.desktopMode);
|
||||
const inferredCompleted = desktopMode !== null;
|
||||
|
||||
const profiles: ShellConnectionProfile[] = [];
|
||||
const profileSource = Array.isArray(candidate.profiles) ? candidate.profiles : [];
|
||||
for (const [index, profileValue] of profileSource.entries()) {
|
||||
const normalizedProfile = normalizeProfileRecord(profileValue, index);
|
||||
if (!normalizedProfile) {
|
||||
continue;
|
||||
}
|
||||
const uniqueName = ensureUniqueProfileName(normalizedProfile.name, profiles);
|
||||
const idAlreadyUsed = profiles.some((profile) => profile.id === normalizedProfile.id);
|
||||
const id = idAlreadyUsed
|
||||
? createDeterministicProfileId(uniqueName, normalizedProfile.serverUrl, profiles)
|
||||
: normalizedProfile.id;
|
||||
profiles.push({ ...normalizedProfile, name: uniqueName, id });
|
||||
}
|
||||
|
||||
const persistedActiveId = typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null;
|
||||
const activeProfileId = persistedActiveId && profiles.some((profile) => profile.id === persistedActiveId)
|
||||
? persistedActiveId
|
||||
: null;
|
||||
|
||||
return {
|
||||
desktopMode,
|
||||
hasCompletedModeSelection: typeof candidate.hasCompletedModeSelection === "boolean" ? candidate.hasCompletedModeSelection : inferredCompleted,
|
||||
activeProfileId: typeof candidate.activeProfileId === "string" ? candidate.activeProfileId : null,
|
||||
profiles: Array.isArray(candidate.profiles)
|
||||
? (candidate.profiles.filter((item) => item && typeof item === "object") as ShellConnectionProfile[])
|
||||
: [],
|
||||
activeProfileId,
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSavedProfile(
|
||||
settings: DesktopShellSettings,
|
||||
input: { id?: string; name: string; serverUrl: string; authToken?: string | null },
|
||||
): ShellConnectionProfile {
|
||||
const existing = input.id ? settings.profiles.find((item) => item.id === input.id) : undefined;
|
||||
const normalizedServerUrl = normalizeServerUrl(input.serverUrl);
|
||||
const normalizedName = normalizeProfileName(input.name);
|
||||
const name = ensureUniqueProfileName(normalizedName, settings.profiles, existing?.id);
|
||||
const timestamp = nowIso();
|
||||
|
||||
return {
|
||||
id: existing?.id ?? createDeterministicProfileId(name, normalizedServerUrl, settings.profiles, existing?.id),
|
||||
name,
|
||||
serverUrl: normalizedServerUrl,
|
||||
authToken: input.authToken ?? null,
|
||||
createdAt: existing?.createdAt ?? timestamp,
|
||||
updatedAt: timestamp,
|
||||
lastUsedAt: existing?.lastUsedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDeleteProfile(settings: DesktopShellSettings, profileId: string): DesktopShellSettings {
|
||||
const profiles = settings.profiles.filter((item) => item.id !== profileId);
|
||||
const activeProfileId =
|
||||
settings.activeProfileId !== profileId
|
||||
? settings.activeProfileId
|
||||
: profiles.length > 0
|
||||
? profiles[0]?.id ?? null
|
||||
: null;
|
||||
return { ...settings, profiles, activeProfileId };
|
||||
}
|
||||
|
||||
export function applySetActiveProfile(settings: DesktopShellSettings, profileId: string | null): DesktopShellSettings {
|
||||
const activeProfileId = profileId && settings.profiles.some((item) => item.id === profileId) ? profileId : null;
|
||||
const timestamp = nowIso();
|
||||
const profiles = settings.profiles.map((item) =>
|
||||
item.id === activeProfileId
|
||||
? { ...item, lastUsedAt: timestamp, updatedAt: timestamp }
|
||||
: item,
|
||||
);
|
||||
|
||||
return {
|
||||
...settings,
|
||||
activeProfileId,
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user