aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/photo-sync-settings.js
diff options
context:
space:
mode:
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.js264
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>
+ `;
+}