From 1d94a92936abb3d37a8f9bfb36c8850246fee15d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 5 Oct 2026 12:23:18 +0200 Subject: feat: copy a file's or folder's #/name@owner link from Files, Music, Photos and Search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 --- .../src/meshbay_hub/static/search-page.js | 28 ++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/search-page.js') 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)))} /> `} `; -- cgit v1.2.3