From 0d258049120edb010cb50610aed6eb1292efafc0 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Sat, 25 Jul 2026 15:46:45 +0300 Subject: [PATCH] [fix] connect/roles/avatar batch: pill width, pinned tabs, per-peer roles, redesigned request toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core half of a fix batch (pairs with the cloud plugin PR). - Pill WIDTH is now stable across states: connected/pending keep a gray disabled input the same width as idle, so the drawer (which matches the pill width) never reflows on connect. - PINNED-tab UX: the chevron reopens the LAST-viewed tab (not Info); clicking a tab opens the body to it; clicking the active tab collapses the body; the highlight only shows while the body is open (so a pinned bar with a closed body shows no active tab, and clicking away clears it). - ROLES bridge: new cloudHooks `rolesInfo` (+ cloudApi setRolesInfo) lets the plugin publish live roles. Core renders a per-peer role control in the peers popover NEXT TO Watch (a badge, or a select for the admin), shows your own role, adds a name TOOLTIP, and the peer list SCROLLS (max ~6-7 rows) instead of growing unbounded. Peer avatars get rounded-full (they were square when a peer had no picture). - Connection-request toast REDESIGNED (off-pattern green flowbite → a clean dark card): Approve / Approve + edit (grants editor via rolesInfo when the cloud plugin is on) / Reject. Same actions in the drawer's Toasts tab. svelte-check 485/72 (under the 489/72 baseline). Verified headless: pinned-tab UX (open/collapse/click-away/chevron-reopens-last), plugin registration, no runtime errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/menu/Connect.svelte | 46 ++++--- src/components/menu/ConnectInfoDrawer.svelte | 33 +++-- src/components/menu/Toasts.svelte | 126 +++++++++---------- src/components/menu/Users.svelte | 31 ++++- src/lib/cloudHooks.js | 7 ++ src/lib/cloudPlugin.js | 6 +- 6 files changed, 150 insertions(+), 99 deletions(-) diff --git a/src/components/menu/Connect.svelte b/src/components/menu/Connect.svelte index 55d9178d..91594a1f 100644 --- a/src/components/menu/Connect.svelte +++ b/src/components/menu/Connect.svelte @@ -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'); @@ -135,24 +136,31 @@ {#if connState === 'connected'} - - + +
+ + +
{:else if connState === 'pending'} - - + +
+ + +
{:else} diff --git a/src/components/menu/ConnectInfoDrawer.svelte b/src/components/menu/ConnectInfoDrawer.svelte index 6671c589..34929fcd 100644 --- a/src/components/menu/ConnectInfoDrawer.svelte +++ b/src/components/menu/ConnectInfoDrawer.svelte @@ -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 }} */ @@ -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)); @@ -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 @@ -161,11 +171,11 @@ transition:slide={{ duration: 200, easing: cubicOut }} >
- + {#if hasRooms} - + {/if} - @@ -200,7 +210,8 @@
  • Connection request from {String(a.peerId).toUpperCase()}
    - + + {#if $rolesInfo}{/if}
  • @@ -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); } diff --git a/src/components/menu/Toasts.svelte b/src/components/menu/Toasts.svelte index 4bc6535f..54e43db5 100644 --- a/src/components/menu/Toasts.svelte +++ b/src/components/menu/Toasts.svelte @@ -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'; @@ -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 {} +} @@ -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} -
    -{#if approval.status != 'retry'} - -
    - -
    -
    - -

    - Connection request from peer: {approval.peerId} -

    - - - - +
    +
    + 🔗 +
    +
    {approval.status === 'retry' ? 'Reconnect request' : 'Connection request'}
    +
    {String(approval.peerId).toUpperCase()}
    +
    - -{:else} - -
    - +
    + + {#if $rolesInfo && approval.status !== 'retry'} + + {/if} +
    -
    - -

    - Connection  {approval.peerId} already exists -

    - - - - -
    - -{/if}
    {/each} @@ -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) { diff --git a/src/components/menu/Users.svelte b/src/components/menu/Users.svelte index fca77e70..e9650ec5 100644 --- a/src/components/menu/Users.svelte +++ b/src/components/menu/Users.svelte @@ -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) => @@ -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; @@ -211,7 +214,7 @@ >
    {#each $userdata.slice(1, 4) as user (user[0])} - + {/each}
    {$userdata.length - 1} @@ -221,16 +224,17 @@
    +
    {#each $userdata as user, i (user[0])}
    - +
    - {user[1] || 'Peer'} + {user[1] || 'Peer'} {#if i === 0}(you){/if}
    @@ -248,6 +252,17 @@ {/if}
    + {#if ri} + {#if i === 0} + {ri.myRole} + {:else if ri.amAdmin} + + {:else} + {ri.roleOf(user[0])} + {/if} + {/if} {#if i > 0}