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} +
+ + +