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..bb31a793c
--- /dev/null
+++ b/src/components/dash-control-center/activity-feed.jsx
@@ -0,0 +1,253 @@
+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'},
+ 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'}
+};
+
+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, expanded: null};
+ this.unsubscribe = null;
+ bindAll(this, ['handleTick', 'handleUndo', 'handleUndoEntry', 'handleRestoreEntry', 'handleToggleExpand']);
+ }
+ componentDidMount () {
+ this.unsubscribe = activity.subscribe(this.handleTick);
+ }
+ componentWillUnmount () {
+ if (this.unsubscribe) this.unsubscribe();
+ }
+ 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});
+ 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});
+ }
+ }
+ 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 (
+
+
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 && entry.verb.indexOf('renamed') === 0 ? ` (${entry.extraName})` : ''}
+ {expanded ? this.renderDiff(entry) : null}
+
+ {entry.undoable && (
+
+ )}
+ {entry.hasRestore && (
+
+ )}
+
{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..1b57245c0
--- /dev/null
+++ b/src/components/dash-control-center/control-center.css
@@ -0,0 +1,653 @@
+@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;
+}
+
+/* ------------------------------------------------------------------ */
+/* 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/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..7404e0652
--- /dev/null
+++ b/src/components/dash-control-center/participant-list.jsx
@@ -0,0 +1,341 @@
+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();
+ const control = sessionControl.getState();
+ return (
+
+
+
+ {rows.filter(row => row.online).length} online
+
+ {this.props.error ? (
+ {this.props.error}
+ ) : null}
+
+ {rows.map(row => {
+ const isPresenter = control.presentationActive &&
+ ((control.presenterName === 'self' && row.self) ||
+ (control.presenterName && control.presenterName === row.username));
+ return (
+
+
+ {initials(row.username)}
+
+
+
+
+ {row.username}
+ {row.role === 'owner' && (
+ {'\u{1F451}'}
+ )}
+ {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..2ebab7dbb
--- /dev/null
+++ b/src/lib/dash-activity.js
@@ -0,0 +1,523 @@
+/**
+ * @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, diff: null, restore: null};
+ try {
+ switch (name) {
+ 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);
+ }
+ } 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,
+ diff: handle.diff || null,
+ undoable: Boolean(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();
+ }
+ 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,
+ diff: action.diff && typeof action.diff === 'object' ? action.diff : 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;
+ 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,
+ diff
+ }
+ });
+ // 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;
+ }
+
+ /**
+ * 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) */
+ /* ------------------------------------------------------------------ */
+
+ 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;
+ }
+ }
+
+ /**
+ * 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 */
+ /* ------------------------------------------------------------------ */
+
+ 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..7d37d137f
--- /dev/null
+++ b/src/lib/dash-session-control.js
@@ -0,0 +1,385 @@
+/**
+ * @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';
+import viewState from './dash-view-state.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.autoFollowedPresenter = null;
+
+ 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.autoFollowPresenter();
+ this.notify();
+ }
+
+ /** Owner (or forced reset) stops the presentation. */
+ stopPresentation () {
+ this.presenting = false;
+ this.grantedUsers.clear();
+ this.pushControl(true);
+ this.autoFollowPresenter();
+ 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.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 */
+ /* ------------------------------------------------------------------ */
+
+ 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};