summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-05 12:23:18 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-05 12:23:18 +0200
commit1d94a92936abb3d37a8f9bfb36c8850246fee15d (patch)
treef07aa4f65277d703ac64a84882b0c559bf379f30 /packages
parent8f25294b0f6bc3f292442edd69a2e149f0717b52 (diff)
downloadmeshbay-1d94a92936abb3d37a8f9bfb36c8850246fee15d.tar.gz
feat: copy a file's or folder's #/name@owner link from Files, Music, Photos and Search
"Copy link" puts the address group-link.js resolves on the clipboard, on the hub's origin rather than the page's, so a link copied in the desktop application is not app://meshbay. Files offers it for one row, from the right-click menu or the toolbar with one row ticked (a phone's way in); Music on one track's menu, whose dots a phone has; Photos on a right-clicked tile and in the lightbox's bar. The video player and the file preview carry a link button next to Download. Applications get a `linkFor(entry | folderPath)` prop (MESHBAY_DESIGN.md §9.2) and offer the action only when it names a link. The group page builds it from the hub's row; Search from each result's own group and its path before the merged views prefixed it, and names no link for a folder of the merged tree, which a group name alone does not identify. harness/copy_link_probe.py mounts the three applications in Chrome and reads what reached the clipboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/copy-link.js67
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js7
-rw-r--r--packages/meshbay-hub/tests/harness/copy_link_probe.py264
-rw-r--r--packages/meshbay-hub/tests/test_copy_link.py98
21 files changed, 560 insertions, 20 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js b/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js
new file mode 100644
index 0000000..ad6c3be
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/copy-link.js
@@ -0,0 +1,67 @@
+import { t } from './i18n.js';
+import * as platform from './platform.js';
+import { groupLinkRoute } from './group-link.js';
+
+/**
+ * "Copy link": the `#/name@owner/path` address of a file or folder
+ * (group-link.js), on this hub, put on the clipboard.
+ *
+ * The hub's origin, never the page's: in the desktop application the page is
+ * `app://meshbay`, and a link that only opens inside one person's application
+ * is not a link.
+ */
+
+/** The full link to `path` inside `group` (`{ name, owner_username }`). */
+export function groupItemLink(group, path) {
+ return platform.hubOrigin() + '/#' + groupLinkRoute(group, path);
+}
+
+/** An entry's path inside its group: its folder, root first, then its name. */
+export function entryPath(entry, dir = entry.path) {
+ return [dir, entry.name].filter(Boolean).join('/');
+}
+
+// The async clipboard first; the old command where it is refused — a page
+// without focus, a WebView without the permission. Neither needs to read
+// anything back.
+async function _write(text) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch { /* fall through */ }
+ const area = document.createElement('textarea');
+ area.value = text;
+ area.setAttribute('readonly', '');
+ area.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
+ document.body.appendChild(area);
+ area.select();
+ let ok = false;
+ try { ok = document.execCommand('copy'); } catch { ok = false; }
+ area.remove();
+ return ok;
+}
+
+let _note = null;
+let _noteTimer = null;
+
+// Said once and gone, in the playlists' note (style.css `.playlist-note`).
+// Appended to the body rather than rendered by a view, because the views that
+// copy include modals and a context menu that has already closed.
+function _say(text) {
+ if (!_note) {
+ _note = document.createElement('div');
+ _note.className = 'playlist-note copy-link-note';
+ _note.setAttribute('role', 'status');
+ }
+ _note.textContent = text;
+ document.body.appendChild(_note);
+ clearTimeout(_noteTimer);
+ _noteTimer = setTimeout(() => { if (_note) _note.remove(); }, 2500);
+}
+
+/** Copy `url`, and say whether it worked. The link itself is shown if not. */
+export async function copyLink(url) {
+ if (!url) return;
+ const ok = await _write(url);
+ _say(ok ? t('link.copied') : t('link.copy_failed', { url }));
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index 978090c..b3672c9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -13,6 +13,7 @@ import {
import { useStickyBand } from './sticky.js';
import { Menu, useMenu } from './menu.js';
import { askReport } from './report.js';
+import { copyLink } from './copy-link.js';
// ── Files ────────────────────────────────────────────────────────────────────
//
@@ -142,7 +143,7 @@ function FilesPanel({
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, userId, setError, onPreview,
showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, onReport,
- openDirectory,
+ openDirectory, linkFor,
}) {
const [selected, setSelected] = useState(() => new Set());
const [sortKey, setSortKey] = useState('name');
@@ -623,6 +624,14 @@ function FilesPanel({
onSelect: () => run(async () => {
for (const d of dirs) await downloadDirectory(d);
}, clear) },
+ // One file or one folder, ticked (the toolbar, which is also how a phone
+ // gets at it) or under the pointer. Absent rather than disabled where
+ // the view can name no link at all (`linkFor` returns null).
+ linkFor && { key: 'link', icon: 'link', label: t('link.copy'),
+ disabled: files.length + dirs.length !== 1
+ || !linkFor(files.length ? files[0] : dirs[0]),
+ onSelect: () => run(
+ () => copyLink(linkFor(files.length ? files[0] : dirs[0])), clear) },
mayEverDelete && !readOnly && { key: 'delete', icon: 'trash', danger: true,
label: deletableCount > 1 ? t('group.delete_n', { n: deletableCount }) : t('group.delete'),
disabled: status !== 'connected' || deletableCount === 0,
@@ -905,7 +914,7 @@ function FilesPanel({
const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i;
-function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) {
+function FilePreview({ entry, transportRef, gekRef, onClose, onDownload, onCopyLink }) {
const [phase, setPhase] = useState('loading');
const [progress, setProgress] = useState(0);
const [content, setContent] = useState(null);
@@ -1000,6 +1009,11 @@ function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) {
}}>
<div class="video-top-bar">
<span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
+ ${onCopyLink && html`
+ <button class="video-close" onClick=${onCopyLink}
+ title="${t('link.copy')}" aria-label="${t('link.copy')}">
+ <${Icon} name="link" /></button>
+ `}
${onDownload && html`
<button class="video-close ${downloading ? 'dl-active' : ''}"
onClick=${() => {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index a2d6c18..48b1c80 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -6,6 +6,7 @@ import { Icon } from './icon.js';
import { transfers } from './transfers.js';
import { downloadEntry } from './file-utils.js';
import { resolveLinkedPath } from './group-link.js';
+import { copyLink, entryPath, groupItemLink } from './copy-link.js';
import {
HUB, session, hubFetch, ensureFreshToken,
_loadBundleKey, _loadRecoveryKey, _storeBundleKey,
@@ -761,6 +762,14 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
if (onLinkOpened) onLinkOpened();
}, [openPath, status, groupId]);
+ // The link "Copy link" puts on the clipboard, for an entry or a folder path
+ // (group-link.js). Null until the hub's row for the group is in: the handle
+ // is its name and its owner's, and the node knows neither.
+ const linkFor = useCallback((target) => {
+ if (!group || !group.name || !group.owner_username || !target) return null;
+ return groupItemLink(group, typeof target === 'string' ? target : entryPath(target));
+ }, [group]);
+
const refreshIndex = useCallback(async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
@@ -891,7 +900,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
entries, availableEntries, nodeDirs, nodeRoots,
setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, attachRoot, attachDir, userId, setError, onPreview,
- onRefreshIndex: refreshIndex, onActivity: touchActivity,
+ onRefreshIndex: refreshIndex, onActivity: touchActivity, linkFor,
// Plural everywhere, and built from the registry rather than a list of app
// names kept here: Videos and Music read a list, Photos always did, and an
// application added to `APPS` gets its own entry without this file
@@ -1060,7 +1069,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transportRef=${transportRef}
gekRef=${gekRef}
onClose=${() => setPreviewEntry(null)}
- onDownload=${() => downloadFileForModal(previewEntry)} />
+ onDownload=${() => downloadFileForModal(previewEntry)}
+ onCopyLink=${linkFor(previewEntry) && (() => copyLink(linkFor(previewEntry)))} />
`}
${videoEntry && html`
<${VideoPlayer}
@@ -1068,7 +1078,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transportRef=${transportRef}
gekRef=${gekRef}
onClose=${() => setVideoEntry(null)}
- onDownload=${() => downloadFileForModal(videoEntry)} />
+ onDownload=${() => downloadFileForModal(videoEntry)}
+ onCopyLink=${linkFor(videoEntry) && (() => copyLink(linkFor(videoEntry)))} />
`}
</div>
`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
index 4fa4357..4d549f1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js
@@ -65,6 +65,8 @@ const ICON_PATHS = {
plus: ['M12 5v14', 'M5 12h14'],
refresh: ['M20 11a8 8 0 0 0-15.3-2', 'M4 5v4h4',
'M4 13a8 8 0 0 0 15.3 2', 'M20 19v-4h-4'],
+ link: ['M10 13.5a4.5 4.5 0 0 0 6.4.4l3-3a4.5 4.5 0 0 0-6.4-6.4l-1.6 1.6',
+ 'M14 10.5a4.5 4.5 0 0 0-6.4-.4l-3 3a4.5 4.5 0 0 0 6.4 6.4l1.6-1.6'],
clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'],
pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3',
'M14.5 6.5l3 3'],
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 8d14dca..6e42af9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -212,6 +212,9 @@ export default {
'group.download_offline': 'Keine Verbindung zum Node — der Download kann nicht starten. Die Verbindung wird automatisch wiederhergestellt; versuchen Sie es gleich erneut.',
'group.link_unknown': "Dieser Link verweist auf eine Gruppe, in der Sie nicht Mitglied sind, oder die nicht mehr so heißt.",
'group.link_path_unknown': "Unter {path} gibt es in dieser Gruppe nichts — die Datei wurde vielleicht verschoben, umbenannt oder gelöscht.",
+ 'link.copy': "Link kopieren",
+ 'link.copied': "Link kopiert",
+ 'link.copy_failed': "Kopieren nicht möglich — der Link lautet {url}",
'group.download_write_stalled': 'Die Datei wird nicht mehr auf die Festplatte geschrieben ({seconds} s ohne Fortschritt). Der Download wurde abgebrochen statt hängen gelassen; versuchen Sie es erneut.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index a6cd0b4..f94b438 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -212,6 +212,9 @@ export default {
'group.download_offline': 'Not connected to the node — the download cannot start. It reconnects on its own; try again in a moment.',
'group.link_unknown': "This link names a group you are not a member of, or one that no longer goes by that name.",
'group.link_path_unknown': "Nothing at {path} in this group — the file may have been moved, renamed or deleted.",
+ 'link.copy': "Copy link",
+ 'link.copied': "Link copied",
+ 'link.copy_failed': "Could not copy — the link is {url}",
'group.download_write_stalled': 'The file stopped being written to disk ({seconds}s with no progress). The download was stopped rather than left hanging; try it again.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index e066081..6ff8be2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -210,6 +210,9 @@ export default {
'group.download_offline': 'Sin conexión con el nodo — la descarga no puede empezar. Se reconecta sola; inténtelo de nuevo en un momento.',
'group.link_unknown': "Este enlace nombra un grupo del que no eres miembro, o que ya no se llama así.",
'group.link_path_unknown': "No hay nada en {path} en este grupo: puede que el archivo se haya movido, renombrado o eliminado.",
+ 'link.copy': "Copiar enlace",
+ 'link.copied': "Enlace copiado",
+ 'link.copy_failed': "No se pudo copiar: el enlace es {url}",
'group.download_write_stalled': 'El archivo dejó de escribirse en el disco ({seconds} s sin avance). La descarga se detuvo en lugar de quedarse colgada; inténtelo de nuevo.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 01ccfcd..9132bd2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -211,6 +211,9 @@ export default {
'group.download_offline': 'Pas de connexion au node — le téléchargement ne peut pas démarrer. La reconnexion est automatique, réessayez dans un instant.',
'group.link_unknown': "Ce lien désigne un groupe dont vous n'êtes pas membre, ou qui ne porte plus ce nom.",
'group.link_path_unknown': "Rien à l'emplacement {path} dans ce groupe — le fichier a peut-être été déplacé, renommé ou supprimé.",
+ 'link.copy': "Copier le lien",
+ 'link.copied': "Lien copié",
+ 'link.copy_failed': "Copie impossible — le lien est {url}",
'group.download_write_stalled': 'L\'écriture du fichier sur le disque s\'est arrêtée ({seconds} s sans progression). Le téléchargement a été interrompu plutôt que laissé en suspens ; réessayez.',
'device.add_title': 'Ce navigateur n’est pas encore lié à ce nœud',
'device.add_hint': 'Votre compte est connu ici, mais ce navigateur détient une autre clé. Approuvez-le depuis un appareil déjà lié — sans passer par l’opérateur.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 71af9f8..f3ff714 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -211,6 +211,9 @@ export default {
'group.download_offline': 'Nessuna connessione al nodo — il download non può iniziare. La riconnessione è automatica, riprovi tra poco.',
'group.link_unknown': "Questo link indica un gruppo di cui non sei membro, o che non ha più questo nome.",
'group.link_path_unknown': "Non c'è nulla in {path} in questo gruppo: il file potrebbe essere stato spostato, rinominato o eliminato.",
+ 'link.copy': "Copia link",
+ 'link.copied': "Link copiato",
+ 'link.copy_failed': "Impossibile copiare — il link è {url}",
'group.download_write_stalled': 'Il file ha smesso di essere scritto su disco ({seconds} s senza progressi). Il download è stato interrotto invece di restare bloccato; riprovi.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 4b357c8..0690034 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -209,6 +209,9 @@ export default {
'group.download_offline': 'ノードに接続していません — ダウンロードを開始できません。再接続は自動で行われます。少し待って再試行してください。',
'group.link_unknown': "このリンクは、あなたがメンバーでないグループ、またはもうその名前ではないグループを指しています。",
'group.link_path_unknown': "このグループの {path} には何もありません。ファイルが移動、名前変更、または削除された可能性があります。",
+ 'link.copy': "リンクをコピー",
+ 'link.copied': "リンクをコピーしました",
+ 'link.copy_failed': "コピーできませんでした。リンク: {url}",
'group.download_write_stalled': 'ファイルのディスクへの書き込みが止まりました({seconds} 秒間進みません)。ぶら下がったままにせず中止しました。もう一度お試しください。',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 1f4b12c..ce6312c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -212,6 +212,9 @@ export default {
'group.download_offline': 'Geen verbinding met de node — de download kan niet starten. Er wordt automatisch opnieuw verbonden; probeer het zo weer.',
'group.link_unknown': "Deze link verwijst naar een groep waarvan je geen lid bent, of die niet meer zo heet.",
'group.link_path_unknown': "Er staat niets op {path} in deze groep — het bestand is misschien verplaatst, hernoemd of verwijderd.",
+ 'link.copy': "Link kopiëren",
+ 'link.copied': "Link gekopieerd",
+ 'link.copy_failed': "Kopiëren mislukt — de link is {url}",
'group.download_write_stalled': 'Het bestand wordt niet meer naar schijf geschreven ({seconds} s zonder voortgang). De download is gestopt in plaats van te blijven hangen; probeer het opnieuw.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 05a3006..2737532 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -215,6 +215,9 @@ export default {
'group.download_offline': 'Brak połączenia z węzłem — pobieranie nie może się rozpocząć. Połączenie wróci samo; proszę spróbować za chwilę.',
'group.link_unknown': "Ten link wskazuje grupę, do której nie należysz, lub która nie nosi już tej nazwy.",
'group.link_path_unknown': "W tej grupie nie ma niczego pod {path} — plik mógł zostać przeniesiony, zmieniony lub usunięty.",
+ 'link.copy': "Kopiuj link",
+ 'link.copied': "Link skopiowany",
+ 'link.copy_failed': "Nie udało się skopiować — link to {url}",
'group.download_write_stalled': 'Plik przestał być zapisywany na dysk ({seconds} s bez postępu). Pobieranie zostało przerwane, zamiast wisieć w nieskończoność; proszę spróbować ponownie.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 824c69a..c7cdf01 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -212,6 +212,9 @@ export default {
'group.download_offline': 'Sem conexão com o nó — o download não pode começar. Ele reconecta sozinho; tente de novo em instantes.',
'group.link_unknown': "Este link aponta para um grupo do qual você não é membro, ou que não tem mais esse nome.",
'group.link_path_unknown': "Não há nada em {path} neste grupo — o arquivo pode ter sido movido, renomeado ou excluído.",
+ 'link.copy': "Copiar link",
+ 'link.copied': "Link copiado",
+ 'link.copy_failed': "Não foi possível copiar — o link é {url}",
'group.download_write_stalled': 'O arquivo parou de ser gravado no disco ({seconds}s sem progresso). O download foi interrompido em vez de ficar travado; tente de novo.',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index d0d3954..06f61ff 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -208,6 +208,9 @@ export default {
'group.download_offline': '未连接到节点 — 无法开始下载。连接会自动恢复,请稍后重试。',
'group.link_unknown': "此链接指向一个您不是其成员的群组,或该群组已不再使用此名称。",
'group.link_path_unknown': "此群组中 {path} 处没有内容——文件可能已被移动、重命名或删除。",
+ 'link.copy': "复制链接",
+ 'link.copied': "链接已复制",
+ 'link.copy_failed': "无法复制——链接为 {url}",
'group.download_write_stalled': '文件停止写入磁盘({seconds} 秒无进展)。已中止下载而不是让它一直卡住,请重试。',
'device.add_title': 'This browser is not linked to this node yet',
'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
index f2c4374..9adb1e1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -11,6 +11,7 @@ import { Menu, MenuDots, useMenu } from './menu.js';
import { PlaylistMenuButton, NameModal, usePlaylists } from './playlist-menu.js';
import * as P from './playlists.js';
import { CastButton } from './cast-session.js';
+import { copyLink } from './copy-link.js';
// -- Music --------------------------------------------------------------------
//
@@ -564,7 +565,7 @@ function FlatList({ items, onPlayQueue, onMenu }) {
function MusicApp({
groupId, transportRef, gekRef, status, entries, availableEntries,
musicDirectories, musicbrainzConfig, onPlayQueue, userId,
- hideFilter, userPrefs, pageResetKey,
+ hideFilter, userPrefs, pageResetKey, linkFor,
}) {
const [mode, setMode] = useState(loadViewMode);
const [filter, setFilter] = useState('');
@@ -618,6 +619,9 @@ function MusicApp({
// argument slot for one.
const onMenu = useCallback((e, tracks, startIndex) => {
if (!tracks || !tracks.length) return;
+ // One track's menu — a row's, whose dots are how a phone opens it. An
+ // album's carries all its tracks and gets no link: Files has the folder's.
+ const link = tracks.length === 1 && linkFor ? linkFor(tracks[0]) : null;
openAt(e, [
{ label: t('music.menu_play'), icon: 'play',
onSelect: () => onPlayQueue(tracks, startIndex || 0) },
@@ -650,8 +654,10 @@ function MusicApp({
},
],
},
+ ...(link ? [{ divider: true },
+ { label: t('link.copy'), icon: 'link', onSelect: () => copyLink(link) }] : []),
]);
- }, [openAt, onPlayQueue, playlists, addToPlaylist]);
+ }, [openAt, onPlayQueue, playlists, addToPlaylist, linkFor]);
useEffect(() => { setMode(loadViewMode()); }, [groupId]);
useEffect(() => { setFilter(''); }, [groupId]);
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 00bc288..afc815a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -10,6 +10,8 @@ import { transfers } from './transfers.js';
import { MediaThumb, LazyTile } from './media-tiles.js';
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';
// ── Photos ───────────────────────────────────────────────────────────────────
//
@@ -118,11 +120,11 @@ function AlbumLanding({ albums, transportRef, gekRef, onOpen }) {
// ── open album: grid of its own photos ──────────────────────────────────────
-function PhotoTile({ entry, transportRef, gekRef, onOpen }) {
+function PhotoTile({ entry, transportRef, gekRef, onOpen, onMenu }) {
const tRef = entry._tRef || transportRef;
const gRef = entry._gRef || gekRef;
return html`
- <div class="photo-tile" onClick=${onOpen}>
+ <div class="photo-tile" onClick=${onOpen} onContextMenu=${onMenu}>
<${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name}
cls="photo-tile-thumb" transportRef=${tRef} gekRef=${gRef}
emptyIcon="image" />
@@ -153,7 +155,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 }) {
+function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav, linkFor }) {
const entry = photos[index];
const tRef = entry._tRef || transportRef;
const gRef = entry._gRef || gekRef;
@@ -284,6 +286,10 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) {
aria-pressed=${slideshow ? 'true' : 'false'}>
<${Icon} name=${slideshow ? 'pause' : 'play'} /></button>
<${CastButton} variant="overlay" cls="video-close" />
+ ${linkFor && linkFor(entry) && html`
+ <button class="video-close" onClick=${() => copyLink(linkFor(entry))}
+ title=${t('link.copy')} aria-label=${t('link.copy')}>
+ <${Icon} name="link" /></button>`}
<button class="video-close" onClick=${onClose} title=${t('video.close')}>
<${Icon} name="close" /></button>
</div>
@@ -311,8 +317,16 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) {
`;
}
-function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly }) {
+function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly, linkFor }) {
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.
+ 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 zip = useCallback(async () => {
const transport = transportRef.current;
@@ -352,15 +366,16 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea
${album.photos.map((e, i) => html`
<${LazyTile} key=${e.id} cls="photo-tile-slot">
<${PhotoTile} entry=${e} transportRef=${transportRef} gekRef=${gekRef}
- onOpen=${() => setLightboxIndex(i)} />
+ onOpen=${() => setLightboxIndex(i)} onMenu=${(ev) => onTileMenu(ev, e)} />
</${LazyTile}>
`)}
</div>
${lightboxIndex !== null && html`
<${Lightbox} photos=${album.photos} index=${lightboxIndex}
transportRef=${transportRef} gekRef=${gekRef}
- onClose=${() => setLightboxIndex(null)} onNav=${navigate} />
+ onClose=${() => setLightboxIndex(null)} onNav=${navigate} linkFor=${linkFor} />
`}
+ ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`}
`;
}
@@ -369,7 +384,7 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, rea
function PhotosApp({
groupId, transportRef, gekRef, status, entries, availableEntries,
photoDirectories, setError,
- hideFilter, readOnly,
+ hideFilter, readOnly, linkFor,
}) {
const [openDir, setOpenDir] = useState(null);
const [filter, setFilter] = useState('');
@@ -423,7 +438,7 @@ function PhotosApp({
${status === 'connected' && openAlbum && html`
<${AlbumView} album=${openAlbum} entries=${entries}
transportRef=${transportRef} gekRef=${gekRef} setError=${setError}
- onBack=${() => setOpenDir(null)} readOnly=${readOnly} />
+ onBack=${() => setOpenDir(null)} readOnly=${readOnly} linkFor=${linkFor} />
`}
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index 874b7b0..1ede1f6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -13,6 +13,7 @@ import { MusicApp, groupMusicEntries, foldKey } from './music-app.js';
import { PhotosApp, groupPhotoAlbums } from './photos-app.js';
import { VideoPlayer } from './video-player.js';
import { transfers } from './transfers.js';
+import { copyLink, entryPath, groupItemLink } from './copy-link.js';
import { mergeUnitEntries } from './source-merge.js';
import { useStickyBand } from './sticky.js';
import { ConnectionPool, MAX_IN_FLIGHT, MAX_POOL_SIZE } from './connection-pool.js';
@@ -514,6 +515,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
result.push({
...e,
path: SEARCH_VIDEO_ROOT + '/' + e.path,
+ _origPath: e.path,
groupId,
groupName: data.groupName,
groupOwner: data.groupOwner,
@@ -540,6 +542,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
result.push({
...e,
path: SEARCH_AUDIO_ROOT + '/' + e.path,
+ _origPath: e.path,
groupId,
groupName: data.groupName,
groupOwner: data.groupOwner,
@@ -567,6 +570,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
result.push({
...e,
path: '__search_photos__/' + e.path,
+ _origPath: e.path,
groupId,
groupName: data.groupName,
groupOwner: data.groupOwner,
@@ -640,6 +644,19 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
// No-op setters for FilesPanel
const noop = useCallback(() => {}, []);
+
+ // "Copy link" for a result: its own group's handle and its path there, from
+ // `_origPath` — every view here prefixes `path` to merge groups into one tree.
+ // A merged entry is the source it was resolved to (source-merge.js), so the
+ // link names a group that has the file. A folder is a path in that merged
+ // tree, named by group name alone, which two owners may share: no link.
+ const linkFor = useCallback((target) => {
+ if (!target || typeof target === 'string' || !target.groupName || !target.groupOwner) {
+ return null;
+ }
+ return groupItemLink({ name: target.groupName, owner_username: target.groupOwner },
+ entryPath(target, target._origPath));
+ }, []);
// The search field and its view toggle are this page's equivalent of a
// group's tab bar: the same band, pinned the same way, publishing the same
// property for the toolbar underneath (style.css, "Sticky chrome").
@@ -726,6 +743,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
readOnly=${true}
getTransport=${getTransport}
showRefresh=${true}
+ linkFor=${linkFor}
onRefreshIndex=${() => setRefreshTick((n) => n + 1)} />
`}
@@ -754,7 +772,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
entries=${musicEntries}
musicDirectories=${[SEARCH_AUDIO_ROOT]}
musicbrainzConfig=${{ enabled: true }}
- onPlayQueue=${handleMusicPlay} userId=${userId}
+ onPlayQueue=${handleMusicPlay} userId=${userId} linkFor=${linkFor}
userPrefs=${userPrefs} pageResetKey=${q}
hideFilter=${true} />
`}
@@ -767,7 +785,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
status="connected"
entries=${photoEntries}
photoDirectories=${SEARCH_PHOTO_ROOTS}
- setError=${noop}
+ setError=${noop} linkFor=${linkFor}
hideFilter=${true}
readOnly=${true} />
`}
@@ -788,7 +806,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
transportRef=${modalTransportRef}
gekRef=${modalGekRef}
onClose=${() => setPreviewEntry(null)}
- onDownload=${() => downloadForModal(previewEntry)} />
+ onDownload=${() => downloadForModal(previewEntry)}
+ onCopyLink=${linkFor(previewEntry) && (() => copyLink(linkFor(previewEntry)))} />
`}
${videoEntry && html`
<${VideoPlayer}
@@ -796,7 +815,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
transportRef=${modalTransportRef}
gekRef=${modalGekRef}
onClose=${() => setVideoEntry(null)}
- onDownload=${() => downloadForModal(videoEntry)} />
+ onDownload=${() => downloadForModal(videoEntry)}
+ onCopyLink=${linkFor(videoEntry) && (() => copyLink(linkFor(videoEntry)))} />
`}
</div>
`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 9383043..bde1c61 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -5613,6 +5613,14 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
overflow: hidden;
text-overflow: ellipsis;
}
+/* The same note for "Copy link" (copy-link.js). When the clipboard refused, it
+ carries the link itself, which must be readable whole rather than cut. */
+.copy-link-note.playlist-note {
+ white-space: normal;
+ overflow-wrap: anywhere;
+ border-radius: 12px;
+ text-align: center;
+}
/* A run of single-album artists, pooled into one grid so five of them fill one
row instead of spending five (music-app.js `albumSections`).
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
index 37ea727..21abe8e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -403,7 +403,7 @@ function purgeUnscopedResumePositions() {
purgeUnscopedResumePositions();
-function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
+function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload, onCopyLink }) {
const [dlBusy, setDlBusy] = useState(false);
const [phase, setPhase] = useState('loading');
const [error, setError] = useState('');
@@ -1957,6 +1957,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
}
</span>
`}
+ ${onCopyLink && html`
+ <button class="video-close" onClick=${onCopyLink}
+ title="${t('link.copy')}" aria-label="${t('link.copy')}">
+ <${Icon} name="link" /></button>
+ `}
${onDownload && html`
<button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy}
onClick=${() => {
diff --git a/packages/meshbay-hub/tests/harness/copy_link_probe.py b/packages/meshbay-hub/tests/harness/copy_link_probe.py
new file mode 100644
index 0000000..50d269d
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/copy_link_probe.py
@@ -0,0 +1,264 @@
+#!/usr/bin/env python3
+"""
+"Copy link" in Files, Music and Photos, in a real browser.
+
+Mounts the shipped `FilesPanel`, `MusicApp` and `PhotosApp` on a made-up
+index — no node, no transport — with the `linkFor` a group page gives them,
+built by the real `copy-link.js` for a group `demo@someowner`. Opens each
+menu the way a reader would (right-click; the dots on a phone; the Files
+toolbar with one row ticked), picks "Copy link", and reads back what reached
+the clipboard and what the page said.
+
+ copy_link_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 = 8775
+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="files"></div><div id="music"></div><div id="photos"></div>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale, setLocale } from '/i18n.js';
+import { FilesPanel } from '/files-app.js';
+import { MusicApp } from '/music-app.js';
+import { PhotosApp } from '/photos-app.js';
+import { groupItemLink, entryPath } from '/copy-link.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));
+
+// What the clipboard was handed, and nothing else: a headless page has no
+// clipboard permission to test against.
+const COPIES = [];
+Object.defineProperty(navigator, 'clipboard', { configurable: true,
+ value: { writeText: async (text) => { COPIES.push(text); } } });
+
+const GROUP = { name: 'demo', owner_username: 'someowner' };
+const linkFor = (target) => (target
+ ? groupItemLink(GROUP, typeof target === 'string' ? target : entryPath(target)) : null);
+const noop = () => {};
+const ref = { current: null };
+
+const labels = () => [...document.querySelectorAll('.ctx-menu .ctx-menu-label')]
+ .map((el) => el.textContent);
+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;
+};
+const note = () => (document.querySelector('.copy-link-note') || {}).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 rowNamed = (name) => [...document.querySelectorAll('tr.file-row')]
+ .find((tr) => tr.querySelector('.file-name')
+ && tr.querySelector('.file-name').textContent.trim().startsWith(name));
+
+(async () => {
+ const cases = [];
+ try {
+ setLocale('en');
+ await initLocale();
+ try { localStorage.setItem('meshbay_music_view_mode', 'flat'); } catch {}
+
+ // -- Files --
+ const FILES = [
+ { id: 'f1', name: 'IMG 0001.JPG', path: 'Root/trip_2015', size: 10, type: 'image',
+ added_at: 1 },
+ { id: 'f2', name: 'notes.txt', path: 'Root/trip_2015', size: 5, type: 'document',
+ added_at: 2 },
+ ];
+ render(html`<${FilesPanel} groupId="g" transportRef=${ref} gekRef=${ref}
+ status="connected" entries=${FILES} nodeDirs=${['Root', 'Root/trip_2015']}
+ nodeRoots=${[{ name: 'Root', writable: false }]}
+ setEntries=${noop} setNodeDirs=${noop} setNodeRoots=${noop} applyIndex=${noop}
+ isNodeAdmin=${false} operatorPaired=${false} userId="me" setError=${noop}
+ onPreview=${noop} linkFor=${linkFor} />`, document.getElementById('files'));
+ await frame();
+
+ await rightClick(rowNamed('Root'));
+ const folderLabels = labels();
+ await pick('Copy link');
+ cases.push({ case: 'files folder', labels: folderLabels, copied: COPIES.at(-1), note: note() });
+
+ rowNamed('Root').click(); await frame();
+ rowNamed('trip_2015').click(); await frame();
+ await rightClick(rowNamed('IMG 0001.JPG'));
+ const fileLabels = labels();
+ await pick('Copy link');
+ cases.push({ case: 'files file', labels: fileLabels, copied: COPIES.at(-1), note: note() });
+
+ await closeMenus();
+ const linkBtn = () => document.querySelector('.tb-actions button[aria-label="Copy link"]');
+ rowNamed('notes.txt').querySelector('input[type=checkbox]').click(); await frame();
+ const oneTicked = linkBtn() ? !linkBtn().disabled : null;
+ if (linkBtn() && !linkBtn().disabled) { linkBtn().click(); await wait(50); }
+ const copiedFromToolbar = COPIES.at(-1);
+ for (const n of ['IMG 0001.JPG', 'notes.txt']) {
+ rowNamed(n).querySelector('input[type=checkbox]').click(); await frame();
+ }
+ const twoTicked = linkBtn() ? !linkBtn().disabled : null;
+ cases.push({ case: 'files toolbar', one_enabled: oneTicked, two_enabled: twoTicked,
+ copied: copiedFromToolbar });
+ render(null, document.getElementById('files'));
+
+ // -- Music: a loose track, its row's dots (what a phone has) --
+ const TRACKS = [
+ { id: 'a1', name: 'track 01.flac', path: 'Music/Some Album', size: 3, type: 'audio' },
+ { id: 'a2', name: 'track 02.flac', path: 'Music/Some Album', size: 3, type: 'audio' },
+ ];
+ render(html`<${MusicApp} groupId="g" transportRef=${ref} gekRef=${ref}
+ status="connected" entries=${TRACKS} musicDirectories=${['Music']}
+ musicbrainzConfig=${{ enabled: false }} onPlayQueue=${noop} userId=""
+ linkFor=${linkFor} />`, document.getElementById('music'));
+ await frame();
+ await closeMenus();
+ const dots = document.querySelector('.music-flat-track .ctx-dots');
+ if (dots) { dots.click(); await frame(); }
+ const trackLabels = labels();
+ await pick('Copy link');
+ cases.push({ case: 'music track', dots: Boolean(dots), labels: trackLabels,
+ copied: COPIES.at(-1) });
+ render(null, document.getElementById('music'));
+
+ // -- Photos: right-click a tile; the lightbox's button --
+ const PHOTOS = [
+ { id: 'p1', name: 'beach.jpg', path: 'Pics/Summer', size: 4, type: 'image', added_at: 1 },
+ ];
+ render(html`<${PhotosApp} groupId="g" transportRef=${ref} gekRef=${ref}
+ status="connected" entries=${PHOTOS} photoDirectories=${['Pics']}
+ setError=${noop} linkFor=${linkFor} />`, document.getElementById('photos'));
+ await frame();
+ const album = document.querySelector('.photo-album-card');
+ if (album) { album.click(); await frame(); }
+ const tile = document.querySelector('.photo-tile');
+ let tileLabels = [];
+ if (tile) {
+ await rightClick(tile);
+ tileLabels = labels();
+ await pick('Copy link');
+ }
+ const fromTile = COPIES.at(-1);
+ if (tile) { tile.click(); await frame(); }
+ const lb = document.querySelector('.photo-lightbox button[aria-label="Copy link"]');
+ COPIES.length = 0;
+ if (lb) { lb.click(); await wait(50); }
+ cases.push({ case: 'photos', tile: Boolean(tile), labels: tileLabels, copied: fromTile,
+ lightbox_button: Boolean(lb), lightbox_copied: COPIES.at(-1) || null });
+
+ 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_copy_link.py b/packages/meshbay-hub/tests/test_copy_link.py
new file mode 100644
index 0000000..7115d04
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_copy_link.py
@@ -0,0 +1,98 @@
+"""
+"Copy link": the `#/name@owner/path` address of a file or folder, from the
+views that show one (harness/copy_link_probe.py).
+
+Files offers it for one row — right-click, or the toolbar with one row ticked,
+which is how a phone reaches it; Music on one track's menu, whose dots are a
+phone's way in; Photos on a right-clicked tile and in the lightbox. The video
+player and the file preview carry a button. Search passes the same `linkFor`,
+naming each result's own group and its path there rather than the merged tree's.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "copy_link_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+ORIGIN = "http://127.0.0.1:8775"
+
+
+@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_files_a_folder(cases):
+ c = cases["files folder"]
+ assert "Copy link" in c["labels"]
+ assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Root"
+ assert c["note"] == "Link copied"
+
+
+def test_files_a_file(cases):
+ c = cases["files file"]
+ assert c["labels"].index("Copy link") > c["labels"].index("Download")
+ assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Root/trip_2015/IMG%200001.JPG"
+
+
+def test_files_toolbar_one_ticked_row_only(cases):
+ c = cases["files toolbar"]
+ assert c["one_enabled"] is True and c["two_enabled"] is False
+ assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Root/trip_2015/notes.txt"
+
+
+def test_music_a_track_from_its_dots(cases):
+ c = cases["music track"]
+ assert c["dots"] and c["labels"][-1] == "Copy link"
+ assert c["copied"] == f"{ORIGIN}/#/demo@someowner/Music/Some%20Album/track%2001.flac"
+
+
+def test_photos_tile_and_lightbox(cases):
+ c = cases["photos"]
+ link = f"{ORIGIN}/#/demo@someowner/Pics/Summer/beach.jpg"
+ assert c["labels"] == ["Copy link"] and c["copied"] == link
+ assert c["lightbox_button"] and c["lightbox_copied"] == link
+
+
+@pytest.mark.parametrize("module", ["video-player.js", "files-app.js"])
+def test_the_modals_carry_a_link_button(module):
+ source = (STATIC / module).read_text(encoding="utf-8")
+ assert re.search(r"function (VideoPlayer|FilePreview)\(\{[^}]*onCopyLink", source)
+ assert "${onCopyLink && html`" in source
+
+
+@pytest.mark.parametrize("page, n", [("group-page.js", 2), ("search-page.js", 2)])
+def test_both_pages_hand_the_modals_a_link(page, n):
+ source = (STATIC / page).read_text(encoding="utf-8")
+ assert source.count("onCopyLink=${linkFor(") == n
+
+
+def test_search_names_each_results_own_group_and_path():
+ source = (STATIC / "search-page.js").read_text(encoding="utf-8")
+ # Every view prefixes `path`; the link is built from the original.
+ assert source.count("_origPath: e.path,") == 4
+ assert "entryPath(target, target._origPath)" in source
+ for view in ("FilesPanel", "MusicApp", "PhotosApp"):
+ at = source.index(f"<${{{view}}}")
+ assert "linkFor=${linkFor}" in source[at:source.index("/>", at)], view
+
+
+def test_the_link_is_the_hubs_not_the_pages():
+ """In the desktop application the page is app://meshbay."""
+ source = (STATIC / "copy-link.js").read_text(encoding="utf-8")
+ assert "platform.hubOrigin() + '/#' + groupLinkRoute(" in source
+ assert "location.origin" not in source