aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-28 10:09:38 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-28 10:09:38 +0200
commit58ca879b017da62e77f40752d8d94fef3f315a1e (patch)
tree38cb9b1f8007009e39b462bcda16830aa2b0ede3 /packages/meshbay-hub/src/meshbay_hub/static
parent471fa6242fc56a035a8c9639722a82b9341328c2 (diff)
downloadmeshbay-58ca879b017da62e77f40752d8d94fef3f315a1e.tar.gz
feat(hub): group names unique per owner, shown as name@owner
A group's identity stays its UUID. What changes is that "the name is unique" — until now an unenforced expectation — becomes real, scoped to the owner account, and the owner's username is surfaced so two groups called "photos" on different nodes can be told apart. Hub: - `groups` gains a functional unique index `uq_groups_owner_name` on `(admin_id, lower(name))` (model + migration c3d4e5f6a7b8). The migration pre-flights: if the data already clashes it aborts and lists the offending (admin_id, name) pairs rather than renaming anyone's group. meshbay.org checked clean. - `create_group` trims the name, rejects blank (422) and an owner-scoped case-insensitive clash (409), with an IntegrityError backstop for the race, and returns `owner_username`. - `owner_username` added to `/v1/groups/mine`, `GET /v1/groups` (local rows), `POST /v1/groups/{id}/join`, `GET /v1/admin/groups`. SPA: - new `static/group-name.js` — `<GroupName name owner [inline]>` renders the name with the `@owner` handle on a smaller grey line under it. - used in the sidebar, the group-page header, Explore cards, the Admin groups table, and cross-group Search (via a widened `cacheGroupIndex` carrying the owner). Delete/leave confirmations show `name@owner` inline. - federated Explore rows show `@<source_hub>` instead of an account. Design record and the locked decisions: ~/next/groupnames.md (out of repo). MNP unchanged. Tests: test_group_name_unique.py, test_group_name_migration.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-name.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css20
6 files changed, 69 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 9164a4d..18f34fc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -16,6 +16,7 @@ import {
refreshAccessToken,
} from './hub-client.js';
import { GroupPage } from './group-page.js';
+import { GroupName } from './group-name.js';
import { APPS } from './apps.js';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -352,7 +353,9 @@ function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, ha
<span class="presence presence-${state}"
title="${label}"
aria-label="${label}"></span>
- <span class="sidebar-item-name">${g.name}</span>
+ <span class="sidebar-item-name">
+ <${GroupName} name=${g.name} owner=${g.owner_username} />
+ </span>
</a>
`;
})
@@ -699,13 +702,11 @@ function ExplorePage({ token, myGroupIds, allowPublicGroups = true }) {
${groups.map(g => html`
<div key=${g.id} class="group-card">
<a href="#/group/${g.id}" style="text-decoration:none;color:inherit">
- <h3>${g.name}</h3>
+ <h3><${GroupName} name=${g.name}
+ owner=${g.source && g.source !== 'local' ? g.source : g.owner_username} /></h3>
${g.description && html`<p class="group-card-desc">${g.description}</p>`}
</a>
<span class="badge">${g.join_policy}</span>
- ${g.source && g.source !== 'local' && html`
- ${' '}<span class="badge">${g.source}</span>
- `}
${' '}
${isMember(g.id)
? html`<span class="badge">${t('explore.member')}</span>`
@@ -1331,7 +1332,7 @@ function SearchPage() {
if (e.name.toLowerCase().includes(term) ||
(e.path && e.path.toLowerCase().includes(term))) {
hits.push({ ...e, groupId: idx.groupId, groupName: idx.groupName,
- syncedAt: idx.cachedAt });
+ groupOwner: idx.groupOwner, syncedAt: idx.cachedAt });
}
}
}
@@ -1376,7 +1377,9 @@ function SearchPage() {
</td>
<td class="file-size">${formatSize(r.size)}</td>
<td>
- <a href="#/group/${r.groupId}" class="badge">${r.groupName || r.groupId.slice(0, 8)}</a>
+ <a href="#/group/${r.groupId}" class="badge">${r.groupName
+ ? html`<${GroupName} name=${r.groupName} owner=${r.groupOwner} inline=${true} />`
+ : r.groupId.slice(0, 8)}</a>
${r.syncedAt && html`
<div class="search-synced">
${t('search.synced', { ago: formatAgo(r.syncedAt) })}
@@ -2175,7 +2178,7 @@ function AdminPage({ token, role }) {
${groups.length === 0 && html`<tr><td colspan="6" class="admin-empty">${t('admin.no_groups')}</td></tr>`}
${groups.map(g => html`
<tr key=${g.id}>
- <td>${g.name}</td>
+ <td><${GroupName} name=${g.name} owner=${g.owner_username} /></td>
<td><span class="badge">${g.visibility}</span></td>
<td>${g.member_count}</td>
<td><span class="badge ${g.status === 'active' ? 'badge-ok' : g.status === 'suspended' ? 'badge-err' : ''}">${g.status}</span></td>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-name.js b/packages/meshbay-hub/src/meshbay_hub/static/group-name.js
new file mode 100644
index 0000000..af8879c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-name.js
@@ -0,0 +1,22 @@
+import { html } from './vendor/htm-preact.js';
+
+/**
+ * A group's name with the `@owner` handle under it.
+ *
+ * Group names are unique only per owner account (the hub enforces that), so the
+ * `@handle` is what tells two groups called "photos" apart. `owner` is the
+ * owner's username for a local group, or the source hub for a federated one
+ * (decision 5 in ~/next/groupnames.md); the caller decides which.
+ *
+ * `inline` renders "name@owner" on one line, for places that cannot take a
+ * block — a badge, a `confirm()` string built elsewhere.
+ */
+export function GroupName({ name, owner, inline = false }) {
+ if (inline) return owner ? `${name}@${owner}` : name;
+ return html`
+ <span class="gn">
+ <span class="gn-name">${name}</span>
+ ${owner && html`<span class="gn-owner">@${owner}</span>`}
+ </span>
+ `;
+}
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 470f169..4ebe5a7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -9,6 +9,7 @@ import {
HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey,
} from './hub-client.js';
import { visibleApps } from './apps.js';
+import { GroupName } from './group-name.js';
import { FilePreview } from './files-app.js';
import { VideoPlayer } from './video-player.js';
import { MusicPlayerBar } from './music-player.js';
@@ -160,7 +161,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setEntries(fresh);
if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
if (indexMsg.roots) setNodeRoots(indexMsg.roots);
- cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
+ cacheGroupIndex(groupId, group ? group.name : groupId,
+ group ? group.owner_username : null, fresh);
}, [groupId, group]);
// additions/deletions/updates (daemon.py _broadcast_index_change, once
@@ -181,7 +183,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const keptIds = new Set(updated.map((e) => e.id));
const additions = (deltaMsg.additions || []).filter((e) => !keptIds.has(e.id));
const fresh = updated.concat(additions);
- cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
+ cacheGroupIndex(groupId, group ? group.name : groupId,
+ group ? group.owner_username : null, fresh);
return fresh;
});
}, [groupId, group]);
@@ -493,7 +496,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
<div class="group-header">
<div>
<h2 style="margin-bottom:${group && group.description ? '4px' : '0'}">
- ${group ? group.name : t('group.default_name')}
+ ${group
+ ? html`<${GroupName} name=${group.name} owner=${group.owner_username} />`
+ : t('group.default_name')}
</h2>
${editingDesc
? html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 60b0184..59d1456 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -1244,7 +1244,9 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${isOwner
? html`
<button class="admin-btn danger" onClick=${async () => {
- if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return;
+ if (!confirm(t('group.delete_group_confirm', {
+ name: group.owner_username ? `${group.name}@${group.owner_username}` : group.name,
+ }))) return;
try {
// Node detach first (reversible), then hub delete (irreversible)
if (nodeDetected && nodeGroupName) {
@@ -1263,7 +1265,9 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
`
: html`
<button class="admin-btn danger" onClick=${async () => {
- if (!confirm(t('group.leave_confirm', { name: group.name }))) return;
+ if (!confirm(t('group.leave_confirm', {
+ name: group.owner_username ? `${group.name}@${group.owner_username}` : group.name,
+ }))) return;
try {
await hubFetch('/v1/groups/' + groupId + '/leave',
{ method: 'POST', token });
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
index aaf7ddb..176d22b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
@@ -35,12 +35,12 @@ function openDB() {
});
}
-async function cacheGroupIndex(groupId, groupName, entries) {
+async function cacheGroupIndex(groupId, groupName, groupOwner, entries) {
try {
const db = await openDB();
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).put({
- groupId, groupName, entries, cachedAt: Date.now(),
+ groupId, groupName, groupOwner, entries, cachedAt: Date.now(),
});
await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; });
db.close();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index cd9882e..810f06b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2136,9 +2136,29 @@ a.transfer-name {
.sidebar-item-name {
overflow: hidden;
+ min-width: 0;
+}
+
+/* Group name + its @owner handle, stacked. Names are unique only per owner
+ account, so the handle is what tells two "photos" groups apart. */
+.gn { display: block; min-width: 0; }
+.gn-name {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.gn-owner {
+ display: block;
+ overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ font-size: 0.8em;
+ font-weight: 400;
+ color: var(--text-dim);
}
+h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
+.sidebar-item.active .gn-owner { color: inherit; opacity: 0.75; }
/* ── Chat: paging and orientation ─────────────────────────────────────────── */