From 687e9db77b8ba92567781b594b8fafebbaaa0a52 Mon Sep 17 00:00:00 2001 From: Maxim Shishmarev Date: Mon, 23 Sep 2019 11:00:51 +1000 Subject: [PATCH 1/2] Custom message rendering of mentions --- js/modules/loki_public_chat_api.js | 12 ++ js/views/conversation_view.js | 98 +++++++++++---- preload.js | 4 + stylesheets/_mentions.scss | 20 +++ stylesheets/_modules.scss | 2 +- stylesheets/_variables.scss | 1 + ts/components/conversation/AddMentions.tsx | 140 +++++++++++++++++++++ ts/components/conversation/Emojify.tsx | 14 ++- ts/components/conversation/Message.tsx | 32 +++-- ts/components/conversation/MessageBody.tsx | 31 ++++- ts/components/conversation/Quote.tsx | 10 +- ts/types/Util.ts | 1 + tslint.json | 1 + 13 files changed, 328 insertions(+), 38 deletions(-) create mode 100644 ts/components/conversation/AddMentions.tsx diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index 4823b0c1d..396dbb588 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -55,6 +55,16 @@ class LokiPublicChatAPI extends EventEmitter { thisServer.unregisterChannel(channelId); this.servers.splice(i, 1); } + + getListOfMembers() { + return this.allMembers; + } + + // TODO: make this private (or remove altogether) when + // we switch to polling the server for group members + setListOfMembers(members) { + this.allMembers = members; + } } class LokiPublicServerAPI { @@ -221,6 +231,8 @@ class LokiPublicChannelAPI { this.pollForDeletions(); this.pollForChannel(); this.pollForModerators(); + + // TODO: poll for group members here? } stop() { diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 7bf77d5cb..fefc9e06f 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -310,6 +310,26 @@ this.$emojiPanelContainer = this.$('.emoji-panel-container'); this.model.updateTextInputState(); + + this.selectMember = this.selectMember.bind(this); + + const updateMemberList = async () => { + const allMessages = await window.Signal.Data.getMessagesByConversation( + this.model.id, + { + limit: Number.MAX_SAFE_INTEGER, + MessageCollection: Whisper.MessageCollection, + } + ); + + const allMembers = allMessages.models.map(d => d.propsForMessage); + window.lokiPublicChatAPI.setListOfMembers(allMembers); + }; + + if (this.model.id === 'publicChat:1@chat-dev.lokinet.org') { + updateMemberList(); + setInterval(updateMemberList, 10000); + } }, events: { @@ -1563,22 +1583,41 @@ dialog.focusCancel(); }, - selectMember(member) { - const stripQuery = input => { - const pos = input.lastIndexOf('@'); + stripQuery(text, cursorPos) { + const end = text.slice(cursorPos).search(/[^a-fA-F0-9]/); + const mentionEnd = end === -1 ? text.length : cursorPos + end; - // This should never happen, but we check just in case - if (pos === -1) { - return input; - } + const stripped = text.substr(0, mentionEnd); - return input.substr(0, pos); - }; + const mentionStart = stripped.lastIndexOf('@'); - const prev = stripQuery(this.$messageField.val()); - const result = `${prev}@${member.authorPhoneNumber} `; + const query = stripped.substr(mentionStart, mentionEnd - mentionStart); + + return [stripped.substr(0, mentionStart), query, text.substr(mentionEnd)]; + }, + + selectMember(member) { + const cursorPos = this.$messageField[0].selectionStart; + // Note: skipping the middle value here + const [prev, , end] = this.stripQuery( + this.$messageField.val(), + cursorPos + ); + let firstHalf = `${prev}@${member.authorPhoneNumber}`; + let newCursorPos = firstHalf.length; + + const needExtraWhitespace = + end.length === 0 || /[a-fA-F0-9@]/.test(end[0]); + if (needExtraWhitespace) { + firstHalf += ' '; + newCursorPos += 1; + } + + const result = firstHalf + end; this.$messageField.val(result); + this.$messageField[0].selectionStart = newCursorPos; + this.$messageField[0].selectionEnd = newCursorPos; this.$messageField.trigger('input'); }, @@ -2149,7 +2188,10 @@ // Note: not only input, but keypresses too (rename?) handleInputEvent(event) { - this.maybeShowMembers(event); + // Note: schedule the member list handler shortly afterwards, so + // that the input element has time to update its cursor position to + // what the user would expect + window.requestAnimationFrame(this.maybeShowMembers.bind(this, event)); const keyCode = event.which || event.keyCode; @@ -2238,13 +2280,19 @@ return false; }; - const getQuery = input => { + // This is not quite the same as stripQuery + // as this one searches until the current + // cursor position + const getQuery = (srcLine, cursorPos) => { + const input = srcLine.substr(0, cursorPos); + const atPos = input.lastIndexOf('@'); if (atPos === -1) { return null; } - // Whitespace is required right before @ + // Whitespace is required right before @ unless + // the beginning of line if (atPos > 0 && /\w/.test(input.substr(atPos - 1, 1))) { return null; } @@ -2259,24 +2307,28 @@ return query; }; - const query = getQuery(event.target.value); - - // TODO: for now, extract members from the conversation, - // but change to use a server endpoint in the future - let allMembers = this.model.messageCollection.models.map( - d => d.propsForMessage - ); + let allMembers = window.lokiPublicChatAPI.getListOfMembers(); allMembers = allMembers.filter(d => !!d); allMembers = _.uniq(allMembers, true, d => d.authorPhoneNumber); + const cursorPos = event.target.selectionStart; + + // can't use pubkeyPattern here, as we are matching incomplete + // pubkeys (including the single @) + const query = getQuery(event.target.value, cursorPos); + let membersToShow = []; - if (query) { + if (query !== null) { membersToShow = query !== '' ? allMembers.filter(m => filterMembers(query, m)) : allMembers; } - + membersToShow = membersToShow.map(m => ({ + authorPhoneNumber: m.authorPhoneNumber, + authorProfileName: m.authorProfileName, + id: m.id, + })); this.memberView.updateMembers(membersToShow); }, diff --git a/preload.js b/preload.js index fc5b12ca0..89ec4b83e 100644 --- a/preload.js +++ b/preload.js @@ -443,3 +443,7 @@ if (config.environment === 'test') { }; /* eslint-enable global-require, import/no-extraneous-dependencies */ } + +window.shortenPubkey = pubkey => `(...${pubkey.substring(pubkey.length - 6)})`; + +window.pubkeyPattern = /@[a-fA-F0-9]{64,66}\b/g; diff --git a/stylesheets/_mentions.scss b/stylesheets/_mentions.scss index ea6fcaf6e..58c65c5af 100644 --- a/stylesheets/_mentions.scss +++ b/stylesheets/_mentions.scss @@ -30,6 +30,26 @@ } } +.mention-profile-name { + color: rgb(194, 244, 255); + background-color: rgb(66, 121, 150); + text-decoration: underline; + border-radius: 4px; + margin: 2px; + padding: 2px; + user-select: none; +} + +.mention-profile-name-us { + background-color: rgba(255, 197, 50, 1); + color: black; +} + +.message-highlighted { + border-radius: $message-container-border-radius; + background-color: rgba(255, 197, 50, 0.2); +} + .dark-theme { .member-list-container { .member-item { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 332f276e4..b109feb2d 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -167,7 +167,7 @@ .module-message__container { position: relative; display: inline-block; - border-radius: 16px; + border-radius: $message-container-border-radius; padding-right: 12px; padding-left: 12px; padding-top: 10px; diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index b2f734362..a3643e9ae 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -199,6 +199,7 @@ $header-height: 55px; $button-height: 24px; $border-radius: 5px; +$message-container-border-radius: 16px; $font-size: 14px; $font-size-small: (13/14) + em; diff --git a/ts/components/conversation/AddMentions.tsx b/ts/components/conversation/AddMentions.tsx new file mode 100644 index 000000000..c95efd8ca --- /dev/null +++ b/ts/components/conversation/AddMentions.tsx @@ -0,0 +1,140 @@ +import React from 'react'; + +import { RenderTextCallbackType } from '../../types/Util'; +import classNames from 'classnames'; + +declare global { + interface Window { + lokiPublicChatAPI: any; + shortenPubkey: any; + pubkeyPattern: any; + } +} + +interface MentionProps { + key: number; + text: string; +} + +interface MentionState { + found: any; +} + +class Mention extends React.Component { + constructor(props: any) { + super(props); + + this.tryRenameMention = this.tryRenameMention.bind(this); + } + + public componentWillMount() { + const found = this.findMember(this.props.text.slice(1)); + this.setState({ found }); + + this.tryRenameMention(); + // TODO: give up after some period of time? + const intervalHandle = setInterval(this.tryRenameMention, 30000); + this.clearOurInterval = this.clearOurInterval.bind(this, intervalHandle); + } + + public componentWillUnmount() { + this.clearOurInterval(null); + } + + public render() { + if (this.state.found) { + // TODO: We don't have to search the database of message just to know that the message is for us! + const us = + this.state.found.authorPhoneNumber === window.lokiPublicChatAPI.ourKey; + const className = classNames( + 'mention-profile-name', + us && 'mention-profile-name-us' + ); + + return ( + {this.state.found.authorProfileName} + ); + } else { + return ( + + {window.shortenPubkey(this.props.text)} + + ); + } + } + + private clearOurInterval(handle: any) { + clearInterval(handle); + } + + private tryRenameMention() { + const found = this.findMember(this.props.text.slice(1)); + if (found) { + this.setState({ found }); + this.clearOurInterval(null); + } + } + + private findMember(pubkey: String) { + const members = window.lokiPublicChatAPI.getListOfMembers(); + if (!members) { + return null; + } + const filtered = members.filter((m: any) => !!m); + + return filtered.find( + ({ authorPhoneNumber: pn }: any) => pn && pn === pubkey + ); + } +} + +interface Props { + text: string; + renderOther?: RenderTextCallbackType; +} + +export class AddMentions extends React.Component { + public static defaultProps: Partial = { + renderOther: ({ text }) => text, + }; + + public render() { + const { text, renderOther } = this.props; + const results: Array = []; + const FIND_MENTIONS = window.pubkeyPattern; + + // We have to do this, because renderNonNewLine is not required in our Props object, + // but it is always provided via defaultProps. + if (!renderOther) { + return; + } + + let match = FIND_MENTIONS.exec(text); + let last = 0; + let count = 1000; + + if (!match) { + return renderOther({ text, key: 0 }); + } + + while (match) { + if (last < match.index) { + const otherText = text.slice(last, match.index); + results.push(renderOther({ text: otherText, key: count++ })); + } + + const pubkey = text.slice(match.index, FIND_MENTIONS.lastIndex); + results.push(); + + // @ts-ignore + last = FIND_MENTIONS.lastIndex; + match = FIND_MENTIONS.exec(text); + } + + if (last < text.length) { + results.push(renderOther({ text: text.slice(last), key: count++ })); + } + + return results; + } +} diff --git a/ts/components/conversation/Emojify.tsx b/ts/components/conversation/Emojify.tsx index eb30fbfa5..0439de8df 100644 --- a/ts/components/conversation/Emojify.tsx +++ b/ts/components/conversation/Emojify.tsx @@ -56,15 +56,17 @@ interface Props { /** Allows you to customize now non-newlines are rendered. Simplest is just a . */ renderNonEmoji?: RenderTextCallbackType; i18n: LocalizerType; + isPublic?: boolean; } export class Emojify extends React.Component { public static defaultProps: Partial = { renderNonEmoji: ({ text }) => text || '', + isPublic: false, }; public render() { - const { text, sizeClass, renderNonEmoji, i18n } = this.props; + const { text, sizeClass, renderNonEmoji, i18n, isPublic } = this.props; const results: Array = []; const regex = getRegex(); @@ -79,13 +81,15 @@ export class Emojify extends React.Component { let count = 1; if (!match) { - return renderNonEmoji({ text, key: 0 }); + return renderNonEmoji({ text, key: 0, isPublic }); } while (match) { if (last < match.index) { const textWithNoEmoji = text.slice(last, match.index); - results.push(renderNonEmoji({ text: textWithNoEmoji, key: count++ })); + results.push( + renderNonEmoji({ text: textWithNoEmoji, key: count++, isPublic }) + ); } results.push(getImageTag({ match, sizeClass, key: count++, i18n })); @@ -95,7 +99,9 @@ export class Emojify extends React.Component { } if (last < text.length) { - results.push(renderNonEmoji({ text: text.slice(last), key: count++ })); + results.push( + renderNonEmoji({ text: text.slice(last), key: count++, isPublic }) + ); } return results; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 696d21d74..99fbc6904 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -32,6 +32,12 @@ import { isFileDangerous } from '../../util/isFileDangerous'; import { ColorType, LocalizerType } from '../../types/Util'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; +declare global { + interface Window { + shortenPubkey: any; + } +} + interface Trigger { handleContextClick: (event: React.MouseEvent) => void; } @@ -322,9 +328,7 @@ export class Message extends React.PureComponent { return null; } - const shortenedPubkey = `(...${authorPhoneNumber.substring( - authorPhoneNumber.length - 6 - )})`; + const shortenedPubkey = window.shortenPubkey(authorPhoneNumber); const displayedPubkey = authorProfileName ? shortenedPubkey @@ -585,6 +589,7 @@ export class Message extends React.PureComponent { direction, i18n, quote, + isPublic, } = this.props; if (!quote) { @@ -596,9 +601,7 @@ export class Message extends React.PureComponent { const quoteColor = direction === 'incoming' ? authorColor : quote.authorColor; - const shortenedPubkey = `(...${quote.authorPhoneNumber.substring( - quote.authorPhoneNumber.length - 6 - )})`; + const shortenedPubkey = window.shortenPubkey(quote.authorPhoneNumber); const displayedPubkey = quote.authorProfileName ? shortenedPubkey @@ -611,6 +614,7 @@ export class Message extends React.PureComponent { text={quote.text} attachment={quote.attachment} isIncoming={direction === 'incoming'} + isPublic={isPublic} authorPhoneNumber={displayedPubkey} authorProfileName={quote.authorProfileName} authorName={quote.authorName} @@ -741,6 +745,7 @@ export class Message extends React.PureComponent { isRss={isRss} i18n={i18n} textPending={textPending} + isPublic={this.props.isPublic} /> ); @@ -1020,8 +1025,21 @@ export class Message extends React.PureComponent { const width = this.getWidth(); const isShowingImage = this.isShowingImage(); + // We parse the message later, but we still need to do an early check + // to see if the message mentions us, so we can display the entire + // message differently + const mentions = this.props.text + ? this.props.text.match(window.pubkeyPattern) + : []; + const mentionMe = + mentions && + mentions.some(m => m.slice(1) === window.lokiPublicChatAPI.ourKey); + const shouldHightlight = + mentionMe && direction === 'incoming' && this.props.isPublic; + const divClass = shouldHightlight ? 'message-highlighted' : ''; + return ( -
+
` tags. */ disableLinks?: boolean; + isPublic?: boolean; i18n: LocalizerType; } +const renderMentions: RenderTextCallbackType = ({ text, key }) => ( + +); + +const renderDefault: RenderTextCallbackType = ({ text }) => text; + const renderNewLines: RenderTextCallbackType = ({ text: textWithNewLines, key, -}) => ; + isPublic, +}) => { + const renderOther = isPublic ? renderMentions : renderDefault; + + return ( + + ); +}; const renderEmoji = ({ i18n, @@ -29,12 +48,14 @@ const renderEmoji = ({ key, sizeClass, renderNonEmoji, + isPublic, }: { i18n: LocalizerType; text: string; key: number; sizeClass?: SizeClassType; renderNonEmoji: RenderTextCallbackType; + isPublic?: boolean; }) => ( ); @@ -52,6 +74,10 @@ const renderEmoji = ({ * them for you. */ export class MessageBody extends React.Component { + public static defaultProps: Partial = { + isPublic: false, + }; + public addDownloading(jsx: JSX.Element): JSX.Element { const { i18n, textPending } = this.props; @@ -76,6 +102,7 @@ export class MessageBody extends React.Component { disableLinks, isRss, i18n, + isPublic, } = this.props; const sizeClass = disableJumbomoji ? undefined : getSizeClass(text); const textWithPending = textPending ? `${text}...` : text; @@ -88,6 +115,7 @@ export class MessageBody extends React.Component { sizeClass, key: 0, renderNonEmoji: renderNewLines, + isPublic, }) ); } @@ -103,6 +131,7 @@ export class MessageBody extends React.Component { sizeClass, key, renderNonEmoji: renderNewLines, + isPublic, }); }} /> diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index 266ae808f..7d7b381fb 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -19,6 +19,7 @@ interface Props { i18n: LocalizerType; isFromMe: boolean; isIncoming: boolean; + isPublic?: boolean; withContentAbove: boolean; onClick?: () => void; onClose?: () => void; @@ -214,7 +215,7 @@ export class Quote extends React.Component { } public renderText() { - const { i18n, text, attachment, isIncoming } = this.props; + const { i18n, text, attachment, isIncoming, isPublic } = this.props; if (text) { return ( @@ -225,7 +226,12 @@ export class Quote extends React.Component { isIncoming ? 'module-quote__primary__text--incoming' : null )} > - +
); } diff --git a/ts/types/Util.ts b/ts/types/Util.ts index 5b4c9a1b8..cdccb03e7 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -2,6 +2,7 @@ export type RenderTextCallbackType = ( options: { text: string; key: number; + isPublic?: boolean; } ) => JSX.Element | string; diff --git a/tslint.json b/tslint.json index d875d9f6a..fae7c9a7c 100644 --- a/tslint.json +++ b/tslint.json @@ -137,6 +137,7 @@ "prefer-type-cast": false, // We use || and && shortcutting because we're javascript programmers "strict-boolean-expressions": false, + "no-suspicious-comment": false, "react-no-dangerous-html": [ true, { From 16692696e068356702065b1bd732a913e682b8d4 Mon Sep 17 00:00:00 2001 From: Maxim Shishmarev Date: Tue, 24 Sep 2019 11:39:16 +1000 Subject: [PATCH 2/2] address reviews --- js/views/conversation_view.js | 12 ++++++------ ts/components/conversation/AddMentions.tsx | 20 +++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index fefc9e06f..355d55617 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -326,7 +326,7 @@ window.lokiPublicChatAPI.setListOfMembers(allMembers); }; - if (this.model.id === 'publicChat:1@chat-dev.lokinet.org') { + if (this.model.isPublic()) { updateMemberList(); setInterval(updateMemberList, 10000); } @@ -2324,11 +2324,11 @@ ? allMembers.filter(m => filterMembers(query, m)) : allMembers; } - membersToShow = membersToShow.map(m => ({ - authorPhoneNumber: m.authorPhoneNumber, - authorProfileName: m.authorProfileName, - id: m.id, - })); + + membersToShow = membersToShow.map(m => + _.pick(m, ['authorPhoneNumber', 'authorProfileName', 'id']) + ); + this.memberView.updateMembers(membersToShow); }, diff --git a/ts/components/conversation/AddMentions.tsx b/ts/components/conversation/AddMentions.tsx index c95efd8ca..2b14e5488 100644 --- a/ts/components/conversation/AddMentions.tsx +++ b/ts/components/conversation/AddMentions.tsx @@ -21,6 +21,7 @@ interface MentionState { } class Mention extends React.Component { + private intervalHandle: any = null; constructor(props: any) { super(props); @@ -33,12 +34,11 @@ class Mention extends React.Component { this.tryRenameMention(); // TODO: give up after some period of time? - const intervalHandle = setInterval(this.tryRenameMention, 30000); - this.clearOurInterval = this.clearOurInterval.bind(this, intervalHandle); + this.intervalHandle = setInterval(this.tryRenameMention, 30000); } public componentWillUnmount() { - this.clearOurInterval(null); + this.clearOurInterval(); } public render() { @@ -51,9 +51,11 @@ class Mention extends React.Component { us && 'mention-profile-name-us' ); - return ( - {this.state.found.authorProfileName} - ); + const profileName = this.state.found.authorProfileName; + const displayedName = + profileName && profileName.length > 0 ? profileName : 'Anonymous'; + + return {displayedName}; } else { return ( @@ -63,15 +65,15 @@ class Mention extends React.Component { } } - private clearOurInterval(handle: any) { - clearInterval(handle); + private clearOurInterval() { + clearInterval(this.intervalHandle); } private tryRenameMention() { const found = this.findMember(this.props.text.slice(1)); if (found) { this.setState({ found }); - this.clearOurInterval(null); + this.clearOurInterval(); } }