From a0793dd37030f7213ecb07a018b11e6a8adf9238 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 10 Oct 2026 12:56:26 +0200 Subject: feat(android): back the phone's contacts up to a group of one's own A Contacts backup section on the Android Sync page sends a dated .vcf into /-contacts once a day when the address book changed, only to a group the account is alone in, checked again at every run. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/phone-sync-settings.js | 210 +++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js new file mode 100644 index 0000000..7df83b9 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js @@ -0,0 +1,210 @@ +// The contacts section of the Android Sync page, on the Android application +// only (docs/MESHBAY_DESIGN.md §9.13). Loaded on demand: no other client has it. + +import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { ask } from './ask.js'; +import { + KINDS, subscribe, runDocSync, privateGroups, probeWritable, failureText, +} from './phone-sync.js'; + +// What each kind says in its own words; everything else is shared. +const TEXT = { + contacts: { + intro: 'contactsync.intro', + permission: 'contactsync.permission_needed', + confirm: 'contactsync.confirm', + }, +}; + +function stateLine(st, status) { + switch (st.phase) { + case 'connecting': return t('photosync.state_connecting'); + case 'sending': return t('phonesync.state_sending'); + case 'offline': return t('photosync.state_offline'); + case 'interrupted': return t('photosync.state_interrupted'); + case 'up_to_date': return t('photosync.state_up_to_date'); + default: return status && status.lastCompleted ? t('photosync.state_up_to_date') : ''; + } +} + +export function DocSyncSection({ kind, user, groups }) { + const { sync, heading } = KINDS[kind]; + const [status, setStatus] = useState(null); + const [run, setRun] = useState({ phase: 'idle' }); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(''); + + const refresh = useCallback(() => sync.status().then(setStatus).catch(() => {}), [sync]); + useEffect(() => { refresh(); }, [refresh]); + useEffect(() => subscribe(kind, (st) => { + setRun(st); + if (st.phase !== 'sending' && st.phase !== 'connecting') refresh(); + }), [kind, refresh]); + + if (!status) return null; + const cfg = status.config; + const busy = run.phase === 'sending' || run.phase === 'connecting'; + + const backUpNow = async () => { + setError(''); + try { await runDocSync(kind, { force: true }); } catch (e) { setError(e.message); } + }; + + const turnOff = async () => { + setError(''); + try { setStatus(await sync.configure(null)); } catch (e) { setError(e.message); } + }; + + return html` +
+

${t(heading)}

+ ${!cfg && !editing && html` +

${t(TEXT[kind].intro)}

+ + `} + ${cfg && !editing && html` +
+ + ${t('phonesync.summary', { group: cfg.groupName, dir: status.dir })} + +
+ ${cfg.account !== user.username && html` +

${t('photosync.other_account', { account: cfg.account })}

`} +

+ ${t('phonesync.copies_sent', { n: status.sent })} + ${' · '} + ${status.lastCompleted + ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() }) + : t('photosync.never')} +

+ ${status.lastSent && html` +

${t('phonesync.last_file', { name: status.lastSent })}

`} + ${status.failure + ? html`

${failureText(status.failure)}

` + : stateLine(run, status) && html`

${stateLine(run, status)}

`} +
+ + + +
+ `} + ${editing && html` + <${Setup} kind=${kind} user=${user} groups=${groups} status=${status} + onPermission=${setStatus} + onDone=${(next) => { setEditing(false); if (next) { setStatus(next); runDocSync(kind); } }} />`} + ${error && html`

${error}

`} +
+ `; +} + +function Setup({ kind, user, groups, status, onPermission, onDone }) { + const { sync, suffix } = KINDS[kind]; + const cfg = status.config && status.config.account === user.username ? status.config : null; + const [candidates, setCandidates] = useState(null); // null while the hub is asked + const [groupId, setGroupId] = useState(cfg ? cfg.groupId : ''); + const [folders, setFolders] = useState(null); + const [probe, setProbe] = useState(''); // '', 'checking', 'offline' + const [folder, setFolder] = useState(cfg ? cfg.folder : ''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const permitted = status.permission === 'granted'; + + // Only groups this account is alone in: what is sent is readable by every + // member, and an address book or a conversation is not 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(() => { + setFolders(null); + if (!groupId) return undefined; + let gone = false; + setProbe('checking'); + probeWritable(groupId).then((r) => { + if (gone) return; + setProbe(''); + setFolders(r.folders); + setFolder((f) => (r.folders.includes(f) ? f : (r.folders[0] || ''))); + }).catch(() => { if (!gone) setProbe('offline'); }); + return () => { gone = true; }; + }, [groupId]); + + const allow = async () => { + setError(''); + try { onPermission(await sync.permit()); } catch (e) { setError(e.message); } + }; + + const dir = folder ? `${folder}/${user.username}-${suffix}` : ''; + + const start = async () => { + const group = (candidates || []).find((g) => g.id === groupId); + if (!group || !folder) return; + setBusy(true); + setError(''); + try { + if (!cfg || cfg.groupId !== groupId || cfg.folder !== folder) { + const yes = await ask(t(TEXT[kind].confirm, { group: group.name, dir })); + if (!yes) { setBusy(false); return; } + } + onDone(await sync.configure({ + account: user.username, groupId, groupName: group.name, + owner: group.owner_username || '', folder, + })); + } catch (e) { + setError(e.message); + setBusy(false); + } + }; + + return html` +
+ ${!permitted && html` +

${t(TEXT[kind].permission)}

+
+ + +
+ `} + ${permitted && 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')}

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

${t('phonesync.no_folder')}

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

${t('phonesync.destination', { dir })}

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

${error}

`} +
+ `; +} -- cgit v1.2.3