diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-09 18:23:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-09 18:23:56 +0200 |
| commit | 25152ddb61a89ea3ea29d3aef5de13343429d32a (patch) | |
| tree | ebf8f11f66146caaa3c01270cd6cfcb0ee51723c /packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js | |
| parent | c85c7f48e8f29923038d4c90cc1a6f9b8bcd7673 (diff) | |
| download | meshbay-25152ddb61a89ea3ea29d3aef5de13343429d32a.tar.gz | |
The Android application sends the photos taken on the phone to one folder of
one group chosen by the member (docs/MESHBAY_DESIGN.md §9.12). The phone lists
MediaStore, keeps a ledger of what was sent and hands each photo's bytes to the
page by an opaque token on the packaged origin; the page decides when a run is
due and uploads through the existing path, one photo at a time under a slot.
- Once a day from the last finished run, on an unmetered network only;
"Back up now" asks first on mobile data. Leaving Wi-Fi stops after the file
in flight.
- Photos already on the phone are sent by default, newest first, under
<folder>/YYYY/MM; edits are sent beside the original as -edited-<date>.
- Additive by construction: nothing is ever deleted, renamed or replaced on
the node, and a photo deleted on the node is not sent again.
- A confirmation names the group, owner, members, folder and size when the
destination or starting point changes; a lasting refusal (disk full, folder
read-only or gone, no longer a member) is said once and retried a day later.
- No ACCESS_MEDIA_LOCATION, so the platform redacts photo locations.
- A dataSync foreground service keeps a run going with the screen off.
HEIC/HEIF photos are sent but not shown in Photos yet (§15.2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js | 264 |
1 files changed, 264 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js new file mode 100644 index 0000000..1ec68d3 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js @@ -0,0 +1,264 @@ +// The "Photo backup" section of Settings, on the Android application only +// (docs/MESHBAY_DESIGN.md §9.12). Loaded on demand: no other client has it. + +import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import * as platform from './platform.js'; +import { hubFetch } from './hub-client.js'; +import { formatSize } from './file-utils.js'; +import { ask } from './ask.js'; +import { subscribe, runPhotoSync, probeGroup } from './photo-sync.js'; + +function stateLine(st, status) { + switch (st.phase) { + case 'waiting_wifi': return t('photosync.state_waiting_wifi'); + case 'connecting': return t('photosync.state_connecting'); + case 'sending': return t('photosync.state_sending', { done: st.done, total: st.total }); + 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') : ''; + } +} + +const FAILURES = { + disk_full: 'photosync.fail_disk_full', + root_read_only: 'photosync.fail_read_only', + no_writable_root: 'photosync.fail_read_only', + no_such_root: 'photosync.fail_no_folder', + no_such_directory: 'photosync.fail_no_folder', + root_unavailable: 'photosync.fail_unavailable', + not_a_member: 'photosync.fail_not_member', + permission: 'photosync.fail_permission', +}; + +export function PhotoSyncSection({ user, groups }) { + const [status, setStatus] = useState(null); + const [run, setRun] = useState({ phase: 'idle' }); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(''); + + const refresh = useCallback(() => platform.photoSync.status().then(setStatus).catch(() => {}), []); + useEffect(() => { refresh(); }, [refresh]); + // A run that ends changes what the phone says (sent, last backup, a + // failure); read it again then rather than on a timer. + useEffect(() => subscribe((st) => { + setRun(st); + if (st.phase !== 'sending' && st.phase !== 'connecting') refresh(); + }), [refresh]); + + if (!status) return null; + const cfg = status.config; + + const backUpNow = async () => { + setError(''); + try { + if (!status.unmetered) { + const est = await platform.photoSync.estimate(cfg); + if (est.count === 0) { await runPhotoSync({ force: true, metered: true }); return; } + const yes = await ask(t('photosync.metered_confirm', { + count: est.count, size: formatSize(est.bytes) })); + if (!yes) return; + await runPhotoSync({ force: true, metered: true }); + } else { + await runPhotoSync({ force: true }); + } + } catch (e) { setError(e.message); } + }; + + const turnOff = async () => { + setError(''); + try { setStatus(await platform.photoSync.configure(null)); } catch (e) { setError(e.message); } + }; + + return html` + <div class="settings-section"> + <h3 class="settings-heading">${t('photosync.heading')}</h3> + ${!cfg && !editing && html` + <p class="settings-hint">${t('photosync.intro')}</p> + <button class="admin-btn" onClick=${() => setEditing(true)}>${t('photosync.setup')}</button> + `} + ${cfg && !editing && html` + <div class="settings-row"> + <span class="settings-label settings-name"> + ${t('photosync.summary', { group: cfg.groupName, owner: cfg.owner, folder: cfg.folder })} + </span> + </div> + ${cfg.account !== user.username && html` + <p class="settings-hint">${t('photosync.other_account', { account: cfg.account })}</p>`} + <p class="settings-hint"> + ${t('photosync.sent_total', { n: status.sent })} + ${' · '} + ${status.lastCompleted + ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() }) + : t('photosync.never')} + </p> + ${status.permission === 'partial' && html` + <p class="settings-hint">${t('photosync.permission_partial')}</p>`} + ${status.failure + ? html`<p class="error-msg">${t(FAILURES[status.failure] || 'photosync.fail_other')}</p>` + : stateLine(run, status) && html`<p class="settings-hint">${stateLine(run, status)}</p>`} + <div class="settings-row" style="gap:8px; justify-content:flex-start; flex-wrap:wrap"> + <button class="admin-btn" disabled=${run.phase === 'sending' || run.phase === 'connecting'} + onClick=${backUpNow}>${t('photosync.now')}</button> + <button class="btn-secondary" onClick=${() => setEditing(true)}>${t('photosync.change')}</button> + <button class="btn-secondary" onClick=${turnOff}>${t('photosync.turn_off')}</button> + </div> + `} + ${editing && html` + <${Setup} user=${user} groups=${groups} status=${status} + onPermission=${setStatus} + onDone=${(next) => { setEditing(false); if (next) { setStatus(next); runPhotoSync(); } }} />`} + ${error && html`<p class="error-msg">${error}</p>`} + </div> + `; +} + +function Setup({ user, groups, status, onPermission, onDone }) { + const cfg = status.config && status.config.account === user.username ? status.config : null; + const [groupId, setGroupId] = useState(cfg ? cfg.groupId : ''); + const [folders, setFolders] = useState(null); // null while unknown + const [probe, setProbe] = useState(''); // '', 'checking', 'offline' + const [folder, setFolder] = useState(cfg ? cfg.folder : ''); + const [albums, setAlbums] = useState(null); + const [chosen, setChosen] = useState(() => new Set(cfg ? cfg.albums : [])); + const [existing, setExisting] = useState(cfg ? cfg.includeExisting : true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const permitted = status.permission !== 'denied'; + + useEffect(() => { + if (!permitted) return; + platform.photoSync.albums().then((list) => { + setAlbums(list); + // The camera, and only the camera, unless the person chose before: + // screenshots and saved images are where photos nobody meant to share live. + if (!cfg) setChosen(new Set(list.filter((a) => a.camera).map((a) => a.id))); + }).catch((e) => setError(e.message)); + }, [permitted]); + + useEffect(() => { + setFolders(null); + if (!groupId) return; + let gone = false; + setProbe('checking'); + probeGroup(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 platform.photoSync.permit()); } catch (e) { setError(e.message); } + }; + + const toggle = (id) => setChosen((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + + const start = async () => { + const group = groups.find((g) => g.id === groupId); + if (!group || !folder || !chosen.size) return; + setBusy(true); + setError(''); + try { + const settings = { + account: user.username, groupId, groupName: group.name, + owner: group.owner_username || '', folder, albums: [...chosen], includeExisting: existing, + }; + // Asked when the photos would go somewhere new, or from somewhere new in + // time — never for a change of albums alone, which the person just made. + const same = cfg && cfg.groupId === groupId && cfg.folder === folder + && cfg.includeExisting === existing; + if (!same) { + const [est, members] = await Promise.all([ + platform.photoSync.estimate(settings), + hubFetch(`/v1/groups/${groupId}/members`, { token: user.token }) + .then((r) => (r.members || []).length).catch(() => null), + ]); + const params = { + group: group.name, owner: group.owner_username || '', folder, + members: members == null ? '?' : members, + count: est.count, size: formatSize(est.bytes), + }; + const yes = await ask(t(existing && est.count > 0 ? 'photosync.confirm' + : 'photosync.confirm_new', params)); + if (!yes) { setBusy(false); return; } + } + onDone(await platform.photoSync.configure(settings)); + } catch (e) { + setError(e.message); + setBusy(false); + } + }; + + return html` + <div> + ${!permitted && html` + <p class="settings-hint">${t('photosync.permission_needed')}</p> + <button class="admin-btn" onClick=${allow}>${t('photosync.permission_allow')}</button> + `} + ${status.permission === 'partial' && html` + <p class="settings-hint">${t('photosync.permission_partial')}</p> + <button class="btn-secondary" onClick=${allow}>${t('photosync.permission_allow')}</button> + `} + ${permitted && 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> + ${groups.map((g) => html`<option key=${g.id} value=${g.id}> + ${g.owner_username ? `${g.name} (@${g.owner_username})` : 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>`} + ${folders && !folders.length && html`<p class="settings-hint">${t('photosync.no_folder')}</p>`} + ${folders && 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)}> + ${folders.map((f) => html`<option key=${f} value=${f}>${f}</option>`)} + </select> + </div> + `} + <p class="settings-label" style="margin-top:10px">${t('photosync.albums')}</p> + ${(albums || []).map((a) => html` + <label class="settings-choice" key=${a.id}> + <input type="checkbox" checked=${chosen.has(a.id)} onChange=${() => toggle(a.id)} /> + <span class="settings-name"> + <strong>${a.camera ? t('photosync.album_camera') : a.name}</strong> + <span class="settings-hint"> + ${t('photosync.album_count', { n: a.count, size: formatSize(a.bytes) })}</span> + </span> + </label> + `)} + <label class="settings-choice"> + <input type="radio" name="photosync-scope" checked=${existing} + onChange=${() => setExisting(true)} /> + <span><strong>${t('photosync.scope_existing')}</strong></span> + </label> + <label class="settings-choice"> + <input type="radio" name="photosync-scope" checked=${!existing} + onChange=${() => setExisting(false)} /> + <span><strong>${t('photosync.scope_new')}</strong></span> + </label> + <div class="settings-row" style="gap:8px; justify-content:flex-start"> + <button class="admin-btn" onClick=${start} + disabled=${busy || !groupId || !folder || !chosen.size}>${t('photosync.start')}</button> + <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button> + </div> + `} + ${!permitted && html` + <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button>`} + ${error && html`<p class="error-msg">${error}</p>`} + </div> + `; +} |