From ee0402e83fa940d1ecc6b15cd3e90bd664fb5281 Mon Sep 17 00:00:00 2001 From: Professionalarduino-max Date: Fri, 16 Oct 2026 18:59:21 +0000 Subject: [PATCH 1/2] feat(collaboration): control center, follow mode, presenter mode and activity feed Control center (replaces the basic collaborators modal): - Participant list with colorful avatars, online status, role badges - Owner moderation: change roles, remove collaborators (server-enforced kick), remove + ban with a persistent session block list - Invite link with one-click copy - Tabs: participants, activity feed and the session chat Follow mode and presentation: - Share your view (editor tab, sprite, zoom, scroll) over the presence channel and follow any participant with one click - Presenter mode for lessons and streams: the owner presents, everyone else watches read-only and can raise a hand; the presenter grants speech - Floating follow bar: follow state, live presentation chip, raise-hand button, raised-hand counter Activity feed and per-user undo: - Live feed of sprite/costume/sound edits, presentation and moderation events with author, target name and time - Personal undo: each participant undoes only their own changes (restore functions captured from the VM), never other people's work - Per-entry Undo buttons plus a global 'undo my last change' Presence: action events are dispatched per message so consecutive edits never collapse; presentation and view state ride the best-effort channel. --- .../dash-control-center/activity-feed.jsx | 183 ++++++ .../control-center-modal.jsx | 456 ++++++++++++++ .../dash-control-center/control-center.css | 591 ++++++++++++++++++ .../dash-control-center/follow-bar.jsx | 135 ++++ .../dash-control-center/participant-list.jsx | 332 ++++++++++ src/components/gui/gui.jsx | 2 + src/components/menu-bar/menu-bar.jsx | 5 +- src/containers/gui.jsx | 39 ++ src/lib/dash-activity.js | 449 +++++++++++++ src/lib/dash-collaboration-session.js | 24 +- src/lib/dash-presence.js | 26 +- src/lib/dash-session-control.js | 353 +++++++++++ src/lib/dash-view-state.js | 268 ++++++++ 13 files changed, 2856 insertions(+), 7 deletions(-) create mode 100644 src/components/dash-control-center/activity-feed.jsx create mode 100644 src/components/dash-control-center/control-center-modal.jsx create mode 100644 src/components/dash-control-center/control-center.css create mode 100644 src/components/dash-control-center/follow-bar.jsx create mode 100644 src/components/dash-control-center/participant-list.jsx create mode 100644 src/lib/dash-activity.js create mode 100644 src/lib/dash-session-control.js create mode 100644 src/lib/dash-view-state.js diff --git a/src/components/dash-control-center/activity-feed.jsx b/src/components/dash-control-center/activity-feed.jsx new file mode 100644 index 000000000..d44c72b6d --- /dev/null +++ b/src/components/dash-control-center/activity-feed.jsx @@ -0,0 +1,183 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {FormattedMessage, injectIntl, intlShape} from 'react-intl'; + +import activity from '../../lib/dash-activity.js'; +import * as styles from './control-center.css'; + +const VERBS = { + addedSprite: {id: 'dash.activity.addedSprite', defaultMessage: 'added sprite'}, + deletedSprite: {id: 'dash.activity.deletedSprite', defaultMessage: 'deleted sprite'}, + renamedSprite: {id: 'dash.activity.renamedSprite', defaultMessage: 'renamed sprite'}, + duplicatedSprite: {id: 'dash.activity.duplicatedSprite', defaultMessage: 'duplicated sprite'}, + addedCostume: {id: 'dash.activity.addedCostume', defaultMessage: 'added costume'}, + deletedCostume: {id: 'dash.activity.deletedCostume', defaultMessage: 'deleted costume'}, + renamedCostume: {id: 'dash.activity.renamedCostume', defaultMessage: 'renamed costume'}, + duplicatedCostume: {id: 'dash.activity.duplicatedCostume', defaultMessage: 'duplicated costume'}, + editedCostume: {id: 'dash.activity.editedCostume', defaultMessage: 'edited costume'}, + addedBackdrop: {id: 'dash.activity.addedBackdrop', defaultMessage: 'added backdrop'}, + addedSound: {id: 'dash.activity.addedSound', defaultMessage: 'added sound'}, + deletedSound: {id: 'dash.activity.deletedSound', defaultMessage: 'deleted sound'}, + renamedSound: {id: 'dash.activity.renamedSound', defaultMessage: 'renamed sound'}, + duplicatedSound: {id: 'dash.activity.duplicatedSound', defaultMessage: 'duplicated sound'}, + editedSound: {id: 'dash.activity.editedSound', defaultMessage: 'edited sound'}, + sharedBlocks: {id: 'dash.activity.sharedBlocks', defaultMessage: 'shared blocks'}, + sharedCostume: {id: 'dash.activity.sharedCostume', defaultMessage: 'shared costume'}, + sharedSound: {id: 'dash.activity.sharedSound', defaultMessage: 'shared sound'}, + reorderedSprites: {id: 'dash.activity.reorderedSprites', defaultMessage: 'reordered sprites'}, + reorderedCostumes: {id: 'dash.activity.reorderedCostumes', defaultMessage: 'reordered costumes'}, + reorderedSounds: {id: 'dash.activity.reorderedSounds', defaultMessage: 'reordered sounds'}, + changedProject: {id: 'dash.activity.changedProject', defaultMessage: 'changed the project'}, + undidAction: {id: 'dash.activity.undidAction', defaultMessage: 'undid their change to'}, + startedPresentation: {id: 'dash.activity.startedPresentation', defaultMessage: 'started the presentation'}, + stoppedPresentation: {id: 'dash.activity.stoppedPresentation', defaultMessage: 'ended the presentation'}, + grantedSpeech: {id: 'dash.activity.grantedSpeech', defaultMessage: 'gave speech to'}, + removedUser: {id: 'dash.activity.removedUser', defaultMessage: 'removed'}, + bannedUser: {id: 'dash.activity.bannedUser', defaultMessage: 'banned'}, + raisedHand: {id: 'dash.activity.raisedHand', defaultMessage: 'raised their hand'}, + joined: {id: 'dash.activity.joined', defaultMessage: 'joined the session'}, + left: {id: 'dash.activity.left', defaultMessage: 'left the session'} +}; + +const ICONS = { + sprite: '\u{1F47E}', + costume: '\u{1F58C}', + sound: '\u{1F3B5}', + people: '\u{1F465}', + present: '\u{1F3A4}', + undo: '\u{21A9}', + edit: '\u{270F}' +}; + +const formatTime = ts => { + const date = new Date(ts); + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; +}; + +class ActivityFeed extends React.Component { + constructor (props) { + super(props); + this.state = {tick: 0, busy: false, error: null}; + this.unsubscribe = null; + bindAll(this, ['handleTick', 'handleUndo', 'handleUndoEntry']); + } + componentDidMount () { + this.unsubscribe = activity.subscribe(this.handleTick); + } + componentWillUnmount () { + if (this.unsubscribe) this.unsubscribe(); + } + handleTick () { + this.setState({tick: this.state.tick + 1}); + } + async handleUndo () { + if (this.state.busy) return; + this.setState({busy: true, error: null}); + try { + await activity.undoEntry(null); + } catch (error) { + this.setState({error: error.message}); + } finally { + this.setState({busy: false}); + } + } + async handleUndoEntry (entryId) { + if (this.state.busy) return; + this.setState({busy: true, error: null}); + try { + await activity.undoEntry(entryId); + } catch (error) { + this.setState({error: error.message}); + } finally { + this.setState({busy: false}); + } + } + renderEntry (entry) { + const verb = VERBS[entry.verb] || VERBS.changedProject; + return ( +
+
{ICONS[entry.icon] || ICONS.edit}
+
+ {entry.username === 'self' ? 'You' : entry.username} + {' '} + {this.props.intl.formatMessage(verb)} + {entry.targetName ? ` "${entry.targetName}"` : ''} + {entry.extraName && entry.verb === 'renamedSprite' ? ` (${entry.extraName})` : ''} + {entry.extraName && entry.verb === 'renamedCostume' ? ` (${entry.extraName})` : ''} + {entry.extraName && entry.verb === 'renamedSound' ? ` (${entry.extraName})` : ''} +
+ {entry.undoable && ( + + )} +
{formatTime(entry.ts)}
+
+ ); + } + render () { + const {entries, undoable} = activity.getState(); + return ( + + {undoable.length > 0 && ( +
+ + + + +
+ )} + {this.state.error ?

{this.state.error}

: null} + {entries.length === 0 ? ( +
+ +
+ ) : ( +
+ {entries.slice(0, 60).map(entry => this.renderEntry(entry))} +
+ )} +
+ ); + } +} + +ActivityFeed.propTypes = { + vm: PropTypes.object, + intl: intlShape +}; + +export default injectIntl(ActivityFeed); diff --git a/src/components/dash-control-center/control-center-modal.jsx b/src/components/dash-control-center/control-center-modal.jsx new file mode 100644 index 000000000..1a3070e76 --- /dev/null +++ b/src/components/dash-control-center/control-center-modal.jsx @@ -0,0 +1,456 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {FormattedMessage, injectIntl, intlShape} from 'react-intl'; + +import {listenPresence} from '../../lib/dash-presence.js'; +import sessionControl from '../../lib/dash-session-control.js'; +import activity from '../../lib/dash-activity.js'; +import viewState from '../../lib/dash-view-state.js'; +import {requestDashApi} from '../../lib/dash-api.js'; +import DashChat from '../dash-chat/dash-chat.jsx'; +import ParticipantList from './participant-list.jsx'; +import ActivityFeed from './activity-feed.jsx'; +import * as styles from './control-center.css'; + +const TABS = ['participants', 'activity', 'chat']; + +class ControlCenterModal extends React.Component { + constructor (props) { + super(props); + this.state = { + tab: 'participants', + tick: 0, + collaborators: null, + busy: false, + error: null + }; + this.unsubscribeList = []; + bindAll(this, [ + 'handleTick', + 'handleClose', + 'handleTab', + 'handleStartPresentation', + 'handleStopPresentation', + 'handleKick', + 'handleBan', + 'handleRoleChange', + 'handleCopyInvite', + 'loadCollaborators' + ]); + } + componentDidMount () { + this.unsubscribeList.push(sessionControl.subscribe(this.handleTick)); + this.unsubscribeList.push(activity.subscribe(this.handleTick)); + this.unsubscribeList.push(viewState.subscribe(this.handleTick)); + this.unsubscribeList.push(listenPresence(this.props.vm, this.handleTick)); + this.loadCollaborators(); + } + componentWillUnmount () { + for (const unsubscribe of this.unsubscribeList) unsubscribe(); + } + handleTick () { + this.setState({tick: this.state.tick + 1}); + } + handleClose () { + if (this.props.onClose) this.props.onClose(); + } + handleTab (tab) { + this.setState({tab}); + } + async loadCollaborators () { + if (!this.props.canManage || !this.props.projectId) return; + try { + const response = await requestDashApi( + `/projects/${String(this.props.projectId)}/collaborators`, + {credentials: 'include'} + ); + if (!response.ok) throw new Error(`(${response.status})`); + const data = await response.json(); + this.setState({collaborators: data.collaborators || data || []}); + } catch (error) { + this.setState({collaborators: []}); + } + } + handleStartPresentation () { + sessionControl.startPresentation(); + activity.recordSession({kind: 'presentation', verb: 'startedPresentation', icon: 'present'}); + } + handleStopPresentation () { + sessionControl.stopPresentation(); + activity.recordSession({kind: 'presentation', verb: 'stoppedPresentation', icon: 'present'}); + } + async handleKick (userId, username) { + if (this.state.busy) return; + this.setState({busy: true, error: null}); + try { + await sessionControl.kickUser(userId, {username, ban: false}); + activity.recordSession({ + kind: 'kick', + verb: 'removedUser', + icon: 'people', + targetName: username + }); + await this.loadCollaborators(); + } catch (error) { + this.setState({error: error.message}); + } finally { + this.setState({busy: false}); + } + } + async handleBan (userId, username) { + if (this.state.busy) return; + this.setState({busy: true, error: null}); + try { + await sessionControl.kickUser(userId, {username, ban: true}); + activity.recordSession({ + kind: 'ban', + verb: 'bannedUser', + icon: 'people', + targetName: username + }); + await this.loadCollaborators(); + } catch (error) { + this.setState({error: error.message}); + } finally { + this.setState({busy: false}); + } + } + async handleRoleChange (username, role) { + if (this.state.busy) return; + this.setState({busy: true, error: null}); + try { + const response = await requestDashApi( + `/projects/${String(this.props.projectId)}/collaborators`, + { + method: 'POST', + credentials: 'include', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username, role}) + } + ); + if (!response.ok) throw new Error(`(${response.status})`); + await this.loadCollaborators(); + } catch (error) { + this.setState({error: error.message}); + } finally { + this.setState({busy: false}); + } + } + handleCopyInvite () { + const url = `${window.location.origin}${window.location.pathname}?projectId=${String(this.props.projectId)}`; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(url); + } else { + const input = this.inviteInput; + if (input) { + input.select(); + document.execCommand('copy'); + } + } + } catch (error) { + // Clipboard can be unavailable; the input stays selectable. + } + } + renderPresentationStrip () { + const control = sessionControl.getState(); + const isPresenter = control.presenting || (control.isOwner && control.presentationActive); + return ( +
+ {control.presentationActive ? ( + + {control.presenterName === 'self' ? ( + + ) : ( + + )} + + ) : ( + + + + )} + {control.isOwner && !control.presentationActive && ( + + )} + {control.isOwner && control.presentationActive && ( + + )} + {!control.isOwner && control.presentationActive && !isPresenter && ( + + )} +
+ ); + } + renderHandQueue () { + const control = sessionControl.getState(); + const queue = control.handRaiseList || []; + if (!queue.length || !control.presenting) return null; + return ( +
+
+ +
+ {queue.map(entry => ( +
+ {entry.self ? 'You' : entry.username} + +
+ ))} +
+ ); + } + renderBody () { + switch (this.state.tab) { + case 'activity': + return ; + case 'chat': + return ( +
+ +
+ ); + default: + return ( + + {this.renderHandQueue()} + + {this.props.canManage && ( + +
+ +
+
+ { + this.inviteInput = ref; + }} + value={`${window.location.origin}${window.location.pathname}?projectId=${String(this.props.projectId)}`} + /> + +
+
+ )} +
+ ); + } + } + render () { + const control = sessionControl.getState(); + const activityState = activity.getState(); + return ( +
{ + if (event.target === event.currentTarget) this.handleClose(); + }} + > +
+
+
{'\u{1F465}'}
+
+
+ +
+
+ +
+
+ +
+ {this.renderPresentationStrip()} +
+ {TABS.map(tab => ( + + ))} +
+
+ {this.renderBody()} +
+
+
+ ); + } +} + +ControlCenterModal.propTypes = { + vm: PropTypes.object.isRequired, + projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + canManage: PropTypes.bool, + isRtl: PropTypes.bool, + onClose: PropTypes.func, + intl: intlShape +}; + +ControlCenterModal.defaultProps = { + canManage: false, + isRtl: false +}; + +export default injectIntl(ControlCenterModal); diff --git a/src/components/dash-control-center/control-center.css b/src/components/dash-control-center/control-center.css new file mode 100644 index 000000000..07086962a --- /dev/null +++ b/src/components/dash-control-center/control-center.css @@ -0,0 +1,591 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; +@import "../../css/z-index.css"; + +/* ------------------------------------------------------------------ */ +/* Modal shell */ +/* ------------------------------------------------------------------ */ + +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: $z-index-modal; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.55); + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +.modal { + display: flex; + flex-direction: column; + width: min(46rem, 100%); + max-height: min(40rem, calc(100vh - 2rem)); + border-radius: 14px; + background: $ui-white; + color: $text-primary; + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.45); + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.9rem 1.1rem; + background: linear-gradient(135deg, #4c97ff 0%, #7b5cff 55%, #a855f7 100%); + color: #fff; +} + +.headerIcon { + display: flex; + align-items: center; + justify-content: center; + width: 2.4rem; + height: 2.4rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.22); + font-size: 1.35rem; +} + +.headerTitles { + flex: 1; + min-width: 0; +} + +.headerTitle { + font-size: 1.05rem; + font-weight: bold; + line-height: 1.25; +} + +.headerSubtitle { + font-size: 0.75rem; + opacity: 0.92; +} + +.closeButton { + display: flex; + align-items: center; + justify-content: center; + width: 2.15rem; + height: 2.15rem; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.22); + color: #fff; + font-size: 1.15rem; + cursor: pointer; + touch-action: manipulation; +} + +.closeButton:hover { + background: rgba(255, 255, 255, 0.35); +} + +/* ------------------------------------------------------------------ */ +/* Presentation strip */ +/* ------------------------------------------------------------------ */ + +.presenterStrip { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.55rem 1.1rem; + background: #1f2430; + color: #fff; + font-size: 0.8rem; +} + +.presenterStripLive { + background: #b4232f; +} + +.presenterLabel { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.presenterAction { + padding: 0.35rem 0.85rem; + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + color: #fff; + font-size: 0.75rem; + font-weight: bold; + cursor: pointer; + touch-action: manipulation; +} + +.presenterAction:hover { + background: rgba(255, 255, 255, 0.28); +} + +.presenterActionPrimary { + background: #fff; + color: #b4232f; + border-color: #fff; +} + +/* ------------------------------------------------------------------ */ +/* Tabs */ +/* ------------------------------------------------------------------ */ + +.tabs { + display: flex; + gap: 0.25rem; + padding: 0.55rem 1.1rem 0; + border-bottom: 1px solid $ui-black-transparent; + background: $ui-white; +} + +.tab { + padding: 0.55rem 0.95rem; + border: none; + border-radius: 10px 10px 0 0; + background: transparent; + color: $text-primary; + font-size: 0.85rem; + font-weight: bold; + cursor: pointer; + opacity: 0.62; + touch-action: manipulation; +} + +.tab:hover { + opacity: 1; + background: $ui-tertiary; +} + +.tabActive { + opacity: 1; + background: #eef3ff; + box-shadow: inset 0 -3px 0 #4c97ff; +} + +.tabBadge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + margin-left: 0.35rem; + padding: 0 0.3rem; + border-radius: 999px; + background: #b4232f; + color: #fff; + font-size: 0.66rem; +} + +.body { + flex: 1; + min-height: 14rem; + padding: 1rem 1.1rem 1.15rem; + overflow-y: auto; +} + +/* ------------------------------------------------------------------ */ +/* Participant rows */ +/* ------------------------------------------------------------------ */ + +.sectionTitle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin: 0.25rem 0 0.65rem; + font-size: 0.72rem; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.65; +} + +.participantList { + display: flex; + flex-direction: column; + gap: 0.45rem; + margin-bottom: 1.15rem; +} + +.participantRow { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 0.7rem; + border: 1px solid $ui-black-transparent; + border-radius: 12px; + background: #fafbff; +} + +.participantRowSelf { + border-color: #4c97ff; + background: #eef3ff; +} + +.avatar { + position: relative; + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 2.3rem; + height: 2.3rem; + border-radius: 50%; + color: #fff; + font-size: 0.95rem; + font-weight: bold; +} + +.avatarDot { + position: absolute; + right: -1px; + bottom: -1px; + width: 0.85rem; + height: 0.85rem; + border: 2px solid #fafbff; + border-radius: 50%; + background: #2fc156; +} + +.avatarDotOffline { + background: #b8b8b8; +} + +.participantInfo { + flex: 1; + min-width: 0; +} + +.participantNameRow { + display: flex; + align-items: center; + gap: 0.4rem; + min-width: 0; +} + +.participantName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.88rem; + font-weight: bold; +} + +.youTag { + padding: 0.05rem 0.42rem; + border-radius: 999px; + background: #4c97ff; + color: #fff; + font-size: 0.62rem; + font-weight: bold; +} + +.roleBadge { + padding: 0.12rem 0.52rem; + border-radius: 999px; + font-size: 0.65rem; + font-weight: bold; +} + +.roleOwner { + background: #ffd34d; + color: #6b4d00; +} + +.roleEditor { + background: #d7ecff; + color: #1858a8; +} + +.roleViewer { + background: #e8e8e8; + color: #555; +} + +.roleGuest { + background: #e9dcff; + color: #5b2ea6; +} + +.participantMeta { + margin-top: 0.12rem; + font-size: 0.72rem; + opacity: 0.72; +} + +.participantActions { + display: flex; + flex: none; + flex-wrap: wrap; + gap: 0.32rem; + justify-content: flex-end; +} + +.smallButton { + padding: 0.34rem 0.66rem; + border: 1px solid $ui-black-transparent; + border-radius: 8px; + background: #fff; + color: $text-primary; + font-size: 0.72rem; + font-weight: bold; + cursor: pointer; + touch-action: manipulation; +} + +.smallButton:hover { + border-color: #4c97ff; + color: #2f6fd6; +} + +.smallButtonActive { + background: #4c97ff; + border-color: #4c97ff; + color: #fff; +} + +.smallButtonDanger { + border-color: #f0b4b4; + color: #b4232f; +} + +.smallButtonDanger:hover { + background: #b4232f; + border-color: #b4232f; + color: #fff; +} + +/* ------------------------------------------------------------------ */ +/* Invite */ +/* ------------------------------------------------------------------ */ + +.inviteBox { + display: flex; + gap: 0.5rem; + margin-top: 0.55rem; +} + +.inviteInput { + flex: 1; + padding: 0.55rem 0.75rem; + border: 1px solid $ui-black-transparent; + border-radius: 10px; + background: $input-background; + font-size: 0.85rem; +} + +/* ------------------------------------------------------------------ */ +/* Activity feed */ +/* ------------------------------------------------------------------ */ + +.feedList { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.feedEntry { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.55rem 0.7rem; + border-radius: 10px; + background: #f6f7fb; +} + +.feedIcon { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 1.95rem; + height: 1.95rem; + border-radius: 9px; + background: #e3ebff; + font-size: 0.95rem; +} + +.feedText { + flex: 1; + min-width: 0; + font-size: 0.8rem; + line-height: 1.35; +} + +.feedTime { + flex: none; + font-size: 0.68rem; + opacity: 0.6; +} + +.feedEmpty { + padding: 2.25rem 1rem; + text-align: center; + font-size: 0.85rem; + opacity: 0.65; +} + +.feedHighlight { + background: #fff6e3; +} + +.undoBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + margin-bottom: 0.75rem; + padding: 0.65rem 0.85rem; + border-radius: 12px; + background: linear-gradient(120deg, #eef3ff, #f5edff); + font-size: 0.8rem; +} + +.primaryButton { + padding: 0.5rem 1rem; + border: none; + border-radius: 999px; + background: #4c97ff; + color: #fff; + font-size: 0.78rem; + font-weight: bold; + cursor: pointer; + touch-action: manipulation; +} + +.primaryButton:hover { + background: #2f6fd6; +} + +.primaryButton:disabled { + opacity: 0.5; + cursor: default; +} + +/* ------------------------------------------------------------------ */ +/* Hand-raise queue */ +/* ------------------------------------------------------------------ */ + +.queueBox { + margin-bottom: 0.9rem; + padding: 0.75rem 0.85rem; + border: 1px solid #ffd34d; + border-radius: 12px; + background: #fff9e8; +} + +.queueRow { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.35rem 0; + font-size: 0.8rem; +} + +.queueName { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ------------------------------------------------------------------ */ +/* Chat tab */ +/* ------------------------------------------------------------------ */ + +.chatWrap { + display: flex; + flex-direction: column; + min-height: 16rem; + max-height: 22rem; +} + +.chatWrap > * { + flex: 1; + min-height: 0; +} + +/* ------------------------------------------------------------------ */ +/* Follow bar (floating) */ +/* ------------------------------------------------------------------ */ + +.followBar { + position: fixed; + left: 50%; + bottom: calc(env(safe-area-inset-bottom, 0px) + 1.15rem); + z-index: $z-index-alerts; + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.5rem 0.65rem 0.5rem 0.95rem; + border-radius: 999px; + background: #1f2430; + color: #fff; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 0.8rem; + box-shadow: 0 10px 32px rgba(0, 0, 0, 0.38); + transform: translateX(-50%); +} + +.followChip { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.28rem 0.55rem; + border-radius: 999px; + background: rgba(76, 151, 255, 0.3); + font-weight: bold; +} + +.followStop { + border: none; + background: transparent; + color: #fff; + font-size: 0.95rem; + line-height: 1; + cursor: pointer; + opacity: 0.85; +} + +.followStop:hover { + opacity: 1; +} + +.followLive { + background: rgba(180, 35, 47, 0.85); + padding: 0.28rem 0.75rem; + border-radius: 999px; + font-weight: bold; +} + +.followButton { + padding: 0.42rem 0.95rem; + border: none; + border-radius: 999px; + background: #4c97ff; + color: #fff; + font-size: 0.78rem; + font-weight: bold; + cursor: pointer; + touch-action: manipulation; +} + +.followButton:hover { + background: #67a9ff; +} + +.followButtonRaised { + background: #ffd34d; + color: #6b4d00; +} diff --git a/src/components/dash-control-center/follow-bar.jsx b/src/components/dash-control-center/follow-bar.jsx new file mode 100644 index 000000000..0ece13112 --- /dev/null +++ b/src/components/dash-control-center/follow-bar.jsx @@ -0,0 +1,135 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {listenPresence} from '../../lib/dash-presence.js'; +import sessionControl from '../../lib/dash-session-control.js'; +import viewState from '../../lib/dash-view-state.js'; +import activity from '../../lib/dash-activity.js'; +import * as styles from './control-center.css'; + +/** + * Floating status bar for collaboration: follow mode, presentation state + * and the raise-hand button. Rendered by the GUI while a session is active. + */ +class FollowBar extends React.Component { + constructor (props) { + super(props); + this.state = {tick: 0}; + this.unsubscribeList = []; + bindAll(this, ['handleTick']); + } + componentDidMount () { + this.unsubscribeList.push(sessionControl.subscribe(this.handleTick)); + this.unsubscribeList.push(viewState.subscribe(this.handleTick)); + this.unsubscribeList.push(listenPresence(this.props.vm, this.handleTick)); + } + componentWillUnmount () { + for (const unsubscribe of this.unsubscribeList) unsubscribe(); + } + handleTick () { + this.setState({tick: this.state.tick + 1}); + } + render () { + const presence = this.props.vm.dashCollaboration && this.props.vm.dashCollaboration.presence; + const connected = Boolean(presence && presence.getMembers().length); + const control = sessionControl.getState(); + const following = viewState.getFollowTarget(); + const followName = viewState.getFollowName(); + const hasSessionContent = connected || following || control.presentationActive; + if (!hasSessionContent) return null; + return ( +
+ {following && ( +
+ {'\u{1F441}'} + + + + +
+ )} + {control.presentationActive && ( +
+ {'\u{1F3A4} '} + {control.presenterName === 'self' ? ( + + ) : ( + + )} +
+ )} + {control.presentationActive && !control.presenting && control.presenterName !== 'self' && ( + + )} + {control.presenting && (control.handRaiseList || []).length > 0 && ( +
+ {'\u{270B} '} + +
+ )} +
+ ); + } +} + +FollowBar.propTypes = { + vm: PropTypes.object.isRequired +}; + +export default FollowBar; diff --git a/src/components/dash-control-center/participant-list.jsx b/src/components/dash-control-center/participant-list.jsx new file mode 100644 index 000000000..986c07fe5 --- /dev/null +++ b/src/components/dash-control-center/participant-list.jsx @@ -0,0 +1,332 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {listenPresence} from '../../lib/dash-presence.js'; +import sessionControl from '../../lib/dash-session-control.js'; +import viewState from '../../lib/dash-view-state.js'; +import * as styles from './control-center.css'; + +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 initials = name => { + const trimmed = String(name || '?').trim(); + if (!trimmed) return '?'; + const parts = trimmed.split(/\s+/); + return ((parts[0][0] || '?') + (parts[1] ? parts[1][0] : '')).toUpperCase(); +}; + +const ROLE_LABELS = { + owner: {className: 'roleOwner'}, + editor: {className: 'roleEditor'}, + viewer: {className: 'roleViewer'} +}; + +const RoleMessage = ({role}) => { + switch (role) { + case 'owner': return ( + + ); + case 'editor': return ( + + ); + default: return ( + + ); + } +}; + +RoleMessage.propTypes = { + role: PropTypes.string +}; + +class ParticipantList extends React.Component { + constructor (props) { + super(props); + this.state = {tick: 0}; + this.unsubscribe = null; + bindAll(this, ['handleTick', 'handleFollow']); + } + componentDidMount () { + this.unsubscribe = listenPresence(this.props.vm, this.handleTick); + } + componentWillUnmount () { + if (this.unsubscribe) this.unsubscribe(); + } + handleTick () { + this.setState({tick: this.state.tick + 1}); + } + handleFollow (key) { + if (viewState.getFollowTarget() === key) { + viewState.setFollowTarget(null); + } else { + viewState.setFollowTarget(key); + } + } + mergedRows () { + const presence = this.props.vm.dashCollaboration && this.props.vm.dashCollaboration.presence; + const members = presence ? presence.getMembers() : []; + const peers = presence ? presence.getPeers() : []; + const peerByUser = new Map(); + for (const peer of peers) { + peerByUser.set(peer.userId || peer.id, peer); + } + const rows = []; + const seen = new Set(); + for (const member of members) { + const key = member.connectionId || member.userId; + seen.add(String(member.userId)); + const peer = peerByUser.get(member.userId); + rows.push({ + key, + userId: member.userId, + username: member.username, + role: member.role, + online: true, + color: presenceColor(member.userId), + viewTarget: peer && peer.state && peer.state.view ? peer.state.view.targetName : null, + self: Boolean(presence && presence.selfUserId && String(member.userId) === String(presence.selfUserId)) + }); + } + // Offline collaborators from the management list. + for (const collaborator of this.props.collaborators || []) { + const id = String(collaborator.userId || collaborator.id || ''); + if (!id || seen.has(id)) continue; + rows.push({ + key: `offline-${id}`, + userId: id, + username: collaborator.username || id, + role: collaborator.role || 'editor', + online: false, + color: presenceColor(id), + viewTarget: null, + self: false + }); + } + // Keep the local user first, then online, then offline. + rows.sort((a, b) => { + if (a.self !== b.self) return a.self ? -1 : 1; + if (a.online !== b.online) return a.online ? -1 : 1; + return String(a.username).localeCompare(String(b.username)); + }); + return rows; + } + renderRoleBadge (role) { + const meta = ROLE_LABELS[role] || ROLE_LABELS.viewer; + return ( + + + + ); + } + renderActions (row) { + const control = sessionControl.getState(); + const following = viewState.getFollowTarget() === row.key; + const actions = []; + if (!row.self) { + actions.push( + + ); + } + if (control.presenting && !row.self && (control.handRaiseList || []).some(entry => entry.userId === row.userId)) { + actions.push( + + ); + } + if (this.props.canManage && control.isOwner && !row.self && row.role !== 'owner') { + actions.push( + + ); + actions.push( + + ); + actions.push( + + ); + } + return actions; + } + render () { + const rows = this.mergedRows(); + return ( + +
+ + {rows.filter(row => row.online).length} online +
+ {this.props.error ? ( +

{this.props.error}

+ ) : null} +
+ {rows.map(row => ( +
+
+ {initials(row.username)} + +
+
+
+ {row.username} + {row.self && ( + + + + )} + {this.renderRoleBadge(row.role)} +
+
+ {row.online ? ( + row.viewTarget ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+
+ {this.renderActions(row)} +
+
+ ))} +
+
+ ); + } +} + +ParticipantList.propTypes = { + vm: PropTypes.object.isRequired, + collaborators: PropTypes.array, + canManage: PropTypes.bool, + busy: PropTypes.bool, + error: PropTypes.string, + onKick: PropTypes.func, + onBan: PropTypes.func, + onRoleChange: PropTypes.func, + onReload: PropTypes.func +}; + +ParticipantList.defaultProps = { + collaborators: [], + canManage: false +}; + +export default ParticipantList; diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index ecca002df..8c72bf7ea 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -39,6 +39,7 @@ import TWUnknownPlatformModal from '../../containers/tw-unknown-platform-modal.j import TWInvalidProjectModal from '../../containers/tw-invalid-project-modal.jsx'; import DashWelcomeModal from '../../containers/dash-welcome-modal.jsx'; import DashCollaborationStatus from '../dash-collaboration-status/dash-collaboration-status.jsx'; +import FollowBar from '../dash-control-center/follow-bar.jsx'; import {STAGE_SIZE_MODES, FIXED_WIDTH, UNCONSTRAINED_NON_STAGE_WIDTH} from '../../lib/layout-constants'; import {resolveStageSize} from '../../lib/screen-utils'; @@ -346,6 +347,7 @@ const GUIComponent = props => { + )} diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index cfb4441ad..2a7c5d0ff 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -40,6 +40,10 @@ import GUIComponent from '../components/gui/gui.jsx'; import {setIsScratchDesktop} from '../lib/isScratchDesktop.js'; import TWFullScreenResizerHOC from '../lib/tw-fullscreen-resizer-hoc.jsx'; import TWThemeManagerHOC from './tw-theme-manager-hoc.jsx'; +import AddonHooks from '../addons/hooks'; +import viewState from '../lib/dash-view-state.js'; +import sessionControl from '../lib/dash-session-control.js'; +import activity from '../lib/dash-activity.js'; const {RequestMetadata, setMetadata, unsetMetadata} = storage.scratchFetch; @@ -59,6 +63,33 @@ class GUI extends React.Component { this.props.onStorageInit(storage); this.props.onVmInit(this.props.vm); setProjectIdMetadata(this.props.projectId); + + // Collaboration extras: view sharing/follow, presenter mode, activity feed. + const tabNames = ['code', 'costumes', 'sounds']; + this.detachDashCollabExtras = [ + viewState.attach(this.props.vm, { + getTab: () => tabNames[this.props.activeTabIndex] || 'code', + setTab: name => { + const index = tabNames.indexOf(name); + if (index >= 0 && index !== this.props.activeTabIndex) this.props.onActivateTab(index); + }, + getTargetId: () => this.props.vm.editingTarget && this.props.vm.editingTarget.id, + getTargetName: () => this.props.vm.editingTarget && this.props.vm.editingTarget.sprite ? + this.props.vm.editingTarget.sprite.name : null, + setSprite: id => { + if (this.props.vm.editingTarget && this.props.vm.editingTarget.id !== id) { + this.props.vm.setEditingTarget(id); + } + }, + getWorkspace: () => AddonHooks.blocklyWorkspace, + getStageMode: () => null, + setStageMode: () => {} + }), + sessionControl.attach(this.props.vm, this.props.projectId), + activity.attach(this.props.vm) + ]; + // Keep the published view fresh while the user works. + this.dashViewTimer = setInterval(() => viewState.capture(), 900); } componentDidUpdate (prevProps) { if (this.props.projectId !== prevProps.projectId) { @@ -66,6 +97,7 @@ class GUI extends React.Component { this.props.onUpdateProjectId(this.props.projectId); } setProjectIdMetadata(this.props.projectId); + sessionControl.attach(this.props.vm, this.props.projectId); } if (this.props.isShowingProject && !prevProps.isShowingProject) { // this only notifies container when a project changes from not yet loaded to loaded @@ -73,6 +105,13 @@ class GUI extends React.Component { this.props.onProjectLoaded(); } } + componentWillUnmount () { + clearInterval(this.dashViewTimer); + if (this.detachDashCollabExtras) { + for (const detach of this.detachDashCollabExtras) detach(); + this.detachDashCollabExtras = null; + } + } render () { if (this.props.isError) { throw this.props.error; diff --git a/src/lib/dash-activity.js b/src/lib/dash-activity.js new file mode 100644 index 000000000..73e48ff64 --- /dev/null +++ b/src/lib/dash-activity.js @@ -0,0 +1,449 @@ +/** + * @fileoverview Session activity feed with per-user undo and restore. + * + * Records what happened in a collaboration session: + * - Local edits (sprite/costume/sound operations), with undo data captured + * from the VM (delete operations return restore functions; renames store + * the previous name) + * - Remote edits, relayed as best-effort presence action events + * - Session events (join/leave, presentation, speech grants, kicks) + * + * Undo is strictly per-user: each participant can only undo their own + * operations, because shared undo in a live collaboration would destroy + * other people's work. + */ + +const MAX_ENTRIES = 200; +const MAX_UNDO = 50; + +const OP_KINDS = { + addSprite: {icon: 'sprite', verb: 'addedSprite'}, + deleteSprite: {icon: 'sprite', verb: 'deletedSprite', undoable: true}, + renameSprite: {icon: 'sprite', verb: 'renamedSprite', undoable: true}, + duplicateSprite: {icon: 'sprite', verb: 'duplicatedSprite'}, + addCostume: {icon: 'costume', verb: 'addedCostume', undoable: true}, + deleteCostume: {icon: 'costume', verb: 'deletedCostume', undoable: true}, + renameCostume: {icon: 'costume', verb: 'renamedCostume', undoable: true}, + duplicateCostume: {icon: 'costume', verb: 'duplicatedCostume'}, + updateBitmap: {icon: 'costume', verb: 'editedCostume'}, + updateSvg: {icon: 'costume', verb: 'editedCostume'}, + addBackdrop: {icon: 'costume', verb: 'addedBackdrop'}, + addSound: {icon: 'sound', verb: 'addedSound', undoable: true}, + deleteSound: {icon: 'sound', verb: 'deletedSound', undoable: true}, + renameSound: {icon: 'sound', verb: 'renamedSound', undoable: true}, + duplicateSound: {icon: 'sound', verb: 'duplicatedSound'}, + updateSoundBuffer: {icon: 'sound', verb: 'editedSound'}, + shareBlocksToTarget: {icon: 'sprite', verb: 'sharedBlocks'}, + shareCostumeToTarget: {icon: 'costume', verb: 'sharedCostume'}, + shareSoundToTarget: {icon: 'sound', verb: 'sharedSound'}, + reorderTarget: {icon: 'sprite', verb: 'reorderedSprites'}, + reorderCostume: {icon: 'costume', verb: 'reorderedCostumes'}, + reorderSound: {icon: 'sound', verb: 'reorderedSounds'} +}; + +let sequence = 0; +const nextId = () => `a${++sequence}-${Date.now().toString(36)}`; + +const targetNameById = (vm, id) => { + try { + const target = vm && vm.runtime && vm.runtime.getTargetById && vm.runtime.getTargetById(id); + return target && target.sprite ? target.sprite.name : null; + } catch (error) { + return null; + } +}; + +const editingTargetName = vm => { + try { + return vm && vm.editingTarget && vm.editingTarget.sprite ? vm.editingTarget.sprite.name : null; + } catch (error) { + return null; + } +}; + +class DashActivity { + constructor () { + this.entries = []; + this.undoStack = []; // newest first; entries with inverse() + this.listeners = new Set(); + this.vm = null; + this.statusListener = null; + this.eventUnsubscribe = null; + this.membersUnsubscribe = null; + this.knownMembers = new Set(); + } + + /* ------------------------------------------------------------------ */ + /* Wiring */ + /* ------------------------------------------------------------------ */ + + /** + * Attach to a VM collaboration session (re-attaches on restart). + * @param {VirtualMachine} vm the VM owning the session + * @returns {function} detach + */ + attach (vm) { + this.detach(); + this.vm = vm; + const bind = () => { + const collaboration = vm.dashCollaboration || null; + const presence = collaboration && collaboration.presence; + if (this.eventUnsubscribe) { + this.eventUnsubscribe(); + this.eventUnsubscribe = null; + } + if (presence) { + this.eventUnsubscribe = presence.onEvent(({peer, action}) => { + this.recordRemote(peer, action); + }); + } + }; + this.statusListener = bind; + vm.on('DASH_COLLABORATION_STATUS', bind); + bind(); + return () => this.detach(); + } + + detach () { + if (this.vm && this.statusListener) { + this.vm.removeListener('DASH_COLLABORATION_STATUS', this.statusListener); + } + if (this.eventUnsubscribe) { + this.eventUnsubscribe(); + this.eventUnsubscribe = null; + } + this.vm = null; + } + + /* ------------------------------------------------------------------ */ + /* Recording */ + /* ------------------------------------------------------------------ */ + + /** + * Called by the collaboration session wrapper before a local VM + * operation runs. Captures context for the feed and for undo. + * @param {string} name VM method name + * @param {Array} args method arguments + * @param {VirtualMachine} vm the VM + * @returns {?object} handle for endLocalOperation + */ + beginLocalOperation (name, args, vm) { + const kind = OP_KINDS[name]; + if (!kind) return null; + const handle = {name, kind, args, vm, before: null, targetName: null}; + try { + switch (name) { + case 'deleteSprite': + handle.targetName = targetNameById(vm, args[0]) || editingTargetName(vm); + break; + case 'renameSprite': + handle.targetName = targetNameById(vm, args[0]); + handle.before = {previousName: handle.targetName, id: args[0]}; + break; + case 'renameCostume': + handle.targetName = editingTargetName(vm); + handle.before = {previousName: this.currentCostumeName(vm, args[0]), index: args[0]}; + break; + case 'renameSound': + handle.targetName = editingTargetName(vm); + handle.before = {previousName: this.currentSoundName(vm, args[0]), index: args[0]}; + break; + case 'deleteCostume': + handle.targetName = editingTargetName(vm); + handle.before = {name: this.currentCostumeName(vm, args[0]), index: args[0]}; + break; + case 'deleteSound': + handle.targetName = editingTargetName(vm); + handle.before = {name: this.currentSoundName(vm, args[0]), index: args[0]}; + break; + case 'addSprite': + handle.targetName = this.spriteNameFromArg(args[0]); + break; + case 'addCostume': + handle.targetName = editingTargetName(vm); + handle.before = {name: args[0] && args[0].name}; + break; + case 'addSound': + handle.targetName = editingTargetName(vm); + handle.before = {name: args[0] && args[0].name}; + break; + default: + handle.targetName = editingTargetName(vm); + } + } catch (error) { + // Feed context is best-effort. + } + return handle; + } + + /** + * Called after the local operation finished. Records the entry and, + * when possible, stores an inverse for personal undo. Also broadcasts + * a remote-visible action event. + * @param {?object} handle from beginLocalOperation + * @param {*} result return value of the VM method (may hold a restore fn) + */ + endLocalOperation (handle, result) { + if (!handle) return; + const {name, kind, before, targetName} = handle; + let inverse = null; + const vm = handle.vm; + + const resolveRestore = value => { + if (typeof value === 'function') return value; + if (value && typeof value.then === 'function') { + // deleteSprite returns a promise of a restore function. + return () => Promise.resolve(value).then(restore => { + if (typeof restore === 'function') return restore(); + return undefined; + }); + } + return null; + }; + + switch (name) { + case 'deleteSprite': + case 'deleteCostume': + case 'deleteSound': + inverse = resolveRestore(result); + break; + case 'renameSprite': + if (before && before.previousName && before.previousName !== targetName) { + const id = before.id; + const previous = before.previousName; + inverse = () => vm.renameSprite(id, previous); + } + break; + case 'renameCostume': + if (before && before.previousName) { + const index = before.index; + const previous = before.previousName; + inverse = () => vm.renameCostume(index, previous); + } + break; + case 'renameSound': + if (before && before.previousName) { + const index = before.index; + const previous = before.previousName; + inverse = () => vm.renameSound(index, previous); + } + break; + case 'addCostume': + case 'addSound': + // The added asset lands at the end of the list of the editing + // target: undo removes it again. + { + const targetId = vm && vm.editingTarget && vm.editingTarget.id; + if (targetId && name === 'addSound') { + inverse = () => { + const target = vm.runtime.getTargetById(targetId); + const count = target && target.getSounds ? target.getSounds().length : 0; + if (count > 0) return vm.deleteSound(count - 1); + return undefined; + }; + } else if (targetId && name === 'addCostume') { + inverse = () => { + const target = vm.runtime.getTargetById(targetId); + const count = target && target.getCostumes ? target.getCostumes().length : 0; + if (count > 1) return vm.deleteCostume(count - 1); + return undefined; + }; + } + } + break; + default: + break; + } + + const entry = this.push({ + source: 'local', + kind: name, + icon: kind.icon, + verb: kind.verb, + targetName, + extraName: before && before.name, + undoable: Boolean(inverse) + }); + if (inverse) { + this.undoStack.unshift({entryId: entry.id, inverse, label: entry}); + if (this.undoStack.length > MAX_UNDO) this.undoStack.pop(); + } + this.broadcastAction(entry); + } + + /** + * Record a remote action relayed through presence. + * @param {object} peer presence peer + * @param {object} action action payload + */ + recordRemote (peer, action) { + if (!action || typeof action.kind !== 'string') return; + const kind = OP_KINDS[action.kind]; + this.push({ + source: 'remote', + userId: peer.userId, + username: peer.username, + kind: action.kind, + icon: (kind && kind.icon) || 'edit', + verb: (kind && kind.verb) || 'changedProject', + targetName: typeof action.targetName === 'string' ? action.targetName : null, + extraName: typeof action.extraName === 'string' ? action.extraName : null, + undoable: false + }); + } + + /** + * Record a session-level event (presence, roles, presentation...). + * @param {object} event {verb, username, targetName, icon} + */ + recordSession (event) { + return this.push({ + source: 'session', + kind: event.kind || 'session', + icon: event.icon || 'people', + verb: event.verb, + username: event.username || null, + userId: event.userId || null, + targetName: event.targetName || null, + extraName: event.extraName || null, + undoable: false + }); + } + + push (entry) { + const record = { + id: nextId(), + ts: Date.now(), + username: 'self', + ...entry + }; + this.entries.push(record); + if (this.entries.length > MAX_ENTRIES) this.entries.shift(); + this.notify(); + return record; + } + + broadcastAction (entry) { + try { + const presence = this.vm && this.vm.dashCollaboration && this.vm.dashCollaboration.presence; + if (!presence) return; + presence.update({ + action: { + kind: entry.kind, + targetName: entry.targetName || undefined, + extraName: entry.extraName || undefined + } + }); + // Actions are events, not state: clear so they do not stick. + setTimeout(() => presence.update({action: null}), 100); + } catch (error) { + // Presence is optional. + } + } + + /* ------------------------------------------------------------------ */ + /* Undo / restore */ + /* ------------------------------------------------------------------ */ + + canUndoEntry (entryId) { + return this.undoStack.some(item => item.entryId === entryId); + } + + getUndoableEntries () { + return this.undoStack.map(item => item.label); + } + + /** + * Undo one of the local user's own operations. + * @param {?string} entryId entry to undo; defaults to the most recent + * @returns {Promise} result of the inverse operation + */ + async undoEntry (entryId) { + const index = entryId ? + this.undoStack.findIndex(item => item.entryId === entryId) : + 0; + if (index < 0) throw new Error('This action cannot be undone'); + const [item] = this.undoStack.splice(index, 1); + try { + const result = item.inverse(); + await result; + } finally { + this.push({ + source: 'local', + kind: 'undo', + icon: 'undo', + verb: 'undidAction', + targetName: item.label.targetName || null, + undoable: false + }); + } + return true; + } + + /* ------------------------------------------------------------------ */ + /* Name lookups (best-effort, before the change is applied) */ + /* ------------------------------------------------------------------ */ + + currentCostumeName (vm, index) { + try { + const target = vm && vm.editingTarget; + const costumes = target && target.getCostumes ? target.getCostumes() : null; + const costume = costumes && costumes[index]; + return costume ? costume.name : null; + } catch (error) { + return null; + } + } + + currentSoundName (vm, index) { + try { + const target = vm && vm.editingTarget; + const sounds = target && target.getSounds ? target.getSounds() : null; + const sound = sounds && sounds[index]; + return sound ? sound.name : null; + } catch (error) { + return null; + } + } + + spriteNameFromArg (arg) { + try { + if (typeof arg !== 'string') return null; + const parsed = JSON.parse(arg); + return parsed && parsed.name ? parsed.name : null; + } catch (error) { + return null; + } + } + + /* ------------------------------------------------------------------ */ + /* Subscriptions */ + /* ------------------------------------------------------------------ */ + + subscribe (listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + notify () { + for (const listener of [...this.listeners]) { + try { + listener(); + } catch (error) { + // Listener errors must never break the feed. + } + } + } + + getState () { + return { + entries: [...this.entries].reverse(), + undoable: this.getUndoableEntries() + }; + } +} + +const activity = new DashActivity(); + +export default activity; +export {OP_KINDS}; diff --git a/src/lib/dash-collaboration-session.js b/src/lib/dash-collaboration-session.js index 044b5522e..cfcf35baf 100644 --- a/src/lib/dash-collaboration-session.js +++ b/src/lib/dash-collaboration-session.js @@ -4,6 +4,8 @@ import AddonHooks from '../addons/hooks'; import DashCollaboration from './dash-collaboration'; import DashPresence from './dash-presence'; import DashChat from './dash-chat'; +import activity from './dash-activity'; +import sessionControl from './dash-session-control'; const maps = ['blocks', 'variables', 'lists', 'broadcasts', 'comments']; const blockStructureFields = ['opcode', 'next', 'parent', 'inputs', 'shadow', 'mutation']; @@ -624,9 +626,15 @@ export default class DashCollaborationSession { if (typeof original !== 'function') continue; const wrapped = (...args) => { this.vm.assertCollaborationWritable(); + // Presentation mode: only the presenter (or granted speakers) edit. + if (!sessionControl.canEdit()) { + throw new Error('The presenter locked editing. Raise your hand to ask for speech.'); + } this.localOperations++; - const finish = () => { + const activityHandle = activity.beginLocalOperation(name, args, this.vm); + const finish = value => { this.localOperations--; + activity.endLocalOperation(activityHandle, value); this.onLocalChange(); }; let result; @@ -636,8 +644,16 @@ export default class DashCollaborationSession { finish(); throw error; } - if (result && typeof result.then === 'function') return Promise.resolve(result).finally(finish); - finish(); + if (result && typeof result.then === 'function') { + return Promise.resolve(result).then(value => { + finish(value); + return value; + }, error => { + finish(); + throw error; + }); + } + finish(result); return result; }; this.operationMethods.set(name, {original, wrapped}); @@ -707,7 +723,7 @@ export default class DashCollaborationSession { canEdit () { return this.loaded && Boolean(this.base) && !this.restoreOnReconnect && !this.disposed && !this.applying && this.client.state === 'connected' && - this.client.role !== 'viewer'; + this.client.role !== 'viewer' && sessionControl.canEdit(); } fail (error) { this.client.fail(error.message || String(error)); diff --git a/src/lib/dash-presence.js b/src/lib/dash-presence.js index 172afb094..c0e51c743 100644 --- a/src/lib/dash-presence.js +++ b/src/lib/dash-presence.js @@ -71,6 +71,7 @@ class DashPresence { this.send = null; this.sendTimer = null; this.notifyTimer = null; + this.eventListeners = new Set(); this.pruneTimer = setInterval(() => this.prune(), 3000); } /** @@ -128,14 +129,26 @@ class DashPresence { 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, { + const peer = { 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.peers.set(key, peer); + // Action events are firehose events (activity feed): dispatch per + // message so consecutive actions are never collapsed by state updates. + if (state.action && typeof state.action === 'object' && !Array.isArray(state.action)) { + for (const listener of [...this.eventListeners]) { + try { + listener({peer, action: state.action}); + } catch (error) { + // Event listeners must never break presence. + } + } + } this.scheduleNotify(); } prune () { @@ -159,6 +172,15 @@ class DashPresence { this.listeners.add(listener); return () => this.listeners.delete(listener); } + /** + * Subscribe to remote action events (activity feed firehose). + * @param {function({peer: object, action: object}): void} listener called per event + * @returns {function} unsubscribe + */ + onEvent (listener) { + this.eventListeners.add(listener); + return () => this.eventListeners.delete(listener); + } scheduleNotify () { if (this.notifyTimer) return; this.notifyTimer = setTimeout(() => { diff --git a/src/lib/dash-session-control.js b/src/lib/dash-session-control.js new file mode 100644 index 000000000..f8cd9495e --- /dev/null +++ b/src/lib/dash-session-control.js @@ -0,0 +1,353 @@ +/** + * @fileoverview Session control for collaboration: presenter mode, hand-raise + * queue, kick and session bans. + * + * Presenter mode ("Ведущий"): the project owner leads a lesson or stream. + * Everyone else becomes an audience (read-only) and can raise their hand to + * ask for speech. The presenter can grant speech to individual participants. + * + * Transport: best-effort presence channel (`control` field), so viewers can + * participate too and nothing touches the persistent project document. + * Kicks additionally revoke the collaborator grant on the server, which + * forces the removed client's session to close (server-enforced). + */ + +import {requestDashApi} from './dash-api.js'; + +const BANS_STORAGE_KEY = 'dash-session-bans'; + +const now = () => Date.now(); + +const loadBans = projectId => { + try { + const raw = localStorage.getItem(`${BANS_STORAGE_KEY}:${projectId}`); + const parsed = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (error) { + return {}; + } +}; + +const saveBans = (projectId, bans) => { + try { + localStorage.setItem(`${BANS_STORAGE_KEY}:${projectId}`, JSON.stringify(bans)); + } catch (error) { + // Persistence is optional. + } +}; + +class DashSessionControl { + constructor () { + this.vm = null; + this.listeners = new Set(); + this.statusListener = null; + this.presenceUnsubscribe = null; + this.selfUserId = null; + this.selfConnectionId = null; + + // Local intents (published through presence). + this.presenting = false; + this.handRaisedAt = 0; + this.grantedUsers = new Map(); // userId -> timestamp (presenter grants) + + // Mirrored remote state. + this.remotePresenterUserId = null; + this.remoteHandRaises = new Map(); // userId -> timestamp + this.bans = {}; // userId -> {username, ts} + + this.projectId = null; + } + + /* ------------------------------------------------------------------ */ + /* Wiring */ + /* ------------------------------------------------------------------ */ + + /** + * Attach to a VM collaboration session (re-attaches on restart). + * @param {VirtualMachine} vm the VM owning the session + * @param {?string} projectId project id for persistent ban storage + * @returns {function} detach + */ + attach (vm, projectId) { + this.detach(); + this.vm = vm; + this.projectId = projectId ? String(projectId) : null; + if (this.projectId) this.bans = loadBans(this.projectId); + const bind = () => { + const presence = (vm.dashCollaboration && vm.dashCollaboration.presence) || null; + if (this.presenceUnsubscribe) { + this.presenceUnsubscribe(); + this.presenceUnsubscribe = null; + } + if (presence) { + this.presenceUnsubscribe = presence.subscribe(() => this.onPresenceChange()); + this.pushControl(true); + } + this.onPresenceChange(); + }; + this.statusListener = bind; + vm.on('DASH_COLLABORATION_STATUS', bind); + bind(); + return () => this.detach(); + } + + detach () { + if (this.vm && this.statusListener) { + this.vm.removeListener('DASH_COLLABORATION_STATUS', this.statusListener); + } + if (this.presenceUnsubscribe) { + this.presenceUnsubscribe(); + this.presenceUnsubscribe = null; + } + this.vm = null; + } + + presence () { + return (this.vm && this.vm.dashCollaboration && this.vm.dashCollaboration.presence) || null; + } + + client () { + // vm.dashCollaboration is the session wrapper; the transport with the + // role lives on its .client property (fallback for plain transports). + const session = (this.vm && this.vm.dashCollaboration) || null; + if (!session) return null; + return session.client || session; + } + + /* ------------------------------------------------------------------ */ + /* Roles helpers */ + /* ------------------------------------------------------------------ */ + + getRole () { + const client = this.client(); + return client ? client.role : null; + } + + isOwner () { + return this.getRole() === 'owner'; + } + + canEdit () { + const client = this.client(); + if (!client || client.role === 'viewer') return false; + if (!this.isPresentationActive()) return true; + return this.isPresenterSelf() || this.isSpeechGranted(this.selfUserId); + } + + /* ------------------------------------------------------------------ */ + /* Presenter mode */ + /* ------------------------------------------------------------------ */ + + isPresentationActive () { + return Boolean(this.presenting || this.remotePresenterUserId); + } + + isPresenterSelf () { + return this.presenting || (this.isOwner() && this.isPresentationActive() && + !this.remotePresenterUserId); + } + + getPresenterName () { + const presence = this.presence(); + if (!this.isPresentationActive()) return null; + if (this.presenting) return 'self'; + const members = presence ? presence.getMembers() : []; + const member = members.find(entry => entry.userId === this.remotePresenterUserId); + return member ? member.username : null; + } + + /** Owner starts a presentation. */ + startPresentation () { + if (!this.isOwner()) return; + this.presenting = true; + this.grantedUsers.clear(); + this.pushControl(true); + this.notify(); + } + + /** Owner (or forced reset) stops the presentation. */ + stopPresentation () { + this.presenting = false; + this.grantedUsers.clear(); + this.pushControl(true); + this.notify(); + } + + /* ------------------------------------------------------------------ */ + /* Hand-raise queue */ + /* ------------------------------------------------------------------ */ + + isHandRaised (userId) { + const id = userId || this.selfUserId; + if (!id) return false; + return id === this.selfUserId ? this.handRaisedAt > 0 : this.remoteHandRaises.has(id); + } + + getHandRaiseList () { + const presence = this.presence(); + const members = presence ? presence.getMembers() : []; + const entries = []; + for (const [userId, ts] of [...this.remoteHandRaises]) { + const member = members.find(entry => entry.userId === userId); + entries.push({userId, username: member ? member.username : userId, ts}); + } + if (this.handRaisedAt > 0) { + entries.push({userId: this.selfUserId, username: 'self', ts: this.handRaisedAt, self: true}); + } + return entries.sort((a, b) => a.ts - b.ts); + } + + requestSpeech () { + if (!this.isPresentationActive() || this.isPresenterSelf()) return; + this.handRaisedAt = this.handRaisedAt ? 0 : now(); + this.pushControl(true); + this.notify(); + } + + grantSpeech (userId) { + if (!this.isPresenterSelf()) return; + this.grantedUsers.set(String(userId), now()); + this.pushControl(true); + this.notify(); + } + + revokeSpeech (userId) { + if (!this.isPresenterSelf()) return; + this.grantedUsers.delete(String(userId)); + this.pushControl(true); + this.notify(); + } + + isSpeechGranted (userId) { + if (!userId) return false; + return this.grantedUsers.has(String(userId)); + } + + /* ------------------------------------------------------------------ */ + /* Kick and session bans */ + /* ------------------------------------------------------------------ */ + + /** + * Remove a collaborator from the project (server-enforced kick). + * With `ban`, also remember the user so the owner's UI refuses re-adding + * them, and broadcast the ban so clients show a proper message. + * @param {string} userId collaborator user id + * @param {{username: ?string, ban: boolean}} options kick options + */ + async kickUser (userId, options = {}) { + if (!this.isOwner() || !this.projectId) throw new Error('Only the project owner can remove collaborators'); + const id = String(userId); + const response = await requestDashApi( + `/projects/${this.projectId}/collaborators/${encodeURIComponent(id)}`, + {method: 'DELETE', credentials: 'include'} + ); + if (!response.ok) { + let message = `Could not remove collaborator (${response.status})`; + try { + const data = await response.json(); + if (data && data.error) message = data.error; + } catch (error) { + // Keep the generic message. + } + throw new Error(message); + } + if (options.ban) { + this.bans[id] = {username: options.username || id, ts: now()}; + if (this.projectId) saveBans(this.projectId, this.bans); + } + this.pushControl(true); + this.notify(); + return true; + } + + isBanned (userId) { + return Boolean(this.bans[String(userId)]); + } + + getBans () { + return Object.entries(this.bans).map(([userId, entry]) => ({userId, ...entry})); + } + + unban (userId) { + delete this.bans[String(userId)]; + if (this.projectId) saveBans(this.projectId, this.bans); + this.pushControl(true); + this.notify(); + } + + /* ------------------------------------------------------------------ */ + /* Presence transport */ + /* ------------------------------------------------------------------ */ + + pushControl (immediate = false) { + const presence = this.presence(); + if (!presence) return; + const control = { + presenting: this.presenting, + handRaise: this.handRaisedAt, + granted: [...this.grantedUsers.keys()], + bans: this.isOwner() ? Object.keys(this.bans) : undefined + }; + if (immediate) presence.update({control}); + else presence.update({control}); + } + + onPresenceChange () { + const presence = this.presence(); + if (!presence) return; + const self = presence.selfUserId; + if (self) this.selfUserId = String(self); + // Merge peer control states. + let presenter = null; + const raises = new Map(); + for (const peer of presence.getPeers()) { + const control = peer.state && peer.state.control; + if (!control) continue; + if (control.presenting) presenter = String(peer.userId); + if (Number.isFinite(control.handRaise) && control.handRaise > 0) { + raises.set(String(peer.userId), control.handRaise); + } + } + this.remotePresenterUserId = presenter; + this.remoteHandRaises = raises; + this.notify(); + } + + /* ------------------------------------------------------------------ */ + /* Subscriptions */ + /* ------------------------------------------------------------------ */ + + subscribe (listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + notify () { + for (const listener of [...this.listeners]) { + try { + listener(); + } catch (error) { + // Listener errors must never break the channel. + } + } + } + + /** UI snapshot. */ + getState () { + return { + role: this.getRole(), + isOwner: this.isOwner(), + presenting: this.presenting, + presentationActive: this.isPresentationActive(), + presenterName: this.getPresenterName(), + handRaised: this.isHandRaised(), + handRaiseList: this.getHandRaiseList(), + canEdit: this.canEdit(), + bans: this.getBans() + }; + } +} + +const sessionControl = new DashSessionControl(); + +export default sessionControl; diff --git a/src/lib/dash-view-state.js b/src/lib/dash-view-state.js new file mode 100644 index 000000000..18ceec16b --- /dev/null +++ b/src/lib/dash-view-state.js @@ -0,0 +1,268 @@ +/** + * @fileoverview View-state sharing and follow mode for collaboration sessions. + * + * Every participant can publish what they are looking at (editor tab, sprite, + * workspace zoom/scroll, stage mode) through the best-effort presence channel. + * "Follow" applies a peer's view continuously, so classrooms and streams can + * keep everyone on the same screen. + * + * The module never owns UI: GUI provides an adapter with getters/setters for + * the parts of the editor it can drive (tab, sprite, workspace view, stage). + */ + +const SEND_THROTTLE = 180; + +const VIEW_FIELDS = ['tab', 'targetId', 'targetName', 'scale', 'scrollX', 'scrollY', 'stageMode']; + +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + +const sameView = (a, b) => { + if (!a || !b) return false; + return VIEW_FIELDS.every(field => a[field] === b[field]); +}; + +class DashViewState { + constructor () { + this.vm = null; + this.adapter = null; + this.local = { + tab: null, + targetId: null, + targetName: null, + scale: null, + scrollX: null, + scrollY: null, + stageMode: null + }; + this.followTargetKey = null; // presence peer key (connectionId/userId) + this.listeners = new Set(); + this.presenceUnsubscribe = null; + this.statusListener = null; + this.sendTimer = null; + this.applyingRemote = false; + } + + /* ------------------------------------------------------------------ */ + /* Wiring */ + /* ------------------------------------------------------------------ */ + + /** + * Attach to a VM's collaboration session (re-attaches automatically + * when the session restarts). + * @param {VirtualMachine} vm the VM owning the session + * @param {object} adapter {getTab, setTab, getWorkspace, setSprite, getStageMode, setStageMode} + * @returns {function} detach + */ + attach (vm, adapter) { + this.detach(); + this.vm = vm; + this.adapter = adapter; + const bind = () => { + const presence = (vm.dashCollaboration && vm.dashCollaboration.presence) || null; + if (this.presenceUnsubscribe) { + this.presenceUnsubscribe(); + this.presenceUnsubscribe = null; + } + if (presence) { + this.presenceUnsubscribe = presence.subscribe(() => this.onPresenceChange()); + this.pushLocal(true); + } + this.onPresenceChange(); + }; + this.statusListener = bind; + vm.on('DASH_COLLABORATION_STATUS', bind); + bind(); + return () => this.detach(); + } + + detach () { + if (this.vm && this.statusListener) { + this.vm.removeListener('DASH_COLLABORATION_STATUS', this.statusListener); + } + if (this.presenceUnsubscribe) { + this.presenceUnsubscribe(); + this.presenceUnsubscribe = null; + } + clearTimeout(this.sendTimer); + this.vm = null; + this.adapter = null; + this.followTargetKey = null; + } + + /* ------------------------------------------------------------------ */ + /* Local view publishing */ + /* ------------------------------------------------------------------ */ + + /** + * Publish a patch of the local view. Throttled; ignored while following + * someone else (their view is what matters then). + * @param {object} patch partial view fields + */ + update (patch) { + if (this.applyingRemote) return; + let changed = false; + for (const field of VIEW_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(patch, field)) continue; + if (this.local[field] !== patch[field]) { + this.local[field] = patch[field]; + changed = true; + } + } + if (changed) { + this.notify(); + this.pushLocal(); + } + } + + /** Publish the current view immediately from the adapter. */ + capture () { + if (!this.adapter) return; + const workspace = typeof this.adapter.getWorkspace === 'function' ? this.adapter.getWorkspace() : null; + this.update({ + tab: this.adapter.getTab ? this.adapter.getTab() : null, + targetId: this.adapter.getTargetId ? this.adapter.getTargetId() : null, + targetName: this.adapter.getTargetName ? this.adapter.getTargetName() : null, + scale: workspace ? Math.round((workspace.scale || 1) * 100) / 100 : null, + scrollX: workspace ? Math.round(workspace.scrollX || 0) : null, + scrollY: workspace ? Math.round(workspace.scrollY || 0) : null, + stageMode: this.adapter.getStageMode ? this.adapter.getStageMode() : null + }); + } + + pushLocal (immediate = false) { + const presence = this.presence(); + if (!presence) return; + if (this.followTargetKey) return; // do not echo a followed view + const send = () => presence.update({view: {...this.local}}); + if (immediate) { + clearTimeout(this.sendTimer); + send(); + return; + } + if (this.sendTimer) return; + this.sendTimer = setTimeout(() => { + this.sendTimer = null; + send(); + }, SEND_THROTTLE); + } + + presence () { + return (this.vm && this.vm.dashCollaboration && this.vm.dashCollaboration.presence) || null; + } + + /* ------------------------------------------------------------------ */ + /* Follow mode */ + /* ------------------------------------------------------------------ */ + + /** + * Follow a peer (or stop with null). While following, the peer's view is + * applied to the local editor on every presence update. + * @param {?string} key presence peer key (connectionId or userId) + */ + setFollowTarget (key) { + if (this.followTargetKey === key) return; + this.followTargetKey = key; + if (!key) { + // Resume publishing our own view. + this.pushLocal(true); + } else { + this.onPresenceChange(); + } + this.notify(); + } + + getFollowTarget () { + return this.followTargetKey; + } + + /** Human-readable name of the followed peer, if any. */ + getFollowName () { + const peer = this.findPeer(this.followTargetKey); + return peer ? peer.username : null; + } + + findPeer (key) { + const presence = this.presence(); + if (!presence || !key) return null; + return presence.getPeers().find(peer => + `${peer.connectionId || ''}:${peer.userId || ''}` === key || peer.userId === key + ) || null; + } + + onPresenceChange () { + if (!this.followTargetKey || !this.adapter) return; + const peer = this.findPeer(this.followTargetKey); + if (!peer) { + // Peer vanished: stop following rather than freezing the view. + this.setFollowTarget(null); + return; + } + const view = peer.state && peer.state.view; + if (!view) return; + this.applyRemoteView(view); + this.notify(); + } + + /** + * Apply a remote view to the local editor, guarding against loops. + * @param {object} view remote view fields + */ + applyRemoteView (view) { + if (!this.adapter || !view) return; + this.applyingRemote = true; + try { + if (typeof view.tab === 'string' && this.adapter.setTab) { + this.adapter.setTab(view.tab); + } + if (view.targetId && this.adapter.setSprite) { + this.adapter.setSprite(view.targetId); + } + const workspace = this.adapter.getWorkspace ? this.adapter.getWorkspace() : null; + if (workspace && Number.isFinite(view.scale)) { + const scale = clamp(view.scale, 0.25, 4); + if (Math.abs((workspace.scale || 1) - scale) > 0.01) workspace.scale = scale; + if (Number.isFinite(view.scrollX)) workspace.scrollX = view.scrollX; + if (Number.isFinite(view.scrollY)) workspace.scrollY = view.scrollY; + if (typeof workspace.resize === 'function') workspace.resize(); + } + if (typeof view.stageMode === 'string' && this.adapter.setStageMode) { + this.adapter.setStageMode(view.stageMode); + } + } finally { + this.applyingRemote = false; + } + } + + /* ------------------------------------------------------------------ */ + /* Subscriptions */ + /* ------------------------------------------------------------------ */ + + subscribe (listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + notify () { + for (const listener of [...this.listeners]) { + try { + listener(); + } catch (error) { + // Listener errors must never break the channel. + } + } + } + + /** Snapshot for debugging/UI: local view + follow target. */ + getState () { + return { + local: {...this.local}, + followTargetKey: this.followTargetKey, + followName: this.getFollowName() + }; + } +} + +const viewState = new DashViewState(); + +export default viewState; +export {sameView, VIEW_FIELDS}; From 70546e7662f24983d7781d66758d9ed27b6360c5 Mon Sep 17 00:00:00 2001 From: Professionalarduino-max Date: Fri, 16 Oct 2026 19:14:13 +0000 Subject: [PATCH 2/2] feat(collaboration): activity diffs, targeted restore and auto-follow the presenter - Activity entries capture before/after diffs (renames show old and new names, deletes show a sprite summary); click an entry to expand the diff, including entries relayed from other participants - Targeted Restore: bring back a deleted sprite/costume/sound or roll a name back straight from the history, independent of the undo stack - While a presentation is live the audience automatically follows the presenter's view (tab, sprite, zoom, scroll); dismissing the follow chip keeps it off until the presenter changes - Owner crown badge and a gold ring on the presenting participant --- .../dash-control-center/activity-feed.jsx | 86 +++++++++++++-- .../dash-control-center/control-center.css | 62 +++++++++++ .../dash-control-center/participant-list.jsx | 103 ++++++++++-------- src/lib/dash-activity.js | 82 +++++++++++++- src/lib/dash-session-control.js | 32 ++++++ 5 files changed, 306 insertions(+), 59 deletions(-) diff --git a/src/components/dash-control-center/activity-feed.jsx b/src/components/dash-control-center/activity-feed.jsx index d44c72b6d..bb31a793c 100644 --- a/src/components/dash-control-center/activity-feed.jsx +++ b/src/components/dash-control-center/activity-feed.jsx @@ -36,6 +36,7 @@ const VERBS = { removedUser: {id: 'dash.activity.removedUser', defaultMessage: 'removed'}, bannedUser: {id: 'dash.activity.bannedUser', defaultMessage: 'banned'}, raisedHand: {id: 'dash.activity.raisedHand', defaultMessage: 'raised their hand'}, + restoredEntry: {id: 'dash.activity.restoredEntry', defaultMessage: 'restored an earlier state of'}, joined: {id: 'dash.activity.joined', defaultMessage: 'joined the session'}, left: {id: 'dash.activity.left', defaultMessage: 'left the session'} }; @@ -58,9 +59,9 @@ const formatTime = ts => { class ActivityFeed extends React.Component { constructor (props) { super(props); - this.state = {tick: 0, busy: false, error: null}; + this.state = {tick: 0, busy: false, error: null, expanded: null}; this.unsubscribe = null; - bindAll(this, ['handleTick', 'handleUndo', 'handleUndoEntry']); + bindAll(this, ['handleTick', 'handleUndo', 'handleUndoEntry', 'handleRestoreEntry', 'handleToggleExpand']); } componentDidMount () { this.unsubscribe = activity.subscribe(this.handleTick); @@ -71,6 +72,9 @@ class ActivityFeed extends React.Component { handleTick () { this.setState({tick: this.state.tick + 1}); } + handleToggleExpand (entryId) { + this.setState({expanded: this.state.expanded === entryId ? null : entryId}); + } async handleUndo () { if (this.state.busy) return; this.setState({busy: true, error: null}); @@ -93,22 +97,74 @@ class ActivityFeed extends React.Component { this.setState({busy: false}); } } + async handleRestoreEntry (entryId) { + if (this.state.busy) return; + this.setState({busy: true, error: null}); + try { + await activity.restoreEntry(entryId); + } catch (error) { + this.setState({error: error.message}); + } finally { + this.setState({busy: false}); + } + } + renderDiff (entry) { + const diff = entry.diff; + if (!diff) return null; + return ( +
+ {typeof diff.before === 'string' && typeof diff.after === 'string' ? ( +
+ {diff.before ? `\u2190 "${diff.before}"` : '\u2190 (new)'} + {' \u27A1 '} + {diff.after ? `"${diff.after}"` : '(deleted)'} +
+ ) : null} + {typeof diff.before === 'string' && diff.after === null ? ( +
+ {`\u2190 "${diff.before}"`} + {' \u27A1 '} + {'(deleted)'} +
+ ) : null} + {diff.before === null && typeof diff.after === 'string' ? ( +
+ {'\u2190 (new)'} + {' \u27A1 '} + {`"${diff.after}"`} +
+ ) : null} + {diff.summary ? ( +
{diff.summary}
+ ) : null} +
+ ); + } renderEntry (entry) { const verb = VERBS[entry.verb] || VERBS.changedProject; + const expanded = this.state.expanded === entry.id; + const expandable = Boolean(entry.diff); return (
-
{ICONS[entry.icon] || ICONS.edit}
-
+
this.handleToggleExpand(entry.id) : undefined} + > + {ICONS[entry.icon] || ICONS.edit} +
+
this.handleToggleExpand(entry.id) : undefined} + > {entry.username === 'self' ? 'You' : entry.username} {' '} {this.props.intl.formatMessage(verb)} {entry.targetName ? ` "${entry.targetName}"` : ''} - {entry.extraName && entry.verb === 'renamedSprite' ? ` (${entry.extraName})` : ''} - {entry.extraName && entry.verb === 'renamedCostume' ? ` (${entry.extraName})` : ''} - {entry.extraName && entry.verb === 'renamedSound' ? ` (${entry.extraName})` : ''} + {entry.extraName && entry.verb && entry.verb.indexOf('renamed') === 0 ? ` (${entry.extraName})` : ''} + {expanded ? this.renderDiff(entry) : null}
{entry.undoable && ( )} + {entry.hasRestore && ( + + )}
{formatTime(entry.ts)}
); diff --git a/src/components/dash-control-center/control-center.css b/src/components/dash-control-center/control-center.css index 07086962a..1b57245c0 100644 --- a/src/components/dash-control-center/control-center.css +++ b/src/components/dash-control-center/control-center.css @@ -589,3 +589,65 @@ background: #ffd34d; color: #6b4d00; } + +/* ------------------------------------------------------------------ */ +/* Activity diffs */ +/* ------------------------------------------------------------------ */ + +.feed-entry-expandable { + cursor: pointer; +} + +.feed-entry-expandable:hover { + background: #eef2fb; +} + +.diff-panel { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.45rem; + padding: 0.55rem 0.7rem; + border-radius: 9px; + background: #fff; + border: 1px solid $ui-black-transparent; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.72rem; +} + +.diff-line { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: baseline; + word-break: break-word; +} + +.diff-before { + color: #b4232f; + text-decoration: line-through; + text-decoration-color: rgba(180, 35, 47, 0.5); +} + +.diff-arrow { + opacity: 0.55; +} + +.diff-after { + color: #1a7f37; + font-weight: bold; +} + +.diff-summary { + opacity: 0.72; + font-style: italic; +} + +/* Presenter / owner accents */ +.avatar-presenter { + box-shadow: 0 0 0 3px #ffd34d; +} + +.crown { + font-size: 0.85rem; +} diff --git a/src/components/dash-control-center/participant-list.jsx b/src/components/dash-control-center/participant-list.jsx index 986c07fe5..7404e0652 100644 --- a/src/components/dash-control-center/participant-list.jsx +++ b/src/components/dash-control-center/participant-list.jsx @@ -239,6 +239,7 @@ class ParticipantList extends React.Component { } render () { const rows = this.mergedRows(); + const control = sessionControl.getState(); return (
@@ -253,59 +254,67 @@ class ParticipantList extends React.Component {

{this.props.error}

) : null}
- {rows.map(row => ( -
-
- {initials(row.username)} - -
-
-
- {row.username} - {row.self && ( - - - - )} - {this.renderRoleBadge(row.role)} + {rows.map(row => { + const isPresenter = control.presentationActive && + ((control.presenterName === 'self' && row.self) || + (control.presenterName && control.presenterName === row.username)); + return ( +
+
+ {initials(row.username)} +
-
- {row.online ? ( - row.viewTarget ? ( - +
+
+ {row.username} + {row.role === 'owner' && ( + {'\u{1F451}'} + )} + {row.self && ( + + + + )} + {this.renderRoleBadge(row.role)} +
+
+ {row.online ? ( + row.viewTarget ? ( + + ) : ( + + ) ) : ( - ) - ) : ( - - )} + )} +
+
+
+ {this.renderActions(row)}
-
- {this.renderActions(row)} -
-
- ))} + ); + })}
); diff --git a/src/lib/dash-activity.js b/src/lib/dash-activity.js index 73e48ff64..2ebab7dbb 100644 --- a/src/lib/dash-activity.js +++ b/src/lib/dash-activity.js @@ -130,42 +130,53 @@ class DashActivity { beginLocalOperation (name, args, vm) { const kind = OP_KINDS[name]; if (!kind) return null; - const handle = {name, kind, args, vm, before: null, targetName: null}; + const handle = {name, kind, args, vm, before: null, targetName: null, diff: null, restore: null}; try { switch (name) { - case 'deleteSprite': + case 'deleteSprite': { handle.targetName = targetNameById(vm, args[0]) || editingTargetName(vm); + const sprite = this.targetSummary(vm, args[0]); + handle.diff = {before: handle.targetName, after: null, summary: sprite}; break; + } case 'renameSprite': handle.targetName = targetNameById(vm, args[0]); handle.before = {previousName: handle.targetName, id: args[0]}; + handle.diff = {before: handle.targetName, after: String(args[1])}; break; case 'renameCostume': handle.targetName = editingTargetName(vm); handle.before = {previousName: this.currentCostumeName(vm, args[0]), index: args[0]}; + handle.diff = {before: handle.before.previousName, after: String(args[1])}; break; case 'renameSound': handle.targetName = editingTargetName(vm); handle.before = {previousName: this.currentSoundName(vm, args[0]), index: args[0]}; + handle.diff = {before: handle.before.previousName, after: String(args[1])}; break; case 'deleteCostume': handle.targetName = editingTargetName(vm); handle.before = {name: this.currentCostumeName(vm, args[0]), index: args[0]}; + handle.diff = {before: handle.before.name, after: null}; break; case 'deleteSound': handle.targetName = editingTargetName(vm); handle.before = {name: this.currentSoundName(vm, args[0]), index: args[0]}; + handle.diff = {before: handle.before.name, after: null}; break; case 'addSprite': handle.targetName = this.spriteNameFromArg(args[0]); + handle.diff = {before: null, after: handle.targetName}; break; case 'addCostume': handle.targetName = editingTargetName(vm); handle.before = {name: args[0] && args[0].name}; + handle.diff = {before: null, after: handle.before.name || null}; break; case 'addSound': handle.targetName = editingTargetName(vm); handle.before = {name: args[0] && args[0].name}; + handle.diff = {before: null, after: handle.before.name || null}; break; default: handle.targetName = editingTargetName(vm); @@ -262,9 +273,15 @@ class DashActivity { verb: kind.verb, targetName, extraName: before && before.name, + diff: handle.diff || null, undoable: Boolean(inverse) }); - if (inverse) { + // Restore re-applies the captured "before" state at any later point + // from the history (targeted restore, independent of the undo stack). + const restore = inverse || null; + if (restore) { + entry.restore = restore; + entry.hasRestore = true; this.undoStack.unshift({entryId: entry.id, inverse, label: entry}); if (this.undoStack.length > MAX_UNDO) this.undoStack.pop(); } @@ -288,6 +305,7 @@ class DashActivity { verb: (kind && kind.verb) || 'changedProject', targetName: typeof action.targetName === 'string' ? action.targetName : null, extraName: typeof action.extraName === 'string' ? action.extraName : null, + diff: action.diff && typeof action.diff === 'object' ? action.diff : null, undoable: false }); } @@ -327,11 +345,17 @@ class DashActivity { try { const presence = this.vm && this.vm.dashCollaboration && this.vm.dashCollaboration.presence; if (!presence) return; + const diff = entry.diff && { + before: typeof entry.diff.before === 'string' ? entry.diff.before.slice(0, 80) : null, + after: typeof entry.diff.after === 'string' ? entry.diff.after.slice(0, 80) : null, + summary: typeof entry.diff.summary === 'string' ? entry.diff.summary.slice(0, 80) : undefined + }; presence.update({ action: { kind: entry.kind, targetName: entry.targetName || undefined, - extraName: entry.extraName || undefined + extraName: entry.extraName || undefined, + diff } }); // Actions are events, not state: clear so they do not stick. @@ -380,6 +404,35 @@ class DashActivity { return true; } + /** + * Targeted restore from the history: re-applies the captured "before" + * state of a specific entry (bring back a deleted sprite/costume/sound, + * roll a name back) even long after the change happened. + * @param {string} entryId entry to restore from + * @returns {Promise} result of the restore operation + */ + async restoreEntry (entryId) { + const entry = this.entries.find(item => item.id === entryId); + if (!entry) throw new Error('Entry not found'); + if (typeof entry.restore !== 'function') throw new Error('This change cannot be restored'); + // Restoring the before-state makes the matching undo slot redundant. + const index = this.undoStack.findIndex(item => item.entryId === entryId); + if (index >= 0) this.undoStack.splice(index, 1); + try { + await entry.restore(); + } finally { + this.push({ + source: 'local', + kind: 'restore', + icon: 'undo', + verb: 'restoredEntry', + targetName: entry.targetName || null, + undoable: false + }); + } + return true; + } + /* ------------------------------------------------------------------ */ /* Name lookups (best-effort, before the change is applied) */ /* ------------------------------------------------------------------ */ @@ -416,6 +469,27 @@ class DashActivity { } } + /** + * Short human summary of a sprite (for delete diffs). + * @param {VirtualMachine} vm the VM + * @param {string} targetId sprite id + * @returns {?string} e.g. "3 costumes, 2 sounds, 12 blocks" + */ + targetSummary (vm, targetId) { + try { + const target = vm && vm.runtime && vm.runtime.getTargetById && vm.runtime.getTargetById(targetId); + if (!target) return null; + const costumes = target.getCostumes ? target.getCostumes().length : 0; + const sounds = target.getSounds ? target.getSounds().length : 0; + const blocks = target.blocks && target.blocks._blocks ? Object.keys(target.blocks._blocks).length : null; + const parts = [`${costumes} costume(s)`, `${sounds} sound(s)`]; + if (blocks !== null) parts.push(`${blocks} block(s)`); + return parts.join(', '); + } catch (error) { + return null; + } + } + /* ------------------------------------------------------------------ */ /* Subscriptions */ /* ------------------------------------------------------------------ */ diff --git a/src/lib/dash-session-control.js b/src/lib/dash-session-control.js index f8cd9495e..7d37d137f 100644 --- a/src/lib/dash-session-control.js +++ b/src/lib/dash-session-control.js @@ -13,6 +13,7 @@ */ import {requestDashApi} from './dash-api.js'; +import viewState from './dash-view-state.js'; const BANS_STORAGE_KEY = 'dash-session-bans'; @@ -54,6 +55,7 @@ class DashSessionControl { this.remotePresenterUserId = null; this.remoteHandRaises = new Map(); // userId -> timestamp this.bans = {}; // userId -> {username, ts} + this.autoFollowedPresenter = null; this.projectId = null; } @@ -162,6 +164,7 @@ class DashSessionControl { this.presenting = true; this.grantedUsers.clear(); this.pushControl(true); + this.autoFollowPresenter(); this.notify(); } @@ -170,6 +173,7 @@ class DashSessionControl { this.presenting = false; this.grantedUsers.clear(); this.pushControl(true); + this.autoFollowPresenter(); this.notify(); } @@ -310,9 +314,37 @@ class DashSessionControl { } this.remotePresenterUserId = presenter; this.remoteHandRaises = raises; + this.autoFollowPresenter(); this.notify(); } + /** + * While someone presents, the audience automatically follows the + * presenter's view (tab, sprite, zoom, scroll). Applied once per + * presenter; dismissing the follow chip keeps it off until the + * presenter changes. + */ + autoFollowPresenter () { + const presenter = this.presenting ? this.selfUserId : this.remotePresenterUserId; + if (!presenter || presenter === this.selfUserId) { + if (this.autoFollowedPresenter && + viewState.getFollowTarget() === this.autoFollowedPresenter) { + viewState.setFollowTarget(null); + } + this.autoFollowedPresenter = null; + return; + } + if (this.autoFollowedPresenter === presenter) return; + // Only take over when the user is not following someone already. + if (!viewState.getFollowTarget()) { + viewState.setFollowTarget(presenter); + this.autoFollowedPresenter = presenter; + } else if (viewState.getFollowTarget() === this.autoFollowedPresenter) { + viewState.setFollowTarget(presenter); + this.autoFollowedPresenter = presenter; + } + } + /* ------------------------------------------------------------------ */ /* Subscriptions */ /* ------------------------------------------------------------------ */