aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js57
-rwxr-xr-xpackages/meshbay-hub/tests/harness/photo_delete_probe.py285
-rw-r--r--packages/meshbay-hub/tests/test_photo_delete.py60
3 files changed, 394 insertions, 8 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
index afc815a..f18acd2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -12,6 +12,7 @@ import { SourceTag } from './group-name.js';
import { CastButton, castSession, useCastDevice, showPhoto } from './cast-session.js';
import { Menu, useMenu } from './menu.js';
import { copyLink } from './copy-link.js';
+import { ask } from './ask.js';
// ── Photos ───────────────────────────────────────────────────────────────────
//
@@ -155,7 +156,7 @@ const ZOOM_MAX = 400;
// a slow node must not skip a photo nobody saw.
const SLIDESHOW_MS = 5000;
-function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor }) {
+function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor, onDelete }) {
const entry = photos[index];
const tRef = entry._tRef || transportRef;
const gRef = entry._gRef || gekRef;
@@ -290,6 +291,10 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
<button class="video-close" onClick=${() => copyLink(linkFor(entry))}
title=${t('link.copy')} aria-label=${t('link.copy')}>
<${Icon} name="link" /></button>`}
+ ${onDelete && onDelete(entry) && html`
+ <button class="video-close" onClick=${onDelete(entry)}
+ title=${t('group.delete')} aria-label=${t('group.delete')}>
+ <${Icon} name="trash" /></button>`}
<button class="video-close" onClick=${onClose} title=${t('video.close')}>
<${Icon} name="close" /></button>
</div>
@@ -317,17 +322,31 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor
`;
}
-function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor }) {
+function AlbumView({
+ album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor, onDelete,
+}) {
const [lightboxIndex, setLightboxIndex] = useState(null);
- // Right-click a photo for its link. A tile has no dots to press, so on a
- // touchscreen the link is in the lightbox's bar instead.
+ // Right-click a photo for its link, or to delete it. A tile has no dots to
+ // press, so on a touchscreen both are in the lightbox's bar instead.
const { menu, openAt, close: closeMenu } = useMenu();
const onTileMenu = (e, entry) => {
const link = linkFor ? linkFor(entry) : null;
- if (!link) return;
- openAt(e, [{ label: t('link.copy'), icon: 'link', onSelect: () => copyLink(link) }]);
+ const remove = onDelete ? onDelete(entry) : null;
+ const items = [
+ link && { label: t('link.copy'), icon: 'link', onSelect: () => copyLink(link) },
+ remove && { label: t('group.delete'), icon: 'trash', danger: true, onSelect: remove },
+ ].filter(Boolean);
+ if (!items.length) return;
+ openAt(e, items);
};
+ // A photo deleted from under the lightbox: show its neighbour, or close
+ // when it was the album's last.
+ const count = album.photos.length;
+ useEffect(() => {
+ setLightboxIndex((i) => (i === null || i < count ? i : (count ? count - 1 : null)));
+ }, [count]);
+
const zip = useCallback(async () => {
const transport = transportRef.current;
await downloadDirectory(
@@ -373,7 +392,8 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea
${lightboxIndex !== null && html`
<${Lightbox} photos=${album.photos} index=${lightboxIndex}
transportRef=${transportRef} gekRef=${gekRef}
- onClose=${() => setLightboxIndex(null)} onNav=${navigate} linkFor=${linkFor} />
+ onClose=${() => setLightboxIndex(null)} onNav=${navigate} linkFor=${linkFor}
+ onDelete=${onDelete} />
`}
${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`}
`;
@@ -385,6 +405,7 @@ function PhotosApp({
groupId, transportRef, gekRef, status, entries, availableEntries,
photoDirectories, setError,
hideFilter, readOnly, linkFor,
+ isNodeAdmin, userId, applyIndex,
}) {
const [openDir, setOpenDir] = useState(null);
const [filter, setFilter] = useState('');
@@ -402,6 +423,25 @@ function PhotosApp({
const openAlbum = openDir != null ? albums.find((a) => a.dir === openDir) : null;
+ // The rule Files applies, and the node enforces: the node's operator, or
+ // whoever uploaded the photo. Null for a photo this member may not delete,
+ // otherwise what deletes it once the person has said yes.
+ const deleteFor = (entry) => {
+ if (readOnly || status !== 'connected' || !applyIndex) return null;
+ if (!isNodeAdmin && !(userId && entry.uploader_id === userId)) return null;
+ return async () => {
+ if (!await ask(t('group.delete_confirm', { name: entry.name }))) return;
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ try {
+ await transport.deleteFile(entry.id, transport.signFn);
+ applyIndex(await transport.fetchIndex());
+ } catch (err) {
+ setError(err.message);
+ }
+ };
+ };
+
return html`
${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
<p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
@@ -438,7 +478,8 @@ function PhotosApp({
${status === 'connected' && openAlbum && html`
<${AlbumView} album=${openAlbum} entries=${entries}
transportRef=${transportRef} gekRef=${gekRef} setError=${setError}
- onBack=${() => setOpenDir(null)} readOnly=${readOnly} linkFor=${linkFor} />
+ onBack=${() => setOpenDir(null)} readOnly=${readOnly} linkFor=${linkFor}
+ onDelete=${deleteFor} />
`}
`;
}
diff --git a/packages/meshbay-hub/tests/harness/photo_delete_probe.py b/packages/meshbay-hub/tests/harness/photo_delete_probe.py
new file mode 100755
index 0000000..a99566d
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/photo_delete_probe.py
@@ -0,0 +1,285 @@
+#!/usr/bin/env python3
+"""
+Deleting a photo from the Photos app, in a real browser.
+
+Mounts the shipped `PhotosApp` on a made-up index, with a transport that
+records what it is asked to delete and an index that drops what was deleted,
+as a node's does. Right-clicks a tile, picks Delete, answers the page's own
+confirmation (ask.js) one way then the other, and does the same from the
+lightbox's bar.
+
+ photo_delete_probe.py
+
+Prints JSON: one entry per case.
+"""
+
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8776
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<div id="photos"></div>
+<script type="module">
+import { html, render, useState } from '/vendor/htm-preact.js';
+import { initLocale, setLocale } from '/i18n.js';
+import { PhotosApp } from '/photos-app.js';
+
+const LOGS = [];
+addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e)));
+addEventListener('unhandledrejection',
+ (e) => LOGS.push('rejection: ' + (e.reason && e.reason.message || e.reason)));
+const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const PHOTOS = [
+ { id: 'p1', name: 'beach.jpg', path: 'Pics/Summer', size: 4, type: 'image', added_at: 1,
+ uploader_id: 'me' },
+ { id: 'p2', name: 'dunes.jpg', path: 'Pics/Summer', size: 4, type: 'image', added_at: 2,
+ uploader_id: 'me' },
+ { id: 'p3', name: 'theirs.jpg', path: 'Pics/Summer', size: 4, type: 'image', added_at: 3,
+ uploader_id: 'someone' },
+];
+const DELETED = [];
+let onNode = [];
+const ref = { current: null };
+const noop = () => {};
+
+function Host({ isNodeAdmin = false, readOnly = false }) {
+ const [entries, setEntries] = useState(onNode);
+ ref.current = {
+ connected: true,
+ signFn: 'sign-fn',
+ async deleteFile(id, signFn) {
+ DELETED.push([id, signFn]);
+ onNode = onNode.filter((e) => e.id !== id);
+ return { type: 'file_delete_ack' };
+ },
+ async fetchIndex() { return { entries: onNode }; },
+ };
+ return html`<${PhotosApp} groupId="g" transportRef=${ref} gekRef=${{ current: null }}
+ status="connected" entries=${entries} photoDirectories=${['Pics']}
+ setError=${noop} linkFor=${() => null} readOnly=${readOnly}
+ isNodeAdmin=${isNodeAdmin} userId="me"
+ applyIndex=${(idx) => setEntries(idx.entries)} />`;
+}
+
+const mount = async (props) => {
+ onNode = PHOTOS.map((p) => ({ ...p }));
+ DELETED.length = 0;
+ const root = document.getElementById('photos');
+ render(null, root);
+ render(html`<${Host} ...${props} />`, root);
+ await frame();
+ const album = document.querySelector('.photo-album-card');
+ if (album) { album.click(); await frame(); }
+};
+const tileNamed = (name) => [...document.querySelectorAll('.photo-tile')]
+ .find((t) => (t.querySelector('img') || {}).alt === name || t.textContent.includes(name));
+const tiles = () => document.querySelectorAll('.photo-tile').length;
+const labels = () => [...document.querySelectorAll('.ctx-menu .ctx-menu-label')]
+ .map((el) => el.textContent);
+const closeMenus = async () => {
+ document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ await frame();
+};
+const rightClick = async (el) => {
+ await closeMenus();
+ const r = el.getBoundingClientRect();
+ el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true,
+ clientX: r.left + 10, clientY: r.top + 5 }));
+ await frame();
+};
+const pick = async (label) => {
+ const item = [...document.querySelectorAll('.ctx-menu .ctx-menu-item')]
+ .find((b) => b.querySelector('.ctx-menu-label').textContent === label);
+ if (!item) return false;
+ item.click();
+ await wait(50);
+ return true;
+};
+// The page's own confirmation: what it says, whether it is the topmost thing
+// on screen, and an answer.
+const dialog = () => document.querySelector('[role="alertdialog"]');
+const onTop = () => {
+ const d = dialog();
+ if (!d) return false;
+ const r = d.getBoundingClientRect();
+ const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
+ return Boolean(hit && d.contains(hit));
+};
+const answer = async (yes) => {
+ const d = dialog();
+ const buttons = [...d.querySelectorAll('button')];
+ const b = yes ? buttons.find((x) => x.type === 'submit') || buttons.at(-1)
+ : buttons.find((x) => x.type !== 'submit') || buttons[0];
+ b.click();
+ await wait(100);
+ await frame();
+};
+
+(async () => {
+ const cases = [];
+ try {
+ setLocale('en');
+ await initLocale();
+
+ // -- one's own photo, from the tile: cancelled, then confirmed --
+ await mount({});
+ const tile = document.querySelectorAll('.photo-tile')[0];
+ await rightClick(tile);
+ const own = labels();
+ await pick('Delete');
+ const asked = dialog() ? dialog().textContent : null;
+ await answer(false);
+ const afterNo = { deleted: DELETED.length, tiles: tiles() };
+ await rightClick(document.querySelectorAll('.photo-tile')[0]);
+ await pick('Delete');
+ await answer(true);
+ await wait(100); await frame();
+ cases.push({ case: 'tile', labels: own, asked, after_no: afterNo,
+ deleted: DELETED.map((d) => d.slice()), tiles: tiles() });
+
+ // -- somebody else's photo: nothing to delete there --
+ const others = [...document.querySelectorAll('.photo-tile')];
+ let theirLabels = null;
+ for (const t of others) {
+ await rightClick(t);
+ const l = labels();
+ if (!l.includes('Delete')) { theirLabels = l; break; }
+ }
+ await closeMenus();
+ cases.push({ case: 'not mine', menu_without_delete: theirLabels });
+
+ // -- the node's operator may delete anyone's --
+ await mount({ isNodeAdmin: true });
+ const all = [];
+ for (const t of document.querySelectorAll('.photo-tile')) {
+ await rightClick(t);
+ all.push(labels().includes('Delete'));
+ }
+ await closeMenus();
+ cases.push({ case: 'operator', delete_offered: all });
+
+ // -- from the lightbox: the button, the dialog above it, the next photo --
+ await mount({});
+ document.querySelectorAll('.photo-tile')[0].click();
+ await frame();
+ const title = () => (document.querySelector('.photo-lightbox .video-title') || {}).textContent;
+ const before = title();
+ const btn = document.querySelector('.photo-lightbox button[aria-label="Delete"]');
+ if (btn) btn.click();
+ await wait(50);
+ const dialogOnTop = onTop();
+ if (dialog()) await answer(true);
+ await wait(100); await frame();
+ cases.push({ case: 'lightbox', button: Boolean(btn), dialog_on_top: dialogOnTop,
+ before, after: title() || null, open: Boolean(document.querySelector('.photo-lightbox')),
+ deleted: DELETED.map((d) => d[0]) });
+
+ // -- read-only (Search): never --
+ await mount({ readOnly: true, isNodeAdmin: true });
+ const ro = document.querySelectorAll('.photo-tile')[0];
+ await rightClick(ro);
+ const roLabels = labels();
+ await closeMenus();
+ ro.click(); await frame();
+ cases.push({ case: 'read only', labels: roLabels,
+ lightbox_button: Boolean(document.querySelector('.photo-lightbox button[aria-label="Delete"]')) });
+
+ parent.postMessage({ cases, logs: LOGS, origin: location.origin }, '*');
+ } catch (err) {
+ parent.postMessage({ error: String(err && (err.stack || err)), logs: LOGS }, '*');
+ }
+})();
+</script></body></html>"""
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><div id="frames"></div><script>
+addEventListener('message', (e) => {
+ fetch('/log', { method: 'POST', body: JSON.stringify(e.data) });
+});
+const f = document.createElement('iframe');
+f.src = '/case';
+f.style.cssText = 'width:1100px;height:800px;border:0;display:block';
+document.getElementById('frames').appendChild(f);
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.append(json.loads(self.rfile.read(length).decode()))
+ else:
+ self.rfile.read(length)
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+ if path == "/":
+ self._send(PAGE.encode(), "text/html; charset=utf-8")
+ elif path == "/case":
+ self._send(FRAME.encode(), "text/html; charset=utf-8")
+ else:
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ self._send(asset.read_bytes(),
+ "text/css" if asset.suffix == ".css"
+ else "text/javascript" if asset.suffix == ".js"
+ else "application/octet-stream")
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=1100,900",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(300):
+ if RECORDS:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ if not RECORDS:
+ print(json.dumps({"error": "no measurement"}), file=sys.stderr)
+ return 1
+ print(json.dumps(RECORDS[0], indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_photo_delete.py b/packages/meshbay-hub/tests/test_photo_delete.py
new file mode 100644
index 0000000..bcc943f
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_photo_delete.py
@@ -0,0 +1,60 @@
+"""
+Deleting a photo from the Photos app (harness/photo_delete_probe.py).
+
+On a right-clicked tile and in the lightbox's bar, which is how a phone gets
+at it. Offered on the rule Files applies and the node enforces: the node's
+operator, or whoever uploaded the photo. Never without the page's own
+confirmation, and never from Search, which is read-only.
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "photo_delete_probe.py"
+
+
+@pytest.fixture(scope="module")
+def cases():
+ if shutil.which("google-chrome") is None:
+ pytest.skip("Chrome is not available")
+ proc = subprocess.run([sys.executable, str(HARNESS)],
+ capture_output=True, text=True, timeout=180)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = json.loads(proc.stdout)
+ assert "error" not in out, out["error"]
+ assert out["logs"] == []
+ return {c["case"]: c for c in out["cases"]}
+
+
+def test_ones_own_photo_is_deleted_only_once_confirmed(cases):
+ c = cases["tile"]
+ assert "Delete" in c["labels"]
+ assert c["asked"].startswith("Delete beach.jpg?")
+ assert c["after_no"] == {"deleted": 0, "tiles": 3}
+ assert c["deleted"] == [["p1", "sign-fn"]]
+ assert c["tiles"] == 2
+
+
+def test_somebody_elses_photo_offers_no_delete(cases):
+ assert "Delete" not in cases["not mine"]["menu_without_delete"]
+
+
+def test_the_operator_may_delete_any_photo(cases):
+ assert cases["operator"]["delete_offered"] == [True, True, True]
+
+
+def test_the_lightbox_deletes_and_shows_the_next_photo(cases):
+ c = cases["lightbox"]
+ assert c["button"] and c["dialog_on_top"]
+ assert c["deleted"] == ["p1"]
+ assert c["open"] and c["before"] == "beach.jpg" and c["after"] == "dunes.jpg"
+
+
+def test_search_results_offer_no_delete(cases):
+ c = cases["read only"]
+ assert "Delete" not in c["labels"] and not c["lightbox_button"]