`;
}
// ── Nav ──────────────────────────────────────────────────────────────────────
function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount,
hubUnset }) {
return html`
`;
}
// ── Sidebar ──────────────────────────────────────────────────────────────────
function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey,
allowPublicGroups = true }) {
const isStaff = role === 'moderator' || role === 'admin';
return html`
`;
}
// ── Home Page ────────────────────────────────────────────────────────────────
function NotificationFeed({ notifications, onMarkRead, onPurge }) {
if (!notifications.length) return null;
return html`
${t('notif.title')}
${notifications.map(n => html`
{
// Reading it is the point of clicking it: it goes, here and in the
// count, rather than sitting there greyed out.
onMarkRead(n.id);
if (n.link) navigate(n.link);
}}>
${n.kind}${n.title}${new Date(n.created_at).toLocaleDateString()}
`;
return html`<${_NodePage} ...${props} />`;
}
// ── App ──────────────────────────────────────────────────────────────────────
function App() {
const route = useRoute();
const [theme, setTheme] = useState(getInitialTheme);
const [user, setUser] = useState(loadAuth);
// A desktop build with a remembered device signs in without asking. Null
// until it has tried, so nothing renders a sign-in form the user is about to
// be taken past.
const [deviceTried, setDeviceTried] = useState(!platform.device.available);
// Native, and nowhere to talk to yet.
const [needsHub, setNeedsHub] = useState(
platform.isNative && !platform.hubBase());
const [groups, setGroups] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notifDisabled, setNotifDisabled] = useState(false);
const [userPrefs, setUserPrefs] = useState({});
const [hasNodeKey, setHasNodeKey] = useState(false);
// Instance policy, fetched once, unauthenticated. `null` until it answers;
// treat unknown as "allowed" so a slow hub never blocks a legitimate private
// group — the hub refuses a public one server-side regardless.
const [hubInfo, setHubInfo] = useState(null);
const allowPublicGroups = !hubInfo || hubInfo.allow_public_groups !== false;
// -- Persistent music player (lifted from group-page.js) --
const [musicQueue, setMusicQueue] = useState(null);
const musicPoolRef = useRef(null);
const userRef = useRef(user);
userRef.current = user;
const groupTransportRef = useRef(null);
useEffect(() => {
musicPoolRef.current = new ConnectionPool(HUB);
return () => { if (musicPoolRef.current) musicPoolRef.current.closeAll(); };
}, []);
const getMusicConnection = useCallback(async (groupId) => {
const gt = groupTransportRef.current;
if (gt && gt.groupId === groupId) {
const tr = gt.transportRef.current;
if (tr && tr.connected) return { transport: tr, gek: gt.gekRef.current };
}
const u = userRef.current;
if (!u || !musicPoolRef.current) throw new Error('no connection');
const bundleKey = session.bundleKey || await _loadBundleKey();
if (bundleKey) session.bundleKey = bundleKey;
const conn = await musicPoolRef.current.connect(
groupId, u.token, bundleKey, u.username, u.userId);
return { transport: conn.transport, gek: conn.gek };
}, []);
const handlePlayQueue = useCallback((tracks, startIndex, source) => {
if (source && source.transportRef) {
groupTransportRef.current = {
groupId: source.groupId,
transportRef: source.transportRef,
gekRef: source.gekRef,
};
} else {
groupTransportRef.current = null;
}
setMusicQueue({ tracks, startIndex, nonce: Date.now() });
}, []);
const handleStopMusic = useCallback(() => setMusicQueue(null), []);
const resolved = resolveTheme(theme);
// Keep the session alive without anyone having to think about it.
useEffect(() => {
// A renewal can happen inside hubFetch, well away from any render. This is
// how the component learns about it — including a failed one, which sets
// null and lands on the login page instead of failing every later call.
setAuthChangeListener((auth) => setUser(auth));
// On mount above all: a tab reopened tomorrow holds an hour-old access
// token and a refresh token good for a month, and used to greet its owner
// with "invalid token" rather than spending the second on renewing it.
ensureFreshToken();
const timer = setInterval(ensureFreshToken, TOKEN_CHECK_MS);
// A backgrounded tab has its timers throttled hard, so the check above may
// not have run for the whole time it was away. Coming back is exactly when
// the token is most likely to be stale.
const onVisible = () => {
if (document.visibilityState === 'visible') ensureFreshToken();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
setAuthChangeListener(null);
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, []);
useEffect(() => {
document.documentElement.className = `theme-${resolved}`;
localStorage.setItem(THEME_KEY, theme);
}, [theme, resolved]);
useEffect(() => {
hubFetch('/v1/hub/info').then(setHubInfo).catch(() => {});
}, []);
const fetchNotifications = useCallback(() => {
if (!user || notifDisabled) {
setNotifications([]); setUnreadCount(0); return;
}
// `unread_only`: clicking one is what dismisses it (see markRead), so a
// read notification is a dismissed notification and must not come back on
// the next launch. Without this the two halves disagreed — the click
// removed it here and marked it read on the hub, and the next startup
// asked for everything and put it straight back. `unread_count` is
// computed server-side and is unaffected by the filter.
hubFetch('/v1/notifications?limit=20&unread_only=true', { token: user.token })
.then(data => {
setNotifications(data.notifications || []);
setUnreadCount(data.unread_count || 0);
})
.catch(() => {});
}, [user, notifDisabled]);
useEffect(() => {
if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false); return; }
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => setGroups([]));
hubFetch('/v1/users/me/preferences', { token: user.token })
.then(prefs => {
setUserPrefs(prefs || {});
if (prefs.notifications_disabled === 'true') setNotifDisabled(true);
})
.catch(() => {});
if (platform.capabilities.nodeAdmin) {
hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
.then(data => setHasNodeKey(Boolean(data.pk_node_ed25519)))
.catch(() => {});
}
fetchNotifications();
}, [user]);
// The group list lives here, so an edit made three components down has to come
// back up rather than be re-fetched: a reload would drop the WebRTC connection
// the page is holding.
const updateGroup = useCallback((gid, patch) => {
setGroups(prev => prev.map(g => (g.id === gid ? { ...g, ...patch } : g)));
}, []);
// What this browser saw for itself, which beats what the hub reported. Held
// for the session only: it is a cache of observations, not a source of truth,
// and a reload should go back to asking.
const [presence, setPresence] = useState({});
// Percentage alongside 'indexing' presence — kept separate from `presence`
// itself so a changing % does not require treating every tick as a new
// presence state (see the Sidebar dot's title/aria-label).
const [indexProgressPct, setIndexProgressPct] = useState({});
const notePresence = useCallback((gid, state, pct) => {
setPresence(prev => (prev[gid] === state ? prev : { ...prev, [gid]: state }));
if (pct !== undefined) {
setIndexProgressPct(prev => (prev[gid] === pct ? prev : { ...prev, [gid]: pct }));
}
}, []);
const handleLeftGroup = useCallback((gid) => {
setGroups(prev => prev.filter(g => g.id !== gid));
setPresence(prev => {
const next = { ...prev };
delete next[gid];
return next;
});
navigate('/');
}, []);
const markRead = useCallback((id) => {
if (!user) return;
// Drop it here and now. Waiting for the round trip leaves it on screen while
// the page navigates, which reads as "the click did nothing".
setNotifications(prev => prev.filter(n => n.id !== id));
setUnreadCount(c => Math.max(0, c - 1));
// DELETE, not `/read`: dismissing one drops the row. The old path still
// works and still deletes, for interfaces older than the hub.
hubFetch(`/v1/notifications/${id}`, { method: 'DELETE', token: user.token })
.catch(() => fetchNotifications());
}, [user, fetchNotifications]);
const purgeNotifications = useCallback(() => {
if (!user) return;
setNotifications([]);
setUnreadCount(0);
hubFetch('/v1/notifications', { method: 'DELETE', token: user.token })
.catch(() => fetchNotifications());
}, [user, fetchNotifications]);
/** Clear the invitation for a group once its code has actually been redeemed. */
const dismissGroupNotifications = useCallback((groupId) => {
if (!user) return;
setNotifications(prev => {
const gone = prev.filter(n => n.group_id === groupId && n.kind === 'group_invite');
gone.forEach(n => hubFetch(`/v1/notifications/${n.id}`,
{ method: 'DELETE', token: user.token }).catch(() => {}));
if (gone.length) setUnreadCount(c => Math.max(0, c - gone.length));
return prev.filter(n => !gone.includes(n));
});
}, [user]);
// Sign in with this device's key, once, at startup.
//
// The passphrase stays the account's credential and its recovery path; this
// is what saves entering it every launch. A refusal is not an error worth
// showing — the key may have been retired from another device, or the hub may
// have forgotten it — so it falls through to the ordinary form.
useEffect(() => {
// Nothing to do when a session was restored from storage, or when this is
// a browser. `user` is read once here on purpose: this runs at startup and
// must not re-fire when the session it just created lands.
if (deviceTried || user) { setDeviceTried(true); return; }
let cancelled = false;
(async () => {
try {
// `loadAuth` keeps the username even when the tokens in it are stale,
// and `app://meshbay` is a stable origin, so localStorage survives a
// relaunch. A fresh install has nothing here and asks for a passphrase,
// which is right: the first sign-in is what registers the device.
const saved = loadAuth();
const username = saved && saved.username;
if (!username) return;
const signed = await platform.device.sign(username);
if (!signed) return;
const data = await hubFetch('/v1/users/auth', {
method: 'POST',
body: { username, timestamp: signed.timestamp,
signature: signed.signature },
});
const me = await hubFetch('/v1/users/me', { token: data.access_token });
if (cancelled) return;
const u = { username, userId: me.user_id, token: data.access_token,
refreshToken: data.refresh_token, role: me.role };
setAuth(u);
setUser(u);
} catch {
// Falls through to the sign-in form, which is the honest outcome.
} finally {
if (!cancelled) setDeviceTried(true);
}
})();
return () => { cancelled = true; };
}, []);
useEffect(() => { setMenuOpen(false); }, [route]);
const changeTheme = useCallback((val) => {
setTheme(val);
}, []);
/**
* Register this device's hub key, once, after a passphrase sign-in.
*
* Deliberately not fatal: a hub that refuses it, or a machine with no key
* storage, means the passphrase is asked for again next time — which is
* exactly what a browser does, and is a worse experience rather than a
* broken one.
*/
const registerThisDevice = useCallback(async (token) => {
if (!platform.device.available) return;
try {
const backend = await platform.secrets.backend();
if (backend === 'unavailable') return;
const pk = await platform.device.ensure();
if (!pk) return;
await hubFetch('/v1/users/devices', {
method: 'POST', token,
body: { pk_auth_ed25519: pk, label: t('device.this_device') },
});
} catch (err) {
console.warn('device not registered:', err.message);
}
}, []);
const authCtx = {
user,
login: async (username, password) => {
let token, refreshToken;
if (window.MeshBayKeys) {
const data = await window.MeshBayKeys.loginAndRecover(username, password);
token = data.accessToken;
refreshToken = data.refreshToken;
// The only thing sign-in produces: the key that opens a node's bundle.
// Which identity we use is decided per node, when we get there.
session.bundleKey = data.bundleKey;
await _storeBundleKey(session.bundleKey);
} else {
const data = await hubFetch('/v1/users/login', {
method: 'POST',
body: { username, password },
});
token = data.access_token;
refreshToken = data.refresh_token;
}
const me = await hubFetch('/v1/users/me', { token });
const u = { username, userId: me.user_id, token, refreshToken, role: me.role };
// On a desktop build, remember this device so the next launch does not ask
// for the passphrase again. The key is generated and held by the main
// process; what travels here is only its public half.
await registerThisDevice(token);
// setAuth, not saveAuth: it is the one writer that also updates the copy
// hubFetch renews from. Storing the session without it left the renewal
// path with no refresh token to present.
setAuth(u);
setUser(u);
},
logout: () => {
// Navigating away leaves transfers running; signing out does not. They
// are moving data on tokens that are about to stop being ours.
transfers.reset();
setAuth(null);
setUser(null);
setGroups([]);
navigate('/login');
},
};
// Group membership is baked into the access token at login and the hub does not
// push updates, so someone invited after they signed in carries a token that
// says they are in nothing. Refreshing re-reads membership from the database.
// Goes through refreshAccessToken like everything else. It used to call the
// endpoint here and keep only the access token, dropping the rotated refresh
// token that came back with it — so the refresh token was spent on first use,
// and presenting the spent one again revoked the whole family. Which is how
// a session that should last a month ended at "invalid token" with signing
// out as the only way back.
const refreshAuth = useCallback(() => refreshAccessToken(), []);
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. Deliberately not
// defaulted to meshbay.org: a client that picks its own hub is a client that
// can be pointed at one.
if (needsHub) {
page = html`<${FirstRunPage} onSet=${() => setNeedsHub(false)} />`;
} else if (!deviceTried) {
// Signing in with this device's key. Showing a form here would be showing
// one the user is about to be taken past.
page = html`
${musicQueue && html`
<${MusicPlayerBar}
getConnection=${getMusicConnection}
queue=${musicQueue}
userPrefs=${userPrefs}
onClose=${handleStopMusic} />
`}
/>
`;
}
// ── Boot ─────────────────────────────────────────────────────────────────────
// Catalogues are fetched, so the first render waits for one: mounting earlier
// would paint the interface in English and then swap every string. initLocale()
// falls back to English rather than rejecting, so this cannot strand the page.
const mount = () => render(html`<${App} />`, document.getElementById('app'));
initLocale().then(mount, (err) => {
// Nothing in initLocale() is supposed to reject. If something does, an
// English interface is still an interface; an unhandled rejection here is a
// blank page.
console.error('[MeshBay] locale init failed, continuing in English:', err);
mount();
});