M12 — the Android client, end to end #2

Merged
bvandeusen merged 86 commits from dev into main 2026-08-21 08:53:58 -04:00
Showing only changes of commit 659237ccc6 - Show all commits
+60 -1
View File
@@ -11,6 +11,7 @@ import EmptyState from "../components/EmptyState.vue";
import FilterBar from "../components/FilterBar.vue";
import NoteCard from "../components/NoteCard.vue";
import NoteEditor from "../components/NoteEditor.vue";
import { isDesktop, sync as syncBridge } from "../desktop/bridge";
const notes = useNotesStore();
const config = useConfigStore();
@@ -157,12 +158,45 @@ const retentionNotice = computed(() => {
: `Notes here are permanently deleted ${days} days after you trash them. Restore one to keep it.`;
});
/**
* Whether this desktop install has been linked to a server. Only consulted for the
* first-run copy below — offering to connect a server to someone who already did
* would be noise. Never set on the web build, which IS a server's UI.
*/
const linkedToServer = ref(false);
/**
* The desktop's start state: a fresh install, nothing written yet.
*
* There is no login and no onboarding gate — the app is usable the instant it
* opens, and interrupting that with a welcome modal would tax the one thing the
* product is for (dumping a thought in a second). So the empty board carries the
* message instead, and retires itself the moment a first note exists. That also
* means no "seen it" flag to persist, migrate, or get out of step with reality.
*/
const startState = computed(
() =>
isDesktop() &&
isMainBoard.value &&
!filtered.value &&
!linkedToServer.value &&
notes.items.length === 0,
);
const emptyState = computed(() => {
if (filtered.value) return { title: "No notes match these filters", subtitle: "Try clearing or loosening a facet." };
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
if (currentView.value === "archived")
return { title: "Nothing archived", subtitle: "Archived notes are tucked away here." };
if (currentLabel.value) return { title: "No notes with this label", subtitle: "Tag a note to see it here." };
// Says what "no account" actually means for the notes about to be written here.
// A new user otherwise has no way to tell whether this thing is storing their
// thoughts locally, silently waiting for a login, or quietly sending them off.
if (startState.value)
return {
title: "Your notes live on this device",
subtitle: "ThoughtSync works fully offline — no account, nothing to sign in to.",
};
return { title: "No notes yet", subtitle: "Your captured thoughts will show up here." };
});
@@ -178,6 +212,17 @@ async function reload() {
onMounted(() => {
void reload();
window.addEventListener("keydown", onBoardKey);
// Best-effort and never awaited: the board must render at full speed whether or
// not this answers. A failure just leaves the start-state copy showing, which is
// the correct reading of "we don't know of a server".
if (isDesktop()) {
void syncBridge
.status()
.then((s) => {
linkedToServer.value = s.linked;
})
.catch(() => {});
}
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", onBoardKey);
@@ -234,7 +279,21 @@ async function onDrop(target: Note) {
error-title="Couldn't load your notes"
@retry="reload"
>
<EmptyState v-if="notes.items.length === 0" :title="emptyState.title" :subtitle="emptyState.subtitle" />
<EmptyState v-if="notes.items.length === 0" :title="emptyState.title" :subtitle="emptyState.subtitle">
<!-- v-if on the slot itself, so every OTHER empty state renders without a
stray call-to-action box beneath it. -->
<template v-if="startState" #default>
<RouterLink
to="/sync"
class="inline-block rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-600 transition hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Connect a sync server
</RouterLink>
<p class="mt-2 text-xs text-neutral-400">
Optional only if you want these notes on your other devices.
</p>
</template>
</EmptyState>
<template v-else>
<template v-if="isMainBoard">
<section v-if="pinnedNotes.length">