diff --git a/frontend/src/components/AccountList.vue b/frontend/src/components/AccountList.vue index 1680681..dd73a47 100644 --- a/frontend/src/components/AccountList.vue +++ b/frontend/src/components/AccountList.vue @@ -3,9 +3,9 @@ import { onMounted, ref } from "vue"; import { api } from "../api/client"; import { errorMessage } from "../api/errors"; import { useSessionStore } from "../stores/session"; -import { useUiStore } from "../stores/ui"; import { appLink } from "../router/links"; import OneTimeLink from "./OneTimeLink.vue"; +import { useRowAction } from "../composables/useAction"; // Admin: the accounts on this instance, and a password reset link for any of them // (#5173). There is no mail path, so the admin hands the link over. @@ -18,12 +18,11 @@ interface Account { } const session = useSessionStore(); -const ui = useUiStore(); const accounts = ref([]); const loading = ref(true); const error = ref(""); -const busy = ref(""); +const { busy, act } = useRowAction(); // The reset link just made, and whose it is. Never retrievable again once dismissed. const fresh = ref<{ link: string; email: string } | null>(null); @@ -44,15 +43,14 @@ async function resetLink(account: Account) { ? "Make a password reset link for your own account? Using it signs you out everywhere, this browser included." : `Make a password reset link for ${account.email}? Using it signs them out everywhere and unlinks their apps.`; if (!window.confirm(warning)) return; - busy.value = account.id; - try { - const res = await api.post<{ token: string }>(`/api/accounts/${account.id}/reset-link`, {}); - fresh.value = { link: appLink({ name: "reset-password", query: { token: res.token } }), email: account.email }; - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't make a reset link.")); - } finally { - busy.value = ""; - } + await act( + account.id, + async () => { + const res = await api.post<{ token: string }>(`/api/accounts/${account.id}/reset-link`, {}); + fresh.value = { link: appLink({ name: "reset-password", query: { token: res.token } }), email: account.email }; + }, + "Couldn't make a reset link.", + ); } onMounted(() => { diff --git a/frontend/src/components/DesktopIntegrationPrompt.vue b/frontend/src/components/DesktopIntegrationPrompt.vue index 0a3449e..f1a5a05 100644 --- a/frontend/src/components/DesktopIntegrationPrompt.vue +++ b/frontend/src/components/DesktopIntegrationPrompt.vue @@ -4,7 +4,7 @@ import BaseModal from "./BaseModal.vue"; import BaseButton from "./BaseButton.vue"; import { isDesktop, desktop } from "../desktop/bridge"; import { useUiStore } from "../stores/ui"; -import { errorMessage } from "../api/errors"; +import { useAction } from "../composables/useAction"; // First-run prompt (Linux AppImage only) offering to add Inkwell to the // applications menu so it behaves like an installed app instead of a loose file. @@ -15,7 +15,7 @@ const DISMISS_KEY = "desktop.integration.prompted"; const ui = useUiStore(); const show = ref(false); -const working = ref(false); +const { busy: working, act } = useAction(); onMounted(async () => { if (!isDesktop() || localStorage.getItem(DISMISS_KEY)) return; @@ -33,17 +33,12 @@ function dismiss() { } async function integrate() { - working.value = true; - try { + await act(async () => { await desktop.integrate(); localStorage.setItem(DISMISS_KEY, "1"); show.value = false; ui.showToast("Inkwell added to your applications menu."); - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't add to applications.")); - } finally { - working.value = false; - } + }, "Couldn't add to applications."); } diff --git a/frontend/src/components/DesktopMenuEntry.vue b/frontend/src/components/DesktopMenuEntry.vue index b1daf81..aa2fd71 100644 --- a/frontend/src/components/DesktopMenuEntry.vue +++ b/frontend/src/components/DesktopMenuEntry.vue @@ -2,8 +2,8 @@ import { onMounted, ref } from "vue"; import BaseButton from "./BaseButton.vue"; import { desktop, isDesktop, type IntegrationStatus } from "../desktop/bridge"; -import { errorMessage } from "../api/errors"; import { useUiStore } from "../stores/ui"; +import { useAction } from "../composables/useAction"; // The applications-menu entry for a Linux AppImage: add it, or take it away again. // Renders nothing anywhere else — the web, Windows, or an AppImage-less Linux build. @@ -12,7 +12,7 @@ import { useUiStore } from "../stores/ui"; // first-run prompt promised ("undo any time") with no way to reach it. const ui = useUiStore(); const status = ref(null); -const busy = ref(false); +const { busy, act } = useAction(); onMounted(async () => { if (!isDesktop()) return; @@ -24,17 +24,12 @@ onMounted(async () => { }); async function toggle() { - busy.value = true; - try { + await act(async () => { status.value = status.value?.is_integrated ? await desktop.unintegrate() : await desktop.integrate(); ui.showToast( status.value?.is_integrated ? "Added to your applications menu." : "Removed from your applications menu.", ); - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't update the menu entry.")); - } finally { - busy.value = false; - } + }, "Couldn't update the menu entry."); } diff --git a/frontend/src/components/ExportNotes.vue b/frontend/src/components/ExportNotes.vue index 00c684c..65b6785 100644 --- a/frontend/src/components/ExportNotes.vue +++ b/frontend/src/components/ExportNotes.vue @@ -1,27 +1,21 @@ diff --git a/frontend/src/components/GroupList.vue b/frontend/src/components/GroupList.vue index 0b1ffd8..03bfd62 100644 --- a/frontend/src/components/GroupList.vue +++ b/frontend/src/components/GroupList.vue @@ -2,10 +2,10 @@ import { computed, onMounted, ref } from "vue"; import { api } from "../api/client"; import { errorMessage } from "../api/errors"; -import { useUiStore } from "../stores/ui"; import BaseButton from "./BaseButton.vue"; import BaseInput from "./BaseInput.vue"; import Icon from "./Icon.vue"; +import { useRowAction } from "../composables/useAction"; // Admin: groups of people a note can be shared with in one go (#5177). A group is // the instance's; anyone can share with it, and a note shared with it reaches whoever @@ -23,7 +23,6 @@ interface Group { members: Person[]; } -const ui = useUiStore(); const groups = ref([]); const accounts = ref([]); @@ -31,7 +30,7 @@ const loading = ref(true); const error = ref(""); const name = ref(""); const creating = ref(false); -const busy = ref(""); +const { busy, act } = useRowAction(); // Which group's "Add someone" picker holds which account id. const picks = ref>({}); @@ -64,17 +63,6 @@ function replace(updated: Group) { groups.value = groups.value.map((g) => (g.id === updated.id ? updated : g)).sort(byName); } -async function act(id: string, fn: () => Promise, fallback: string) { - busy.value = id; - try { - await fn(); - } catch (e) { - ui.showToast(errorMessage(e, fallback)); - } finally { - busy.value = ""; - } -} - async function create() { if (!name.value.trim()) return; creating.value = true; diff --git a/frontend/src/components/ImportNotes.vue b/frontend/src/components/ImportNotes.vue index 4890003..d2c1174 100644 --- a/frontend/src/components/ImportNotes.vue +++ b/frontend/src/components/ImportNotes.vue @@ -4,7 +4,7 @@ import Icon from "./Icon.vue"; import { useNotesStore } from "../stores/notes"; import { useLabelsStore } from "../stores/labels"; import { useUiStore } from "../stores/ui"; -import { errorMessage } from "../api/errors"; +import { useAction } from "../composables/useAction"; // Sidebar counterpart to Export: pick a .zip (an Inkwell export for round-trip, // or a Google Keep Takeout archive) and import its notes. Additive — never @@ -13,7 +13,7 @@ const notes = useNotesStore(); const labels = useLabelsStore(); const ui = useUiStore(); const inputRef = ref(null); -const busy = ref(false); +const { busy, act } = useAction(); function pick() { if (!busy.value) inputRef.value?.click(); @@ -24,18 +24,13 @@ async function onFile(e: Event) { const file = input.files?.[0]; input.value = ""; // reset so picking the same file again re-fires change if (!file) return; - busy.value = true; - try { + await act(async () => { const res = await notes.importNotes(file); await labels.load(); // surface any labels the import created const noun = res.imported === 1 ? "note" : "notes"; const tail = res.skipped ? ` (${res.skipped} skipped)` : ""; ui.showToast(`Imported ${res.imported} ${noun}${tail}.`); - } catch (err) { - ui.showToast(errorMessage(err, "Import failed.")); - } finally { - busy.value = false; - } + }, "Import failed."); } diff --git a/frontend/src/components/InviteList.vue b/frontend/src/components/InviteList.vue index 082d1c2..f5a95b9 100644 --- a/frontend/src/components/InviteList.vue +++ b/frontend/src/components/InviteList.vue @@ -2,11 +2,11 @@ import { onMounted, ref } from "vue"; import { api } from "../api/client"; import { errorMessage } from "../api/errors"; -import { useUiStore } from "../stores/ui"; import { appLink } from "../router/links"; import BaseButton from "./BaseButton.vue"; import BaseInput from "./BaseInput.vue"; import OneTimeLink from "./OneTimeLink.vue"; +import { toastOnFailure } from "../composables/useAction"; // Admin: invite one person to register while registration stays closed (#5172). // The server keeps only a hash of each invite's token, so the link is shown once, @@ -29,7 +29,6 @@ const LIFETIMES = [ { days: 30, label: "30 days" }, ]; -const ui = useUiStore(); const invites = ref([]); const loading = ref(true); @@ -42,7 +41,7 @@ const freshLink = ref(""); async function load() { error.value = ""; - try { + await toastOnFailure(async () => { invites.value = (await api.get<{ invites: Invite[] }>("/api/invites")).invites; } catch (e) { error.value = errorMessage(e, "Couldn't load invites."); @@ -75,9 +74,7 @@ async function revoke(invite: Invite) { try { const updated = await api.del(`/api/invites/${invite.id}`); invites.value = invites.value.map((i) => (i.id === updated.id ? updated : i)); - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't revoke that invite.")); - } + }, "Couldn't revoke that invite."); } function who(invite: Invite): string { diff --git a/frontend/src/composables/useAction.ts b/frontend/src/composables/useAction.ts new file mode 100644 index 0000000..3862442 --- /dev/null +++ b/frontend/src/composables/useAction.ts @@ -0,0 +1,47 @@ +import { ref } from "vue"; +import { errorMessage } from "../api/errors"; +import { useUiStore } from "../stores/ui"; + +/** + * Run `work`, and say so in a toast if it fails: the server's own reason when it + * gave one, else `fallback`. The one way a button's action reports a failure it + * has no better place to show. + */ +export async function toastOnFailure(work: () => Promise, fallback: string): Promise { + try { + await work(); + } catch (e) { + useUiStore().showToast(errorMessage(e, fallback)); + } +} + +/** A single action and whether it is running, for the button that starts it. */ +export function useAction() { + const busy = ref(false); + async function act(work: () => Promise, fallback: string): Promise { + busy.value = true; + try { + await toastOnFailure(work, fallback); + } finally { + busy.value = false; + } + } + return { busy, act }; +} + +/** + * The same for a list whose rows each carry an action: `busy` is the id of the row + * whose action is running, or "" when none is, so only that row's buttons wait. + */ +export function useRowAction() { + const busy = ref(""); + async function act(id: string, work: () => Promise, fallback: string): Promise { + busy.value = id; + try { + await toastOnFailure(work, fallback); + } finally { + busy.value = ""; + } + } + return { busy, act }; +} diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index 3a0cce4..d62dc53 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -13,6 +13,7 @@ import { errorMessage } from "../api/errors"; import { formatDateTime } from "../notes/datetime"; import { repo } from "../adapters"; import type { StorageUse } from "../adapters/repo"; +import { useAction } from "../composables/useAction"; // The signed-in person's own account (not admin): the native clients linked to it // (the desktop and Android apps sync with a device bearer token issued here), its @@ -96,7 +97,7 @@ const currentPassword = ref(""); const newPassword = ref(""); const changing = ref(false); const passwordError = ref(""); -const signingOut = ref(false); +const { busy: signingOut, act } = useAction(); function unlinkedNote(n: number): string { if (n === 0) return ""; @@ -124,16 +125,11 @@ async function signOutElsewhere() { "Sign out of every other browser and unlink every device? Each app will need to sign in again.", ); if (!ok) return; - signingOut.value = true; - try { + await act(async () => { const unlinked = await session.signOutElsewhere(); ui.showToast(`Signed out everywhere else.${unlinkedNote(unlinked)}`); await devices.load(); - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't sign out everywhere else.")); - } finally { - signingOut.value = false; - } + }, "Couldn't sign out everywhere else."); } onMounted(() => { diff --git a/frontend/src/views/SyncView.vue b/frontend/src/views/SyncView.vue index 2dc4228..d5b2ed5 100644 --- a/frontend/src/views/SyncView.vue +++ b/frontend/src/views/SyncView.vue @@ -24,6 +24,7 @@ import { } from "../desktop/bridge"; import { errorMessage } from "../api/errors"; import { formatDateTime } from "../notes/datetime"; +import { toastOnFailure } from "../composables/useAction"; // Opt-in server sync for the desktop app. Being UNLINKED is the normal resting // state, not an incomplete setup — the app is local-first and fully usable having @@ -319,7 +320,7 @@ async function disconnect() { ) { return; } - try { + await toastOnFailure(async () => { const result = await syncBridge.unlink(); status.value = result.status; linkedAs.value = ""; @@ -331,9 +332,7 @@ async function disconnect() { ? "Disconnected, and this device is signed out on the server." : "Disconnected. This device now works offline only.", ); - } catch (e) { - ui.showToast(errorMessage(e, "Couldn't disconnect.")); - } + }, "Couldn't disconnect."); } // A background cycle finished — redraw from it, so this screen is never staler