From 59d45b69a90b00a1ed90c7f7bda23fbad012668e Mon Sep 17 00:00:00 2001 From: Ian Macdonald Date: Sun, 20 Mar 2022 18:13:41 +0100 Subject: [PATCH 1/3] Skip characters that are not in any alphabet or number system. --- ts/util/getInitials.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/ts/util/getInitials.ts b/ts/util/getInitials.ts index c2ac667b8..69d313137 100644 --- a/ts/util/getInitials.ts +++ b/ts/util/getInitials.ts @@ -8,11 +8,14 @@ export function getInitials(name?: string): string { return upperAndShorten(name[2]); } - if (name.indexOf(' ') === -1) { - // there is no space, just return the first 2 chars of the name + if (name.split(/[-\s]/).length === 1) { + // there is one word, so just return the first 2 alphanumeric chars of the name if (name.length > 1) { - return upperAndShorten(name.slice(0, 2)); + const alphanum = name.match(/[\p{L}\p{N}]+/u); + if (alphanum) { + return upperAndShorten(alphanum[0].slice(0, 2)); + } } return upperAndShorten(name[0]); } @@ -20,11 +23,12 @@ export function getInitials(name?: string): string { // name has a space, just extract the first char of each words return upperAndShorten( name - .split(' ') + .split(/[-\s]/) .slice(0, 2) - .map(n => { - return n[0]; - }) + .map(n => + // Allow a letter or a digit from any alphabet. + n.match(/^[\p{L}\p{N}]/u) + ) .join('') ); } From a115d385ddd0c6cf5d7840d68a3e4d4fe7e54425 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Wed, 27 Apr 2022 13:40:00 +1000 Subject: [PATCH 2/3] merge linkify component to messagebody as this is the only one using it --- ts/components/conversation/Linkify.tsx | 95 ------------------- .../message/message-content/MessageBody.tsx | 95 ++++++++++++++++++- 2 files changed, 92 insertions(+), 98 deletions(-) delete mode 100644 ts/components/conversation/Linkify.tsx diff --git a/ts/components/conversation/Linkify.tsx b/ts/components/conversation/Linkify.tsx deleted file mode 100644 index ef50a69c6..000000000 --- a/ts/components/conversation/Linkify.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react'; - -import LinkifyIt from 'linkify-it'; - -import { RenderTextCallbackType } from '../../types/Util'; -import { updateConfirmModal } from '../../state/ducks/modalDialog'; -import { shell } from 'electron'; -import { MessageInteraction } from '../../interactions'; -import { useDispatch } from 'react-redux'; -import { LinkPreviews } from '../../util/linkPreviews'; - -const linkify = LinkifyIt(); - -type Props = { - text: string; - /** Allows you to customize now non-links are rendered. Simplest is just a . */ - renderNonLink?: RenderTextCallbackType; - isGroup: boolean; -}; - -const SUPPORTED_PROTOCOLS = /^(http|https):/i; - -const defaultRenderNonLink = ({ text }: { text: string }) => <>{text}; - -export const Linkify = (props: Props): JSX.Element => { - const { text, isGroup, renderNonLink } = props; - const results: Array = []; - let count = 1; - const dispatch = useDispatch(); - const matchData = linkify.match(text) || []; - let last = 0; - // disable click on elements so clicking a message containing a link doesn't - // select the message.The link will still be opened in the browser. - const handleClick = (e: any) => { - e.preventDefault(); - e.stopPropagation(); - - const url = e.target.href; - - const openLink = () => { - void shell.openExternal(url); - }; - - dispatch( - updateConfirmModal({ - title: window.i18n('linkVisitWarningTitle'), - message: window.i18n('linkVisitWarningMessage', url), - okText: window.i18n('open'), - cancelText: window.i18n('editMenuCopy'), - showExitIcon: true, - onClickOk: openLink, - onClickClose: () => { - dispatch(updateConfirmModal(null)); - }, - - onClickCancel: () => { - MessageInteraction.copyBodyToClipboard(url); - }, - }) - ); - }; - - const renderWith = renderNonLink || defaultRenderNonLink; - - if (matchData.length === 0) { - return renderWith({ text, key: 0, isGroup }); - } - - matchData.forEach((match: { index: number; url: string; lastIndex: number; text: string }) => { - if (last < match.index) { - const textWithNoLink = text.slice(last, match.index); - results.push(renderWith({ text: textWithNoLink, isGroup, key: count++ })); - } - - const { url, text: originalText } = match; - const isLink = SUPPORTED_PROTOCOLS.test(url) && !LinkPreviews.isLinkSneaky(url); - if (isLink) { - results.push( - - {originalText} - - ); - } else { - results.push(renderWith({ text: originalText, isGroup, key: count++ })); - } - - last = match.lastIndex; - }); - - if (last < text.length) { - results.push(renderWith({ text: text.slice(last), isGroup, key: count++ })); - } - - return <>{results}; -}; diff --git a/ts/components/conversation/message/message-content/MessageBody.tsx b/ts/components/conversation/message/message-content/MessageBody.tsx index 50f72c6f2..0ff3960a1 100644 --- a/ts/components/conversation/message/message-content/MessageBody.tsx +++ b/ts/components/conversation/message/message-content/MessageBody.tsx @@ -1,16 +1,24 @@ -import React from 'react'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { shell } from 'electron'; +import LinkifyIt from 'linkify-it'; + import { RenderTextCallbackType } from '../../../../types/Util'; import { getEmojiSizeClass, SizeClassType } from '../../../../util/emoji'; import { AddMentions } from '../../AddMentions'; import { AddNewLines } from '../../AddNewLines'; import { Emojify } from '../../Emojify'; -import { Linkify } from '../../Linkify'; +import { MessageInteraction } from '../../../../interactions'; +import { updateConfirmModal } from '../../../../state/ducks/modalDialog'; +import { LinkPreviews } from '../../../../util/linkPreviews'; + +const linkify = LinkifyIt(); type Props = { text: string; /** If set, all emoji will be the same size. Otherwise, just one emoji will be large. */ disableJumbomoji: boolean; - /** If set, links will be left alone instead of turned into clickable `` tags. */ + /** If set, links will be left alone instead of turned into clickable `` tags. Used in quotes, convo list item, etc */ disableLinks: boolean; isGroup: boolean; }; @@ -77,6 +85,7 @@ const JsxSelectable = (jsx: JSX.Element): JSX.Element => { ); }; + export const MessageBody = (props: Props) => { const { text, disableJumbomoji, disableLinks, isGroup } = props; const sizeClass: SizeClassType = disableJumbomoji ? 'default' : getEmojiSizeClass(text); @@ -113,3 +122,83 @@ export const MessageBody = (props: Props) => { /> ); }; + +type LinkifyProps = { + text: string; + /** Allows you to customize now non-links are rendered. Simplest is just a . */ + renderNonLink: RenderTextCallbackType; + isGroup: boolean; +}; + +const SUPPORTED_PROTOCOLS = /^(http|https):/i; + +const Linkify = (props: LinkifyProps): JSX.Element => { + const { text, isGroup, renderNonLink } = props; + const results: Array = []; + let count = 1; + const dispatch = useDispatch(); + const matchData = linkify.match(text) || []; + let last = 0; + + // disable click on elements so clicking a message containing a link doesn't + // select the message. The link will still be opened in the browser. + const handleClick = useCallback((e: any) => { + e.preventDefault(); + e.stopPropagation(); + + const url = e.target.href; + + const openLink = () => { + void shell.openExternal(url); + }; + + dispatch( + updateConfirmModal({ + title: window.i18n('linkVisitWarningTitle'), + message: window.i18n('linkVisitWarningMessage', url), + okText: window.i18n('open'), + cancelText: window.i18n('editMenuCopy'), + showExitIcon: true, + onClickOk: openLink, + onClickClose: () => { + dispatch(updateConfirmModal(null)); + }, + + onClickCancel: () => { + MessageInteraction.copyBodyToClipboard(url); + }, + }) + ); + }, []); + + if (matchData.length === 0) { + return renderNonLink({ text, key: 0, isGroup }); + } + + matchData.forEach((match: { index: number; url: string; lastIndex: number; text: string }) => { + if (last < match.index) { + const textWithNoLink = text.slice(last, match.index); + results.push(renderNonLink({ text: textWithNoLink, isGroup, key: count++ })); + } + + const { url, text: originalText } = match; + const isLink = SUPPORTED_PROTOCOLS.test(url) && !LinkPreviews.isLinkSneaky(url); + if (isLink) { + results.push( + + {originalText} + + ); + } else { + results.push(renderNonLink({ text: originalText, isGroup, key: count++ })); + } + + last = match.lastIndex; + }); + + if (last < text.length) { + results.push(renderNonLink({ text: text.slice(last), isGroup, key: count++ })); + } + + return <>{results}; +}; From 71aa6e8bb4bc7b9ed7c8738ab7782003a22ce9bd Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Wed, 27 Apr 2022 13:48:49 +1000 Subject: [PATCH 3/3] lint and add test for getInitials and name with '-' as separator --- ts/test/session/unit/utils/Initials_test.ts | 29 +++++++++++++++++++++ ts/util/getInitials.ts | 4 +-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/ts/test/session/unit/utils/Initials_test.ts b/ts/test/session/unit/utils/Initials_test.ts index e86cb4376..d6b204f29 100644 --- a/ts/test/session/unit/utils/Initials_test.ts +++ b/ts/test/session/unit/utils/Initials_test.ts @@ -59,6 +59,35 @@ describe('getInitials', () => { }); }); + describe('name has a - in its content', () => { + it('initials: return the first char of each first 2 words if a - is present ', () => { + expect(getInitials('John-Doe')).to.be.equal('JD', 'should have return JD'); + }); + + it('initials: return the first char capitalized of each first 2 words if a - is present ', () => { + expect(getInitials('John-doe')).to.be.equal('JD', 'should have return JD capitalized'); + }); + + it('initials: return the first char capitalized of each first 2 words if a - is present, even with more than 2 words ', () => { + expect(getInitials('John-Doe-Alice')).to.be.equal('JD', 'should have return JD capitalized'); + }); + + it('initials: return the first char capitalized of each first 2 words if a - is present, even with more than 2 words ', () => { + expect(getInitials('John-doe-Alice')).to.be.equal('JD', 'should have return JD capitalized'); + }); + + describe('name is not ascii', () => { + // ß maps to SS in uppercase + it('initials: shorten to 2 char at most if the uppercase form length is > 2 ', () => { + expect(getInitials('John-ß')).to.be.equal('JS', 'should have return JS capitalized'); + }); + + it('initials: shorten to 2 char at most if the uppercase form length is > 2 ', () => { + expect(getInitials('ß-ß')).to.be.equal('SS', 'should have return SS capitalized'); + }); + }); + }); + describe('name has NO spaces in its content', () => { it('initials: return the first 2 chars of the first word if the name has no space ', () => { expect(getInitials('JOHNY')).to.be.equal('JO', 'should have return JO'); diff --git a/ts/util/getInitials.ts b/ts/util/getInitials.ts index 69d313137..0cbebc7bd 100644 --- a/ts/util/getInitials.ts +++ b/ts/util/getInitials.ts @@ -14,7 +14,7 @@ export function getInitials(name?: string): string { if (name.length > 1) { const alphanum = name.match(/[\p{L}\p{N}]+/u); if (alphanum) { - return upperAndShorten(alphanum[0].slice(0, 2)); + return upperAndShorten(alphanum[0].slice(0, 2)); } } return upperAndShorten(name[0]); @@ -26,7 +26,7 @@ export function getInitials(name?: string): string { .split(/[-\s]/) .slice(0, 2) .map(n => - // Allow a letter or a digit from any alphabet. + // Allow a letter or a digit from any alphabet. n.match(/^[\p{L}\p{N}]/u) ) .join('')