diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 14:21:46 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 14:21:46 +0200 |
| commit | 2f2a9a6542d2194e50ffba6f382e4fdffd481838 (patch) | |
| tree | 5437330f9e8b963f8af0904f3c5d80d1afc6dc3b /packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js | |
| parent | 914d2f0af848fb80d70d517901f44420a4e47764 (diff) | |
| download | meshbay-2f2a9a6542d2194e50ffba6f382e4fdffd481838.tar.gz | |
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 <folder>/<account>-photos and -contacts. Owner and sole member are
checked at set-up and before every run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/android-sync-page.js | 173 |
1 files changed, 169 insertions, 4 deletions
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`<div><h2>${t('androidsync.title')}</h2> + <p class="page-message"><span class="spinner"></span></p></div>`; + } + 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` <div> <h2>${t('androidsync.title')}</h2> + <${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} />`)} + </div> + `; +} + +function DestinationSection({ user, groups, dest, onChange }) { + const [editing, setEditing] = useState(false); + const mine = dest && dest.account === user.username ? dest : null; + return html` + <div class="settings-section"> + <h3 class="settings-heading">${t('androidsync.dest_heading')}</h3> + ${!editing && mine && html` + <div class="settings-row"> + <span class="settings-label settings-name"> + ${t('androidsync.dest_summary', { group: mine.groupName, folder: mine.folder })} + </span> + </div> + <p class="settings-hint settings-name"> + ${t('androidsync.dest_dirs', { dirs: kindDirs(mine.folder, user.username).join(', ') })} + </p> + <button class="btn-secondary" onClick=${() => setEditing(true)}>${t('photosync.change')}</button> + `} + ${!editing && !mine && html` + <p class="settings-hint"> + ${dest ? t('photosync.other_account', { account: dest.account }) : t('androidsync.dest_intro')} + </p> + <button class="admin-btn" onClick=${() => setEditing(true)}>${t('photosync.setup')}</button> + `} + ${editing && html` + <${DestinationSetup} user=${user} groups=${groups} current=${mine} + onDone=${(next) => { setEditing(false); if (next) onChange(next); }} />`} + </div> + `; +} + +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` + <div> + ${candidates === null && html`<p class="settings-hint">${t('phonesync.checking_groups')}</p>`} + ${candidates && !candidates.length && html` + <p class="settings-hint">${t('phonesync.no_private_group')}</p>`} + ${candidates && candidates.length > 0 && html` + <div class="settings-row"> + <span class="settings-label">${t('photosync.group')}</span> + <select class="settings-select" value=${groupId} + onChange=${(e) => setGroupId(e.target.value)}> + <option value="">${t('photosync.choose_group')}</option> + ${candidates.map((g) => html`<option key=${g.id} value=${g.id}>${g.name}</option>`)} + </select> + </div> + `} + ${probe === 'checking' && html`<p class="settings-hint">${t('photosync.checking_group')}</p>`} + ${probe === 'offline' && html`<p class="settings-hint">${t('photosync.group_offline')}</p>`} + ${found && !found.folders.length && html`<p class="settings-hint">${t('phonesync.no_folder')}</p>`} + ${found && found.folders.length > 0 && html` + <div class="settings-row"> + <span class="settings-label">${t('photosync.folder')}</span> + <select class="settings-select" value=${folder} onChange=${(e) => setFolder(e.target.value)}> + ${found.folders.map((f) => html`<option key=${f} value=${f}>${f}</option>`)} + </select> + </div> + ${folder && html`<p class="settings-hint settings-name"> + ${t('androidsync.dest_dirs', { dirs: kindDirs(folder, user.username).join(', ') })}</p>`} + ${hiddenFromPhotos && html` + <p class="settings-hint settings-name">${t('androidsync.dest_not_in_photos', { dir: photosDir })}</p>`} + `} + <div class="settings-row" style="gap:8px; justify-content:flex-start"> + <button class="admin-btn" onClick=${save} + disabled=${busy || !groupId || !folder}>${t('androidsync.dest_save')}</button> + <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button> + </div> + ${error && html`<p class="error-msg">${error}</p>`} </div> `; } |