1/* eslint-env browser */ 2import * as React from 'react' 3import * as Kb from '../../../../common-adapters' 4import * as Styles from '../../../../styles' 5import * as Types from '../../../../constants/types/chat2' 6import SetExplodingMessagePopup from '../../messages/set-explode-popup/container' 7import {formatDurationShort} from '../../../../util/timestamp' 8import {KeyEventHandler} from '../../../../util/key-event-handler.desktop' 9import WalletsIcon from './wallets-icon/container' 10import {PlatformInputPropsInternal} from './platform-input' 11import Typing from './typing/container' 12import AddSuggestors from '../suggestors' 13import {indefiniteArticle} from '../../../../util/string' 14import {EmojiPickerDesktop} from '../../messages/react-button/emoji-picker/container' 15 16type State = { 17 emojiPickerOpen: boolean 18} 19 20class _PlatformInput extends React.Component<PlatformInputPropsInternal, State> { 21 _input: Kb.PlainInput | null = null 22 _lastText?: string 23 _fileInput: HTMLInputElement | null = null 24 state = { 25 emojiPickerOpen: false, 26 } 27 28 _inputSetRef = (ref: null | Kb.PlainInput) => { 29 this._input = ref 30 this.props.inputSetRef(ref) 31 // @ts-ignore this is probably wrong: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31065 32 this.props.inputRef.current = ref 33 } 34 35 _inputFocus = () => { 36 this._input && this._input.focus() 37 } 38 39 _emojiPickerToggle = () => { 40 this.setState(({emojiPickerOpen}) => ({emojiPickerOpen: !emojiPickerOpen})) 41 } 42 43 _filePickerFiles = () => (this._fileInput && this._fileInput.files) || [] 44 45 _filePickerOpen = () => { 46 this._fileInput && this._fileInput.click() 47 } 48 49 _filePickerSetRef = (r: HTMLInputElement | null) => { 50 this._fileInput = r 51 } 52 53 _filePickerSetValue = (value: string) => { 54 if (this._fileInput) this._fileInput.value = value 55 } 56 57 _getText = () => { 58 return this._lastText || '' 59 } 60 61 // Key-handling code shared by both the input key handler 62 // (_onKeyDown) and the global key handler 63 // (_globalKeyDownPressHandler). 64 _commonOnKeyDown = (e: React.KeyboardEvent | KeyboardEvent) => { 65 const text = this._getText() 66 if (e.key === 'ArrowUp' && !this.props.isEditing && !text) { 67 e.preventDefault() 68 this.props.onEditLastMessage() 69 return true 70 } else if (e.key === 'Escape' && this.props.isEditing) { 71 this.props.onCancelEditing() 72 return true 73 } else if (e.key === 'Escape' && this.props.showReplyPreview) { 74 this.props.onCancelReply() 75 return true 76 } else if (e.key === 'u' && (e.ctrlKey || e.metaKey)) { 77 this._filePickerOpen() 78 return true 79 } else if (e.key === 'PageDown') { 80 this.props.onRequestScrollDown() 81 return true 82 } else if (e.key === 'PageUp') { 83 this.props.onRequestScrollUp() 84 return true 85 } 86 87 return false 88 } 89 90 _onKeyDown = (e: React.KeyboardEvent) => { 91 this._commonOnKeyDown(e) 92 this.props.onKeyDown && this.props.onKeyDown(e) 93 } 94 95 _onChangeText = (text: string) => { 96 this._lastText = text 97 this.props.onChangeText(text) 98 } 99 100 _globalKeyDownPressHandler = (ev: KeyboardEvent) => { 101 const target = ev.target 102 if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { 103 return 104 } 105 106 if (this._commonOnKeyDown(ev)) { 107 return 108 } 109 110 const isPasteKey = ev.key === 'v' && (ev.ctrlKey || ev.metaKey) 111 const isValidSpecialKey = [ 112 'Backspace', 113 'Delete', 114 'ArrowLeft', 115 'ArrowRight', 116 'ArrowUp', 117 'ArrowDown', 118 'Enter', 119 'Escape', 120 ].includes(ev.key) 121 if (ev.type === 'keypress' || isPasteKey || isValidSpecialKey) { 122 this._inputFocus() 123 } 124 } 125 126 _insertEmoji = (emojiColons: string) => { 127 if (this._input) { 128 this._input.transformText(({text, selection}) => { 129 const newText = text.slice(0, selection.start || 0) + emojiColons + text.slice(selection.end || 0) 130 const pos = (selection.start || 0) + emojiColons.length 131 return { 132 selection: {end: pos, start: pos}, 133 text: newText, 134 } 135 }, true) 136 this._inputFocus() 137 } 138 } 139 140 _pickFile = () => { 141 const fileList = this._filePickerFiles() 142 const paths: Array<string> = fileList.length 143 ? Array.prototype.map 144 .call(fileList, (f: File) => { 145 // We rely on path being here, even though it's 146 // not part of the File spec. 147 return f.path as string 148 }) 149 .reduce<Array<string>>((arr, p: any) => { 150 p && arr.push(p) 151 return arr 152 }, []) 153 : [] 154 if (paths) { 155 this.props.onAttach(paths) 156 } 157 this._filePickerSetValue('') 158 } 159 160 _toggleShowingMenu = () => { 161 if (this.props.isEditing || this.props.cannotWrite) return 162 this.props.toggleShowingMenu() 163 } 164 165 private getHintText = () => { 166 if (this.props.cannotWrite) { 167 return `You must be at least ${indefiniteArticle(this.props.minWriterRole)} ${ 168 this.props.minWriterRole 169 } to post.` 170 } else if (this.props.isEditing) { 171 return 'Edit your message' 172 } else if (this.props.isExploding) { 173 return 'Write an exploding message' 174 } 175 return this.props.inputHintText || 'Write a message' 176 } 177 178 render() { 179 return ( 180 <KeyEventHandler 181 onKeyDown={this._globalKeyDownPressHandler} 182 onKeyPress={this._globalKeyDownPressHandler} 183 > 184 <Kb.Box style={styles.container}> 185 <Kb.Box 186 style={Styles.collapseStyles([ 187 styles.inputWrapper, 188 { 189 backgroundColor: this.props.isEditing 190 ? Styles.globalColors.yellowLight 191 : Styles.globalColors.white, 192 borderColor: this.props.explodingModeSeconds 193 ? Styles.globalColors.black 194 : Styles.globalColors.black_20, 195 }, 196 ])} 197 > 198 {!this.props.isEditing && !this.props.cannotWrite && ( 199 <HoverBox 200 className={Styles.classNames({expanded: this.props.showingMenu})} 201 onClick={this._toggleShowingMenu} 202 ref={this.props.setAttachmentRef} 203 style={Styles.collapseStyles([ 204 styles.explodingIconContainer, 205 !this.props.cannotWrite && styles.explodingIconContainerClickable, 206 !!this.props.explodingModeSeconds && { 207 backgroundColor: Styles.globalColors.black, 208 }, 209 ])} 210 > 211 {this.props.explodingModeSeconds ? ( 212 <Kb.Text type="BodyTinyBold" negative={true}> 213 {formatDurationShort(this.props.explodingModeSeconds * 1000)} 214 </Kb.Text> 215 ) : ( 216 <Kb.WithTooltip tooltip="Timer"> 217 <Kb.Icon 218 className="timer" 219 colorOverride={this.props.cannotWrite ? Styles.globalColors.black_20 : null} 220 onClick={this.props.cannotWrite ? undefined : this._toggleShowingMenu} 221 padding="xtiny" 222 type="iconfont-timer" 223 /> 224 </Kb.WithTooltip> 225 )} 226 </HoverBox> 227 )} 228 {this.props.isEditing && ( 229 <Kb.Button 230 label="Cancel" 231 onClick={this.props.onCancelEditing} 232 small={true} 233 style={styles.cancelEditingBtn} 234 type="Dim" 235 /> 236 )} 237 <input 238 type="file" 239 style={styles.hidden} 240 ref={this._filePickerSetRef} 241 onChange={this._pickFile} 242 multiple={true} 243 /> 244 <Kb.Box2 direction="horizontal" fullWidth={true} style={styles.inputBox}> 245 <Kb.PlainInput 246 allowKeyboardEvents={true} 247 disabled={this.props.cannotWrite ?? false} 248 autoFocus={false} 249 ref={this._inputSetRef} 250 placeholder={this.getHintText()} 251 style={Styles.collapseStyles([styles.input, this.props.isEditing && styles.inputEditing])} 252 onChangeText={this._onChangeText} 253 multiline={true} 254 rowsMin={1} 255 rowsMax={10} 256 onKeyDown={this._onKeyDown} 257 /> 258 </Kb.Box2> 259 {this.props.showingMenu && ( 260 <SetExplodingMessagePopup 261 attachTo={this.props.getAttachmentRef} 262 conversationIDKey={this.props.conversationIDKey} 263 onAfterSelect={this._inputFocus} 264 onHidden={this.props.toggleShowingMenu} 265 visible={this.props.showingMenu} 266 /> 267 )} 268 {this.state.emojiPickerOpen && ( 269 <EmojiPicker 270 conversationIDKey={this.props.conversationIDKey} 271 emojiPickerToggle={this._emojiPickerToggle} 272 onClick={this._insertEmoji} 273 /> 274 )} 275 {!this.props.cannotWrite && this.props.showWalletsIcon && ( 276 <Kb.WithTooltip tooltip="Lumens"> 277 <WalletsIcon 278 size={16} 279 style={styles.walletsIcon} 280 conversationIDKey={this.props.conversationIDKey} 281 /> 282 </Kb.WithTooltip> 283 )} 284 {!this.props.cannotWrite && ( 285 <> 286 <Kb.WithTooltip tooltip="GIF"> 287 <Kb.Box style={styles.icon}> 288 <Kb.Icon onClick={this.props.onGiphyToggle} type="iconfont-gif" /> 289 </Kb.Box> 290 </Kb.WithTooltip> 291 <Kb.WithTooltip tooltip="Emoji"> 292 <Kb.Box style={styles.icon}> 293 <Kb.Icon 294 color={this.state.emojiPickerOpen ? Styles.globalColors.black : null} 295 onClick={this._emojiPickerToggle} 296 type="iconfont-emoji" 297 /> 298 </Kb.Box> 299 </Kb.WithTooltip> 300 <Kb.WithTooltip tooltip="Attachment"> 301 <Kb.Box style={styles.icon}> 302 <Kb.Icon onClick={this._filePickerOpen} type="iconfont-attachment" /> 303 </Kb.Box> 304 </Kb.WithTooltip> 305 </> 306 )} 307 </Kb.Box> 308 <Kb.Box style={styles.footerContainer}> 309 <Typing conversationIDKey={this.props.conversationIDKey} /> 310 <Kb.Text 311 lineClamp={1} 312 type="BodyTiny" 313 style={styles.footer} 314 onClick={this._inputFocus} 315 selectable={true} 316 > 317 {`*bold*, _italics_, \`code\`, >quote, @user, @team, #channel`} 318 </Kb.Text> 319 </Kb.Box> 320 </Kb.Box> 321 </KeyEventHandler> 322 ) 323 } 324} 325const PlatformInput = AddSuggestors(_PlatformInput) 326 327const EmojiPicker = ({ 328 conversationIDKey, 329 emojiPickerToggle, 330 onClick, 331}: { 332 conversationIDKey: Types.ConversationIDKey 333 emojiPickerToggle: () => void 334 onClick: (c: any) => void 335}) => ( 336 <Kb.Box> 337 <Kb.Box style={styles.emojiPickerContainerWrapper} onClick={emojiPickerToggle} /> 338 <Kb.Box style={styles.emojiPickerRelative}> 339 <Kb.Box style={styles.emojiPickerContainer}> 340 <EmojiPickerDesktop 341 conversationIDKey={conversationIDKey} 342 onPickAction={onClick} 343 onDidPick={emojiPickerToggle} 344 /> 345 </Kb.Box> 346 </Kb.Box> 347 </Kb.Box> 348) 349 350const styles = Styles.styleSheetCreate( 351 () => 352 ({ 353 cancelEditingBtn: { 354 margin: Styles.globalMargins.xtiny, 355 }, 356 container: { 357 ...Styles.globalStyles.flexBoxColumn, 358 backgroundColor: Styles.globalColors.white, 359 width: '100%', 360 }, 361 emojiPickerContainer: Styles.platformStyles({ 362 common: { 363 borderRadius: 4, 364 bottom: 32, 365 position: 'absolute', 366 right: -64, 367 }, 368 isElectron: { 369 ...Styles.desktopStyles.boxShadow, 370 }, 371 }), 372 emojiPickerContainerWrapper: { 373 ...Styles.globalStyles.fillAbsolute, 374 }, 375 emojiPickerRelative: { 376 position: 'relative', 377 }, 378 explodingIconContainer: Styles.platformStyles({ 379 common: { 380 ...Styles.globalStyles.flexBoxColumn, 381 alignItems: 'center', 382 alignSelf: 'stretch', 383 borderBottomLeftRadius: 3, 384 borderTopLeftRadius: 3, 385 justifyContent: 'center', 386 textAlign: 'center', 387 width: 32, 388 }, 389 isElectron: { 390 borderRight: `1px solid ${Styles.globalColors.black_20}`, 391 }, 392 }), 393 explodingIconContainerClickable: Styles.platformStyles({ 394 isElectron: {...Styles.desktopStyles.clickable}, 395 }), 396 footer: { 397 alignSelf: 'flex-end', 398 color: Styles.globalColors.black_20, 399 marginBottom: Styles.globalMargins.xtiny, 400 marginRight: Styles.globalMargins.medium + 2, 401 marginTop: 2, 402 textAlign: 'right', 403 }, 404 footerContainer: { 405 ...Styles.globalStyles.flexBoxRow, 406 alignItems: 'flex-start', 407 justifyContent: 'space-between', 408 }, 409 hidden: { 410 display: 'none', 411 }, 412 icon: { 413 alignSelf: 'flex-end', 414 marginBottom: 2, 415 marginRight: Styles.globalMargins.xtiny, 416 padding: Styles.globalMargins.xtiny, 417 }, 418 input: Styles.platformStyles({ 419 isElectron: { 420 backgroundColor: Styles.globalColors.transparent, 421 height: 22, 422 // Line height change is so that emojis (unicode characters inside 423 // textarea) are not clipped at the top. This change is accompanied by 424 // a change in padding to offset the increased line height 425 lineHeight: 22, 426 minHeight: 22, 427 }, 428 }), 429 inputBox: { 430 flex: 1, 431 paddingBottom: Styles.globalMargins.xtiny, 432 paddingLeft: 6, 433 paddingRight: 6, 434 paddingTop: Styles.globalMargins.tiny - 2, 435 textAlign: 'left', 436 }, 437 inputEditing: { 438 color: Styles.globalColors.blackOrBlack, 439 }, 440 inputWrapper: { 441 ...Styles.globalStyles.flexBoxRow, 442 alignItems: 'flex-end', 443 borderRadius: 4, 444 borderStyle: 'solid', 445 borderWidth: 1, 446 marginLeft: Styles.globalMargins.small, 447 marginRight: Styles.globalMargins.small, 448 paddingRight: Styles.globalMargins.xtiny, 449 }, 450 walletsIcon: { 451 alignSelf: 'flex-end', 452 marginBottom: 2, 453 marginRight: Styles.globalMargins.xtiny, 454 }, 455 } as const) 456) 457 458const HoverBox = Styles.styled(Kb.Box)(() => ({ 459 ':hover .timer, &.expanded .timer': { 460 color: Styles.globalColors.black, 461 }, 462})) 463 464export default Kb.OverlayParentHOC(PlatformInput) 465