diff --git a/src/components/dash-control-center/activity-feed.jsx b/src/components/dash-control-center/activity-feed.jsx
deleted file mode 100644
index bb31a793c..000000000
--- a/src/components/dash-control-center/activity-feed.jsx
+++ /dev/null
@@ -1,253 +0,0 @@
-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
deleted file mode 100644
index 1a3070e76..000000000
--- a/src/components/dash-control-center/control-center-modal.jsx
+++ /dev/null
@@ -1,456 +0,0 @@
-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
deleted file mode 100644
index 1b57245c0..000000000
--- a/src/components/dash-control-center/control-center.css
+++ /dev/null
@@ -1,653 +0,0 @@
-@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
deleted file mode 100644
index 0ece13112..000000000
--- a/src/components/dash-control-center/follow-bar.jsx
+++ /dev/null
@@ -1,135 +0,0 @@
-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
deleted file mode 100644
index 7404e0652..000000000
--- a/src/components/dash-control-center/participant-list.jsx
+++ /dev/null
@@ -1,341 +0,0 @@
-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 8c72bf7ea..ecca002df 100644
--- a/src/components/gui/gui.jsx
+++ b/src/components/gui/gui.jsx
@@ -39,7 +39,6 @@ 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';
@@ -347,7 +346,6 @@ const GUIComponent = props => {
-
)}
diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx
index 2a7c5d0ff..cfb4441ad 100644
--- a/src/containers/gui.jsx
+++ b/src/containers/gui.jsx
@@ -40,10 +40,6 @@ 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;
@@ -63,33 +59,6 @@ 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) {
@@ -97,7 +66,6 @@ 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
@@ -105,13 +73,6 @@ 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
deleted file mode 100644
index 2ebab7dbb..000000000
--- a/src/lib/dash-activity.js
+++ /dev/null
@@ -1,523 +0,0 @@
-/**
- * @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 cfcf35baf..044b5522e 100644
--- a/src/lib/dash-collaboration-session.js
+++ b/src/lib/dash-collaboration-session.js
@@ -4,8 +4,6 @@ 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'];
@@ -626,15 +624,9 @@ 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 activityHandle = activity.beginLocalOperation(name, args, this.vm);
- const finish = value => {
+ const finish = () => {
this.localOperations--;
- activity.endLocalOperation(activityHandle, value);
this.onLocalChange();
};
let result;
@@ -644,16 +636,8 @@ export default class DashCollaborationSession {
finish();
throw error;
}
- if (result && typeof result.then === 'function') {
- return Promise.resolve(result).then(value => {
- finish(value);
- return value;
- }, error => {
- finish();
- throw error;
- });
- }
- finish(result);
+ if (result && typeof result.then === 'function') return Promise.resolve(result).finally(finish);
+ finish();
return result;
};
this.operationMethods.set(name, {original, wrapped});
@@ -723,7 +707,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' && sessionControl.canEdit();
+ this.client.role !== 'viewer';
}
fail (error) {
this.client.fail(error.message || String(error));
diff --git a/src/lib/dash-presence.js b/src/lib/dash-presence.js
index c0e51c743..172afb094 100644
--- a/src/lib/dash-presence.js
+++ b/src/lib/dash-presence.js
@@ -71,7 +71,6 @@ class DashPresence {
this.send = null;
this.sendTimer = null;
this.notifyTimer = null;
- this.eventListeners = new Set();
this.pruneTimer = setInterval(() => this.prune(), 3000);
}
/**
@@ -129,26 +128,14 @@ 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 : {};
- const peer = {
+ this.peers.set(key, {
id: key,
userId: typeof message.userId === 'string' ? message.userId : '',
username: typeof message.username === 'string' && message.username ? message.username : '...',
color: presenceColor(key),
state,
seen: Date.now()
- };
- this.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 () {
@@ -172,15 +159,6 @@ 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
deleted file mode 100644
index 7d37d137f..000000000
--- a/src/lib/dash-session-control.js
+++ /dev/null
@@ -1,385 +0,0 @@
-/**
- * @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
deleted file mode 100644
index 18ceec16b..000000000
--- a/src/lib/dash-view-state.js
+++ /dev/null
@@ -1,268 +0,0 @@
-/**
- * @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};