Add Joined devices dialogue and version notices

This commit is contained in:
vorotamoroz committed 2026-10-23 10:13:46 +00:00
1 parent ce0524d49f
commit 404d4279c0
23 files changed
+1589 -6

No files matched your search

@@ -7,6 +7,7 @@ import {
type ObsidianLiveSyncSettings,
LOG_LEVEL_VERBOSE,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
import { Menu, type ButtonComponent } from "@/deps.ts";
import { $msg } from "@/common/translation";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
@@ -101,6 +102,22 @@ export function paneRemoteConfig(
paneEl: HTMLElement,
{ addPanel }: PageFunctions
): void {
new Setting(paneEl)
.setName($msg("Joined devices"))
.setDesc($msg("Review device records for the active CouchDB or Object Storage connection."))
.addButton((button) =>
button.setButtonText($msg("Open")).onClick(async () => {
const result = await this.services.UI.commands.invoke(OPEN_JOINED_DEVICES, undefined);
if (result.status === "unavailable" || result.value === "unavailable") {
this.services.API.addLog(
$msg(
"Joined devices are available after database startup with an active CouchDB or Object Storage connection."
),
LOG_LEVEL_NOTICE
);
}
})
);
{
/* E2EE */
const E2EEInitialProps = {
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const runtime = vi.hoisted(() => ({
buttonClasses: [] as string[],
clickHandlers: [] as Array<() => Promise<void> | void>,
clickHandlers: [] as Array<{ setting: string; run: () => Promise<void> | void }>,
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
settingClasses: [] as string[],
}));
@@ -32,7 +32,10 @@ vi.mock("./LiveSyncSetting.ts", () => ({
},
};
setName() {
settingName = "";
setName(name: string) {
this.settingName = name;
return this;
}
@@ -41,6 +44,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
}
addButton(callback: (button: unknown) => void) {
const setting = this.settingName;
const button = {
buttonEl: {
classList: {
@@ -53,7 +57,7 @@ vi.mock("./LiveSyncSetting.ts", () => ({
return this;
},
onClick(callback: () => Promise<void> | void) {
runtime.clickHandlers.push(callback);
runtime.clickHandlers.push({ setting, run: callback });
return this;
},
setButtonText() {
@@ -107,6 +111,7 @@ vi.mock("./remoteConfigBuffer.ts", () => ({
syncActivatedRemoteSettings: vi.fn(),
}));
import { OPEN_JOINED_DEVICES } from "@/serviceFeatures/joinedDevicesCommand";
import { paneRemoteConfig } from "./PaneRemoteConfig.ts";
function createPanelElement(): HTMLElement {
@@ -124,6 +129,18 @@ afterEach(() => {
});
describe("paneRemoteConfig", () => {
it("opens joined devices through the shared UI command", async () => {
const invoke = vi.fn(async () => ({ status: "handled", value: "closed" }));
const host = {
editingSettings: { remoteConfigurations: {} },
services: { UI: { commands: { invoke } } },
};
const addPanel = vi.fn(() => ({ then: vi.fn() }));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers.find((handler) => handler.setting === "Joined devices")!.run();
expect(invoke).toHaveBeenCalledWith(OPEN_JOINED_DEVICES, undefined);
});
it("destroys the E2EE info panel when the settings page lifetime unloads", async () => {
const callbacks: Array<() => unknown> = [];
const lifetimeComponent = {
@@ -187,7 +204,7 @@ describe("paneRemoteConfig", () => {
}));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers[0]();
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce();
expect(host.editingSettings.encryptInternalMetadata).toBe(true);
@@ -233,7 +250,7 @@ describe("paneRemoteConfig", () => {
}));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers[0]();
await runtime.clickHandlers.find((handler) => handler.setting === "Configure E2EE")!.run();
expect(host.editingSettings.idDerivationVersion).toBe(1);
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
@@ -0,0 +1,391 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import { $msg as translate } from "@/common/translation";
import type {
JoinedDeviceRow,
JoinedDevicesDialogOptions,
JoinedDevicesView,
} from "@/serviceFeatures/joinedDevicesDialogTypes";
const { setResult, getInitialData }: GuestDialogProps<"close", JoinedDevicesDialogOptions> = $props();
let options = $state<JoinedDevicesDialogOptions>();
let view = $state<JoinedDevicesView>();
let isRefreshing = $state(false);
let confirmingId = $state<string>();
let forgettingId = $state<string>();
let statusMessage = $state("");
let errorMessage = $state("");
let disposed = false;
const busy = $derived(isRefreshing || forgettingId !== undefined);
const title = translate("Joined devices");
onMount(() => {
options = getInitialData?.();
if (!options) {
errorMessage = translate(
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed."
);
return;
}
void refreshView();
});
onDestroy(() => {
disposed = true;
options?.cancel();
});
async function refreshView(afterForget = false) {
const dialogOptions = options;
if (!dialogOptions || disposed || isRefreshing || (forgettingId !== undefined && !afterForget)) return;
isRefreshing = true;
errorMessage = "";
if (!afterForget) {
statusMessage = "";
confirmingId = undefined;
}
try {
const refreshedView = await dialogOptions.refresh();
if (disposed) return;
view = refreshedView;
} catch {
if (!disposed) {
errorMessage = translate(
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed."
);
}
} finally {
if (!disposed) isRefreshing = false;
}
}
function toggleConfirmation(device: JoinedDeviceRow) {
if (busy || device.self) return;
confirmingId = confirmingId === device.id ? undefined : device.id;
errorMessage = "";
statusMessage = "";
}
async function forgetDevice(device: JoinedDeviceRow) {
const dialogOptions = options;
if (!dialogOptions || disposed || busy || device.self || confirmingId !== device.id) return;
forgettingId = device.id;
confirmingId = undefined;
errorMessage = "";
statusMessage = "";
try {
const result = await dialogOptions.forget(device.id);
if (disposed) return;
if (result === "forgotten") {
statusMessage = translate("The device record was forgotten.");
await refreshView(true);
} else if (result === "not-found") {
statusMessage = translate("The device record was already absent.");
await refreshView(true);
} else if (result === "conflict") {
errorMessage = translate(
"The device list changed while you were trying to forget this device. Refresh the list before trying again."
);
} else {
errorMessage = translate(
"This remote does not support conditional updates, so the device record could not be removed."
);
}
} catch {
if (!disposed) {
errorMessage = translate(
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again."
);
}
} finally {
if (!disposed) forgettingId = undefined;
}
}
function formatLastConnected(timestamp: number | undefined) {
if (timestamp === undefined || !Number.isFinite(timestamp) || timestamp <= 0) return translate("Not recorded");
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return translate("Not recorded");
return date.toLocaleString();
}
</script>
<DialogHeader {title} />
<div class="joined-devices-dialog">
<p>
{translate("This list shows device information recorded by the remote. It does not show whether a device is online now.")}
</p>
{#if view}
{#if !view.milestonePresent}
<p class="state-message">{translate("This remote does not currently have a record of joined devices.")}</p>
{/if}
{#if view.cleaned}
<p class="state-message">
{translate(
"This remote has been cleaned. Use the existing recovery process to join devices again as needed."
)}
</p>
{/if}
{#if view.locked}
<p class="state-message">
{translate(
"This remote is locked. Use the existing recovery process for devices that need to join again."
)}
</p>
{/if}
{/if}
{#if errorMessage}
<p class="error-message" role="alert">{errorMessage}</p>
{/if}
{#if statusMessage}
<p class="status-message" role="status">{statusMessage}</p>
{/if}
{#if isRefreshing && !view}
<p role="status">{translate("Loading joined devices…")}</p>
{/if}
{#if view}
{#if view.devices.length > 0}
<ul class="device-list">
{#each view.devices as device (device.id)}
<li class="device-card">
<div class="device-heading">
<div class="device-identity">
{#if device.self}
<span class="device-badge">{translate("This device")}</span>
{/if}
<code class="device-id">{device.id}</code>
</div>
<div class="device-badges">
<span class="device-badge">
{device.accepted ? translate("Accepted") : translate("Not accepted")}
</span>
{#if device.lockedOut}
<span class="device-badge device-badge-warning">{translate("Locked out")}</span>
{/if}
</div>
</div>
{#if device.self && device.lockedOut}
<p class="state-message">
{translate(
"This device is locked out. Use the existing recovery process to join again."
)}
</p>
{/if}
<dl class="device-details">
<div>
<dt>{translate("Platform")}</dt>
<dd>{device.platform || translate("Unknown platform")}</dd>
</div>
<div>
<dt>{translate("Plug-in version")}</dt>
<dd>{device.pluginVersion || translate("Not recorded")}</dd>
</div>
<div>
<dt>{translate("App version")}</dt>
<dd>{device.appVersion || translate("Not recorded")}</dd>
</div>
<div>
<dt>{translate("Last recorded")}</dt>
<dd>{formatLastConnected(device.lastConnected)}</dd>
</div>
</dl>
{#if !device.self}
{#if confirmingId === device.id}
<div class="forget-confirmation">
<p><strong>{translate("Forget this device record?")}</strong></p>
<p>
{translate("This removes only this device's record from the remote.")}
</p>
<p>
{translate(
"Forgetting the record does not delete Vault files or revoke credentials."
)}
</p>
<p>
{view.locked
? translate(
"This remote is locked. If the device returns, use the existing recovery process to join it again."
)
: translate("This remote is unlocked. A device that returns can register again.")}
</p>
<div class="device-actions">
<button disabled={busy} onclick={() => toggleConfirmation(device)}>
{translate("Cancel")}
</button>
<button
class="mod-warning"
disabled={busy}
onclick={() => void forgetDevice(device)}
>
{translate("Forget device")}
</button>
</div>
</div>
{:else}
<div class="device-actions">
<button disabled={busy} onclick={() => toggleConfirmation(device)}>
{forgettingId === device.id
? translate("Forgetting…")
: translate("Forget device")}
</button>
</div>
{/if}
{/if}
</li>
{/each}
</ul>
{:else if !isRefreshing}
<p>{translate("No joined devices are recorded on this remote.")}</p>
{/if}
{/if}
<div class="dialog-controls">
<button disabled={busy} onclick={() => void refreshView()}>{translate("Refresh")}</button>
<button onclick={() => setResult("close")}>{translate("Close")}</button>
</div>
</div>
<style>
.joined-devices-dialog {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
min-width: 0;
max-width: 100%;
}
p {
margin: 0;
overflow-wrap: anywhere;
}
.state-message,
.forget-confirmation {
border-left: 3px solid var(--interactive-accent);
padding: var(--size-4-2);
background: var(--background-secondary);
}
.error-message {
color: var(--text-error);
}
.device-list {
display: grid;
gap: var(--size-4-2);
list-style: none;
margin: 0;
min-width: 0;
padding: 0;
}
.device-card {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
min-width: 0;
overflow-wrap: anywhere;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-3);
}
.device-heading {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--size-4-2);
min-width: 0;
}
.device-identity,
.device-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--size-4-2);
min-width: 0;
}
.device-id {
display: block;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
white-space: normal;
}
.device-badge {
border-radius: var(--radius-s);
background: var(--background-secondary);
padding: 0 var(--size-4-2);
font-size: var(--font-ui-smaller);
}
.device-badge-warning {
color: var(--text-warning);
}
.device-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: var(--size-4-2) var(--size-4-4);
margin: 0;
min-width: 0;
}
.device-details > div {
min-width: 0;
}
dt {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
.forget-confirmation {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
min-width: 0;
}
.device-actions,
.dialog-controls {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--size-4-2);
}
button {
min-height: 44px;
}
@media (max-width: 480px) {
.device-card {
padding: var(--size-4-2);
}
.device-details {
grid-template-columns: minmax(0, 1fr);
}
}
</style>