From 2f2a9a6542d2194e50ffba6f382e4fdffd481838 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 10 Oct 2026 14:21:46 +0200 Subject: feat(android): one backup destination, a group the account owns alone Chosen once at the top of Android Sync for every kind; photos and contacts go into /-photos and -contacts. Owner and sole member are checked at set-up and before every run. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/android-sync-page.js | 173 ++++++++++++++++++++- 1 file changed, 169 insertions(+), 4 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js b/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js index 5145515..b4d85d0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js @@ -1,21 +1,186 @@ // The Android Sync page: what this phone sends to its groups, on the Android // application only (docs/MESHBAY_DESIGN.md §9.12, §9.13). Loaded on demand, // like the sections it holds: no other client has it. +// +// Where everything goes is chosen once, at the top, and every section below +// sends into its own folder there. -import { html } from './vendor/htm-preact.js'; +import { html, useState, useEffect } from './vendor/htm-preact.js'; import { t } from './i18n.js'; +import { ask } from './ask.js'; import * as platform from './platform.js'; import { PhotoSyncSection } from './photo-sync-settings.js'; import { DocSyncSection } from './phone-sync-settings.js'; +import { runPhotoSync } from './photo-sync.js'; +import { runDocSync, KINDS } from './phone-sync.js'; +import { + destination, privateGroups, probeGroup, photosTabShows, +} from './sync-destination.js'; + +/** The folder each kind this phone has goes into, named as the phone names it. */ +function kindDirs(folder, account) { + const suffixes = [ + ...(platform.photoSync.available ? ['photos'] : []), + ...Object.values(KINDS).filter((k) => k.sync.available).map((k) => k.suffix), + ]; + return suffixes.map((s) => `${folder}/${account}-${s}`); +} export function AndroidSyncPage({ user, groups }) { + const [dest, setDest] = useState(undefined); // undefined while asked, null when none + useEffect(() => { destination.get().then(setDest).catch(() => setDest(null)); }, []); + + if (dest === undefined) { + return html`

${t('androidsync.title')}

+

`; + } + const mine = dest && dest.account === user.username ? dest : null; + const place = mine ? `${mine.groupId}\n${mine.folder}` : 'none'; + + const moved = (next) => { + setDest(next); + // Each kind that is on starts again in the new place, now. + if (platform.photoSync.available) runPhotoSync(); + for (const kind of Object.keys(KINDS)) if (KINDS[kind].sync.available) runDocSync(kind); + }; + return html`

${t('androidsync.title')}

+ <${DestinationSection} user=${user} groups=${groups || []} dest=${dest} onChange=${moved} /> ${platform.photoSync.available && html` - <${PhotoSyncSection} user=${user} groups=${groups || []} />`} - ${platform.contactSync.available && html` - <${DocSyncSection} kind="contacts" user=${user} groups=${groups || []} />`} + <${PhotoSyncSection} key=${'photos' + place} destination=${mine} />`} + ${Object.keys(KINDS).filter((k) => KINDS[k].sync.available).map((kind) => html` + <${DocSyncSection} key=${kind + place} kind=${kind} destination=${mine} />`)} +
+ `; +} + +function DestinationSection({ user, groups, dest, onChange }) { + const [editing, setEditing] = useState(false); + const mine = dest && dest.account === user.username ? dest : null; + return html` +
+

${t('androidsync.dest_heading')}

+ ${!editing && mine && html` +
+ + ${t('androidsync.dest_summary', { group: mine.groupName, folder: mine.folder })} + +
+

+ ${t('androidsync.dest_dirs', { dirs: kindDirs(mine.folder, user.username).join(', ') })} +

+ + `} + ${!editing && !mine && html` +

+ ${dest ? t('photosync.other_account', { account: dest.account }) : t('androidsync.dest_intro')} +

+ + `} + ${editing && html` + <${DestinationSetup} user=${user} groups=${groups} current=${mine} + onDone=${(next) => { setEditing(false); if (next) onChange(next); }} />`} +
+ `; +} + +function DestinationSetup({ user, groups, current, onDone }) { + const [candidates, setCandidates] = useState(null); // null while the hub is asked + const [groupId, setGroupId] = useState(current ? current.groupId : ''); + const [found, setFound] = useState(null); // { folders, photoDirectories } + const [probe, setProbe] = useState(''); // '', 'checking', 'offline' + const [folder, setFolder] = useState(current ? current.folder : ''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + // Only groups this account owns and is alone in: what is sent there is + // readable by every member, and none of it is the group's. + useEffect(() => { + let gone = false; + privateGroups(groups).then((list) => { + if (gone) return; + setCandidates(list); + setGroupId((g) => (list.some((x) => x.id === g) ? g : '')); + }); + return () => { gone = true; }; + }, [groups]); + + useEffect(() => { + setFound(null); + if (!groupId) return undefined; + let gone = false; + setProbe('checking'); + probeGroup(groupId).then((r) => { + if (gone) return; + setProbe(''); + setFound(r); + setFolder((f) => (r.folders.includes(f) ? f : (r.folders[0] || ''))); + }).catch(() => { if (!gone) setProbe('offline'); }); + return () => { gone = true; }; + }, [groupId]); + + const photosDir = folder ? `${folder}/${user.username}-photos` : ''; + const hiddenFromPhotos = platform.photoSync.available && found && folder + && !photosTabShows(photosDir, found.photoDirectories); + + const save = async () => { + const group = (candidates || []).find((g) => g.id === groupId); + if (!group || !folder) return; + setBusy(true); + setError(''); + try { + // Moving is a fresh start for every kind: said before it happens. + if (current && (current.groupId !== groupId || current.folder !== folder)) { + const yes = await ask(t('androidsync.dest_change_confirm', { group: group.name, folder })); + if (!yes) { setBusy(false); return; } + } + onDone(await destination.set({ + account: user.username, groupId, groupName: group.name, folder, + })); + } catch (e) { + setError(e.message); + setBusy(false); + } + }; + + return html` +
+ ${candidates === null && html`

${t('phonesync.checking_groups')}

`} + ${candidates && !candidates.length && html` +

${t('phonesync.no_private_group')}

`} + ${candidates && candidates.length > 0 && html` +
+ ${t('photosync.group')} + +
+ `} + ${probe === 'checking' && html`

${t('photosync.checking_group')}

`} + ${probe === 'offline' && html`

${t('photosync.group_offline')}

`} + ${found && !found.folders.length && html`

${t('phonesync.no_folder')}

`} + ${found && found.folders.length > 0 && html` +
+ ${t('photosync.folder')} + +
+ ${folder && html`

+ ${t('androidsync.dest_dirs', { dirs: kindDirs(folder, user.username).join(', ') })}

`} + ${hiddenFromPhotos && html` +

${t('androidsync.dest_not_in_photos', { dir: photosDir })}

`} + `} +
+ + +
+ ${error && html`

${error}

`}
`; } -- cgit v1.2.3