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