summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-05 11:53:57 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-05 11:53:57 +0200
commit8f25294b0f6bc3f292442edd69a2e149f0717b52 (patch)
tree58cfe661b15fce395ab5116f19341bdb6a5b07fe /packages/meshbay-hub/src/meshbay_hub
parent28752696f376eb11feb686580a435b166750a723 (diff)
downloadmeshbay-8f25294b0f6bc3f292442edd69a2e149f0717b52.tar.gz
feat: open a group, a folder or a file from a #/name@owner link
A group can now be reached by the handle shown under its name, and a path after it points inside the group: #/name@owner/root/dir/file downloads the file and opens Files on its folder; a folder opens Files there. The handle is resolved in the client against the account's own /v1/groups/mine, so no hub route answers for a name and nobody can probe for one. While a group is open the address shows the handle (replace, no history entry); a linked path is taken out of the address once acted on, so a reload does not download twice. Signing in no longer sends everyone home: the form stood in for the page the address named, and that is where a link opened signed out was going. group-link.js holds the parsing and lookups, executed whole by test_group_link.py; harness/group_link_probe.py drives the router in Chrome. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js49
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-link.js95
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
15 files changed, 202 insertions, 11 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index c5f2733..bdab271 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -21,6 +21,7 @@ import {
} from './hub-client.js';
import { startIdleWatch, markActive } from './idle.js';
import { GroupPage } from './group-page.js';
+import { parseGroupLink, groupLinkRoute, findLinkedGroup } from './group-link.js';
import { lazy } from './lazy.js';
import { ConnectionPool } from './connection-pool.js';
import { MusicPlayerBar } from './music-player.js';
@@ -766,6 +767,9 @@ function App() {
const [needsHub, setNeedsHub] = useState(
platform.isNative && !platform.hubBase());
const [groups, setGroups] = useState([]);
+ // Whether `/mine` has answered for this sign-in. A `#/name@owner` link can
+ // only be called unknown once the list it is looked up in has arrived.
+ const [groupsLoaded, setGroupsLoaded] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
@@ -1008,11 +1012,13 @@ function App() {
if (!user) {
nodeKeyAskedForRef.current = null;
setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false);
+ setGroupsLoaded(false);
return;
}
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
- .catch(() => setGroups([]));
+ .catch(() => setGroups([]))
+ .finally(() => setGroupsLoaded(true));
hubFetch('/v1/users/me/preferences', { token: user.token })
.then(async (prefs) => {
setUserPrefs(prefs || {});
@@ -1233,6 +1239,31 @@ function App() {
// out as the only way back.
const refreshAuth = useCallback(() => refreshAccessToken(), []);
+ // A group is reached by its id (`#/group/<id>`, what every link inside the
+ // application uses) or by its handle (`#/name@owner[/path]`, the one a person
+ // types or shares — group-link.js). Both open the same page; `groupRoute` is
+ // the first form whichever was used, so the sidebar and the page see one.
+ const groupLink = route.startsWith('/group/') ? null : parseGroupLink(route);
+ const linkedGroup = groupLink ? findLinkedGroup(groups, groupLink) : null;
+ const groupId = route.startsWith('/group/') ? route.slice(7)
+ : linkedGroup ? linkedGroup.id : null;
+ const groupRoute = groupId ? '/group/' + groupId : route;
+ const shownGroup = groupId ? groups.find(g => g.id === groupId) : null;
+ const linkPath = groupLink && linkedGroup ? groupLink.path : '';
+ // The address shows the handle, which is the link worth copying. `replace`,
+ // so this is not a history entry; and only once the path a link named has
+ // been acted on (`onLinkOpened`), so a reload does not download the file a
+ // second time.
+ const shownGroupRoute = shownGroup ? groupLinkRoute(shownGroup) : null;
+ useEffect(() => {
+ if (shownGroupRoute && !linkPath && route !== shownGroupRoute) {
+ window.location.replace('#' + shownGroupRoute);
+ }
+ }, [route, shownGroupRoute, linkPath]);
+ const onLinkOpened = useCallback(() => {
+ if (shownGroupRoute) window.location.replace('#' + shownGroupRoute);
+ }, [shownGroupRoute]);
+
let page;
// A desktop build with no hub configured cannot do anything at all, so it
// asks before showing a sign-in form that could not work. The field comes
@@ -1282,17 +1313,23 @@ function App() {
}} />`;
} else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) {
page = html`<${LazyNodePage} groups=${groups} token=${user.token} username=${user.username} />`;
- } else if (route.startsWith('/group/')) {
- const groupId = route.slice(7);
- const group = groups.find(g => g.id === groupId);
+ } else if (groupId) {
page = html`<${GroupPage}
- groupId=${groupId} group=${group} token=${user.token}
+ groupId=${groupId} group=${shownGroup} token=${user.token}
+ openPath=${linkPath} onLinkOpened=${onLinkOpened}
username=${user.username} userId=${user.userId}
userPrefs=${userPrefs}
onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications}
onGroupUpdated=${updateGroup} onPresence=${notePresence}
onLeft=${handleLeftGroup}
onPlayQueue=${handlePlayQueue} onStopMusic=${handleStopMusic} />`;
+ } else if (groupLink) {
+ // Not among this account's groups — or not yet known to be. The same words
+ // whether it does not exist or is someone else's: the list it was looked
+ // up in is this account's own, so there is nothing else to tell.
+ page = groupsLoaded
+ ? html`<div class="page-content"><p class="page-message">${t('group.link_unknown')}</p></div>`
+ : html`<div class="page-content"><p class="page-message"><span class="spinner"></span></p></div>`;
} else if (route === '/admin') {
page = (user.role === 'moderator' || user.role === 'admin')
? html`<${LazyAdminPage} token=${user.token} role=${user.role} />`
@@ -1334,7 +1371,7 @@ function App() {
groups=${groups}
presence=${presence}
indexProgressPct=${indexProgressPct}
- route=${route}
+ route=${groupRoute}
menuOpen=${menuOpen}
role=${user.role}
allowPublicGroups=${allowPublicGroups}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index c12db72..57db475 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -185,8 +185,12 @@ export function LoginPage({ onLogin }) {
// Trimmed to match the hub's stored username and every client-side key
// derivation (auth_key, bundle_key, recovery_key all fold the username in).
await onLogin(name, password);
- // Back to the invitation that sent them here, if one is waiting.
- navigate(loadPending() ? '/invite' : '/');
+ // Back to the invitation that sent them here, if one is waiting. Otherwise
+ // nowhere: on `#/login` or `#/register` the router sends a signed-in
+ // person home itself, and anywhere else this form stood in for the page
+ // the address names — a group, a file in one — which is where they were
+ // going. Sending everyone home lost every link opened signed out.
+ if (loadPending()) navigate('/invite');
} catch (err) {
if (err.message === 'email_verification_required') {
setPendingVerif(true);
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 38157b9..978090c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -142,12 +142,16 @@ function FilesPanel({
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, userId, setError, onPreview,
showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, onReport,
+ openDirectory,
}) {
const [selected, setSelected] = useState(() => new Set());
const [sortKey, setSortKey] = useState('name');
const [sortAsc, setSortAsc] = useState(true);
const [filter, setFilter] = useState('');
- const [currentPath, setCurrentPath] = useState('');
+ const [currentPath, setCurrentPath] = useState(() => (openDirectory ? openDirectory.dir : ''));
+ // A link into the group named a folder (group-page.js); a new object each
+ // time, so the same folder linked twice is opened twice.
+ useEffect(() => { if (openDirectory) setCurrentPath(openDirectory.dir); }, [openDirectory]);
const [refreshing, setRefreshing] = useState(false);
// The toolbar pins below the page's own band and tells the column heads how
// far down to pin. Its height is not a constant — it wraps to three rows on
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-link.js b/packages/meshbay-hub/src/meshbay_hub/static/group-link.js
new file mode 100644
index 0000000..3c9063c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-link.js
@@ -0,0 +1,95 @@
+/**
+ * A group named in the address: `#/name@owner`, optionally followed by a path
+ * inside it — `#/name@owner/root/folder/file.jpg`.
+ *
+ * The same handle `GroupName` shows under every group, so a link reads the way
+ * the group is labelled. It is resolved against the signed-in account's own
+ * group list and nothing else: no hub route answers "which group is this
+ * name", so a name says nothing to someone who is not already a member, and
+ * nobody can probe for one. The UUID stays the group's identity; a renamed
+ * group breaks its name links, never its `#/group/<id>` ones.
+ *
+ * In the fragment, never the path: what follows `#` is not sent to the server,
+ * so a group's name and a file's path appear in no hub or proxy log and in no
+ * Referer.
+ *
+ * The owner is everything after the *last* `@`: a username cannot contain one
+ * (users.py, RegisterRequest), a group name can. Each segment is
+ * percent-decoded on its own, so a `/` inside a name or a file name travels as
+ * `%2F` without splitting the path.
+ *
+ * No imports, so `test_group_link.py` can execute the module as it is.
+ */
+
+function _decode(segment) {
+ try { return decodeURIComponent(segment); } catch { return null; }
+}
+
+// `@` is legal in a fragment and the owner is found by the last one, so the
+// name's own need no escaping; everything else is escaped as a URI component.
+function _encode(segment) {
+ return encodeURIComponent(segment).replace(/%40/g, '@');
+}
+
+/**
+ * `{ name, owner, path }` for a route (the hash without its `#`), or null when
+ * the route does not name a group. `path` is '' for the group itself.
+ */
+function parseGroupLink(route) {
+ if (!route || route[0] !== '/') return null;
+ const parts = route.slice(1).split('/');
+ const head = _decode(parts[0]);
+ if (!head) return null;
+ const at = head.lastIndexOf('@');
+ if (at <= 0 || at === head.length - 1) return null;
+ const rest = parts.slice(1).filter(Boolean).map(_decode);
+ if (rest.some((s) => s === null || s === '.' || s === '..')) return null;
+ return { name: head.slice(0, at), owner: head.slice(at + 1), path: rest.join('/') };
+}
+
+/** The route naming `group`, and `path` inside it when one is given. */
+function groupLinkRoute(group, path = '') {
+ const head = `/${_encode(group.name)}@${_encode(group.owner_username || '')}`;
+ const tail = path ? '/' + path.split('/').filter(Boolean).map(_encode).join('/') : '';
+ return head + tail;
+}
+
+/**
+ * The group `link` names among `groups` (`/v1/groups/mine` rows), or null.
+ *
+ * Case-insensitively on the name, as the hub keeps it unique
+ * (`uq_groups_owner_name` is on `lower(name)`); the owner as typed first, then
+ * case-insensitively when that finds exactly one.
+ */
+function findLinkedGroup(groups, link) {
+ if (!link) return null;
+ const name = link.name.toLowerCase();
+ const named = (groups || []).filter((g) => (g.name || '').toLowerCase() === name);
+ const exact = named.find((g) => g.owner_username === link.owner);
+ if (exact) return exact;
+ const owner = link.owner.toLowerCase();
+ const loose = named.filter((g) => (g.owner_username || '').toLowerCase() === owner);
+ return loose.length === 1 ? loose[0] : null;
+}
+
+/**
+ * What `path` names in a group's index: `{ kind: 'file', entry }`,
+ * `{ kind: 'dir', dir }`, or null. An entry's `path` is its folder, root
+ * first, and `name` its file name — so a file is matched on both together.
+ * Folders come from the files under them and from the node's own listing,
+ * which is the only place an empty one appears.
+ */
+function resolveLinkedPath(entries, nodeDirs, path) {
+ if (!path) return null;
+ const cut = path.lastIndexOf('/');
+ const dir = cut < 0 ? '' : path.slice(0, cut);
+ const name = cut < 0 ? path : path.slice(cut + 1);
+ const entry = (entries || []).find((e) => (e.path || '') === dir && e.name === name);
+ if (entry) return { kind: 'file', entry };
+ const prefix = path + '/';
+ const isDir = (nodeDirs || []).includes(path)
+ || (entries || []).some((e) => (e.path || '') === path || (e.path || '').startsWith(prefix));
+ return isDir ? { kind: 'dir', dir: path } : null;
+}
+
+export { parseGroupLink, groupLinkRoute, findLinkedGroup, resolveLinkedPath };
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 fe858b2..a2d6c18 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -5,6 +5,7 @@ import { t } from './i18n.js';
import { Icon } from './icon.js';
import { transfers } from './transfers.js';
import { downloadEntry } from './file-utils.js';
+import { resolveLinkedPath } from './group-link.js';
import {
HUB, session, hubFetch, ensureFreshToken,
_loadBundleKey, _loadRecoveryKey, _storeBundleKey,
@@ -33,7 +34,8 @@ import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js';
*/
function GroupPage({ groupId, group, token, username, userId, userPrefs,
onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft,
- onPlayQueue: parentOnPlayQueue, onStopMusic }) {
+ onPlayQueue: parentOnPlayQueue, onStopMusic,
+ openPath = '', onLinkOpened }) {
const [status, setStatus] = useState('idle');
// The tab bar pins under the navigation bar and tells the application's own
// toolbar how far down to pin (style.css, "Sticky chrome").
@@ -313,12 +315,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
});
}, []);
+ // Which group `entries` is the index of. The page is not remounted between
+ // groups, so for one render after a switch `status` and `entries` are still
+ // the last group's — and a link into the new one must not be looked up there.
+ const indexGroupRef = useRef(null);
+
// One place that takes an index from the node and puts it everywhere it has to
// go. Deleting a file used to refresh the table and leave the cache alone, so
// the search page went on offering a file that no longer existed until the
// group was reconnected.
const applyIndex = useCallback((indexMsg) => {
const fresh = indexMsg.entries || [];
+ indexGroupRef.current = groupId;
setEntries(fresh);
if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
if (indexMsg.roots) setNodeRoots(indexMsg.roots);
@@ -731,6 +739,28 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
await downloadEntry(transfers, transport, gekRef.current, entry);
}, []);
+ // A link that named a path inside the group (`#/name@owner/root/dir/file`,
+ // group-link.js): a file is downloaded, and Files opens on its folder either
+ // way. Acted on once the index is in — before that, nothing can say whether
+ // the path is a file, a folder or nothing — and then handed back to the
+ // router, which takes the path out of the address so a reload does not
+ // download it again.
+ const [openDirectory, setOpenDirectory] = useState(null);
+ useEffect(() => { setOpenDirectory(null); }, [groupId]);
+ useEffect(() => {
+ if (!openPath || status !== 'connected' || indexGroupRef.current !== groupId) return;
+ const target = resolveLinkedPath(entries, nodeDirs, openPath);
+ if (!target) {
+ setError(t('group.link_path_unknown', { path: openPath }));
+ } else {
+ const dir = target.kind === 'file' ? (target.entry.path || '') : target.dir;
+ setOpenDirectory({ dir });
+ chooseTab('files');
+ if (target.kind === 'file') downloadFileForModal(target.entry);
+ }
+ if (onLinkOpened) onLinkOpened();
+ }, [openPath, status, groupId]);
+
const refreshIndex = useCallback(async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
@@ -987,7 +1017,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
</div>
${apps.map(a => tab === a.key && html`
- <${a.Component} key=${a.key + '-' + groupId} ...${commonProps} />
+ <${a.Component} key=${a.key + '-' + groupId} ...${commonProps}
+ ...${a.key === 'files' ? { openDirectory } : {}} />
`)}
${tab === 'settings' && html`
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 5cc9968..8d14dca 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -210,6 +210,8 @@ export default {
'group.mkdir_prompt': 'Name des neuen Ordners',
'group.mkdir_offline': 'Nicht mit dem Node verbunden.',
'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.",
'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 5aa53b7..a6cd0b4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -210,6 +210,8 @@ export default {
'group.mkdir_prompt': 'New folder name',
'group.mkdir_offline': 'Not connected to the node.',
'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.",
'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 6558a46..e066081 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -208,6 +208,8 @@ export default {
'group.mkdir_prompt': 'Nombre de la nueva carpeta',
'group.mkdir_offline': 'Sin conexión con el nodo.',
'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.",
'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 5e4c0bf..01ccfcd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -209,6 +209,8 @@ export default {
'group.mkdir_prompt': 'Nom du nouveau dossier',
'group.mkdir_offline': 'Non connecté au nœud.',
'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é.",
'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 8772376..71af9f8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -209,6 +209,8 @@ export default {
'group.mkdir_prompt': 'Nome della nuova cartella',
'group.mkdir_offline': 'Non connesso al nodo.',
'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.",
'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 fa34470..4b357c8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -207,6 +207,8 @@ export default {
'group.mkdir_prompt': '新しいフォルダー名',
'group.mkdir_offline': 'ノードに接続していません。',
'group.download_offline': 'ノードに接続していません — ダウンロードを開始できません。再接続は自動で行われます。少し待って再試行してください。',
+ 'group.link_unknown': "このリンクは、あなたがメンバーでないグループ、またはもうその名前ではないグループを指しています。",
+ 'group.link_path_unknown': "このグループの {path} には何もありません。ファイルが移動、名前変更、または削除された可能性があります。",
'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 fa0841d..1f4b12c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -210,6 +210,8 @@ export default {
'group.mkdir_prompt': 'Naam van de nieuwe map',
'group.mkdir_offline': 'Niet verbonden met de node.',
'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.",
'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 9de29ea..05a3006 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -213,6 +213,8 @@ export default {
'group.mkdir_prompt': 'Nazwa nowego folderu',
'group.mkdir_offline': 'Brak połączenia z węzłem.',
'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.",
'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 908a8db..824c69a 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
@@ -210,6 +210,8 @@ export default {
'group.mkdir_prompt': 'Nome da nova pasta',
'group.mkdir_offline': 'Sem conexão com o nó.',
'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.",
'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 d6fe782..d0d3954 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
@@ -206,6 +206,8 @@ export default {
'group.mkdir_prompt': '新文件夹名称',
'group.mkdir_offline': '未连接到节点。',
'group.download_offline': '未连接到节点 — 无法开始下载。连接会自动恢复,请稍后重试。',
+ 'group.link_unknown': "此链接指向一个您不是其成员的群组,或该群组已不再使用此名称。",
+ 'group.link_path_unknown': "此群组中 {path} 处没有内容——文件可能已被移动、重命名或删除。",
'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.',