From 8ac10a4495be0cbd523bea3b885513f768989096 Mon Sep 17 00:00:00 2001 From: John Jeng Date: Tue, 12 Mar 2019 18:42:46 +0000 Subject: [PATCH 1/5] Send mentions after message is sent --- src/smc-webapp/chat/actions.coffee | 14 ++++++++++++++ src/smc-webapp/chat/store.ts | 4 +++- src/smc-webapp/side_chat.cjsx | 13 +++++++------ src/smc-webapp/smc_chat.tsx | 29 ++++++++++++++--------------- 4 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/smc-webapp/chat/actions.coffee b/src/smc-webapp/chat/actions.coffee index dd03aa4706..cd8c1b5dd3 100644 --- a/src/smc-webapp/chat/actions.coffee +++ b/src/smc-webapp/chat/actions.coffee @@ -147,6 +147,20 @@ class ChatActions extends Actions set_use_saved_position: (use_saved_position) => @setState(use_saved_position:use_saved_position) + set_unsent_user_mentions: (user_mentions) => + @setState(unsent_user_mentions: user_mentions) + + submit_user_mentions: (project_id, path) => + @store.get('unsent_user_mentions').map((mention) => + webapp_client.mention({ + project_id: project_id + path: path + target: mention.id + priority: 2 + }) + ) + @setState(unsent_user_mentions: immutable.List()) + save_scroll_state: (position, height, offset) => # height == 0 means chat room is not rendered if height != 0 diff --git a/src/smc-webapp/chat/store.ts b/src/smc-webapp/chat/store.ts index b1c3b35ab7..0004e77c59 100644 --- a/src/smc-webapp/chat/store.ts +++ b/src/smc-webapp/chat/store.ts @@ -20,6 +20,7 @@ interface ChatState { is_saving: boolean; has_uncommitted_changes: boolean; has_unsaved_changes: boolean; + unsent_user_mentions: immutable.List<{id: string, display: string}>; } export class ChatStore extends Store { @@ -39,7 +40,8 @@ export class ChatStore extends Store { add_collab: true, is_saving: false, has_uncommitted_changes: false, - has_unsaved_changes: false + has_unsaved_changes: false, + unsent_user_mentions: immutable.List() }; }; } diff --git a/src/smc-webapp/side_chat.cjsx b/src/smc-webapp/side_chat.cjsx index f8300b282f..3946eba00e 100644 --- a/src/smc-webapp/side_chat.cjsx +++ b/src/smc-webapp/side_chat.cjsx @@ -411,11 +411,16 @@ ChatRoom = rclass ({name}) -> else if e.keyCode == 38 and @props.input == '' # up arrow and empty @props.actions.set_to_last_input() + on_input_change: (e, _, __, mentions) -> + @props.actions.set_unsent_user_mentions(mentions) + @props.actions.set_input(e.target.value) + on_send_click: (e) -> @button_send_chat(e) analytics_event('side_chat', 'send_chat', 'click') button_send_chat: (e) -> + @props.actions.submit_user_mentions(@props.project_id, @props.path) send_chat(e, @refs.log_container, @props.input, @props.actions) on_scroll: (e) -> @@ -506,9 +511,6 @@ ChatRoom = rclass ({name}) -> # E.g, this is critical for taks lists... @props.redux.getActions('page').erase_active_key_handler() - on_mention: (id, display) -> - webapp_client.mention({project_id:@props.project_id, path:misc.original_path(@props.path), target:id, priority:2}) - render: -> if not @props.messages? or not @props.redux? return @@ -603,17 +605,16 @@ ChatRoom = rclass ({name}) -> "@" + display} style = {input_style} - markup = '@__display__' + markup = '@__display__' autoFocus = {false} onKeyDown = {(e) => mark_as_read(); @on_keydown(e)} value = {@props.input} placeholder = {if has_collaborators then "Type a message, @name..." else "Type a message..."} - onChange = {(e) => @props.actions.set_input(e.target.value)} + onChange = {@on_input_change} > diff --git a/src/smc-webapp/smc_chat.tsx b/src/smc-webapp/smc_chat.tsx index 12bfd0cc16..ec940b57ed 100644 --- a/src/smc-webapp/smc_chat.tsx +++ b/src/smc-webapp/smc_chat.tsx @@ -71,8 +71,6 @@ const { const { VideoChatButton } = require("./video-chat"); const { SMC_Dropwrapper } = require("./smc-dropzone"); -const { webapp_client } = require("./webapp_client"); - interface MessageProps { actions?: any; @@ -923,6 +921,10 @@ class ChatRoom0 extends Component { keydown = (e: any) => { // TODO: Add timeout component to is_typing if (e.keyCode === 13 && e.shiftKey) { + this.props.actions.submit_user_mentions( + this.props.project_id, + this.props.path + ); // 13: enter key return send_chat( e, @@ -954,6 +956,10 @@ class ChatRoom0 extends Component { }; button_send_chat = e => { + this.props.actions.submit_user_mentions( + this.props.project_id, + this.props.path + ); send_chat(e, this.refs.log_container, this.props.input, this.props.actions); this.input_ref.current.focus(); }; @@ -1270,13 +1276,10 @@ class ChatRoom0 extends Component { } }; - on_mention = id => { - webapp_client.mention({ - project_id: this.props.project_id, - path: this.props.path, - target: id, - priority: 2 - }); + on_input_change = (e, _, __, mentions) => { + this.props.actions.set_unsent_user_mentions(mentions); + this.props.actions.set_input(e.target.value); + this.mark_as_read(); }; render_body() { @@ -1424,7 +1427,7 @@ class ChatRoom0 extends Component { autoFocus={!IS_MOBILE || isMobile.Android()} displayTransform={(_, display) => "@" + display} style={chat_input_style} - markup='@__display__' + markup='@__display__' inputRef={this.input_ref} onKeyDown={this.keydown} value={this.props.input} @@ -1434,15 +1437,11 @@ class ChatRoom0 extends Component { : "Type a message..." } onPaste={this.handle_paste_event} - onChange={(e: any) => { - this.props.actions.set_input(e.target.value); - this.mark_as_read(); - }} + onChange={this.on_input_change} > From 66249fbef08711f36823d1f8ae1fbb9e644bd403 Mon Sep 17 00:00:00 2001 From: John Jeng Date: Tue, 12 Mar 2019 21:11:57 +0000 Subject: [PATCH 2/5] Refactor chat input --- src/smc-webapp/chat/actions.coffee | 3 +- src/smc-webapp/chat/input.tsx | 164 +++++++++++++++++++++++++ src/smc-webapp/package.json | 1 + src/smc-webapp/smc_chat.tsx | 187 +++++++---------------------- 4 files changed, 213 insertions(+), 142 deletions(-) create mode 100644 src/smc-webapp/chat/input.tsx diff --git a/src/smc-webapp/chat/actions.coffee b/src/smc-webapp/chat/actions.coffee index cd8c1b5dd3..088d085e44 100644 --- a/src/smc-webapp/chat/actions.coffee +++ b/src/smc-webapp/chat/actions.coffee @@ -86,6 +86,7 @@ class ChatActions extends Actions date : time_stamp @setState(last_sent: mesg) @save() + @set_input('') set_editing: (message, is_editing) => if not @syncdb? @@ -155,7 +156,7 @@ class ChatActions extends Actions webapp_client.mention({ project_id: project_id path: path - target: mention.id + target: mention.get('id') priority: 2 }) ) diff --git a/src/smc-webapp/chat/input.tsx b/src/smc-webapp/chat/input.tsx new file mode 100644 index 0000000000..963f897bfd --- /dev/null +++ b/src/smc-webapp/chat/input.tsx @@ -0,0 +1,164 @@ +import * as React from "react"; +import memoizeOne from "memoize-one"; +import * as immutable from "immutable"; +import { MentionsInput, Mention } from "react-mentions"; + +import { cmp_Date } from "smc-util/misc2"; +const { Space } = require("../r_misc"); +const { Avatar } = require("../other-users"); +const { IS_MOBILE, isMobile } = require("../feature"); + +const USER_MENTION_MARKUP = + '@__display__'; + +interface Props { + input: string; + input_ref: any; + input_style?: any; // Used to override defaults + enable_mentions: boolean; + project_users: any; + user_store: any; + font_size: number; + on_paste: (e) => void; + on_change: (value, mentions) => void; + on_send: (value) => void; + on_clear: () => void; + on_set_to_last_input: () => void; + account_id: string; +} + +export class ChatInput extends React.PureComponent { + static defaultProps = { + enable_mentions: true, + font_size: 14 + }; + + input_style = memoizeOne(font_size => { + return { + "&multiLine": { + highlighter: { + padding: 5 + }, + + input: { + height: "90px", + fontSize: font_size, + border: "1px solid #ccc", + borderRadius: "4px", + boxShadow: "inset 0 1px 1px rgba(0,0,0,.075)", + overflow: "auto", + padding: "5px 10px" + } + }, + + suggestions: { + list: { + backgroundColor: "white", + border: "1px solid #ccc", + borderRadius: "4px", + fontSize: font_size, + position: "absolute", + bottom: "10px", + overflow: "auto", + maxHeight: "145px", + width: "max-content", + display: "flex", + flexDirection: "column" + }, + + item: { + padding: "5px 15px 5px 10px", + borderBottom: "1px solid rgba(0,0,0,0.15)", + + "&focused": { + backgroundColor: "rgb(66, 139, 202, 0.4)" + } + } + } + }; + }); + + mentions_data = memoizeOne((project_users: immutable.Map) => { + const user_array = project_users + .keySeq() + .filter(account_id => { + return account_id !== this.props.account_id; + }) + .map(account_id => { + return { + id: account_id, + display: this.props.user_store.get_name(account_id), + last_active: this.props.user_store.get_last_active(account_id) + }; + }) + .toJS(); + + user_array.sort((x, y) => -cmp_Date(x.last_active, y.last_active)); + + return user_array; + }); + + on_change = (e, _, __, mentions) => { + this.props.on_change(e.target.value, mentions); + }; + + on_keydown = (e: any) => { + // TODO: Add timeout component to is_typing + if (e.keyCode === 13 && e.shiftKey) { + e.preventDefault(); + if (this.props.input.length && this.props.input.trim().length >= 1) { + this.props.on_send(this.props.input); + } + } else if (e.keyCode === 38 && this.props.input === "") { + // Up arrow on an empty input + this.props.on_set_to_last_input(); + } else if (e.keyCode === 27) { + // Esc + this.props.on_clear(); + } + }; + + render_user_suggestion = (entry: { id: string; display: string }) => { + return ( + + + + + {entry.display} + + ); + }; + + render() { + const user_array = this.mentions_data(this.props.project_users); + + const style = + this.props.input_style || this.input_style(this.props.font_size); + + return ( + "@" + display} + style={style} + markup={USER_MENTION_MARKUP} + inputRef={this.props.input_ref} + onKeyDown={this.on_keydown} + value={this.props.input} + placeholder={ + this.props.enable_mentions + ? "Type a message, @name..." + : "Type a message..." + } + onPaste={this.props.on_paste} + onChange={this.on_change} + > + + + ); + } +} diff --git a/src/smc-webapp/package.json b/src/smc-webapp/package.json index e9be755341..837283a6cb 100644 --- a/src/smc-webapp/package.json +++ b/src/smc-webapp/package.json @@ -70,6 +70,7 @@ "markdown-it-mathjax": "^2.0.0", "mathjax": "2.7.4", "md5": "^2", + "memoize-one": "^5.0.0", "node-forge": "^0.7.6", "nodeunit": "^0.11.3", "octicons": "^3.5.0", diff --git a/src/smc-webapp/smc_chat.tsx b/src/smc-webapp/smc_chat.tsx index ec940b57ed..f406cedbf3 100644 --- a/src/smc-webapp/smc_chat.tsx +++ b/src/smc-webapp/smc_chat.tsx @@ -21,7 +21,7 @@ // standard non-CoCalc libraries import * as immutable from "immutable"; -const { IS_MOBILE, IS_TOUCH, isMobile } = require("./feature"); +const { IS_MOBILE, IS_TOUCH } = require("./feature"); import { debounce } from "underscore"; // CoCalc libraries @@ -29,15 +29,13 @@ const { Avatar } = require("./other-users"); const misc = require("smc-util/misc"); const misc_page = require("./misc_page"); -import { cmp_Date } from "smc-util/misc2"; - import { SaveButton } from "./frame-editors/frame-tree/save-button"; -import { MentionsInput, Mention } from "react-mentions"; +import { ChatInput } from "./chat/input"; // React libraries import { React, ReactDOM, Component, rclass, rtypes } from "./app-framework"; -const { Icon, Loading, SearchInput, Space, TimeAgo, Tip } = require("./r_misc"); +const { Icon, Loading, SearchInput, TimeAgo, Tip } = require("./r_misc"); import { Alert, Button, @@ -62,7 +60,6 @@ const { render_history_title, render_history_footer, render_history, - send_chat, is_at_bottom, scroll_to_bottom, scroll_to_position @@ -918,26 +915,6 @@ class ChatRoom0 extends Component { } }, 300); - keydown = (e: any) => { - // TODO: Add timeout component to is_typing - if (e.keyCode === 13 && e.shiftKey) { - this.props.actions.submit_user_mentions( - this.props.project_id, - this.props.path - ); - // 13: enter key - return send_chat( - e, - this.refs.log_container, - this.props.input, - this.props.actions - ); - } else if (e.keyCode === 38 && this.props.input === "") { - // Up arrow on an empty input - this.props.actions.set_to_last_input(); - } - }; - componentWillUnmount() { this._is_mounted = false; this.save_scroll_position(); @@ -955,14 +932,10 @@ class ChatRoom0 extends Component { } }; - button_send_chat = e => { - this.props.actions.submit_user_mentions( - this.props.project_id, - this.props.path - ); - send_chat(e, this.refs.log_container, this.props.input, this.props.actions); - this.input_ref.current.focus(); - }; + on_send_button_click = (e) => { + e.preventDefault(); + this.on_send(this.props.input); + } button_scroll_to_bottom = () => { scroll_to_bottom(this.refs.log_container, this.props.actions); @@ -973,7 +946,7 @@ class ChatRoom0 extends Component { this.input_ref.current.focus(); }; - button_on_click = () => { + on_preview_button_click = () => { this.props.actions.set_is_preview(true); this.input_ref.current.focus(); if ( @@ -1203,17 +1176,6 @@ class ChatRoom0 extends Component { ); } - render_user_suggestion = (entry: { id: string; display: string }) => { - return ( - - - - - {entry.display} - - ); - }; - generate_temp_upload_text = file => { return `[Uploading...]\(${file.name}\)`; }; @@ -1255,7 +1217,7 @@ class ChatRoom0 extends Component { private dropzoneWrapperRef: any; - handle_paste_event = (e: React.ClipboardEvent) => { + handle_paste_event = (e: React.ClipboardEvent) => { const items = e.clipboardData.items; for (let i = 0; i < items.length; i++) { const item = items[i]; @@ -1276,12 +1238,26 @@ class ChatRoom0 extends Component { } }; - on_input_change = (e, _, __, mentions) => { + on_input_change = (value, mentions) => { + console.log(value, mentions) this.props.actions.set_unsent_user_mentions(mentions); - this.props.actions.set_input(e.target.value); + this.props.actions.set_input(value); this.mark_as_read(); }; + on_send = input => { + this.props.actions.submit_user_mentions( + this.props.project_id, + this.props.path + ); + this.props.actions.send_chat(input); + this.input_ref.current.focus(); + }; + + on_clear = () => { + this.props.actions.set_input(''); + } + render_body() { const grid_style: React.CSSProperties = { maxWidth: "1200px", @@ -1300,75 +1276,13 @@ class ChatRoom0 extends Component { flex: 1 }; - const chat_input_style: { - [key: string]: - | string - | React.CSSProperties - | { [key: string]: React.CSSProperties }; - } = { - "&multiLine": { - highlighter: { - padding: 5 - }, - - input: { - height: "90px", - fontSize: this.props.font_size, - border: "1px solid #ccc", - borderRadius: "4px", - boxShadow: "inset 0 1px 1px rgba(0,0,0,.075)", - overflow: "auto", - padding: "5px 10px" - } - }, - - suggestions: { - list: { - backgroundColor: "white", - border: "1px solid #ccc", - borderRadius: "4px", - fontSize: this.props.font_size, - position: "absolute", - bottom: "10px", - overflow: "auto", - maxHeight: "145px", - width: "max-content", - display: "flex", - flexDirection: "column" - }, - - item: { - padding: "5px 15px 5px 10px", - borderBottom: "1px solid rgba(0,0,0,0.15)", - - "&focused": { - backgroundColor: "rgb(66, 139, 202, 0.4)" - } - } - } - }; - - let has_collaborators = false; - - const user_store = this.props.redux.getStore("users"); // the immutable.Map() default is because of admins: // https://github.com/sagemathinc/cocalc/issues/3669 - const user_array = this.props.project_map - .getIn([this.props.project_id, "users"], immutable.Map()) - .keySeq() - .filter(account_id => { - return account_id !== this.props.account_id; - }) - .map(account_id => { - has_collaborators = true; - return { - id: account_id, - display: user_store.get_name(account_id), - last_active: user_store.get_last_active(account_id) - }; - }) - .toJS(); - user_array.sort((x, y) => -cmp_Date(x.last_active, y.last_active)); + const project_users = this.props.project_map.getIn( + [this.props.project_id, "users"], + immutable.Map() + ); + const has_collaborators = project_users.size > 1; return ( @@ -1423,29 +1337,20 @@ class ChatRoom0 extends Component { sending: this.start_upload }} > - "@" + display} - style={chat_input_style} - markup='@__display__' - inputRef={this.input_ref} - onKeyDown={this.keydown} - value={this.props.input} - placeholder={ - has_collaborators - ? "Type a message, @name..." - : "Type a message..." - } - onPaste={this.handle_paste_event} - onChange={this.on_input_change} - > - - + { > {!IS_MOBILE ? (