From 93dc387e9628428eb7af94417d92f6ba0b565641 Mon Sep 17 00:00:00 2001 From: Professionalarduino-max Date: Fri, 16 Oct 2026 14:17:43 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(collaboration):=20live=20presence=20?= =?UTF-8?q?=E2=80=94=20cursors,=20drag=20highlight,=20sprite=20indicators?= =?UTF-8?q?=20and=20avatars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Figma-style real-time presence to live-collaboration sessions: - dash-presence: ephemeral presence channel (cursor surface/position, open sprite, dragged block) relayed over the collaboration WebSocket as best-effort 'presence' messages — never persisted, available to viewers - PresenceCursors overlay: colored cursors with name labels on the stage and in the blocks workspace (workspace coordinates stay glued across zoom/scroll), plus a colored outline on blocks being dragged remotely - Sprite tiles show colored dots and a ring for sprites open in other participants' editors - PresenceAvatars in the menu bar: who is in the session, who is online - DashCollaboration: sendPresence()/onPresence + connectionId from 'ready' Requires the matching DashBlocks/server change that relays 'presence' messages and includes connectionId in the room 'ready' message. --- src/components/blocks/blocks.css | 1 + .../dash-presence/presence-avatars.css | 41 ++++ .../dash-presence/presence-avatars.jsx | 90 +++++++++ .../dash-presence/presence-cursors.css | 49 +++++ .../dash-presence/presence-cursors.jsx | 130 ++++++++++++ src/components/menu-bar/menu-bar.jsx | 4 + .../sprite-selector-item.css | 30 +++ .../sprite-selector-item.jsx | 19 +- .../sprite-selector/sprite-list.jsx | 6 + .../sprite-selector/sprite-selector.jsx | 6 + src/components/stage/stage.jsx | 2 + src/components/target-pane/target-pane.jsx | 6 + src/containers/blocks.jsx | 96 ++++++++- src/containers/stage.jsx | 36 ++++ src/containers/target-pane.jsx | 33 ++++ src/lib/dash-collaboration-session.js | 6 + src/lib/dash-collaboration.js | 28 ++- src/lib/dash-presence.js | 187 ++++++++++++++++++ 18 files changed, 766 insertions(+), 4 deletions(-) create mode 100644 src/components/dash-presence/presence-avatars.css create mode 100644 src/components/dash-presence/presence-avatars.jsx create mode 100644 src/components/dash-presence/presence-cursors.css create mode 100644 src/components/dash-presence/presence-cursors.jsx create mode 100644 src/lib/dash-presence.js diff --git a/src/components/blocks/blocks.css b/src/components/blocks/blocks.css index 89de82012..98c523bd9 100644 --- a/src/components/blocks/blocks.css +++ b/src/components/blocks/blocks.css @@ -4,6 +4,7 @@ .blocks { height: 100%; + position: relative; } .drag-over:after { diff --git a/src/components/dash-presence/presence-avatars.css b/src/components/dash-presence/presence-avatars.css new file mode 100644 index 000000000..ba1a22875 --- /dev/null +++ b/src/components/dash-presence/presence-avatars.css @@ -0,0 +1,41 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; + +.avatars { + display: flex; + flex-direction: row; + align-items: center; + padding: 0 $space; +} + +.avatar, +.avatar-online { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + margin-left: -0.35rem; + border-radius: 50%; + border: 2px solid $ui-black-transparent; + background-color: $ui-primary; + color: #fff; + font-size: 0.7rem; + font-weight: bold; + user-select: none; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.avatar:first-child, +.avatar-online:first-child { + margin-left: 0; +} + +.avatar { + opacity: 0.55; + filter: grayscale(60%); +} + +.avatar-online { + opacity: 1; +} diff --git a/src/components/dash-presence/presence-avatars.jsx b/src/components/dash-presence/presence-avatars.jsx new file mode 100644 index 000000000..03261003b --- /dev/null +++ b/src/components/dash-presence/presence-avatars.jsx @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import {listenPresence, presenceColor} from '../../lib/dash-presence.js'; +import * as styles from './presence-avatars.css'; + +const MAX_AVATARS = 5; + +/** + * Compact avatar stack for the menu bar showing who is in the live-collaboration + * session and who is currently online (sending presence). + */ +class PresenceAvatars extends React.Component { + constructor (props) { + super(props); + this.state = {version: 0}; + this.handlePresenceChange = this.handlePresenceChange.bind(this); + this.detachPresence = null; + } + componentDidMount () { + this.detachPresence = listenPresence(this.props.vm, this.handlePresenceChange); + } + componentWillUnmount () { + if (this.detachPresence) this.detachPresence(); + } + handlePresenceChange () { + this.setState({version: this.state.version + 1}); + } + getPresence () { + return (this.props.vm.dashCollaboration && this.props.vm.dashCollaboration.presence) || null; + } + render () { + const session = this.props.vm.dashCollaboration; + if (!session) return null; + const presence = this.getPresence(); + const onlinePeers = presence ? presence.getPeers() : []; + const onlineByUser = new Map(); + for (const peer of onlinePeers) { + if (peer.userId) onlineByUser.set(peer.userId, peer); + } + const client = session.client || {}; + const seen = new Set(); + const entries = []; + for (const member of session.members || []) { + const id = member.connectionId || member.userId; + if (!id || seen.has(member.userId)) continue; + seen.add(member.userId); + entries.push({ + id, + username: member.username, + role: member.role, + online: Boolean(onlineByUser.get(member.userId)) || + (member.userId === client.userId && session.state === 'connected'), + self: member.userId === client.userId + }); + } + if (!entries.length) return null; + const visible = entries.slice(0, MAX_AVATARS); + const overflow = entries.length - visible.length; + return ( +
+ {visible.map(entry => ( +
+ {entry.username.slice(0, 1).toUpperCase()} +
+ ))} + {overflow > 0 ? ( +
+ {`+${overflow}`} +
+ ) : null} +
+ ); + } +} + +PresenceAvatars.propTypes = { + vm: PropTypes.object.isRequired +}; + +export default PresenceAvatars; diff --git a/src/components/dash-presence/presence-cursors.css b/src/components/dash-presence/presence-cursors.css new file mode 100644 index 000000000..b08136545 --- /dev/null +++ b/src/components/dash-presence/presence-cursors.css @@ -0,0 +1,49 @@ +@import "../../css/z-index.css"; + +.layer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + overflow: hidden; + z-index: $z-index-drag-layer; +} + +.cursor { + position: absolute; + display: flex; + flex-direction: row; + align-items: flex-start; + transform: translate(-2px, -2px); + will-change: left, top; +} + +.pointer { + flex: none; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.35)); +} + +.label { + margin-left: -2px; + margin-top: 12px; + padding: 0.075rem 0.4rem; + border-radius: 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.85); + color: #fff; + font-size: 0.625rem; + font-weight: bold; + line-height: 1.35; + white-space: nowrap; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.25); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + overflow: hidden; + text-overflow: ellipsis; + max-width: 8rem; +} + +.dragBadge { + margin-left: 0.25rem; + font-size: 0.7rem; +} diff --git a/src/components/dash-presence/presence-cursors.jsx b/src/components/dash-presence/presence-cursors.jsx new file mode 100644 index 000000000..a4f4f3b43 --- /dev/null +++ b/src/components/dash-presence/presence-cursors.jsx @@ -0,0 +1,130 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import {listenPresence} from '../../lib/dash-presence.js'; +import * as styles from './presence-cursors.css'; + +/** + * Overlay layer that renders remote collaboration cursors (Figma-style). + * Place it inside a positioned container matching the surface being tracked. + * Cursor coordinates are surface-local: `x`/`y` in 0..1 for the stage, or + * workspace units for the blocks area (when a `transform` prop is provided). + */ +class PresenceCursors extends React.Component { + constructor (props) { + super(props); + this.state = {version: 0}; + this.handlePresenceChange = this.handlePresenceChange.bind(this); + this.detachPresence = null; + this.highlighted = new Map(); + } + componentDidMount () { + this.detachPresence = listenPresence(this.props.vm, this.handlePresenceChange); + } + componentWillUnmount () { + if (this.detachPresence) this.detachPresence(); + this.clearHighlights(); + } + componentDidUpdate () { + this.updateHighlights(); + } + handlePresenceChange () { + this.setState({version: this.state.version + 1}); + } + getPresence () { + return (this.props.vm.dashCollaboration && this.props.vm.dashCollaboration.presence) || null; + } + clearHighlights () { + for (const [element, restore] of this.highlighted) { + element.style.filter = restore; + } + this.highlighted.clear(); + } + /** + * Outline blocks that remote participants are currently dragging. + */ + updateHighlights () { + if (!this.props.highlightDragged) return; + const presence = this.getPresence(); + const root = this.props.highlightRoot; + const wanted = new Map(); + if (presence && root) { + for (const peer of presence.getPeers()) { + if (peer.state.dragging) wanted.set(peer.state.dragging, peer.color); + } + } + for (const [element, restore] of [...this.highlighted]) { + if (!wanted.has(element.getAttribute('data-id'))) { + element.style.filter = restore; + this.highlighted.delete(element); + } + } + for (const [blockId, color] of wanted) { + const element = root.querySelector(`[data-id="${blockId}"]`); + if (!element || this.highlighted.has(element)) continue; + this.highlighted.set(element, element.style.filter); + element.style.filter = `drop-shadow(0 0 3px ${color}) drop-shadow(0 0 3px ${color})`; + } + } + render () { + const presence = this.getPresence(); + if (!presence) return null; + const {surface, transform} = this.props; + const cursors = presence.getPeers().filter(peer => + peer.state.cursor && peer.state.cursor.surface === surface); + if (!cursors.length) return null; + return ( +
+ {cursors.map(peer => { + const cursor = peer.state.cursor; + const position = transform ? transform(cursor) : { + left: `${Math.max(0, Math.min(1, cursor.x)) * 100}%`, + top: `${Math.max(0, Math.min(1, cursor.y)) * 100}%` + }; + return ( +
+ + + + + {peer.username} + {peer.state.dragging ? ( + {'⠿'} + ) : null} + +
+ ); + })} +
+ ); + } +} + +PresenceCursors.propTypes = { + surface: PropTypes.oneOf(['stage', 'blocks']).isRequired, + transform: PropTypes.func, + highlightDragged: PropTypes.bool, + highlightRoot: PropTypes.object, + vm: PropTypes.object.isRequired +}; + +export default PresenceCursors; diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 9fd1a9ffd..5765062da 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -29,6 +29,7 @@ import SettingsMenu from './settings-menu.jsx'; import AccountNav from '../../containers/account-nav.jsx'; import Spinner from '../spinner/spinner.jsx'; import CollaboratorsModal from '../dash-collaborators/collaborators-modal.jsx'; +import PresenceAvatars from '../dash-presence/presence-avatars.jsx'; import FramerateChanger from '../../containers/tw-framerate-changer.jsx'; import ChangeUsername from '../../containers/tw-change-username.jsx'; @@ -1351,6 +1352,9 @@ class MenuBar extends React.Component { }
+ {!this.props.isPlayerOnly &&
+ +
} {!this.props.isPlayerOnly &&
( ( {typeof props.number === 'undefined' ? null : (
{props.number}
)} + {props.presenceUsers && props.presenceUsers.length ? ( +
+ {props.presenceUsers.map(user => ( + + ))} +
+ ) : null} {props.costumeURL ? (
@@ -114,6 +127,10 @@ SpriteSelectorItem.propTypes = { onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, preventContextMenu: PropTypes.bool, + presenceUsers: PropTypes.arrayOf(PropTypes.shape({ + color: PropTypes.string, + username: PropTypes.string + })), selected: PropTypes.bool.isRequired }; diff --git a/src/components/sprite-selector/sprite-list.jsx b/src/components/sprite-selector/sprite-list.jsx index fe2854a3d..490b86503 100644 --- a/src/components/sprite-selector/sprite-list.jsx +++ b/src/components/sprite-selector/sprite-list.jsx @@ -28,6 +28,7 @@ const SpriteList = function (props) { onAddSortable, onRemoveSortable, ordering, + presenceUsers, raised, selectedId, items @@ -89,6 +90,7 @@ const SpriteList = function (props) { index={index} key={sprite.id} name={sprite.name} + presenceUsers={presenceUsers ? presenceUsers[sprite.id] : null} selected={sprite.id === selectedId} onClick={onSelectSprite} onDeleteButtonClick={onDeleteSprite} @@ -125,6 +127,10 @@ SpriteList.propTypes = { order: PropTypes.number.isRequired })), onAddSortable: PropTypes.func, + presenceUsers: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.shape({ + color: PropTypes.string, + username: PropTypes.string + }))), onDeleteSprite: PropTypes.func, onDuplicateSprite: PropTypes.func, onExportSprite: PropTypes.func, diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx index 9ae811d5b..302ae245c 100644 --- a/src/components/sprite-selector/sprite-selector.jsx +++ b/src/components/sprite-selector/sprite-selector.jsx @@ -62,6 +62,7 @@ const SpriteSelectorComponent = function (props) { onSelectSprite, onSpriteUpload, onSurpriseSpriteClick, + presenceUsers, raised, selectedId, spriteFileInput, @@ -104,6 +105,7 @@ const SpriteSelectorComponent = function (props) { editingTarget={editingTarget} hoveredTarget={hoveredTarget} items={Object.keys(sprites).map(id => sprites[id])} + presenceUsers={presenceUsers} raised={raised} selectedId={selectedId} onDeleteSprite={onDeleteSprite} @@ -170,6 +172,10 @@ SpriteSelectorComponent.propTypes = { onSelectSprite: PropTypes.func, onSpriteUpload: PropTypes.func, onSurpriseSpriteClick: PropTypes.func, + presenceUsers: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.shape({ + color: PropTypes.string, + username: PropTypes.string + }))), raised: PropTypes.bool, selectedId: PropTypes.string, spriteFileInput: PropTypes.func, diff --git a/src/components/stage/stage.jsx b/src/components/stage/stage.jsx index 1bdcd2760..87340b84a 100644 --- a/src/components/stage/stage.jsx +++ b/src/components/stage/stage.jsx @@ -27,6 +27,7 @@ const StageComponent = props => { isRtl, colorInfo, micIndicator, + presenceCursors, question, stageSize, vm, @@ -94,6 +95,7 @@ const StageComponent = props => { stageWidth={stageDimensions.width} /> + {presenceCursors || null} {isColorPicking && colorInfo ? ( ) : null} diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx index 68fb7f58d..da671467d 100644 --- a/src/components/target-pane/target-pane.jsx +++ b/src/components/target-pane/target-pane.jsx @@ -40,6 +40,7 @@ const TargetPane = ({ onSelectSprite, onSpriteUpload, onSurpriseSpriteClick, + presenceUsers, raiseSprites, stage, stageSize, @@ -55,6 +56,7 @@ const TargetPane = ({ { @@ -794,7 +878,15 @@ class Blocks extends React.Component { componentRef={this.setBlocks} onDrop={this.handleDrop} {...props} - /> + > + + {this.state.prompt ? ( = 0 && y >= 0 && x <= rect.width && y <= rect.height; + const current = session.presence.local.cursor; + if (inside) { + session.presence.update({ + cursor: { + surface: 'stage', + x: x / rect.width, + y: y / rect.height + } + }); + } else if (current && current.surface === 'stage') { + session.presence.update({cursor: null}); + } + } + onMouseLeave () { + const session = this.props.vm.dashCollaboration; + if (!session || !session.presence) return; + const current = session.presence.local.cursor; + if (current && current.surface === 'stage') { + session.presence.update({cursor: null}); + } + } onMouseUp (e) { const {x, y} = getEventXY(e); const mousePosition = [x - this.rect.left, y - this.rect.top]; @@ -460,6 +492,10 @@ class Stage extends React.Component { overlay={this.props.vm.runtime.renderer.overlayContainer} colorInfo={this.state.colorInfo} dragRef={this.setDragCanvas} + presenceCursors={} question={this.state.question} onDoubleClick={this.handleDoubleClick} onQuestionAnswered={this.handleQuestionAnswered} diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index 255ac4733..29ad91991 100644 --- a/src/containers/target-pane.jsx +++ b/src/containers/target-pane.jsx @@ -23,6 +23,7 @@ import {fetchSprite, fetchCode} from '../lib/backpack-api'; import randomizeSpritePosition from '../lib/randomize-sprite-position'; import downloadBlob from '../lib/download-blob'; import log from '../lib/log'; +import {listenPresence} from '../lib/dash-presence.js'; import {placeInViewport} from '../lib/backpack/code-payload.js'; class TargetPane extends React.Component { @@ -48,14 +49,45 @@ class TargetPane extends React.Component { 'handlePaintSpriteClick', 'handleFileUploadClick', 'handleSpriteUpload', + 'handlePresenceChange', 'setFileInput' ]); } componentDidMount () { this.props.vm.addListener('BLOCK_DRAG_END', this.handleBlockDragEnd); + this.detachPresence = listenPresence(this.props.vm, this.handlePresenceChange); + this.publishPresenceSelection(); + } + componentDidUpdate (prevProps) { + if (prevProps.editingTarget !== this.props.editingTarget) { + this.publishPresenceSelection(); + } } componentWillUnmount () { this.props.vm.removeListener('BLOCK_DRAG_END', this.handleBlockDragEnd); + if (this.detachPresence) this.detachPresence(); + } + handlePresenceChange () { + this.forceUpdate(); + } + publishPresenceSelection () { + const session = this.props.vm.dashCollaboration; + if (session && session.presence) { + session.presence.update({targetId: this.props.editingTarget || null}); + } + } + getPresenceUsersByTarget () { + const session = this.props.vm.dashCollaboration; + const presence = session && session.presence; + const byTarget = {}; + if (!presence) return byTarget; + for (const peer of presence.getPeers()) { + const targetId = peer.state.targetId; + if (!targetId) continue; + if (!byTarget[targetId]) byTarget[targetId] = []; + byTarget[targetId].push({color: peer.color, username: peer.username}); + } + return byTarget; } handleChangeSpriteDirection (direction) { if (!this.props.vm.canEditCollaboration()) return; @@ -254,6 +286,7 @@ class TargetPane extends React.Component { { if (this.applying || !this.loaded || this.disposed) return; @@ -257,6 +259,7 @@ export default class DashCollaborationSession { this.state = status.state; this.error = status.error; if (status.state === 'connected') { + this.presence.setSelf(client.connectionId, client.userId); if (this.resolveReady) this.resolveReady(); if (this.restoreOnReconnect) { this.restoreAfterReconnect().catch(error => this.fail(error)); @@ -271,10 +274,12 @@ export default class DashCollaborationSession { }, onMembers: members => { this.members = members; + this.presence.setMembers(members); this.notify(); } }); client.doc.on('update', this.onDocumentChange); + this.presence.bind(client); return client; } notify () { @@ -732,6 +737,7 @@ export default class DashCollaborationSession { } this.client.doc.off('update', this.onDocumentChange); this.client.destroy(); + this.presence.destroy(); window.removeEventListener('beforeunload', this.beforeUnload); } } diff --git a/src/lib/dash-collaboration.js b/src/lib/dash-collaboration.js index e351b51c6..52e7e86f9 100644 --- a/src/lib/dash-collaboration.js +++ b/src/lib/dash-collaboration.js @@ -29,12 +29,13 @@ const projectTargetAssets = target => { }; export default class DashCollaboration { - constructor (projectId, {onStatus = () => {}, onMembers = () => {}} = {}) { + constructor (projectId, {onStatus = () => {}, onMembers = () => {}, onPresence = () => {}} = {}) { if (!/^[1-9]\d{0,19}$/.test(String(projectId))) throw new Error('Invalid Dash project ID'); this.projectId = String(projectId); this.doc = new Y.Doc(); this.onStatus = onStatus; this.onMembers = onMembers; + this.onPresence = onPresence; this.active = false; this.generation = 0; this.attempts = 0; @@ -44,6 +45,7 @@ export default class DashCollaboration { this.epoch = null; this.grantId = null; this.userId = null; + this.connectionId = null; this.pending = null; this.queued = null; this.handleUpdate = this.handleUpdate.bind(this); @@ -140,6 +142,7 @@ export default class DashCollaboration { throw new Error('Room or permissions changed. Reopen the project before reconnecting.'); } this.userId = message.userId; + this.connectionId = typeof message.connectionId === 'string' ? message.connectionId : null; this.epoch = message.epoch; this.grantId = message.grantId; this.role = message.role; @@ -160,6 +163,13 @@ export default class DashCollaboration { } } else if (message.type === 'members') { this.onMembers(message.members); + } else if (message.type === 'presence') { + if (!message || typeof message.state !== 'object' || message.state === null || + Array.isArray(message.state) || + (typeof message.connectionId !== 'string' && typeof message.userId !== 'string')) { + return; + } + this.onPresence(message); } else { throw new Error('Unknown collaboration message'); } @@ -176,6 +186,22 @@ export default class DashCollaboration { } return btoa(binary); } + /** + * Broadcast ephemeral presence state (cursor, selection) to other + * participants. Best-effort: never queued, never persisted, allowed for + * viewers. Dropped silently when the connection is busy or closed. + * @param {object} state small JSON-serializable presence payload + */ + sendPresence (state) { + if (!this.active || this.state !== 'connected' || !this.socket || + this.socket.readyState !== WebSocket.OPEN) return; + if (this.socket.bufferedAmount > 64 * 1024) return; + try { + this.socket.send(JSON.stringify({type: 'presence', state})); + } catch (error) { + // Presence is optional; ignore transport errors. + } + } change (operation) { if (this.state !== 'connected' || this.role === 'viewer') throw new Error('Collaboration is not writable'); this.doc.transact(() => operation(this.doc)); diff --git a/src/lib/dash-presence.js b/src/lib/dash-presence.js new file mode 100644 index 000000000..172afb094 --- /dev/null +++ b/src/lib/dash-presence.js @@ -0,0 +1,187 @@ +/** + * Ephemeral presence state for Dash live-collaboration sessions. + * Tracks where each participant's cursor is (stage / blocks workspace), which + * sprite they have open, and which block they are dragging. State is relayed + * over the collaboration WebSocket as best-effort "presence" messages (never + * written to the persistent Yjs document), so viewers can share presence too. + */ + +const PEER_TTL = 12000; +const SEND_INTERVAL = 80; +const NOTIFY_INTERVAL = 50; + +/** + * Stable per-user color so cursors and avatars keep the same color over time. + * @param {string} idString user id, connection id or similar stable identifier + * @returns {string} CSS color + */ +const presenceColor = idString => { + let hash = 0; + const value = String(idString); + for (let i = 0; i < value.length; i++) { + hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0; + } + return `hsl(${Math.abs(hash) % 360}, 72%, 50%)`; +}; + +const sameValue = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +/** + * Subscribe a component to presence/session changes of a VM. + * Re-attaches automatically when a collaboration session starts or ends. + * @param {VirtualMachine} vm the VM owning the collaboration session + * @param {function} onChange called when presence data may have changed + * @returns {function} detach listener + */ +const listenPresence = (vm, onChange) => { + let presence = null; + let unsubscribe = null; + const attach = () => { + const next = (vm.dashCollaboration && vm.dashCollaboration.presence) || null; + if (next === presence) return; + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + presence = next; + if (presence) unsubscribe = presence.subscribe(onChange); + onChange(); + }; + const onStatus = () => attach(); + vm.on('DASH_COLLABORATION_STATUS', onStatus); + attach(); + return () => { + vm.removeListener('DASH_COLLABORATION_STATUS', onStatus); + if (unsubscribe) unsubscribe(); + }; +}; + +class DashPresence { + constructor () { + this.peers = new Map(); + this.listeners = new Set(); + this.local = { + cursor: null, + targetId: null, + dragging: null + }; + this.members = []; + this.selfId = null; + this.selfUserId = null; + this.send = null; + this.sendTimer = null; + this.notifyTimer = null; + this.pruneTimer = setInterval(() => this.prune(), 3000); + } + /** + * Wire this presence channel to a collaboration client. + * @param {DashCollaboration} client active collaboration transport + */ + bind (client) { + this.send = state => client.sendPresence(state); + client.onPresence = message => this.receive(message); + } + setSelf (connectionId, userId) { + this.selfId = connectionId || userId || null; + this.selfUserId = userId || null; + } + setMembers (members) { + this.members = Array.isArray(members) ? members : []; + let changed = false; + for (const [key, peer] of [...this.peers]) { + const alive = this.members.some(member => (member.connectionId || member.userId) === key); + if (!alive) { + this.peers.delete(key); + changed = true; + } + } + if (changed) this.scheduleNotify(); + } + /** + * Publish a patch of the local presence state. + * @param {{cursor: ?object, targetId: ?string, dragging: ?string}} patch partial local state + */ + update (patch) { + let changed = false; + for (const key of Object.keys(patch)) { + if (!sameValue(this.local[key], patch[key])) { + this.local[key] = patch[key]; + changed = true; + } + } + if (!changed) return; + this.scheduleSend(); + this.scheduleNotify(); + } + scheduleSend () { + if (this.sendTimer) return; + this.sendTimer = setTimeout(() => { + this.sendTimer = null; + if (this.send) this.send({...this.local}); + }, SEND_INTERVAL); + } + receive (message) { + if (!message || typeof message !== 'object') return; + const key = message.connectionId || message.userId; + if (typeof key !== 'string' || !key) return; + if (key === this.selfId) return; + if (!this.selfId && this.selfUserId && message.userId === this.selfUserId) return; + const state = message.state && typeof message.state === 'object' && !Array.isArray(message.state) ? + message.state : {}; + this.peers.set(key, { + id: key, + userId: typeof message.userId === 'string' ? message.userId : '', + username: typeof message.username === 'string' && message.username ? message.username : '...', + color: presenceColor(key), + state, + seen: Date.now() + }); + this.scheduleNotify(); + } + prune () { + const now = Date.now(); + let changed = false; + for (const [key, peer] of [...this.peers]) { + if (now - peer.seen > PEER_TTL) { + this.peers.delete(key); + changed = true; + } + } + if (changed) this.scheduleNotify(); + } + getPeers () { + return [...this.peers.values()]; + } + getMembers () { + return this.members; + } + subscribe (listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + scheduleNotify () { + if (this.notifyTimer) return; + this.notifyTimer = setTimeout(() => { + this.notifyTimer = null; + for (const listener of [...this.listeners]) listener(); + }, NOTIFY_INTERVAL); + } + reset () { + this.peers.clear(); + this.local = { + cursor: null, + targetId: null, + dragging: null + }; + this.scheduleNotify(); + } + destroy () { + clearInterval(this.pruneTimer); + clearTimeout(this.sendTimer); + clearTimeout(this.notifyTimer); + this.listeners.clear(); + this.peers.clear(); + } +} + +export {DashPresence as default, presenceColor, listenPresence}; From 4dbd36fb2b2339e335083c6de5b7d37c7dcd0cda Mon Sep 17 00:00:00 2001 From: Professionalarduino-max Date: Fri, 16 Oct 2026 14:43:05 +0000 Subject: [PATCH 2/2] feat(collaboration): session chat with mentions, emoji picker and pinned messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dash-chat: room chat store + panel in the collaboration status bar — history, @mentions with unread notifications, emoji picker with categories, and pinned messages pointing at sprites/costumes/blocks with click-to-focus (block pins flash-highlight the block) - Chat travels over the collaboration WebSocket as ephemeral 'chat' messages with server-side room history — never written to the project document, so viewers can chat too - Feature handshake: 'auth' advertises features ['presence', 'chat'] and 'ready' lists server support; presence/chat are only sent when both sides support them, so old servers and old clients keep working --- src/components/dash-chat/dash-chat.css | 299 +++++++++++ src/components/dash-chat/dash-chat.jsx | 501 ++++++++++++++++++ .../dash-collaboration-status.jsx | 2 + src/lib/dash-chat.js | 117 ++++ src/lib/dash-collaboration-session.js | 7 + src/lib/dash-collaboration.js | 38 +- 6 files changed, 961 insertions(+), 3 deletions(-) create mode 100644 src/components/dash-chat/dash-chat.css create mode 100644 src/components/dash-chat/dash-chat.jsx create mode 100644 src/lib/dash-chat.js diff --git a/src/components/dash-chat/dash-chat.css b/src/components/dash-chat/dash-chat.css new file mode 100644 index 000000000..4895ee156 --- /dev/null +++ b/src/components/dash-chat/dash-chat.css @@ -0,0 +1,299 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; +@import "../../css/z-index.css"; + +.wrapper { + position: relative; + display: flex; + align-items: center; +} + +.toggle { + position: relative; + padding: 0.15rem 0.45rem; + border: 1px solid $ui-black-transparent; + border-radius: 0.25rem; + background: $ui-primary; + font-size: 0.95rem; + line-height: 1.25; + cursor: pointer; +} + +.badge { + position: absolute; + top: -0.35rem; + right: -0.35rem; + min-width: 1rem; + height: 1rem; + padding: 0 0.2rem; + border-radius: 0.5rem; + background: $error-light; + color: #fff; + font-size: 0.625rem; + font-weight: bold; + line-height: 1rem; + text-align: center; +} + +.panel { + position: absolute; + top: calc(100% + 0.4rem); + right: 0; + z-index: $z-index-drag-layer; + display: flex; + flex-direction: column; + width: 20rem; + height: 26rem; + border: 1px solid $ui-black-transparent; + border-radius: $space; + background: $ui-white; + color: $text-primary; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 0.8125rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.22); + overflow: hidden; +} + +.header { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid $ui-black-transparent; + background: $ui-tertiary; + font-weight: bold; +} + +.header-button, +.header-button-active { + margin-left: 0.15rem; + padding: 0.1rem 0.35rem; + border: 1px solid transparent; + border-radius: 0.25rem; + background: transparent; + font-size: 0.85rem; + cursor: pointer; +} + +.header-button:hover { + background: rgba(0, 0, 0, 0.08); +} + +.header-button-active { + background: $looks-transparent; + border-color: $looks-secondary; +} + +.header-button:first-of-type { + margin-left: auto; +} + +.list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.empty { + margin: auto; + color: $text-primary-transparent; + text-align: center; +} + +.message, +.message-self { + display: flex; + flex-direction: column; + gap: 0.15rem; + max-width: 100%; +} + +.message-self .message-text { + background: $looks-transparent; + border-radius: 0.45rem; + padding: 0.25rem 0.45rem; +} + +.message-meta { + display: flex; + flex-direction: row; + align-items: baseline; + gap: 0.4rem; +} + +.message-author { + font-weight: bold; + font-size: 0.75rem; +} + +.message-time { + color: $text-primary-transparent; + font-size: 0.65rem; +} + +.message-text { + word-break: break-word; + white-space: pre-wrap; + line-height: 1.35; +} + +.mention { + color: $looks-secondary; + font-weight: bold; +} + +.mention-me { + color: #fff; + background: $error-light; + border-radius: 0.3rem; + padding: 0 0.25rem; + font-weight: bold; +} + +.pin-chip { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 0.25rem; + align-self: flex-start; + margin-top: 0.15rem; + padding: 0.1rem 0.4rem; + border: 1px solid $ui-black-transparent; + border-radius: 0.5rem; + background: $ui-tertiary; + font-size: 0.65rem; + cursor: pointer; +} + +.pin-chip:hover { + background: $looks-transparent; +} + +.pin-name { + margin-left: 0.2rem; + font-style: italic; + opacity: 0.85; +} + +.pending-pin { + padding: 0.25rem 0.6rem; + border-top: 1px solid $ui-black-transparent; + background: $looks-transparent; + font-size: 0.7rem; +} + +.composer { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 0.3rem; + padding: 0.45rem; + border-top: 1px solid $ui-black-transparent; + background: $ui-tertiary; +} + +.composer-button, +.composer-button-active { + flex: none; + padding: 0.25rem 0.4rem; + border: 1px solid $ui-black-transparent; + border-radius: 0.35rem; + background: $ui-white; + font-size: 0.95rem; + line-height: 1.25; + cursor: pointer; +} + +.composer-button-active { + background: $looks-transparent; + border-color: $looks-secondary; +} + +.input { + flex: 1; + resize: none; + padding: 0.35rem 0.5rem; + border: 1px solid $ui-black-transparent; + border-radius: 0.45rem; + font: inherit; + line-height: 1.35; + max-height: 5.5rem; +} + +.send-button { + flex: none; + padding: 0.3rem 0.55rem; + border: 1px solid $ui-black-transparent; + border-radius: 0.35rem; + background: $looks-secondary; + color: #fff; + font-size: 0.95rem; + cursor: pointer; +} + +.send-button:disabled { + opacity: 0.45; + cursor: default; +} + +.emoji-picker { + position: absolute; + right: 0.45rem; + bottom: 3.6rem; + z-index: $z-index-drag-layer; + width: 17.5rem; + border: 1px solid $ui-black-transparent; + border-radius: $space; + background: $ui-white; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +.emoji-categories { + display: flex; + flex-direction: row; + border-bottom: 1px solid $ui-black-transparent; + background: $ui-tertiary; +} + +.emoji-category, +.emoji-category-active { + flex: 1; + padding: 0.3rem 0; + border: none; + background: transparent; + font-size: 1rem; + cursor: pointer; +} + +.emoji-category-active { + background: $looks-transparent; + box-shadow: inset 0 -2px 0 $looks-secondary; +} + +.emoji-grid { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 0.1rem; + padding: 0.35rem; + max-height: 11.5rem; + overflow-y: auto; +} + +.emoji-cell { + padding: 0.15rem 0; + border: none; + border-radius: 0.25rem; + background: transparent; + font-size: 1.15rem; + line-height: 1.35; + cursor: pointer; +} + +.emoji-cell:hover { + background: $looks-transparent; +} diff --git a/src/components/dash-chat/dash-chat.jsx b/src/components/dash-chat/dash-chat.jsx new file mode 100644 index 000000000..3000f170c --- /dev/null +++ b/src/components/dash-chat/dash-chat.jsx @@ -0,0 +1,501 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; +import {FormattedMessage, injectIntl, intlShape} from 'react-intl'; + +import AddonHooks from '../../addons/hooks'; +import {listenDashChat, MAX_TEXT} from '../../lib/dash-chat.js'; +import {presenceColor} from '../../lib/dash-presence.js'; +import {activateTab, COSTUMES_TAB_INDEX} from '../../reducers/editor-tab'; +import VM from 'scratch-vm'; +import * as styles from './dash-chat.css'; + +const EMOJI_CATEGORIES = [ + { + id: 'smileys', + icon: '\u{1F600}', + emojis: ['\u{1F600}', '\u{1F601}', '\u{1F602}', '\u{1F923}', '\u{1F603}', '\u{1F604}', '\u{1F605}', + '\u{1F606}', '\u{1F609}', '\u{1F60A}', '\u{1F60B}', '\u{1F60D}', '\u{1F970}', '\u{1F618}', '\u{1F617}', + '\u{1F619}', '\u{1F61A}', '\u{1F914}', '\u{1F928}', '\u{1F610}', '\u{1F611}', '\u{1F634}', '\u{1F637}', + '\u{1F60E}', '\u{1F913}', '\u{1F929}', '\u{1F973}', '\u{1F97A}', '\u{1F62D}', '\u{1F622}', '\u{1F621}', + '\u{1F92C}', '\u{1F92F}', '\u{1F631}', '\u{1F633}', '\u{1F92A}', '\u{1F92B}', '\u{1F917}', '\u{1F60F}'] + }, + { + id: 'gestures', + icon: '\u{1F44D}', + emojis: ['\u{1F44D}', '\u{1F44E}', '\u{1F44F}', '\u{1F64C}', '\u{1F44C}', '\u{270C}\u{FE0F}', '\u{1F91E}', + '\u{1F91F}', '\u{1F44B}', '\u{1F91A}', '\u{270B}', '\u{1F590}\u{FE0F}', '\u{1F448}', '\u{1F449}', + '\u{1F446}', '\u{1F447}', '\u{1F44A}', '\u{1F91B}', '\u{1F91C}', '\u{1F450}', '\u{1F64F}', '\u{1F91D}', + '\u{1F9E1}', '\u{1F4AA}', '\u{1F9B6}', '\u{1F485}', '\u{1F933}', '\u{1F930}', '\u{1F60C}', '\u{1F971}'] + }, + { + id: 'hearts', + icon: '\u{2764}\u{FE0F}', + emojis: ['\u{2764}\u{FE0F}', '\u{1F9E1}', '\u{1F49B}', '\u{1F49A}', '\u{1F499}', '\u{1F49C}', '\u{1F90E}', + '\u{1F5A4}', '\u{1F90D}', '\u{1F494}', '\u{2763}\u{FE0F}', '\u{1F495}', '\u{1F496}', '\u{1F497}', + '\u{1F498}', '\u{1F49D}', '\u{1F49E}', '\u{1F493}', '\u{2764}\u{FE0F}\u{200D}\u{1F525}', '\u{1F4AF}'] + }, + { + id: 'nature', + icon: '\u{1F431}', + emojis: ['\u{1F431}', '\u{1F436}', '\u{1F43A}', '\u{1F98A}', '\u{1F43B}', '\u{1F428}', '\u{1F42F}', + '\u{1F42E}', '\u{1F437}', '\u{1F438}', '\u{1F435}', '\u{1F414}', '\u{1F427}', '\u{1F426}', '\u{1F424}', + '\u{1F989}', '\u{1F987}', '\u{1F43C}', '\u{1F43D}', '\u{1F430}', '\u{1F439}', '\u{1F433}', '\u{1F40B}', + '\u{1F42C}', '\u{1F41F}', '\u{1F420}', '\u{1F421}', '\u{1F33B}', '\u{1F337}', '\u{1F341}', '\u{1F340}'] + }, + { + id: 'objects', + icon: '\u{2B50}', + emojis: ['\u{2B50}', '\u{1F31F}', '\u{2728}', '\u{1F4AB}', '\u{1F525}', '\u{1F389}', '\u{1F388}', + '\u{1F381}', '\u{1F3A8}', '\u{1F3AC}', '\u{1F3B5}', '\u{1F3B8}', '\u{1F3B2}', '\u{1F3AE}', '\u{1F579}', + '\u{1F4BB}', '\u{1F4FA}', '\u{1F47E}', '\u{1F916}', '\u{1F680}', '\u{1F6F8}', '\u{2600}\u{FE0F}', + '\u{1F308}', '\u{1F32A}\u{FE0F}', '\u{26C8}\u{FE0F}', '\u{26A1}', '\u{1F4A1}', '\u{1F4BF}', '\u{1F3B9}', + '\u{1F3AF}'] + }, + { + id: 'food', + icon: '\u{1F354}', + emojis: ['\u{1F354}', '\u{1F35F}', '\u{1F355}', '\u{1F363}', '\u{1F361}', '\u{1F373}', '\u{1F35D}', + '\u{1F368}', '\u{1F366}', '\u{1F367}', '\u{1F382}', '\u{1F370}', '\u{1F9C1}', '\u{1F36A}', '\u{1F36B}', + '\u{1F36C}', '\u{1F36D}', '\u{1F95E}', '\u{1F9C0}', '\u{1F35E}', '\u{1F950}', '\u{1F955}', '\u{1F34E}', + '\u{1F34A}', '\u{1F349}', '\u{1F347}', '\u{1F353}', '\u{1F352}', '\u{1F34B}', '\u{1F34C}'] + } +]; + +const MENTION_PATTERN = /(@[A-Za-z0-9_-]+)/g; + +const targetName = target => { + if (!target) return ''; + if (typeof target.getName === 'function') return target.getName(); + if (target.sprite && target.sprite.name) return target.sprite.name; + return ''; +}; + +class DashChat extends React.Component { + constructor (props) { + super(props); + this.state = { + open: false, + version: 0, + draft: '', + pinEnabled: false, + showEmoji: false, + emojiCategory: EMOJI_CATEGORIES[0].id, + pinnedOnly: false + }; + bindAll(this, [ + 'handleChatChange', + 'handleToggleOpen', + 'handleClose', + 'handleChangeDraft', + 'handleKeyDown', + 'handleSend', + 'handleToggleEmoji', + 'handleSelectEmoji', + 'handleSelectEmojiCategory', + 'handleTogglePin', + 'handleTogglePinnedOnly', + 'handleClickPin', + 'handleDocumentClick', + 'setListRef', + 'setInputRef' + ]); + this.detachChat = null; + this.pendingPin = null; + } + componentDidMount () { + this.detachChat = listenDashChat(this.props.vm, this.handleChatChange); + document.addEventListener('mousedown', this.handleDocumentClick, true); + } + componentWillUnmount () { + if (this.detachChat) this.detachChat(); + document.removeEventListener('mousedown', this.handleDocumentClick, true); + } + componentDidUpdate (prevProps, prevState) { + if (this.state.open && (prevState.version !== this.state.version || !prevState.open)) { + this.scrollToBottom(); + } + } + handleDocumentClick (event) { + if (!this.state.showEmoji || !this.wrapperRef) return; + if (!this.wrapperRef.contains(event.target)) this.setState({showEmoji: false}); + } + handleChatChange () { + this.setState({version: this.state.version + 1}); + } + handleToggleOpen () { + const open = !this.state.open; + this.setState({open, showEmoji: false}); + if (open) this.props.vm.dashCollaboration && this.props.vm.dashCollaboration.chat.markRead(); + } + handleClose () { + this.setState({open: false, showEmoji: false}); + } + handleChangeDraft (event) { + this.setState({draft: event.target.value.slice(0, MAX_TEXT)}); + } + handleKeyDown (event) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.handleSend(); + } + } + handleSend () { + const chat = this.props.vm.dashCollaboration && this.props.vm.dashCollaboration.chat; + if (!chat) return; + const pin = this.state.pinEnabled ? this.capturePin() : null; + if (chat.sendText(this.state.draft, pin)) { + this.setState({draft: '', pinEnabled: false}); + } + } + capturePin () { + const vm = this.props.vm; + const target = vm.editingTarget; + if (!target) return null; + const targetId = target.id; + const name = targetName(target); + const workspace = AddonHooks.blocklyWorkspace; + const selected = workspace && workspace.selected; + if (selected && selected.id && this.props.activeTabIndex === 0) { + return {type: 'block', targetId, blockId: selected.id, name}; + } + if (this.props.activeTabIndex === COSTUMES_TAB_INDEX) { + return {type: 'costume', targetId, name}; + } + return {type: 'sprite', targetId, name}; + } + handleToggleEmoji () { + this.setState({showEmoji: !this.state.showEmoji}); + } + handleSelectEmojiCategory (id) { + this.setState({emojiCategory: id}); + } + handleSelectEmoji (emoji) { + const input = this.inputRef; + const draft = this.state.draft; + if (!input) { + this.setState({draft: (draft + emoji).slice(0, MAX_TEXT)}); + return; + } + const start = typeof input.selectionStart === 'number' ? input.selectionStart : draft.length; + const end = typeof input.selectionEnd === 'number' ? input.selectionEnd : draft.length; + const next = (draft.slice(0, start) + emoji + draft.slice(end)).slice(0, MAX_TEXT); + this.setState({draft: next}, () => { + input.focus(); + const caret = Math.min(start + emoji.length, MAX_TEXT); + input.setSelectionRange(caret, caret); + }); + } + handleTogglePin () { + this.setState({pinEnabled: !this.state.pinEnabled}); + } + handleTogglePinnedOnly () { + this.setState({pinnedOnly: !this.state.pinnedOnly}); + } + handleClickPin (pin) { + if (!pin) return; + const vm = this.props.vm; + vm.setEditingTarget(pin.targetId); + if (pin.type === 'costume') this.props.onActivateTab(COSTUMES_TAB_INDEX); + if (pin.type === 'block' && pin.blockId) { + const workspace = AddonHooks.blocklyWorkspace; + const root = workspace && workspace.getInjectionDiv && workspace.getInjectionDiv(); + const element = root && root.querySelector(`[data-id="${pin.blockId}"]`); + if (element) { + const restore = element.style.filter; + element.style.filter = + 'drop-shadow(0 0 3px rgba(76, 151, 255, 1)) drop-shadow(0 0 3px rgba(76, 151, 255, 1))'; + setTimeout(() => { + element.style.filter = restore; + }, 2500); + } + } + } + setListRef (ref) { + this.listRef = ref; + } + setInputRef (ref) { + this.inputRef = ref; + } + scrollToBottom () { + if (this.listRef) this.listRef.scrollTop = this.listRef.scrollHeight; + } + renderMentions (text) { + const self = (this.props.vm.dashCollaboration && this.props.vm.dashCollaboration.chat.selfUsername) || ''; + return String(text).split(MENTION_PATTERN).map((part, index) => { + if (part.startsWith('@')) { + const isMe = self && part.toLowerCase() === `@${self.toLowerCase()}`; + return ( + {part} + ); + } + return part; + }); + } + renderMessage (message) { + return ( +
+
+ + {message.username} + + + {new Date(message.ts).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})} + +
+
{this.renderMentions(message.text)}
+ {message.pin ? ( + + ) : null} +
+ ); + } + render () { + const chat = this.props.vm.dashCollaboration && this.props.vm.dashCollaboration.chat; + if (!chat) return null; + const messages = this.state.pinnedOnly ? + chat.messages.filter(message => message.pin) : chat.messages; + const category = EMOJI_CATEGORIES.find(entry => entry.id === this.state.emojiCategory) || + EMOJI_CATEGORIES[0]; + const pendingPin = this.state.pinEnabled ? this.capturePin() : null; + return ( +
{ + this.wrapperRef = ref; + }} + > + + {this.state.open ? ( +
+
+ + + +
+
+ {messages.length ? messages.map(message => this.renderMessage(message)) : ( +
+ +
+ )} +
+ {pendingPin ? ( +
+ {'\u{1F4CC} '} + {pendingPin.type === 'block' ? ( + + ) : pendingPin.type === 'costume' ? ( + + ) : ( + + )} + {pendingPin.name ? {pendingPin.name} : null} +
+ ) : null} +
+ + +