From 8cf1c4e87d1276499d5eec71e7551a762ece79ef Mon Sep 17 00:00:00 2001 From: Damir Date: Sun, 18 Oct 2026 11:18:23 +0800 Subject: [PATCH] Revert "feat(collaboration): control center, follow mode, presenter mode and activity feed" --- .../dash-control-center/activity-feed.jsx | 253 ------- .../control-center-modal.jsx | 456 ------------ .../dash-control-center/control-center.css | 653 ------------------ .../dash-control-center/follow-bar.jsx | 135 ---- .../dash-control-center/participant-list.jsx | 341 --------- src/components/gui/gui.jsx | 2 - src/components/menu-bar/menu-bar.jsx | 5 +- src/containers/gui.jsx | 39 -- src/lib/dash-activity.js | 523 -------------- src/lib/dash-collaboration-session.js | 24 +- src/lib/dash-presence.js | 26 +- src/lib/dash-session-control.js | 385 ----------- src/lib/dash-view-state.js | 268 ------- 13 files changed, 7 insertions(+), 3103 deletions(-) delete mode 100644 src/components/dash-control-center/activity-feed.jsx delete mode 100644 src/components/dash-control-center/control-center-modal.jsx delete mode 100644 src/components/dash-control-center/control-center.css delete mode 100644 src/components/dash-control-center/follow-bar.jsx delete mode 100644 src/components/dash-control-center/participant-list.jsx delete mode 100644 src/lib/dash-activity.js delete mode 100644 src/lib/dash-session-control.js delete mode 100644 src/lib/dash-view-state.js diff --git a/src/components/dash-control-center/activity-feed.jsx b/src/components/dash-control-center/activity-feed.jsx 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};