diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
| commit | 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch) | |
| tree | b13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/group-settings.js | |
| parent | 8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff) | |
| download | meshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz | |
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).
Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.
Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:
- group-page.js called onRefreshAuth on a stale-token handshake rejection,
but app.js never imported refreshAccessToken from hub-client.js — so a
brand new member (including a group's own creator) hit "Not a member of
this group" and the retry silently failed, throwing before it could
refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
it from i18n.js. Opening Chat on a group with real messages threw mid-
render; uncaught, that appears to wedge Preact's render scheduler, so
every button on the page stopped responding until reload.
Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-settings.js | 635 |
1 files changed, 635 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js new file mode 100644 index 0000000..a14fd21 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -0,0 +1,635 @@ +import { + html, useState, useEffect, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { hubFetch, navigate } from './hub-client.js'; +import { APPS } from './apps.js'; +import * as platform from './platform.js'; + +// ── Members Panel ──────────────────────────────────────────────────────── + +/** + * Everything about the group that is not its files or its chat. + * + * Was "Members", which was a list with three unrelated forms stacked on top of + * it and the group's own controls somewhere else entirely — leaving or deleting + * a group lived in the header, beside its title. One tab now, in sections, with + * the roster last: it is the part that grows without limit, and burying the + * controls under two hundred names is how a tab stops being usable. + */ +function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, + isNodeAdmin, userId, operatorPaired, connected, + memberUpload, onMemberUpload, + enabledApps, onEnabledApps, + onPaired, onLeft }) { + const [members, setMembers] = useState([]); + const [adminId, setAdminId] = useState(''); + const [loading, setLoading] = useState(true); + const [inviteUser, setInviteUser] = useState(''); + const [inviting, setInviting] = useState(false); + const [error, setError] = useState(''); + + // Node loopback state (Electron-only) + const [nodeDetected, setNodeDetected] = useState(false); + const [nodeRoots, setNodeRoots] = useState([]); + const [nodeGroupName, setNodeGroupName] = useState(''); + const [nodeBusy, setNodeBusy] = useState(false); + const [nodeMsg, setNodeMsg] = useState(''); + + const loadNodeInfo = useCallback(async () => { + if (!platform.node.available) return; + try { + const detect = await platform.node.detect(); + if (!detect.detected) { setNodeDetected(false); return; } + setNodeDetected(true); + const data = await platform.node.call('GET', '/api/groups'); + const groups = data.groups || []; + const ng = groups.find(g => g.id === groupId); + if (ng) { + setNodeRoots(ng.roots || []); + setNodeGroupName(ng.name || ''); + } + } catch { setNodeDetected(false); } + }, [groupId]); + + useEffect(() => { loadNodeInfo(); }, [loadNodeInfo]); + const [inviteCode, setInviteCode] = useState(null); + const [pairCode, setPairCode] = useState(''); + const [pairStatus, setPairStatus] = useState(''); + const [pairing, setPairing] = useState(false); + // Your own devices on this node. Not a members feature — it is beside them + // because this is where a live connection to the node exists. + const [devices, setDevices] = useState([]); + const [approveCode, setApproveCode] = useState(''); + const [deviceMsg, setDeviceMsg] = useState(''); + + // Pairing lives here rather than in Settings because this is where a live + // connection to the node exists — and it is offered only when the node itself + // says this account is its operator (is_node_admin comes from the authenticated + // handshake_ack, not from the hub). + const loadDevices = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const out = await transport.listDevices(); + setDevices(out.devices); + } catch { /* a node that has none says so by listing none */ } + }, [transportRef]); + + useEffect(() => { loadDevices(); }, [loadDevices]); + + const approveDevice = useCallback(async (e) => { + e.preventDefault(); + const code = approveCode.trim(); + if (!code) return; + setDeviceMsg(''); + try { + await transportRef.current.approveDevice(userId, code); + setApproveCode(''); + setDeviceMsg(t('device.approved')); + await loadDevices(); + } catch (err) { setDeviceMsg(err.message); } + }, [approveCode, userId, transportRef, loadDevices]); + + const revokeDevice = useCallback(async (device) => { + if (!confirm(t('device.revoke_confirm'))) return; + setDeviceMsg(''); + try { + await transportRef.current.revokeDevice( + userId, device.pk_ed25519, device.pk_x25519 || ''); + await loadDevices(); + } catch (err) { setDeviceMsg(err.message); } + }, [userId, transportRef, loadDevices]); + + const doPair = useCallback(async (e) => { + e.preventDefault(); + const code = pairCode.trim(); + if (!code) return; + setPairing(true); + setPairStatus(''); + try { + const transport = transportRef && transportRef.current; + if (!transport || !transport.connected) throw new Error('Not connected to the node'); + await transport.pairOperator(userId, code); + setPairCode(''); + setPairStatus('paired'); + // The node has pinned this key as an operator key; the form has nothing + // left to do. It used to stay put through a refresh, because what governed + // it was the account, which pairing does not change. + if (onPaired) onPaired(); + } catch (err) { + setPairStatus(err.message); + } finally { + setPairing(false); + } + }, [pairCode, transportRef, userId]); + + const [uploadBusy, setUploadBusy] = useState(false); + const [uploadMsg, setUploadMsg] = useState(''); + + /** + * Close or open uploading for everyone who is not the operator. + * + * Signed, like removing a member: the node refuses an unsigned instruction, + * so this is a request to the node rather than a decision taken here. The + * button does not move until the node has said it did it. + */ + const setUploads = useCallback(async (allowed) => { + const transport = transportRef && transportRef.current; + setUploadMsg(''); + setUploadBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setMemberUpload(allowed, signFn); + if (onMemberUpload) onMemberUpload(allowed); + } catch (err) { + setUploadMsg(err.message); + } finally { + setUploadBusy(false); + } + }, [transportRef, onMemberUpload]); + + const [appsBusy, setAppsBusy] = useState(false); + const [appsMsg, setAppsMsg] = useState(''); + const activeApps = enabledApps && enabledApps.length ? enabledApps : APPS.map(a => a.key); + + /** + * Toggle one app in or out of the group's enabled set. Same shape as + * `setUploads`: signed, and the checkbox does not move until the node has + * said it did it. Refuses to submit an empty set client-side — the node + * refuses it too, but there is no reason to make a round trip to learn that. + */ + const toggleApp = useCallback(async (key) => { + const next = activeApps.includes(key) + ? activeApps.filter(k => k !== key) + : [...activeApps, key]; + if (next.length === 0) { + setAppsMsg(t('members.apps_need_one')); + return; + } + const transport = transportRef && transportRef.current; + setAppsMsg(''); + setAppsBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setAppsEnabled(next, signFn); + if (onEnabledApps) onEnabledApps(next); + } catch (err) { + setAppsMsg(err.message); + } finally { + setAppsBusy(false); + } + }, [transportRef, onEnabledApps, activeApps]); + + const [removing, setRemoving] = useState(''); + + /** + * Take someone out of this group: both halves, in the order that fails safe. + * + * The node first, because that is the half that stops the group key being + * wrapped for them; if the hub removal then fails, they are a member on paper + * with no key. The other order would leave them able to reach a node that + * still serves them. + */ + const removeMember = useCallback(async (member) => { + const transport = transportRef && transportRef.current; + setError(''); + setRemoving(member.user_id); + try { + if (platform.node.available) { + try { + await platform.node.call('POST', + `/api/members/${member.user_id}/revoke?group_id=${groupId}`); + } catch { /* best effort — node may not host this group */ } + try { + await platform.node.call('POST', + `/api/members/${member.user_id}/unpin`); + } catch { /* best effort */ } + } else if (transport && transport.connected && operatorPaired) { + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.revokeMember(member.user_id, signFn); + } + await hubFetch(`/v1/groups/${groupId}/members/${member.username}`, { + method: 'DELETE', token, + }); + loadMembers(); + } catch (err) { + setError(err.message); + } finally { + setRemoving(''); + } + }, [groupId, token, transportRef, operatorPaired]); + + const loadMembers = useCallback(() => { + setLoading(true); + hubFetch(`/v1/groups/${groupId}/members`, { token }) + .then(data => { + setMembers(data.members || []); + setAdminId(data.admin_id || ''); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [groupId, token]); + + useEffect(() => { loadMembers(); }, [loadMembers]); + + const isAdmin = group && group.is_admin; + + const doInvite = useCallback(async (e) => { + e.preventDefault(); + if (!inviteUser.trim()) return; + setInviting(true); + setError(''); + setInviteCode(null); + try { + const transport = transportRef && transportRef.current; + const username = inviteUser.trim(); + if (!transport || !transport.connected) { + throw new Error('Not connected to the node — it must be online to invite'); + } + + // The hub is asked for the account id, and nothing else. It is no longer + // asked for the invitee's public key: the node wraps the group key itself, + // for a key the invitee proves possession of when they connect (H3). A hub + // that answered with the wrong account here would produce an invite whose + // code it never learns — the code goes to a human, out of band. + const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); + + // Signed with the identity this node pinned for us — the only one it + // will accept, and the only one we hold here. + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + const result = await transport.createInvite( + account.user_id, groupId, username, signFn); + + // Membership on the hub is what lets them reach the node at all; the code + // is what gets them the key. + await hubFetch(`/v1/groups/${groupId}/members/${username}`, { + method: 'POST', token, body: {}, + }); + + setInviteCode({ username, code: result.code, expires: result.expires_at }); + setInviteUser(''); + loadMembers(); + } catch (err) { + setError(err.message); + } finally { + setInviting(false); + } + }, [groupId, token, inviteUser, loadMembers, transportRef]); + + if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`; + + const isOwner = Boolean(isAdmin); + + return html` + <div class="members-panel"> + ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} + + ${/* Inviting needs the node: it is the node that wraps the group key and + issues the code, not the hub. Public groups admit anyone — no invite. + The form stays in the DOM so a brief reconnect does not destroy the + input the user is typing into — controls are disabled instead. */ + isAdmin && group?.join_policy !== 'open' && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.invite_title')}</h3> + ${!connected ? html` + <p class="settings-hint">${t('group.offline_title')}</p> + ` : !operatorPaired ? html` + <p class="settings-hint"> + ${isNodeAdmin ? t('members.invite_needs_pairing') + : t('members.invite_ask_operator')} + </p> + ` : ''} + <form onSubmit=${doInvite}> + ${inviteCode && html` + <div class="success-msg" style="margin-bottom:8px"> + <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p> + <p class="code-display">${inviteCode.code}</p> + <p>${t('members.invite_code_hint')}</p> + </div> + `} + <div class="form-row"> + <input type="text" placeholder="${t('members.username_placeholder')}" + value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} + disabled=${!connected || !operatorPaired} required /> + <button class="admin-btn" type="submit" + disabled=${inviting || !connected || !operatorPaired}> + ${inviting ? '...' : t('members.invite_btn')} + </button> + </div> + </form> + </div> + `} + + ${isNodeAdmin && !operatorPaired && connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.pair_title')}</h3> + <p class="settings-hint">${t('members.pair_hint')}</p> + ${pairStatus && html` + <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}> + ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus} + </p> + `} + <form class="form-row" onSubmit=${doPair}> + <input type="text" placeholder="XXXX-XXXX" class="code-input" + value=${pairCode} onInput=${e => setPairCode(e.target.value)} required /> + <button class="admin-btn" type="submit" disabled=${pairing}> + ${pairing ? '...' : t('members.pair_btn')} + </button> + </form> + </div> + `} + + ${/* Which group "applications" members see. New ones (Videos, Music, + Photos) show up here automatically as they register in apps.js — + nothing about this section changes to add one. */ + isNodeAdmin && connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.apps_title')}</h3> + <p class="settings-hint">${t('members.apps_hint')}</p> + <ul class="apps-toggle-list"> + ${APPS.map(a => html` + <li key=${a.key} class="settings-row"> + <label class="settings-label"> + <input type="checkbox" checked=${activeApps.includes(a.key)} + disabled=${appsBusy} + onChange=${() => toggleApp(a.key)} /> + ${' '}${t(a.labelKey)} + </label> + </li> + `)} + </ul> + ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`} + </div> + `} + + ${/* Roots management (Electron-only, when node is local) */ + nodeDetected && nodeRoots.length > 0 && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('settings_node.roots')}</h3> + ${nodeMsg && html`<p class="settings-hint">${nodeMsg}</p>`} + <div class="node-roots"> + ${nodeRoots.map(r => html` + <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}" + key=${r.name}> + <div class="node-root-info"> + <span class="node-root-name"> + <${Icon} name="folder" /> + ${r.name} + </span> + ${r.upload && html` + <span class="node-root-badge">${t('node.upload_root')}</span>`} + ${!r.available && html` + <span class="node-root-badge node-root-badge-warn"> + ${t('node.unavailable')}</span>`} + </div> + ${nodeRoots.length > 1 && !r.upload && html` + <button class="btn btn-small btn-danger" + disabled=${nodeBusy} + onClick=${async () => { + if (!confirm(t('node.root_remove_confirm', { name: r.name }))) return; + setNodeBusy(true); setNodeMsg(''); + try { + await platform.node.call('DELETE', + '/api/groups/' + groupId + '/roots/' + encodeURIComponent(r.name)); + await platform.node.call('POST', '/api/reload'); + setNodeMsg(t('node.root_removed')); + await loadNodeInfo(); + } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } + finally { setNodeBusy(false); } + }}> + ${t('node.remove_root')}</button>`} + </div> + `)} + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${nodeBusy} + onClick=${async () => { + const chosen = await platform.rootPicker.choose(); + if (!chosen) return; + setNodeBusy(true); setNodeMsg(''); + try { + await platform.node.call('POST', + '/api/groups/' + groupId + '/roots', + { path: chosen.path, name: chosen.name }); + await platform.node.call('POST', '/api/reload'); + setNodeMsg(t('node.root_added')); + await loadNodeInfo(); + } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } + finally { setNodeBusy(false); } + }}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> + </div> + </div> + `} + + ${/* Operator only, and only with a live connection: the node is what + holds and enforces this, so there is nothing to show or change + without one. */ isNodeAdmin && connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.uploads_title')}</h3> + <div class="settings-row"> + <span class="settings-label"> + ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} + </span> + <button class="admin-btn" disabled=${uploadBusy} + onClick=${() => setUploads(!memberUpload)}> + ${uploadBusy ? '...' + : (memberUpload ? t('members.uploads_disable') + : t('members.uploads_enable'))} + </button> + </div> + <p class="settings-hint">${t('members.uploads_hint')}</p> + ${uploadMsg && html`<p class="error-msg">${uploadMsg}</p>`} + </div> + `} + + ${/* Upload toggle via loopback when MNP not connected */ + nodeDetected && !connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('members.uploads_title')}</h3> + <div class="settings-row"> + <span class="settings-label"> + ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} + </span> + <button class="admin-btn" disabled=${nodeBusy} + onClick=${async () => { + setNodeBusy(true); setNodeMsg(''); + try { + const newVal = !memberUpload; + await platform.node.call('PUT', + '/api/groups/' + groupId + '/member-upload', + { allowed: newVal }); + if (onMemberUpload) onMemberUpload(newVal); + } catch (err) { setNodeMsg(platform.bridgeMessage(err)); } + finally { setNodeBusy(false); } + }}> + ${memberUpload ? t('members.uploads_disable') + : t('members.uploads_enable')} + </button> + </div> + <p class="settings-hint">${t('members.uploads_hint')}</p> + </div> + `} + + ${/* Delete/leave — node detach first (reversible), then hub delete + (irreversible). */ html` + <div class="settings-section"> + <h3 class="settings-heading"> + ${isOwner ? t('group.delete_group') : t('group.leave')} + </h3> + <div class="settings-row"> + <span class="settings-label"> + ${isOwner ? t('members.danger_delete_hint') + : t('members.danger_leave_hint')} + </span> + ${isOwner + ? html` + <button class="admin-btn danger" onClick=${async () => { + if (!confirm(t('group.delete_group_confirm', { name: group.name }))) return; + try { + // Node detach first (reversible), then hub delete (irreversible) + if (nodeDetected && nodeGroupName) { + try { + await platform.node.call('POST', '/api/groups/detach', + { name: nodeGroupName }); + } catch (detachErr) { + if (!confirm(t('settings_node.detach_failed_continue'))) return; + } + } + await hubFetch('/v1/groups/' + groupId, { method: 'DELETE', token }); + navigate('/'); + window.location.reload(); + } catch (err) { setError(err.message); } + }}>${t('group.delete_group')}</button> + ` + : html` + <button class="admin-btn danger" onClick=${async () => { + if (!confirm(t('group.leave_confirm', { name: group.name }))) return; + try { + await hubFetch('/v1/groups/' + groupId + '/leave', + { method: 'POST', token }); + if (onLeft) onLeft(groupId); + } catch (err) { setError(err.message); } + }}>${t('group.leave')}</button> + `} + </div> + </div> + `} + + ${connected && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('device.mine_title')}</h3> + <p class="settings-hint">${t('device.mine_hint')}</p> + ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`} + ${devices.length === 0 + ? html`<p class="settings-hint">${t('device.mine_empty')}</p>` + : html` + <ul class="device-list"> + ${devices.map(d => html` + <li class="device-row" key=${d.pk_ed25519}> + <span class="device-key">${d.pk_ed25519.slice(0, 16)}…</span> + <span class="device-meta"> + ${d.is_this_one && html` + <span class="badge">${t('device.this_one')}</span>${' '} + `} + ${d.pinned_via}${d.label ? ' · ' + d.label : ''} + </span> + ${!d.is_this_one && devices.length > 1 && html` + <button class="admin-btn" onClick=${() => revokeDevice(d)}> + ${t('device.revoke')} + </button> + `} + </li> + `)} + </ul> + `} + <form onSubmit=${approveDevice} class="settings-subform"> + <p class="settings-hint">${t('device.approve_hint')}</p> + <div class="form-row"> + <input type="text" placeholder="XXXX-XXXX" class="code-input" + value=${approveCode} onInput=${e => setApproveCode(e.target.value)} /> + <button class="admin-btn" type="submit">${t('device.approve_btn')}</button> + </div> + </form> + </div> + `} + + <div class="settings-section"> + <h3 class="settings-heading"> + ${t('group.tab_members')} (${members.length}) + </h3> + <table class="admin-table"> + <thead> + <tr> + <th>${t('admin.col_username')}</th> + <th>${t('members.group_role')}</th> + <th></th> + </tr> + </thead> + <tbody> + ${members.map(m => html` + <tr key=${m.user_id}> + <td>${m.username}</td> + <td> + ${m.user_id === adminId + ? html`<span class="badge badge-owner">${t('members.owner')}</span>` + : html`<span class="badge">${t('members.member')}</span>` + } + </td> + <td class="admin-actions"> + ${isAdmin && m.user_id !== adminId && html` + <button class="admin-btn danger" disabled=${removing === m.user_id} + onClick=${() => { + if (!confirm(t('members.remove_confirm', { user: m.username }))) return; + removeMember(m); + }}> + ${removing === m.user_id ? '...' : t('members.remove')} + </button> + `} + </td> + </tr> + `)} + </tbody> + </table> + ${isAdmin && members.length > 1 && html` + <p class="settings-hint">${t('members.remove_hint')}</p> + `} + </div> + </div> + `; +} + +// ── Chat Panel ────────────────────────────────────────────────────────── + +/** + * Message text with its links made clickable. + * + * Only http and https, and built as elements rather than markup: a message is + * something another member wrote, so it must never become HTML. `javascript:` + * and `data:` are not matched at all, and the anchors carry noopener so the new + * tab cannot reach back into this one. + */ +const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi; + + +export { GroupSettingsPanel }; |