Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 27 additions & 19 deletions src/components/menu/Connect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
// stores. The chevron defaults to Info; the Rooms shortcut button opens it on the
// Rooms tab. Clicking the chevron again closes it.
function toggleInfo() {
if ($connectDrawerOpen) connectDrawerOpen.set(false);
else { connectDrawerTab.set('info'); connectDrawerOpen.set(true); }
// the chevron toggles the body and REOPENS the last-viewed tab (connectDrawerTab
// is retained) rather than resetting to Info.
connectDrawerOpen.update((v) => !v);
}
function openRooms() {
connectDrawerTab.set('rooms');
Expand Down Expand Up @@ -135,24 +136,31 @@
<span class="connect-divider"></span>

{#if connState === 'connected'}
<!-- connected: red Disconnect (connection status now lives in the drawer header) -->
<Button
color="red"
id="disconnect-button"
class="nob shrink-0 rounded-lg bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:text-gray-100 dark:hover:bg-red-800"
on:click={disconnect}
title="Leave the session (your local scene is kept)">Disconnect</Button
>
<!-- connected: a GRAY disabled input keeps the row the SAME width as idle (so
the drawer, which matches the pill width, never reflows), + red Disconnect.
Connection status lives in the drawer header. -->
<div class="cx-connect inline-flex rounded-md shadow-sm">
<Input type="text" disabled placeholder="" class="nob cx-input rounded-r-none border-0 opacity-60" value="" />
<Button
color="red"
id="disconnect-button"
class="nob shrink-0 rounded-l-none rounded-r-lg bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:text-gray-100 dark:hover:bg-red-800"
on:click={disconnect}
title="Leave the session (your local scene is kept)">Disconnect</Button
>
</div>
{:else if connState === 'pending'}
<!-- pending: request out, waiting for their approval. Amber = reversible
abort (red stays reserved for Disconnect). -->
<Button
color="yellow"
id="cancel-request-button"
class="nob shrink-0 rounded-lg bg-amber-500 text-white hover:bg-amber-600 dark:bg-amber-600 dark:text-gray-900 dark:hover:bg-amber-500"
on:click={cancelPending}
title="Cancel the connection request">Cancel</Button
>
<!-- pending: same gray disabled input for a stable width + amber Cancel -->
<div class="cx-connect inline-flex rounded-md shadow-sm">
<Input type="text" disabled placeholder="" class="nob cx-input rounded-r-none border-0 opacity-60" value="" />
<Button
color="yellow"
id="cancel-request-button"
class="nob shrink-0 rounded-l-none rounded-r-lg bg-amber-500 text-white hover:bg-amber-600 dark:bg-amber-600 dark:text-gray-900 dark:hover:bg-amber-500"
on:click={cancelPending}
title="Cancel the connection request">Cancel</Button
>
</div>
{:else}
<!-- idle: dial a peer — the input shrinks (down to cx-input min-width) so
the Connect button stays visible when the row is tight -->
Expand Down
33 changes: 25 additions & 8 deletions src/components/menu/ConnectInfoDrawer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import { peerQuality, qColor } from '$lib/networkQuality';
import { peerServerStatus, peerServerPingUrl, peerServerPeersUrl } from '$lib/peerServer';
import { cancelOutboundRequest } from '$lib/peerApproval';
import { drawerSlot } from '$lib/cloudHooks';
import { drawerSlot, rolesInfo } from '$lib/cloudHooks';
import CloudSlot from '../CloudSlot.svelte';

/** @type {{ onClose?: () => void }} */
Expand All @@ -33,11 +33,12 @@
// --- Toasts tab = LIVE toasts (approvals + transient messages). The viewport copy
// (Toasts.svelte, hidden while the drawer is open) owns each toast's expiry timer,
// so here we just render the shared stores; actions mutate the same stores. ---
function approveRequest(/** @type {any} */ approval) {
function approveRequest(/** @type {any} */ approval, /** @type {string|null} */ role) {
pendingApprovals.set(/** @type {any} */ ($pendingApprovals).filter((/** @type {any} */ p) => p.peerId !== approval.peerId));
$userdata.push([approval.peerId, '', '']);
$peers?.send?.({ type: 'userdata', userdata: $userdata });
$peers?.connectToPeer?.(approval.peerId, true);
if (role && $rolesInfo?.setRole) $rolesInfo.setRole(approval.peerId, role);
}
function rejectRequest(/** @type {any} */ approval) {
pendingApprovals.set(/** @type {any} */ ($pendingApprovals).filter((/** @type {any} */ p) => p.peerId !== approval.peerId));
Expand All @@ -54,8 +55,17 @@
if ($connectDrawerTab === 'rooms' && !hasRooms) connectDrawerTab.set('info');
});

/** @param {'info'|'rooms'|'toasts'} t */
const setTab = (t) => connectDrawerTab.set(t);
/** Click a tab: open the body to it. Click the ACTIVE tab (while open) collapses
* the body — so a pinned drawer's tab bar stays but nothing is highlighted, and
* clicking away (outside-close) likewise clears the highlight.
* @param {'info'|'rooms'|'toasts'} t */
function setTab(t) {
if ($connectDrawerOpen && $connectDrawerTab === t) connectDrawerOpen.set(false);
else {
connectDrawerTab.set(t);
connectDrawerOpen.set(true);
}
}

// Close on outside pointerdown via a WINDOW listener — a fixed click-catcher
// would be sized to the pill, not the viewport: .connect-wrap's translateX makes
Expand Down Expand Up @@ -161,11 +171,11 @@
transition:slide={{ duration: 200, easing: cubicOut }}
>
<div class="cxd-tabs" role="tablist">
<button class="cxd-tab" class:active={$connectDrawerTab === 'info'} role="tab" aria-selected={$connectDrawerTab === 'info'} onclick={() => setTab('info')}>Info</button>
<button class="cxd-tab" class:active={$connectDrawerOpen && $connectDrawerTab === 'info'} role="tab" aria-selected={$connectDrawerOpen && $connectDrawerTab === 'info'} onclick={() => setTab('info')}>Info</button>
{#if hasRooms}
<button class="cxd-tab" class:active={$connectDrawerTab === 'rooms'} role="tab" aria-selected={$connectDrawerTab === 'rooms'} onclick={() => setTab('rooms')}>Rooms</button>
<button class="cxd-tab" class:active={$connectDrawerOpen && $connectDrawerTab === 'rooms'} role="tab" aria-selected={$connectDrawerOpen && $connectDrawerTab === 'rooms'} onclick={() => setTab('rooms')}>Rooms</button>
{/if}
<button class="cxd-tab" class:active={$connectDrawerTab === 'toasts'} role="tab" aria-selected={$connectDrawerTab === 'toasts'} onclick={() => setTab('toasts')}>
<button class="cxd-tab" class:active={$connectDrawerOpen && $connectDrawerTab === 'toasts'} role="tab" aria-selected={$connectDrawerOpen && $connectDrawerTab === 'toasts'} onclick={() => setTab('toasts')}>
Toasts{#if $pendingApprovals.length + $toastStore.length > 0}<span class="cxd-tab-badge" class:req={$pendingApprovals.length > 0}>{Math.min($pendingApprovals.length + $toastStore.length, 9)}{$pendingApprovals.length + $toastStore.length > 9 ? '+' : ''}</span>{/if}
</button>
<span class="flex-1"></span>
Expand Down Expand Up @@ -200,7 +210,8 @@
<li class="cxd-toast cxd-live" data-kind="request">
<div class="cxd-toast-text">Connection request from <span class="cxd-mono">{String(a.peerId).toUpperCase()}</span></div>
<div class="cxd-live-actions">
<button class="cxd-approve" onclick={() => approveRequest(a)}>Approve</button>
<button class="cxd-approve" onclick={() => approveRequest(a, null)}>Approve</button>
{#if $rolesInfo}<button class="cxd-approve cxd-approve-edit" onclick={() => approveRequest(a, 'editor')}>+ edit</button>{/if}
<button class="cxd-reject" onclick={() => rejectRequest(a)}>Reject</button>
</div>
</li>
Expand Down Expand Up @@ -423,6 +434,12 @@
.cxd-approve:hover {
background: #1d4ed8;
}
.cxd-approve-edit {
background: #7c3aed;
}
.cxd-approve-edit:hover {
background: #6d28d9;
}
.cxd-reject {
background: rgb(75 85 99 / 0.8);
}
Expand Down
126 changes: 60 additions & 66 deletions src/components/menu/Toasts.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { peers, loading, loadingcount, pendingApprovals, waitingForApproval, userdata, toastStore, fixLight, showSidebar, specatorMode, restorePanels, appNotice, connectDrawerOpen, toastsInDrawerOnly } from '../../stores/appStore'
import { restoreAvailable, restoreSnapshot, dismissRestore } from '$lib/autosave'
import { cancelOutboundRequest } from '$lib/peerApproval'
import { rolesInfo } from '$lib/cloudHooks'
import { sceneCommand } from '$lib/commandsHandler.svelte';
import { objectsGroup, camSave, globalCamera, globalScene } from '../../stores/sceneStore.js';
import { Progressbar, Toast, Button } from 'flowbite-svelte';
Expand Down Expand Up @@ -57,6 +58,25 @@ function timeout() {
if (--counter > 0) return setTimeout(timeout, 4000);
toastStatus = false;
}

// Approve an incoming connection request. `role` (cloud roles) optionally grants the
// joiner a role right away — "Approve + edit" makes them an editor instead of the
// default viewer. A 'retry' request just re-establishes an existing whitelisted conn.
function approvePeer(approval, role) {
$pendingApprovals = $pendingApprovals.filter((p) => p.peerId !== approval.peerId);
if (approval.status === 'retry') {
try { $peers.connections[approval.peerId]?.close(); } catch {}
} else {
$userdata.push([approval.peerId, '', '']);
}
$peers.send({ type: 'userdata', userdata: $userdata });
$peers.connectToPeer(approval.peerId, true);
if (role && $rolesInfo?.setRole) $rolesInfo.setRole(approval.peerId, role);
}
function rejectPeer(approval) {
$pendingApprovals = $pendingApprovals.filter((p) => p.peerId !== approval.peerId);
try { $peers.connections[approval.peerId]?.close?.(); } catch {}
}
</script>
<!-- E1: CRITICAL container — connection requests + pending outbound requests stay
ABOVE modals (--z-toast) so an approval is never missed while a modal is open. -->
Expand All @@ -65,74 +85,22 @@ class:cxd-hidden={hideCritical}
style="left: 50%; max-width: 500px; transform: translate(-50%, 0%); z-index: var(--z-toast); pointer-events: none;"
>
{#each $pendingApprovals as approval}
<div class="my-1">
{#if approval.status != 'retry'}
<Toast transition={fly} class="p-2 rounded-lg dark:bg-green-800 dark:border-dark-700 border-2 border-green-500" divClass="flex items-center gap-3">
<div style="position: relative; left: 50%; transform: translate(-25%, -50%);">

</div>
<div class="mb-1 text-base font-medium text-green-700 dark:text-green-500 inline-flex items-center">

<p class="text-sm font-medium text-gray-500 dark:text-gray-200 pr-4 overflow-hidden max-w-80">
Connection request from peer:&nbsp;{approval.peerId}
</p>


<Button
color="primary"
class="nob rounded bg-blue-500 text-white dark:bg-green-600 dark:text-gray-200 dark:hover:bg-green-700"
onclick={() => {
// Remove approved peer from pending approvals
$pendingApprovals = $pendingApprovals.filter(peer => peer.peerId !== approval.peerId);

// Add peer to user data (whitelist)
let data = [approval.peerId, '', '']
$userdata.push(data);

// Broadcast updated whitelist to all connected peers
$peers.send({type: 'userdata', userdata: $userdata})

// Simply connect as requester whitelisted us
$peers.connectToPeer(approval.peerId, true);
}}
>Approve</Button
>

<div class="my-1 cxreq" transition:fly={{ y: -8, duration: 180 }}>
<div class="cxreq-top">
<span class="cxreq-icon">🔗</span>
<div class="cxreq-text">
<div class="cxreq-title">{approval.status === 'retry' ? 'Reconnect request' : 'Connection request'}</div>
<div class="cxreq-id">{String(approval.peerId).toUpperCase()}</div>
</div>
</div>
</Toast>
{:else}
<Toast transition={fly} class="p-2 rounded-lg dark:bg-green-800 dark:border-dark-700 border-2 border-green-500" divClass="flex items-center gap-3">
<div style="position: relative; left: 50%; transform: translate(-25%, -50%);">

<div class="cxreq-actions">
<button class="cxreq-btn cxreq-approve" onclick={() => approvePeer(approval, null)}
title={$rolesInfo ? 'Approve as a view-only viewer' : 'Approve the connection'}>Approve</button>
{#if $rolesInfo && approval.status !== 'retry'}
<button class="cxreq-btn cxreq-edit" onclick={() => approvePeer(approval, 'editor')} title="Approve and grant edit access">Approve + edit</button>
{/if}
<button class="cxreq-btn cxreq-reject" onclick={() => rejectPeer(approval)} title="Decline">Reject</button>
</div>
<div class="mb-1 text-base font-medium text-green-700 dark:text-green-500 inline-flex items-center">

<p class="text-sm font-medium text-gray-500 dark:text-gray-200 pr-4 overflow-hidden max-w-80">
Connection &nbsp;{approval.peerId} already exists
</p>


<Button
color="primary"
class="nob rounded bg-blue-500 text-white dark:bg-green-600 dark:text-gray-200 dark:hover:bg-green-700"
onclick={() => {
console.log($peers.connections[approval.peerId])
$peers.connections[approval.peerId].close();
// Remove approved peer from pending approvals
$pendingApprovals = $pendingApprovals.filter(peer => peer.peerId !== approval.peerId);

// Broadcast updated whitelist to all connected peers
$peers.send({type: 'userdata', userdata: $userdata})

// Simply connect as requester whitelisted us
$peers.connectToPeer(approval.peerId, true);
}}
>Retry</Button
>

</div>
</Toast>
{/if}
</div>
{/each}

Expand Down Expand Up @@ -337,6 +305,32 @@ style="left: 50%; max-width: 500px; transform: translate(-50%, 0%); z-index: var
.cxd-hidden {
display: none !important;
}
/* redesigned connection-request card (replaces the off-pattern green toast) */
.cxreq {
pointer-events: auto;
width: min(340px, 92vw);
margin: 4px auto 0;
background: rgb(17 24 39 / 0.98);
border: 1px solid rgb(255 255 255 / 0.12);
border-left: 3px solid #f59e0b;
border-radius: 12px;
padding: 10px 12px;
box-shadow: 0 12px 30px rgb(0 0 0 / 0.45);
backdrop-filter: blur(6px);
}
.cxreq-top { display: flex; align-items: center; gap: 10px; }
.cxreq-icon { font-size: 18px; flex: 0 0 auto; }
.cxreq-text { min-width: 0; }
.cxreq-title { font-size: 13px; font-weight: 600; color: #f3f4f6; }
.cxreq-id { font-size: 11px; color: #9ca3af; font-family: ui-monospace, monospace; }
.cxreq-actions { display: flex; gap: 6px; margin-top: 8px; }
.cxreq-btn { font-size: 12px; padding: 5px 8px; border-radius: 7px; border: 0; cursor: pointer; color: #fff; }
.cxreq-approve { flex: 1 1 auto; background: #2563eb; }
.cxreq-approve:hover { background: #1d4ed8; }
.cxreq-edit { flex: 1 1 auto; background: #7c3aed; }
.cxreq-edit:hover { background: #6d28d9; }
.cxreq-reject { flex: 0 0 auto; background: rgb(75 85 99 / 0.8); }
.cxreq-reject:hover { background: rgb(107 114 128 / 0.9); }
/* narrow: full-width connect bar (row 1) + logo/profile (row 2) sit above; keep
toasts below both */
@media (max-width: 640px) {
Expand Down
31 changes: 26 additions & 5 deletions src/components/menu/Users.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import ContextMenu from '../ContextMenu.svelte';
import NotificationCenter from './NotificationCenter.svelte';
import CloudSlot from '../CloudSlot.svelte';
import { usersSlot, profileSlot } from '$lib/cloudHooks';
import { usersSlot, profileSlot, rolesInfo } from '$lib/cloudHooks';

// N3: latency-band dot color for a peer's network-quality indicator
const qColor = (level: string) =>
Expand Down Expand Up @@ -98,6 +98,9 @@
const effName = $derived($username || cid?.username || 'Anonymous');
/** avatar src: session upload > stored custom > cloud account > default */
const effAvatar = $derived(avatarImage || ls('avatar') || cid?.avatar || '');
/** cloud roles bridge (null without the cloud plugin) */
const ri = $derived($rolesInfo);
function setPeerRole(id: string, e: any) { $rolesInfo?.setRole?.(id, e.target.value); }

function broadcastUserdata() {
if (!$peers?.peer) return;
Expand Down Expand Up @@ -211,7 +214,7 @@
>
<div class="flex -space-x-2">
{#each $userdata.slice(1, 4) as user (user[0])}
<Avatar stacked src={user[2]} class="h-7 w-7 border-2 border-gray-800" />
<Avatar stacked src={user[2]} class="h-7 w-7 rounded-full border-2 border-gray-800" />
{/each}
</div>
<span class="pr-1 text-xs font-semibold text-gray-200">{$userdata.length - 1}</span>
Expand All @@ -221,16 +224,17 @@
<div class="fixed inset-0" style="z-index: 996;" role="presentation" onclick={() => (peersOpen = false)}></div>
<div id="peers-popover" class="ui-panel absolute right-0 top-11 w-72 p-2" style="z-index: 998;">
<p class="ui-section-label">Connected ({$userdata.length - 1})</p>
<div class="peers-scroll">
{#each $userdata as user, i (user[0])}
<div
class="peers-row flex items-center gap-2 rounded px-1.5 py-1 {$specatorMode === user[0]
? 'bg-primary-800/60'
: 'hover:bg-gray-700/60'}"
>
<Avatar src={user[2]} class="h-8 w-8 shrink-0" />
<Avatar src={user[2]} class="h-8 w-8 shrink-0 rounded-full" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1 truncate text-sm text-gray-100">
<span class="truncate">{user[1] || 'Peer'}</span>
<span class="truncate" title={user[1] || 'Peer'}>{user[1] || 'Peer'}</span>
{#if i === 0}<span class="text-[10px] text-primary-300">(you)</span>{/if}
</div>
<div class="flex items-center gap-1.5 text-[10px] text-gray-400">
Expand All @@ -248,6 +252,17 @@
{/if}
</div>
</div>
{#if ri}
{#if i === 0}
<span class="role-badge" data-role={ri.myRole} title="Your role">{ri.myRole}</span>
{:else if ri.amAdmin}
<select class="role-sel" title="Set role" onchange={(e) => setPeerRole(user[0], e)}>
{#each ri.order as r}<option value={r} selected={ri.roleOf(user[0]) === r}>{r}</option>{/each}
</select>
{:else}
<span class="role-badge" data-role={ri.roleOf(user[0])}>{ri.roleOf(user[0])}</span>
{/if}
{/if}
{#if i > 0}
<button
class="peer-watch shrink-0 rounded px-2 py-0.5 text-xs {$specatorMode === user[0]
Expand All @@ -262,6 +277,7 @@
{/if}
</div>
{/each}
</div>
<!-- open-core (M1d): cloud plugin roles section. Empty in the OSS
build; the cloud plugin fills it via cloudApi.mountUsersSection(). -->
{#if $usersSlot}
Expand Down Expand Up @@ -423,7 +439,12 @@
}
/* pin the peers list to the viewport so it can't spill off the left edge
when the trigger sits near a narrow screen's right edge */
#peers-popover {
.peers-scroll { max-height: 264px; overflow-y: auto; }
.role-badge { flex: 0 0 auto; font-size: 10px; padding: 1px 7px; border-radius: 9999px; color: #fff; background: rgb(107 114 128); text-transform: capitalize; }
.role-badge[data-role='editor'] { background: #2563eb; }
.role-badge[data-role='admin'] { background: #7c3aed; }
.role-sel { flex: 0 0 auto; font-size: 10px; padding: 2px 4px; border-radius: 6px; border: 0; background: #374151; color: #fff; text-transform: capitalize; }
#peers-popover {
position: fixed;
top: 122px;
right: 8px;
Expand Down
7 changes: 7 additions & 0 deletions src/lib/cloudHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,10 @@ export const drawerSlot = writable(null);
* here (moved out of the Connect pill).
* @type {import('svelte/store').Writable<((el: HTMLElement) => (() => void) | void) | null>} */
export const profileSlot = writable(null);

/** Roles bridge (2026-07-25): the cloud plugin publishes the live roles so CORE can
* render a per-peer role control next to Watch, gate viewer actions, etc. Shape:
* `{ myId, myRole, amAdmin, order:[…], roleOf(id), setRole(id,role) }` or null (no
* plugin). Kept a plain object so a separately-built plugin owns the logic.
* @type {import('svelte/store').Writable<any>} */
export const rolesInfo = writable(null);
Loading