Migrate most React class components to function components

This commit is contained in:
Jamie Kyle 2023-04-12 16:17:56 -07:00 committed by GitHub
parent 4c9baaef80
commit 558b5a4a38
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1444 additions and 1775 deletions

View file

@ -1,9 +1,8 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { VirtualElement } from '@popperjs/core';
import React from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import React, { useEffect, useState } from 'react';
import { usePopper } from 'react-popper';
import { createPortal } from 'react-dom';
import { showSettings } from '../shims/Whisper';
@ -38,240 +37,183 @@ export type PropsType = {
toggleStoriesView: () => unknown;
};
type StateType = {
showingAvatarPopup: boolean;
popperRoot: HTMLDivElement | null;
outsideClickDestructor?: () => void;
virtualElement: {
getBoundingClientRect: () => DOMRect;
};
};
export function MainHeader({
areStoriesEnabled,
avatarPath,
badge,
color,
hasFailedStorySends,
hasPendingUpdate,
i18n,
name,
phoneNumber,
profileName,
showArchivedConversations,
startComposing,
startUpdate,
theme,
title,
toggleProfileEditor,
toggleStoriesView,
unreadStoriesCount,
}: PropsType): JSX.Element {
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
// https://popper.js.org/docs/v2/virtual-elements/
// Generating a virtual element here so that we can make the menu pop up
// right under the mouse cursor.
function generateVirtualElement(x: number, y: number): VirtualElement {
return {
getBoundingClientRect: () => new DOMRect(x, y),
};
}
const [showAvatarPopup, setShowAvatarPopup] = useState(false);
export class MainHeader extends React.Component<PropsType, StateType> {
public containerRef: React.RefObject<HTMLDivElement> = React.createRef();
const popper = usePopper(targetElement, popperElement, {
placement: 'bottom-start',
strategy: 'fixed',
modifiers: [
{
name: 'offset',
options: {
offset: [null, 4],
},
},
],
});
constructor(props: PropsType) {
super(props);
this.state = {
showingAvatarPopup: false,
popperRoot: null,
virtualElement: generateVirtualElement(0, 0),
useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setPortalElement(div);
return () => {
div.remove();
setPortalElement(null);
};
}
}, []);
public showAvatarPopup = (ev: React.MouseEvent): void => {
const popperRoot = document.createElement('div');
document.body.appendChild(popperRoot);
const outsideClickDestructor = handleOutsideClick(
useEffect(() => {
return handleOutsideClick(
() => {
const { showingAvatarPopup } = this.state;
if (!showingAvatarPopup) {
if (!showAvatarPopup) {
return false;
}
this.hideAvatarPopup();
setShowAvatarPopup(false);
return true;
},
{
containerElements: [popperRoot, this.containerRef],
containerElements: [portalElement, targetElement],
name: 'MainHeader.showAvatarPopup',
}
);
}, [portalElement, targetElement, showAvatarPopup]);
this.setState({
showingAvatarPopup: true,
popperRoot,
outsideClickDestructor,
virtualElement: generateVirtualElement(ev.clientX, ev.clientY),
});
};
public hideAvatarPopup = (): void => {
const { popperRoot, outsideClickDestructor } = this.state;
this.setState({
showingAvatarPopup: false,
popperRoot: null,
outsideClickDestructor: undefined,
});
outsideClickDestructor?.();
if (popperRoot && document.body.contains(popperRoot)) {
document.body.removeChild(popperRoot);
useEffect(() => {
function handleGlobalKeyDown(event: KeyboardEvent) {
if (showAvatarPopup && event.key === 'Escape') {
setShowAvatarPopup(false);
}
}
};
document.addEventListener('keydown', handleGlobalKeyDown, true);
return () => {
document.removeEventListener('keydown', handleGlobalKeyDown, true);
};
}, [showAvatarPopup]);
public override componentDidMount(): void {
const useCapture = true;
document.addEventListener('keydown', this.handleGlobalKeyDown, useCapture);
}
public override componentWillUnmount(): void {
const { popperRoot, outsideClickDestructor } = this.state;
const useCapture = true;
outsideClickDestructor?.();
document.removeEventListener(
'keydown',
this.handleGlobalKeyDown,
useCapture
);
if (popperRoot && document.body.contains(popperRoot)) {
document.body.removeChild(popperRoot);
}
}
public handleGlobalKeyDown = (event: KeyboardEvent): void => {
const { showingAvatarPopup } = this.state;
const { key } = event;
if (showingAvatarPopup && key === 'Escape') {
this.hideAvatarPopup();
}
};
public override render(): JSX.Element {
const {
areStoriesEnabled,
avatarPath,
badge,
color,
hasFailedStorySends,
hasPendingUpdate,
i18n,
name,
phoneNumber,
profileName,
showArchivedConversations,
startComposing,
startUpdate,
theme,
title,
toggleProfileEditor,
toggleStoriesView,
unreadStoriesCount,
} = this.props;
const { showingAvatarPopup, popperRoot } = this.state;
return (
<div className="module-main-header">
<Manager>
<Reference>
{({ ref }) => (
<div
className="module-main-header__avatar--container"
ref={this.containerRef}
>
<Avatar
acceptedMessageRequest
avatarPath={avatarPath}
badge={badge}
className="module-main-header__avatar"
color={color}
conversationType="direct"
i18n={i18n}
isMe
phoneNumber={phoneNumber}
profileName={profileName}
theme={theme}
title={title}
// `sharedGroupNames` makes no sense for yourself, but
// `<Avatar>` needs it to determine blurring.
sharedGroupNames={[]}
size={AvatarSize.TWENTY_EIGHT}
innerRef={ref}
onClick={this.showAvatarPopup}
/>
{hasPendingUpdate && (
<div className="module-main-header__avatar--badged" />
)}
</div>
)}
</Reference>
{showingAvatarPopup && popperRoot
? createPortal(
<Popper referenceElement={this.state.virtualElement}>
{({ ref, style }) => (
<AvatarPopup
acceptedMessageRequest
badge={badge}
innerRef={ref}
i18n={i18n}
isMe
style={{ ...style, zIndex: 10 }}
color={color}
conversationType="direct"
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
theme={theme}
title={title}
avatarPath={avatarPath}
hasPendingUpdate={hasPendingUpdate}
startUpdate={startUpdate}
// See the comment above about `sharedGroupNames`.
sharedGroupNames={[]}
onEditProfile={() => {
toggleProfileEditor();
this.hideAvatarPopup();
}}
onViewPreferences={() => {
showSettings();
this.hideAvatarPopup();
}}
onViewArchive={() => {
showArchivedConversations();
this.hideAvatarPopup();
}}
/>
)}
</Popper>,
popperRoot
)
: null}
</Manager>
<div className="module-main-header__icon-container">
{areStoriesEnabled && (
<button
aria-label={i18n('icu:stories')}
className="module-main-header__stories-icon"
onClick={toggleStoriesView}
title={i18n('icu:stories')}
type="button"
>
{hasFailedStorySends && (
<span className="module-main-header__stories-badge">!</span>
)}
{!hasFailedStorySends && unreadStoriesCount ? (
<span className="module-main-header__stories-badge">
{unreadStoriesCount}
</span>
) : undefined}
</button>
)}
<button
aria-label={i18n('icu:newConversation')}
className="module-main-header__compose-icon"
onClick={startComposing}
title={i18n('icu:newConversation')}
type="button"
/>
</div>
return (
<div className="module-main-header">
<div
className="module-main-header__avatar--container"
ref={setTargetElement}
>
<Avatar
acceptedMessageRequest
avatarPath={avatarPath}
badge={badge}
className="module-main-header__avatar"
color={color}
conversationType="direct"
i18n={i18n}
isMe
phoneNumber={phoneNumber}
profileName={profileName}
theme={theme}
title={title}
// `sharedGroupNames` makes no sense for yourself, but
// `<Avatar>` needs it to determine blurring.
sharedGroupNames={[]}
size={AvatarSize.TWENTY_EIGHT}
onClick={() => {
setShowAvatarPopup(true);
}}
/>
{hasPendingUpdate && (
<div className="module-main-header__avatar--badged" />
)}
</div>
);
}
{showAvatarPopup &&
portalElement != null &&
createPortal(
<div
ref={setPopperElement}
style={{ ...popper.styles.popper, zIndex: 10 }}
{...popper.attributes.popper}
>
<AvatarPopup
acceptedMessageRequest
badge={badge}
i18n={i18n}
isMe
color={color}
conversationType="direct"
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
theme={theme}
title={title}
avatarPath={avatarPath}
hasPendingUpdate={hasPendingUpdate}
startUpdate={startUpdate}
// See the comment above about `sharedGroupNames`.
sharedGroupNames={[]}
onEditProfile={() => {
toggleProfileEditor();
setShowAvatarPopup(false);
}}
onViewPreferences={() => {
showSettings();
setShowAvatarPopup(false);
}}
onViewArchive={() => {
showArchivedConversations();
setShowAvatarPopup(false);
}}
style={{}}
/>
</div>,
portalElement
)}
<div className="module-main-header__icon-container">
{areStoriesEnabled && (
<button
aria-label={i18n('icu:stories')}
className="module-main-header__stories-icon"
onClick={toggleStoriesView}
title={i18n('icu:stories')}
type="button"
>
{hasFailedStorySends && (
<span className="module-main-header__stories-badge">!</span>
)}
{!hasFailedStorySends && unreadStoriesCount ? (
<span className="module-main-header__stories-badge">
{unreadStoriesCount}
</span>
) : undefined}
</button>
)}
<button
aria-label={i18n('icu:newConversation')}
className="module-main-header__compose-icon"
onClick={startComposing}
title={i18n('icu:newConversation')}
type="button"
/>
</div>
</div>
);
}

View file

@ -2,9 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { text } from '@storybook/addon-knobs';
import type { Props } from './AddNewLines';
import { AddNewLines } from './AddNewLines';
@ -14,7 +11,7 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
renderNonNewLine: overrideProps.renderNonNewLine,
text: text('text', overrideProps.text || ''),
text: overrideProps.text || '',
});
export function AllNewlines(): JSX.Element {

View file

@ -13,43 +13,39 @@ export type Props = {
const defaultRenderNonNewLine: RenderTextCallbackType = ({ text }) => text;
export class AddNewLines extends React.Component<Props> {
public override render():
| JSX.Element
| string
| null
| Array<JSX.Element | string | null> {
const { text, renderNonNewLine = defaultRenderNonNewLine } = this.props;
const results: Array<JSX.Element | string> = [];
const FIND_NEWLINES = /\n/g;
export function AddNewLines({
text,
renderNonNewLine = defaultRenderNonNewLine,
}: Props): JSX.Element {
const results: Array<JSX.Element | string> = [];
const FIND_NEWLINES = /\n/g;
let match = FIND_NEWLINES.exec(text);
let last = 0;
let count = 1;
let match = FIND_NEWLINES.exec(text);
let last = 0;
let count = 1;
if (!match) {
return renderNonNewLine({ text, key: 0 });
}
while (match) {
if (last < match.index) {
const textWithNoNewline = text.slice(last, match.index);
count += 1;
results.push(renderNonNewLine({ text: textWithNoNewline, key: count }));
}
count += 1;
results.push(<br key={count} />);
last = FIND_NEWLINES.lastIndex;
match = FIND_NEWLINES.exec(text);
}
if (last < text.length) {
count += 1;
results.push(renderNonNewLine({ text: text.slice(last), key: count }));
}
return results;
if (!match) {
return <>{renderNonNewLine({ text, key: 0 })}</>;
}
while (match) {
if (last < match.index) {
const textWithNoNewline = text.slice(last, match.index);
count += 1;
results.push(renderNonNewLine({ text: textWithNoNewline, key: count }));
}
count += 1;
results.push(<br key={count} />);
last = FIND_NEWLINES.lastIndex;
match = FIND_NEWLINES.exec(text);
}
if (last < text.length) {
count += 1;
results.push(renderNonNewLine({ text: text.slice(last), key: count }));
}
return <>{results}</>;
}

View file

@ -4,7 +4,6 @@
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { boolean } from '@storybook/addon-knobs';
import type { Props } from './ContactDetail';
import { ContactDetail } from './ContactDetail';
@ -23,10 +22,7 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
contact: overrideProps.contact || {},
hasSignalAccount: boolean(
'hasSignalAccount',
overrideProps.hasSignalAccount || false
),
hasSignalAccount: overrideProps.hasSignalAccount || false,
i18n,
onSendMessage: action('onSendMessage'),
});

View file

@ -73,164 +73,97 @@ function getLabelForAddress(
}
}
export class ContactDetail extends React.Component<Props> {
public renderSendMessage({
hasSignalAccount,
i18n,
onSendMessage,
}: {
hasSignalAccount: boolean;
i18n: LocalizerType;
onSendMessage: () => void;
}): JSX.Element | null {
if (!hasSignalAccount) {
return null;
}
export function ContactDetail({
contact,
hasSignalAccount,
i18n,
onSendMessage,
}: Props): JSX.Element {
// We don't want the overall click handler for this element to fire, so we stop
// propagation before handing control to the caller's callback.
const onClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
onSendMessage();
};
// We don't want the overall click handler for this element to fire, so we stop
// propagation before handing control to the caller's callback.
const onClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
onSendMessage();
};
const isIncoming = false;
const module = 'contact-detail';
return (
<button
type="button"
className="module-contact-detail__send-message"
onClick={onClick}
>
<div className="module-contact-detail__send-message__inner">
<div className="module-contact-detail__send-message__bubble-icon" />
{i18n('icu:sendMessageToContact')}
</div>
</button>
);
}
public renderEmail(
items: Array<Email> | undefined,
i18n: LocalizerType
): Array<JSX.Element> | undefined {
if (!items || items.length === 0) {
return undefined;
}
return items.map((item: Email) => {
return (
<div
key={item.value}
className="module-contact-detail__additional-contact"
>
<div className="module-contact-detail__additional-contact__type">
{getLabelForEmail(item, i18n)}
</div>
{item.value}
</div>
);
});
}
public renderPhone(
items: Array<Phone> | undefined,
i18n: LocalizerType
): Array<JSX.Element> | null | undefined {
if (!items || items.length === 0) {
return undefined;
}
return items.map((item: Phone) => {
return (
<div
key={item.value}
className="module-contact-detail__additional-contact"
>
<div className="module-contact-detail__additional-contact__type">
{getLabelForPhone(item, i18n)}
</div>
{item.value}
</div>
);
});
}
public renderAddressLine(value: string | undefined): JSX.Element | undefined {
if (!value) {
return undefined;
}
return <div>{value}</div>;
}
public renderPOBox(
poBox: string | undefined,
i18n: LocalizerType
): JSX.Element | null {
if (!poBox) {
return null;
}
return (
<div>
{i18n('icu:poBox')} {poBox}
return (
<div className="module-contact-detail">
<div className="module-contact-detail__avatar">
{renderAvatar({ contact, i18n, size: 80 })}
</div>
);
}
{renderName({ contact, isIncoming, module })}
{renderContactShorthand({ contact, isIncoming, module })}
public renderAddressLineTwo(address: PostalAddress): JSX.Element | null {
if (address.city || address.region || address.postcode) {
return (
<div>
{address.city} {address.region} {address.postcode}
</div>
);
}
return null;
}
public renderAddresses(
addresses: Array<PostalAddress> | undefined,
i18n: LocalizerType
): Array<JSX.Element> | undefined {
if (!addresses || addresses.length === 0) {
return undefined;
}
return addresses.map((address: PostalAddress, index: number) => {
return (
// eslint-disable-next-line react/no-array-index-key
<div key={index} className="module-contact-detail__additional-contact">
<div className="module-contact-detail__additional-contact__type">
{getLabelForAddress(address, i18n)}
{hasSignalAccount && (
<button
type="button"
className="module-contact-detail__send-message"
onClick={onClick}
>
<div className="module-contact-detail__send-message__inner">
<div className="module-contact-detail__send-message__bubble-icon" />
{i18n('icu:sendMessageToContact')}
</div>
{this.renderAddressLine(address.street)}
{this.renderPOBox(address.pobox, i18n)}
{this.renderAddressLine(address.neighborhood)}
{this.renderAddressLineTwo(address)}
{this.renderAddressLine(address.country)}
</div>
);
});
}
</button>
)}
public override render(): JSX.Element {
const { contact, hasSignalAccount, i18n, onSendMessage } = this.props;
const isIncoming = false;
const module = 'contact-detail';
{contact.number?.map((phone: Phone) => {
return (
<div
key={phone.value}
className="module-contact-detail__additional-contact"
>
<div className="module-contact-detail__additional-contact__type">
{getLabelForPhone(phone, i18n)}
</div>
{phone.value}
</div>
);
})}
return (
<div className="module-contact-detail">
<div className="module-contact-detail__avatar">
{renderAvatar({ contact, i18n, size: 80 })}
</div>
{renderName({ contact, isIncoming, module })}
{renderContactShorthand({ contact, isIncoming, module })}
{this.renderSendMessage({ hasSignalAccount, i18n, onSendMessage })}
{this.renderPhone(contact.number, i18n)}
{this.renderEmail(contact.email, i18n)}
{this.renderAddresses(contact.address, i18n)}
</div>
);
}
{contact.email?.map((email: Email) => {
return (
<div
key={email.value}
className="module-contact-detail__additional-contact"
>
<div className="module-contact-detail__additional-contact__type">
{getLabelForEmail(email, i18n)}
</div>
{email.value}
</div>
);
})}
{contact.address?.map((address: PostalAddress, index: number) => {
return (
<div
// eslint-disable-next-line react/no-array-index-key
key={index}
className="module-contact-detail__additional-contact"
>
<div className="module-contact-detail__additional-contact__type">
{getLabelForAddress(address, i18n)}
</div>
{address.street && <div>{address.street}</div>}
{address.pobox && (
<div>
{i18n('icu:poBox')} {address.pobox}
</div>
)}
{address.neighborhood && <div>{address.neighborhood}</div>}
{(address.city || address.region || address.postcode) && (
<div>
{address.city} {address.region} {address.postcode}
</div>
)}
{address.country && <div>{address.country}</div>}
</div>
);
})}
</div>
);
}

View file

@ -2,9 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { text } from '@storybook/addon-knobs';
import type { Props } from './Emojify';
import { Emojify } from './Emojify';
@ -15,7 +12,7 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
renderNonEmoji: overrideProps.renderNonEmoji,
sizeClass: overrideProps.sizeClass,
text: text('text', overrideProps.text || ''),
text: overrideProps.text || '',
});
export function EmojiOnly(): JSX.Element {

View file

@ -59,25 +59,25 @@ export type Props = {
const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
export class Emojify extends React.Component<Props> {
public override render(): null | Array<JSX.Element | string | null> {
const {
isInvisible,
renderNonEmoji = defaultRenderNonEmoji,
sizeClass,
text,
} = this.props;
export function Emojify({
isInvisible,
renderNonEmoji = defaultRenderNonEmoji,
sizeClass,
text,
}: Props): JSX.Element {
return (
<>
{splitByEmoji(text).map(({ type, value: match }, index) => {
if (type === 'emoji') {
return getImageTag({ isInvisible, match, sizeClass, key: index });
}
return splitByEmoji(text).map(({ type, value: match }, index) => {
if (type === 'emoji') {
return getImageTag({ isInvisible, match, sizeClass, key: index });
}
if (type === 'text') {
return renderNonEmoji({ text: match, key: index });
}
if (type === 'text') {
return renderNonEmoji({ text: match, key: index });
}
throw missingCaseError(type);
});
}
throw missingCaseError(type);
})}
</>
);
}

View file

@ -2,9 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { boolean, number } from '@storybook/addon-knobs';
import type { Props } from './ExpireTimer';
import { ExpireTimer } from './ExpireTimer';
@ -14,23 +11,12 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
direction: overrideProps.direction || 'outgoing',
expirationLength: number(
'expirationLength',
overrideProps.expirationLength || 30 * 1000
),
expirationTimestamp: number(
'expirationTimestamp',
overrideProps.expirationTimestamp || Date.now() + 30 * 1000
),
withImageNoCaption: boolean(
'withImageNoCaption',
overrideProps.withImageNoCaption || false
),
withSticker: boolean('withSticker', overrideProps.withSticker || false),
withTapToViewExpired: boolean(
'withTapToViewExpired',
overrideProps.withTapToViewExpired || false
),
expirationLength: overrideProps.expirationLength || 30 * 1000,
expirationTimestamp:
overrideProps.expirationTimestamp || Date.now() + 30 * 1000,
withImageNoCaption: overrideProps.withImageNoCaption || false,
withSticker: overrideProps.withSticker || false,
withTapToViewExpired: overrideProps.withTapToViewExpired || false,
});
export const _30Seconds = (): JSX.Element => {

View file

@ -1,11 +1,10 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useEffect, useReducer } from 'react';
import classNames from 'classnames';
import { getIncrement, getTimerBucket } from '../../util/timer';
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
export type Props = {
deletedForEveryone?: boolean;
@ -17,65 +16,43 @@ export type Props = {
withTapToViewExpired?: boolean;
};
export class ExpireTimer extends React.Component<Props> {
private interval: NodeJS.Timeout | null;
export function ExpireTimer({
deletedForEveryone,
direction,
expirationLength,
expirationTimestamp,
withImageNoCaption,
withSticker,
withTapToViewExpired,
}: Props): JSX.Element {
const [, forceUpdate] = useReducer(() => ({}), {});
constructor(props: Props) {
super(props);
this.interval = null;
}
public override componentDidMount(): void {
const { expirationLength } = this.props;
useEffect(() => {
const increment = getIncrement(expirationLength);
const updateFrequency = Math.max(increment, 500);
const update = () => {
this.setState({
// Used to trigger renders
// eslint-disable-next-line react/no-unused-state
lastUpdated: Date.now(),
});
const interval = setInterval(forceUpdate, updateFrequency);
return () => {
clearInterval(interval);
};
this.interval = setInterval(update, updateFrequency);
}
}, [expirationLength]);
public override componentWillUnmount(): void {
clearTimeoutIfNecessary(this.interval);
}
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
public override render(): JSX.Element {
const {
deletedForEveryone,
direction,
expirationLength,
expirationTimestamp,
withImageNoCaption,
withSticker,
withTapToViewExpired,
} = this.props;
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
return (
<div
className={classNames(
'module-expire-timer',
`module-expire-timer--${bucket}`,
direction ? `module-expire-timer--${direction}` : null,
deletedForEveryone
? 'module-expire-timer--deleted-for-everyone'
: null,
withTapToViewExpired
? `module-expire-timer--${direction}-with-tap-to-view-expired`
: null,
direction && withImageNoCaption
? 'module-expire-timer--with-image-no-caption'
: null,
withSticker ? 'module-expire-timer--with-sticker' : null
)}
/>
);
}
return (
<div
className={classNames(
'module-expire-timer',
`module-expire-timer--${bucket}`,
direction ? `module-expire-timer--${direction}` : null,
deletedForEveryone ? 'module-expire-timer--deleted-for-everyone' : null,
withTapToViewExpired
? `module-expire-timer--${direction}-with-tap-to-view-expired`
: null,
direction && withImageNoCaption
? 'module-expire-timer--with-image-no-caption'
: null,
withSticker ? 'module-expire-timer--with-sticker' : null
)}
/>
);
}

View file

@ -32,146 +32,152 @@ type PropsHousekeeping = {
export type Props = PropsData & PropsHousekeeping;
export class GroupNotification extends React.Component<Props> {
public renderChange(
change: Change,
from: ConversationType
): JSX.Element | string | null | undefined {
const { contacts, type, newName } = change;
const { i18n } = this.props;
function GroupNotificationChange({
change,
from,
i18n,
}: {
change: Change;
from: ConversationType;
i18n: LocalizerType;
}): JSX.Element | null {
const { contacts, type, newName } = change;
const otherPeople: Array<JSX.Element> = compact(
(contacts || []).map(contact => {
if (contact.isMe) {
return null;
}
const otherPeople: Array<JSX.Element> = compact(
(contacts || []).map(contact => {
if (contact.isMe) {
return null;
}
return (
<span
key={`external-${contact.id}`}
className="module-group-notification__contact"
>
<ContactName title={contact.title} />
</span>
);
})
);
const otherPeopleWithCommas: Array<JSX.Element | string> = compact(
flatten(
otherPeople.map((person, index) => [index > 0 ? ', ' : null, person])
)
);
const contactsIncludesMe = (contacts || []).length !== otherPeople.length;
return (
<span
key={`external-${contact.id}`}
className="module-group-notification__contact"
>
<ContactName title={contact.title} />
</span>
);
})
);
const otherPeopleWithCommas: Array<JSX.Element | string> = compact(
flatten(
otherPeople.map((person, index) => [index > 0 ? ', ' : null, person])
)
);
const contactsIncludesMe = (contacts || []).length !== otherPeople.length;
switch (type) {
case 'name':
return (
<Intl
i18n={i18n}
id="icu:titleIsNow"
components={{ name: newName || '' }}
/>
);
case 'avatar':
return <Intl i18n={i18n} id="icu:updatedGroupAvatar" />;
case 'add':
if (!contacts || !contacts.length) {
throw new Error('Group update is missing contacts');
}
switch (type) {
case 'name':
return (
<Intl
i18n={i18n}
id="icu:titleIsNow"
components={{ name: newName || '' }}
/>
);
case 'avatar':
return <Intl i18n={i18n} id="icu:updatedGroupAvatar" />;
case 'add':
if (!contacts || !contacts.length) {
throw new Error('Group update is missing contacts');
}
return (
<>
{otherPeople.length > 0 && (
<>
{otherPeople.length === 1 ? (
<Intl
i18n={i18n}
id="icu:joinedTheGroup"
components={{ name: otherPeopleWithCommas }}
/>
) : (
<Intl
i18n={i18n}
id="icu:multipleJoinedTheGroup"
components={{ names: otherPeopleWithCommas }}
/>
)}
</>
)}
{contactsIncludesMe && (
<div className="module-group-notification__change">
<Intl i18n={i18n} id="icu:youJoinedTheGroup" />
</div>
)}
</>
);
case 'remove':
if (from && from.isMe) {
return i18n('icu:youLeftTheGroup');
}
if (!contacts || !contacts.length) {
throw new Error('Group update is missing contacts');
}
return contacts.length > 1 ? (
<Intl
id="icu:multipleLeftTheGroup"
i18n={i18n}
components={{ name: otherPeopleWithCommas }}
/>
) : (
<Intl
id="icu:leftTheGroup"
i18n={i18n}
components={{ name: otherPeopleWithCommas }}
/>
);
case 'general':
return;
default:
throw missingCaseError(type);
}
}
public override render(): JSX.Element {
const { changes: rawChanges, i18n, from } = this.props;
// This check is just to be extra careful, and can probably be removed.
const changes: Array<Change> = Array.isArray(rawChanges) ? rawChanges : [];
// Leave messages are always from the person leaving, so we omit the fromLabel if
// the change is a 'leave.'
const firstChange: undefined | Change = changes[0];
const isLeftOnly = changes.length === 1 && firstChange?.type === 'remove';
const fromLabel = from.isMe ? (
<Intl i18n={i18n} id="icu:youUpdatedTheGroup" />
) : (
<Intl
i18n={i18n}
id="icu:updatedTheGroup"
components={{ name: <ContactName title={from.title} /> }}
/>
);
let contents: ReactNode;
if (isLeftOnly) {
contents = this.renderChange(firstChange, from);
} else {
contents = (
return (
<>
<p>{fromLabel}</p>
{changes.map((change, i) => (
// eslint-disable-next-line react/no-array-index-key
<p key={i} className="module-group-notification__change">
{this.renderChange(change, from)}
</p>
))}
{otherPeople.length > 0 && (
<>
{otherPeople.length === 1 ? (
<Intl
i18n={i18n}
id="icu:joinedTheGroup"
components={{ name: otherPeopleWithCommas }}
/>
) : (
<Intl
i18n={i18n}
id="icu:multipleJoinedTheGroup"
components={{ names: otherPeopleWithCommas }}
/>
)}
</>
)}
{contactsIncludesMe && (
<div className="module-group-notification__change">
<Intl i18n={i18n} id="icu:youJoinedTheGroup" />
</div>
)}
</>
);
}
case 'remove':
if (from && from.isMe) {
return <>{i18n('icu:youLeftTheGroup')}</>;
}
return <SystemMessage contents={contents} icon="group" />;
if (!contacts || !contacts.length) {
throw new Error('Group update is missing contacts');
}
return contacts.length > 1 ? (
<Intl
id="icu:multipleLeftTheGroup"
i18n={i18n}
components={{ name: otherPeopleWithCommas }}
/>
) : (
<Intl
id="icu:leftTheGroup"
i18n={i18n}
components={{ name: otherPeopleWithCommas }}
/>
);
case 'general':
return null;
default:
throw missingCaseError(type);
}
}
export function GroupNotification({
changes: rawChanges,
i18n,
from,
}: Props): JSX.Element {
// This check is just to be extra careful, and can probably be removed.
const changes: Array<Change> = Array.isArray(rawChanges) ? rawChanges : [];
// Leave messages are always from the person leaving, so we omit the fromLabel if
// the change is a 'leave.'
const firstChange: undefined | Change = changes[0];
const isLeftOnly = changes.length === 1 && firstChange?.type === 'remove';
const fromLabel = from.isMe ? (
<Intl i18n={i18n} id="icu:youUpdatedTheGroup" />
) : (
<Intl
i18n={i18n}
id="icu:updatedTheGroup"
components={{ name: <ContactName title={from.title} /> }}
/>
);
let contents: ReactNode;
if (isLeftOnly) {
contents = (
<GroupNotificationChange change={firstChange} from={from} i18n={i18n} />
);
} else {
contents = (
<>
<p>{fromLabel}</p>
{changes.map((change, i) => (
// eslint-disable-next-line react/no-array-index-key
<p key={i} className="module-group-notification__change">
<GroupNotificationChange change={change} from={from} i18n={i18n} />
</p>
))}
</>
);
}
return <SystemMessage contents={contents} icon="group" />;
}

View file

@ -4,7 +4,6 @@
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { boolean, number, text } from '@storybook/addon-knobs';
import { pngUrl } from '../../storybook/Fixtures';
import type { Props } from './Image';
@ -24,7 +23,7 @@ export default {
};
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
alt: text('alt', overrideProps.alt || ''),
alt: overrideProps.alt || '',
attachment:
overrideProps.attachment ||
fakeAttachment({
@ -32,42 +31,27 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
fileName: 'sax.png',
url: pngUrl,
}),
blurHash: text('blurHash', overrideProps.blurHash || ''),
bottomOverlay: boolean('bottomOverlay', overrideProps.bottomOverlay || false),
closeButton: boolean('closeButton', overrideProps.closeButton || false),
curveBottomLeft: number(
'curveBottomLeft',
overrideProps.curveBottomLeft || CurveType.None
),
curveBottomRight: number(
'curveBottomRight',
overrideProps.curveBottomRight || CurveType.None
),
curveTopLeft: number(
'curveTopLeft',
overrideProps.curveTopLeft || CurveType.None
),
curveTopRight: number(
'curveTopRight',
overrideProps.curveTopRight || CurveType.None
),
darkOverlay: boolean('darkOverlay', overrideProps.darkOverlay || false),
height: number('height', overrideProps.height || 100),
blurHash: overrideProps.blurHash || '',
bottomOverlay: overrideProps.bottomOverlay || false,
closeButton: overrideProps.closeButton || false,
curveBottomLeft: overrideProps.curveBottomLeft || CurveType.None,
curveBottomRight: overrideProps.curveBottomRight || CurveType.None,
curveTopLeft: overrideProps.curveTopLeft || CurveType.None,
curveTopRight: overrideProps.curveTopRight || CurveType.None,
darkOverlay: overrideProps.darkOverlay || false,
height: overrideProps.height || 100,
i18n,
noBackground: boolean('noBackground', overrideProps.noBackground || false),
noBorder: boolean('noBorder', overrideProps.noBorder || false),
noBackground: overrideProps.noBackground || false,
noBorder: overrideProps.noBorder || false,
onClick: action('onClick'),
onClickClose: action('onClickClose'),
onError: action('onError'),
overlayText: text('overlayText', overrideProps.overlayText || ''),
playIconOverlay: boolean(
'playIconOverlay',
overrideProps.playIconOverlay || false
),
tabIndex: number('tabIndex', overrideProps.tabIndex || 0),
theme: text('theme', overrideProps.theme || 'light') as ThemeType,
url: text('url', 'url' in overrideProps ? overrideProps.url || '' : pngUrl),
width: number('width', overrideProps.width || 100),
overlayText: overrideProps.overlayText || '',
playIconOverlay: overrideProps.playIconOverlay || false,
tabIndex: overrideProps.tabIndex || 0,
theme: overrideProps.theme || ('light' as ThemeType),
url: 'url' in overrideProps ? overrideProps.url || '' : pngUrl,
width: overrideProps.width || 100,
});
export function UrlWithHeightWidth(): JSX.Element {

View file

@ -1,7 +1,7 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useCallback, useMemo } from 'react';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
@ -55,237 +55,220 @@ export type Props = {
onError?: () => void;
};
export class Image extends React.Component<Props> {
private canClick() {
const { onClick, attachment } = this.props;
const { pending } = attachment || { pending: true };
export function Image({
alt,
attachment,
blurHash,
bottomOverlay,
className,
closeButton,
curveBottomLeft,
curveBottomRight,
curveTopLeft,
curveTopRight,
darkOverlay,
isDownloaded,
height = 0,
i18n,
noBackground,
noBorder,
onClick,
onClickClose,
onError,
overlayText,
playIconOverlay,
tabIndex,
theme,
url,
width = 0,
cropWidth = 0,
cropHeight = 0,
}: Props): JSX.Element {
const { caption, pending } = attachment || { caption: null, pending: true };
const imgNotDownloaded = isDownloaded
? false
: !isDownloadedFunction(attachment);
return Boolean(onClick && !pending);
}
const resolvedBlurHash = blurHash || defaultBlurHash(theme);
public handleClick = (event: React.MouseEvent): void => {
if (!this.canClick()) {
event.preventDefault();
event.stopPropagation();
return;
}
const { onClick, attachment } = this.props;
if (onClick) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
}
const curveStyles = {
borderTopLeftRadius: curveTopLeft || CurveType.None,
borderTopRightRadius: curveTopRight || CurveType.None,
borderBottomLeftRadius: curveBottomLeft || CurveType.None,
borderBottomRightRadius: curveBottomRight || CurveType.None,
};
public handleKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
): void => {
if (!this.canClick()) {
event.preventDefault();
event.stopPropagation();
const canClick = useMemo(() => {
return onClick != null && !pending;
}, [pending, onClick]);
return;
}
const handleClick = useCallback(
(event: React.MouseEvent) => {
if (!canClick) {
event.preventDefault();
event.stopPropagation();
const { onClick, attachment } = this.props;
return;
}
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
}
};
if (onClick) {
event.preventDefault();
event.stopPropagation();
public renderPending = (): JSX.Element => {
const { blurHash, height, i18n, width } = this.props;
onClick(attachment);
}
},
[attachment, canClick, onClick]
);
if (blurHash) {
return (
<div className="module-image__download-pending">
<Blurhash
hash={blurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
<div className="module-image__download-pending--spinner-container">
<div
className="module-image__download-pending--spinner"
title={i18n('icu:loading')}
>
<Spinner moduleClassName="module-image-spinner" svgSize="small" />
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!canClick) {
event.preventDefault();
event.stopPropagation();
return;
}
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
}
},
[attachment, canClick, onClick]
);
/* eslint-disable no-nested-ternary */
return (
<div
className={classNames(
'module-image',
className,
!noBackground ? 'module-image--with-background' : null,
cropWidth || cropHeight ? 'module-image--cropped' : null
)}
style={{
width: width - cropWidth,
height: height - cropHeight,
...curveStyles,
}}
>
{pending ? (
blurHash ? (
<div className="module-image__download-pending">
<Blurhash
hash={blurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
<div className="module-image__download-pending--spinner-container">
<div
className="module-image__download-pending--spinner"
title={i18n('icu:loading')}
>
<Spinner
moduleClassName="module-image-spinner"
svgSize="small"
/>
</div>
</div>
</div>
</div>
);
}
return (
<div
className="module-image__loading-placeholder"
style={{
height: `${height}px`,
width: `${width}px`,
lineHeight: `${height}px`,
textAlign: 'center',
}}
title={i18n('icu:loading')}
>
<Spinner svgSize="normal" />
</div>
);
};
public override render(): JSX.Element {
const {
alt,
attachment,
blurHash,
bottomOverlay,
className,
closeButton,
curveBottomLeft,
curveBottomRight,
curveTopLeft,
curveTopRight,
darkOverlay,
isDownloaded,
height = 0,
i18n,
noBackground,
noBorder,
onClickClose,
onError,
overlayText,
playIconOverlay,
tabIndex,
theme,
url,
width = 0,
cropWidth = 0,
cropHeight = 0,
} = this.props;
const { caption, pending } = attachment || { caption: null, pending: true };
const canClick = this.canClick();
const imgNotDownloaded = isDownloaded
? false
: !isDownloadedFunction(attachment);
const resolvedBlurHash = blurHash || defaultBlurHash(theme);
const curveStyles = {
borderTopLeftRadius: curveTopLeft || CurveType.None,
borderTopRightRadius: curveTopRight || CurveType.None,
borderBottomLeftRadius: curveBottomLeft || CurveType.None,
borderBottomRightRadius: curveBottomRight || CurveType.None,
};
const overlay = canClick ? (
// Not sure what this button does.
<button
type="button"
className={classNames('module-image__border-overlay', {
'module-image__border-overlay--with-border': !noBorder,
'module-image__border-overlay--with-click-handler': canClick,
'module-image__border-overlay--dark': darkOverlay,
'module-image--not-downloaded': imgNotDownloaded,
})}
style={curveStyles}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
tabIndex={tabIndex}
>
{imgNotDownloaded ? <span /> : null}
</button>
) : null;
/* eslint-disable no-nested-ternary */
return (
<div
className={classNames(
'module-image',
className,
!noBackground ? 'module-image--with-background' : null,
cropWidth || cropHeight ? 'module-image--cropped' : null
)}
style={{
width: width - cropWidth,
height: height - cropHeight,
...curveStyles,
}}
>
{pending ? (
this.renderPending()
) : url ? (
<img
onError={onError}
className="module-image__image"
alt={alt}
height={height}
width={width}
src={url}
/>
) : resolvedBlurHash ? (
<Blurhash
hash={resolvedBlurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
) : null}
{caption ? (
<img
className="module-image__caption-icon"
src="images/caption-shadow.svg"
alt={i18n('icu:imageCaptionIconAlt')}
/>
) : null}
{bottomOverlay ? (
) : (
<div
className="module-image__bottom-overlay"
className="module-image__loading-placeholder"
style={{
borderBottomLeftRadius: curveBottomLeft || CurveType.None,
borderBottomRightRadius: curveBottomRight || CurveType.None,
height: `${height}px`,
width: `${width}px`,
lineHeight: `${height}px`,
textAlign: 'center',
}}
/>
) : null}
{!pending && !imgNotDownloaded && playIconOverlay ? (
<div className="module-image__play-overlay__circle">
<div className="module-image__play-overlay__icon" />
</div>
) : null}
{overlayText ? (
<div
className="module-image__text-container"
style={{ lineHeight: `${height}px` }}
title={i18n('icu:loading')}
>
{overlayText}
<Spinner svgSize="normal" />
</div>
) : null}
{overlay}
{closeButton ? (
<button
type="button"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
)
) : url ? (
<img
onError={onError}
className="module-image__image"
alt={alt}
height={height}
width={width}
src={url}
/>
) : resolvedBlurHash ? (
<Blurhash
hash={resolvedBlurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
) : null}
{caption ? (
<img
className="module-image__caption-icon"
src="images/caption-shadow.svg"
alt={i18n('icu:imageCaptionIconAlt')}
/>
) : null}
{bottomOverlay ? (
<div
className="module-image__bottom-overlay"
style={{
borderBottomLeftRadius: curveBottomLeft || CurveType.None,
borderBottomRightRadius: curveBottomRight || CurveType.None,
}}
/>
) : null}
{!pending && !imgNotDownloaded && playIconOverlay ? (
<div className="module-image__play-overlay__circle">
<div className="module-image__play-overlay__icon" />
</div>
) : null}
{overlayText ? (
<div
className="module-image__text-container"
style={{ lineHeight: `${height}px` }}
>
{overlayText}
</div>
) : null}
{canClick ? (
<button
type="button"
className={classNames('module-image__border-overlay', {
'module-image__border-overlay--with-border': !noBorder,
'module-image__border-overlay--with-click-handler': canClick,
'module-image__border-overlay--dark': darkOverlay,
'module-image--not-downloaded': imgNotDownloaded,
})}
style={curveStyles}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={tabIndex}
>
{imgNotDownloaded ? <span /> : null}
</button>
) : null}
{closeButton ? (
<button
type="button"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (onClickClose) {
onClickClose(attachment);
}
}}
className="module-image__close-button"
title={i18n('icu:remove-attachment')}
aria-label={i18n('icu:remove-attachment')}
/>
) : null}
</div>
);
/* eslint-enable no-nested-ternary */
}
if (onClickClose) {
onClickClose(attachment);
}
}}
className="module-image__close-button"
title={i18n('icu:remove-attachment')}
aria-label={i18n('icu:remove-attachment')}
/>
) : null}
</div>
);
/* eslint-enable no-nested-ternary */
}

View file

@ -1,69 +1,51 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { ReactNode } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { getInteractionMode } from '../../services/InteractionMode';
export type PropsType = {
type PropsType = {
id: string;
conversationId: string;
isTargeted: boolean;
targetMessage?: (messageId: string, conversationId: string) => unknown;
targetMessage: (messageId: string, conversationId: string) => unknown;
children: ReactNode;
};
export class InlineNotificationWrapper extends React.Component<PropsType> {
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
export function InlineNotificationWrapper({
id,
conversationId,
isTargeted,
targetMessage,
children,
}: PropsType): JSX.Element {
const focusRef = useRef<HTMLDivElement>(null);
public setFocus = (): void => {
const container = this.focusRef.current;
if (container && !container.contains(document.activeElement)) {
container.focus();
useEffect(() => {
if (isTargeted) {
const container = focusRef.current;
if (container && !container.contains(document.activeElement)) {
container.focus();
}
}
};
}, [isTargeted]);
public handleFocus = (): void => {
const handleFocus = useCallback(() => {
if (getInteractionMode() === 'keyboard') {
this.setTargeted();
}
};
public setTargeted = (): void => {
const { id, conversationId, targetMessage } = this.props;
if (targetMessage) {
targetMessage(id, conversationId);
}
};
}, [id, conversationId, targetMessage]);
public override componentDidMount(): void {
const { isTargeted } = this.props;
if (isTargeted) {
this.setFocus();
}
}
public override componentDidUpdate(prevProps: PropsType): void {
const { isTargeted } = this.props;
if (!prevProps.isTargeted && isTargeted) {
this.setFocus();
}
}
public override render(): JSX.Element {
const { children } = this.props;
return (
<div
className="module-inline-notification-wrapper"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
ref={this.focusRef}
onFocus={this.handleFocus}
>
{children}
</div>
);
}
return (
<div
className="module-inline-notification-wrapper"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
ref={focusRef}
onFocus={handleFocus}
>
{children}
</div>
);
}

View file

@ -3,8 +3,6 @@
import * as React from 'react';
import { text } from '@storybook/addon-knobs';
import type { Props } from './Linkify';
import { Linkify } from './Linkify';
@ -14,7 +12,7 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
renderNonLink: overrideProps.renderNonLink,
text: text('text', overrideProps.text || ''),
text: overrideProps.text || '',
});
export function OnlyLink(): JSX.Element {

View file

@ -323,77 +323,71 @@ export const SUPPORTED_PROTOCOLS = /^(http|https):/i;
const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text;
export class Linkify extends React.Component<Props> {
public override render():
| JSX.Element
| string
| null
| Array<JSX.Element | string | null> {
const { text, renderNonLink = defaultRenderNonLink } = this.props;
export function Linkify(props: Props): JSX.Element {
const { text, renderNonLink = defaultRenderNonLink } = props;
if (!shouldLinkifyMessage(text)) {
return renderNonLink({ text, key: 1 });
if (!shouldLinkifyMessage(text)) {
return <>{renderNonLink({ text, key: 1 })}</>;
}
const chunkData: Array<{
chunk: string;
matchData: ReadonlyArray<LinkifyIt.Match>;
}> = splitByEmoji(text).map(({ type, value: chunk }) => {
if (type === 'text') {
return { chunk, matchData: linkify.match(chunk) || [] };
}
const chunkData: Array<{
chunk: string;
matchData: ReadonlyArray<LinkifyIt.Match>;
}> = splitByEmoji(text).map(({ type, value: chunk }) => {
if (type === 'text') {
return { chunk, matchData: linkify.match(chunk) || [] };
if (type === 'emoji') {
return { chunk, matchData: [] };
}
throw missingCaseError(type);
});
const results: Array<JSX.Element | string> = [];
let count = 1;
chunkData.forEach(({ chunk, matchData }) => {
if (matchData.length === 0) {
count += 1;
results.push(renderNonLink({ text: chunk, key: count }));
return;
}
let chunkLastIndex = 0;
matchData.forEach(match => {
if (chunkLastIndex < match.index) {
const textWithNoLink = chunk.slice(chunkLastIndex, match.index);
count += 1;
results.push(renderNonLink({ text: textWithNoLink, key: count }));
}
if (type === 'emoji') {
return { chunk, matchData: [] };
}
throw missingCaseError(type);
});
const results: Array<JSX.Element | string> = [];
let count = 1;
chunkData.forEach(({ chunk, matchData }) => {
if (matchData.length === 0) {
count += 1;
results.push(renderNonLink({ text: chunk, key: count }));
return;
}
let chunkLastIndex = 0;
matchData.forEach(match => {
if (chunkLastIndex < match.index) {
const textWithNoLink = chunk.slice(chunkLastIndex, match.index);
count += 1;
results.push(renderNonLink({ text: textWithNoLink, key: count }));
}
const { url, text: originalText } = match;
count += 1;
if (SUPPORTED_PROTOCOLS.test(url) && !isLinkSneaky(url)) {
results.push(
<a key={count} href={url}>
{originalText}
</a>
);
} else {
results.push(renderNonLink({ text: originalText, key: count }));
}
chunkLastIndex = match.lastIndex;
});
if (chunkLastIndex < chunk.length) {
count += 1;
const { url, text: originalText } = match;
count += 1;
if (SUPPORTED_PROTOCOLS.test(url) && !isLinkSneaky(url)) {
results.push(
renderNonLink({
text: chunk.slice(chunkLastIndex),
key: count,
})
<a key={count} href={url}>
{originalText}
</a>
);
} else {
results.push(renderNonLink({ text: originalText, key: count }));
}
chunkLastIndex = match.lastIndex;
});
return results;
}
if (chunkLastIndex < chunk.length) {
count += 1;
results.push(
renderNonLink({
text: chunk.slice(chunkLastIndex),
key: count,
})
);
}
});
return <>{results}</>;
}

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactChild, ReactNode } from 'react';
import React from 'react';
import React, { useEffect, useRef } from 'react';
import classNames from 'classnames';
import { noop } from 'lodash';
@ -106,22 +106,54 @@ const _keyForError = (error: Error): string => {
return `${error.name}-${error.message}`;
};
export class MessageDetail extends React.Component<Props> {
private readonly focusRef = React.createRef<HTMLDivElement>();
private readonly messageContainerRef = React.createRef<HTMLDivElement>();
export function MessageDetail({
contacts,
errors,
message,
receivedAt,
sentAt,
checkForAccount,
clearTargetedMessage,
contactNameColor,
doubleCheckMissingQuoteReference,
getPreferredBadge,
i18n,
interactionMode,
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
messageExpanded,
openGiftBadge,
platform,
pushPanelForConversation,
renderAudioAttachment,
saveAttachment,
showContactModal,
showConversation,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showLightbox,
showLightboxForViewOnceMedia,
showSpoiler,
startConversation,
theme,
toggleSafetyNumberModal,
viewStory,
}: Props): JSX.Element {
const focusRef = useRef<HTMLDivElement>(null);
const messageContainerRef = useRef<HTMLDivElement>(null);
public override componentDidMount(): void {
// When this component is created, it's initially not part of the DOM, and then it's
// added off-screen and animated in. This ensures that the focus takes.
setTimeout(() => {
if (this.focusRef.current) {
this.focusRef.current.focus();
}
useEffect(() => {
const timer = setTimeout(() => {
// When this component is created, it's initially not part of the DOM, and then it's
// added off-screen and animated in. This ensures that the focus takes.
focusRef.current?.focus();
});
}
return () => {
clearTimeout(timer);
};
}, []);
public renderAvatar(contact: Contact): JSX.Element {
const { getPreferredBadge, i18n, theme } = this.props;
function renderAvatar(contact: Contact): JSX.Element {
const {
acceptedMessageRequest,
avatarPath,
@ -155,9 +187,8 @@ export class MessageDetail extends React.Component<Props> {
);
}
public renderContact(contact: Contact): JSX.Element {
const { i18n, toggleSafetyNumberModal } = this.props;
const errors = contact.errors || [];
function renderContact(contact: Contact): JSX.Element {
const contactErrors = contact.errors || [];
const errorComponent = contact.isOutgoingKeyError ? (
<div className="module-message-detail__contact__error-buttons">
@ -176,17 +207,17 @@ export class MessageDetail extends React.Component<Props> {
return (
<div key={contact.id} className="module-message-detail__contact">
{this.renderAvatar(contact)}
{renderAvatar(contact)}
<div className="module-message-detail__contact__text">
<div className="module-message-detail__contact__name">
<ContactName title={contact.title} />
</div>
{errors.map(error => (
{contactErrors.map(contactError => (
<div
key={_keyForError(error)}
key={_keyForError(contactError)}
className="module-message-detail__contact__error"
>
{error.message}
{contactError.message}
</div>
))}
</div>
@ -204,11 +235,9 @@ export class MessageDetail extends React.Component<Props> {
);
}
private renderContactGroupHeaderText(
function renderContactGroupHeaderText(
sendStatus: undefined | SendStatus
): string {
const { i18n } = this.props;
if (sendStatus === undefined) {
return i18n('icu:from');
}
@ -231,19 +260,19 @@ export class MessageDetail extends React.Component<Props> {
}
}
private renderContactGroup(
function renderContactGroup(
sendStatus: undefined | SendStatus,
contacts: undefined | ReadonlyArray<Contact>
statusContacts: undefined | ReadonlyArray<Contact>
): ReactNode {
if (!contacts || !contacts.length) {
if (!statusContacts || !statusContacts.length) {
return null;
}
const sortedContacts = [...contacts].sort((a, b) =>
const sortedContacts = [...statusContacts].sort((a, b) =>
contactSortCollator.compare(a.title, b.title)
);
const headerText = this.renderContactGroupHeaderText(sendStatus);
const headerText = renderContactGroupHeaderText(sendStatus);
return (
<div key={headerText} className="module-message-detail__contact-group">
@ -256,16 +285,14 @@ export class MessageDetail extends React.Component<Props> {
>
{headerText}
</div>
{sortedContacts.map(contact => this.renderContact(contact))}
{sortedContacts.map(contact => renderContact(contact))}
</div>
);
}
private renderContacts(): ReactChild {
function renderContacts(): ReactChild {
// This assumes that the list either contains one sender (a status of `undefined`) or
// 1+ contacts with `SendStatus`es, but it doesn't check that assumption.
const { contacts } = this.props;
const contactsBySendStatus = groupBy(contacts, contact => contact.status);
return (
@ -279,181 +306,135 @@ export class MessageDetail extends React.Component<Props> {
SendStatus.Sent,
SendStatus.Pending,
].map(sendStatus =>
this.renderContactGroup(
sendStatus,
contactsBySendStatus.get(sendStatus)
)
renderContactGroup(sendStatus, contactsBySendStatus.get(sendStatus))
)}
</div>
);
}
public override render(): JSX.Element {
const {
errors,
message,
receivedAt,
sentAt,
const timeRemaining = message.expirationTimestamp
? DurationInSeconds.fromMillis(message.expirationTimestamp - Date.now())
: undefined;
checkForAccount,
clearTargetedMessage,
contactNameColor,
doubleCheckMissingQuoteReference,
getPreferredBadge,
i18n,
interactionMode,
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
messageExpanded,
openGiftBadge,
platform,
pushPanelForConversation,
renderAudioAttachment,
saveAttachment,
showContactModal,
showConversation,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showLightbox,
showLightboxForViewOnceMedia,
showSpoiler,
startConversation,
theme,
viewStory,
} = this.props;
const timeRemaining = message.expirationTimestamp
? DurationInSeconds.fromMillis(message.expirationTimestamp - Date.now())
: undefined;
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
<div className="module-message-detail" tabIndex={0} ref={this.focusRef}>
<div
className="module-message-detail__message-container"
ref={this.messageContainerRef}
>
<Message
{...message}
renderingContext="conversation/MessageDetail"
checkForAccount={checkForAccount}
clearTargetedMessage={clearTargetedMessage}
contactNameColor={contactNameColor}
containerElementRef={this.messageContainerRef}
containerWidthBreakpoint={WidthBreakpoint.Wide}
renderMenu={undefined}
disableScroll
displayLimit={Number.MAX_SAFE_INTEGER}
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
interactionMode={interactionMode}
kickOffAttachmentDownload={kickOffAttachmentDownload}
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
messageExpanded={messageExpanded}
openGiftBadge={openGiftBadge}
platform={platform}
pushPanelForConversation={pushPanelForConversation}
renderAudioAttachment={renderAudioAttachment}
saveAttachment={saveAttachment}
shouldCollapseAbove={false}
shouldCollapseBelow={false}
shouldHideMetadata={false}
showConversation={showConversation}
showSpoiler={showSpoiler}
scrollToQuotedMessage={() => {
log.warn('MessageDetail: scrollToQuotedMessage called!');
}}
showContactModal={showContactModal}
showExpiredIncomingTapToViewToast={
showExpiredIncomingTapToViewToast
}
showExpiredOutgoingTapToViewToast={
showExpiredOutgoingTapToViewToast
}
showLightbox={showLightbox}
startConversation={startConversation}
theme={theme}
viewStory={viewStory}
onToggleSelect={noop}
onReplyToMessage={noop}
/>
</div>
<table className="module-message-detail__info">
<tbody>
{(errors || []).map(error => (
<tr key={_keyForError(error)}>
<td className="module-message-detail__label">
{i18n('icu:error')}
</td>
<td>
{' '}
<span className="error-message">{error.message}</span>{' '}
</td>
</tr>
))}
<tr>
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
<div className="module-message-detail" tabIndex={0} ref={focusRef}>
<div
className="module-message-detail__message-container"
ref={messageContainerRef}
>
<Message
{...message}
renderingContext="conversation/MessageDetail"
checkForAccount={checkForAccount}
clearTargetedMessage={clearTargetedMessage}
contactNameColor={contactNameColor}
containerElementRef={messageContainerRef}
containerWidthBreakpoint={WidthBreakpoint.Wide}
renderMenu={undefined}
disableScroll
displayLimit={Number.MAX_SAFE_INTEGER}
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
interactionMode={interactionMode}
kickOffAttachmentDownload={kickOffAttachmentDownload}
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
messageExpanded={messageExpanded}
openGiftBadge={openGiftBadge}
platform={platform}
pushPanelForConversation={pushPanelForConversation}
renderAudioAttachment={renderAudioAttachment}
saveAttachment={saveAttachment}
shouldCollapseAbove={false}
shouldCollapseBelow={false}
shouldHideMetadata={false}
showConversation={showConversation}
showSpoiler={showSpoiler}
scrollToQuotedMessage={() => {
log.warn('MessageDetail: scrollToQuotedMessage called!');
}}
showContactModal={showContactModal}
showExpiredIncomingTapToViewToast={showExpiredIncomingTapToViewToast}
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
showLightbox={showLightbox}
startConversation={startConversation}
theme={theme}
viewStory={viewStory}
onToggleSelect={noop}
onReplyToMessage={noop}
/>
</div>
<table className="module-message-detail__info">
<tbody>
{(errors || []).map(error => (
<tr key={_keyForError(error)}>
<td className="module-message-detail__label">
{i18n('icu:sent')}
{i18n('icu:error')}
</td>
<td>
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'StoryDetailsModal__copy-icon',
label: i18n('icu:StoryDetailsModal__copy-timestamp'),
onClick: () => {
void window.navigator.clipboard.writeText(
String(sentAt)
);
},
},
]}
>
<>
<Time timestamp={sentAt}>
{formatDateTimeLong(i18n, sentAt)}
</Time>{' '}
<span className="module-message-detail__unix-timestamp">
({sentAt})
</span>
</>
</ContextMenu>
{' '}
<span className="error-message">{error.message}</span>{' '}
</td>
</tr>
{receivedAt && message.direction === 'incoming' ? (
<tr>
<td className="module-message-detail__label">
{i18n('icu:received')}
</td>
<td>
<Time timestamp={receivedAt}>
{formatDateTimeLong(i18n, receivedAt)}
))}
<tr>
<td className="module-message-detail__label">{i18n('icu:sent')}</td>
<td>
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'StoryDetailsModal__copy-icon',
label: i18n('icu:StoryDetailsModal__copy-timestamp'),
onClick: () => {
void window.navigator.clipboard.writeText(String(sentAt));
},
},
]}
>
<>
<Time timestamp={sentAt}>
{formatDateTimeLong(i18n, sentAt)}
</Time>{' '}
<span className="module-message-detail__unix-timestamp">
({receivedAt})
({sentAt})
</span>
</td>
</tr>
) : null}
{timeRemaining && timeRemaining > 0 && (
<tr>
<td className="module-message-detail__label">
{i18n('icu:MessageDetail--disappears-in')}
</td>
<td>
{formatRelativeTime(i18n, timeRemaining, {
largest: 2,
})}
</td>
</tr>
)}
</tbody>
</table>
{this.renderContacts()}
</div>
);
}
</>
</ContextMenu>
</td>
</tr>
{receivedAt && message.direction === 'incoming' ? (
<tr>
<td className="module-message-detail__label">
{i18n('icu:received')}
</td>
<td>
<Time timestamp={receivedAt}>
{formatDateTimeLong(i18n, receivedAt)}
</Time>{' '}
<span className="module-message-detail__unix-timestamp">
({receivedAt})
</span>
</td>
</tr>
) : null}
{timeRemaining && timeRemaining > 0 && (
<tr>
<td className="module-message-detail__label">
{i18n('icu:MessageDetail--disappears-in')}
</td>
<td>
{formatRelativeTime(i18n, timeRemaining, {
largest: 2,
})}
</td>
</tr>
)}
</tbody>
</table>
{renderContacts()}
</div>
);
}

View file

@ -51,10 +51,6 @@ export type Props = {
doubleCheckMissingQuoteReference?: () => unknown;
};
type State = {
imageBroken: boolean;
};
export type QuotedAttachmentType = Pick<
AttachmentType,
'contentType' | 'fileName' | 'isVoiceMessage' | 'thumbnail' | 'textAttachment'
@ -139,31 +135,41 @@ function getTypeLabel({
return MIME.isAudio(contentType) ? i18n('icu:audio') : undefined;
}
export class Quote extends React.Component<Props, State> {
private getClassName: (modifier?: string) => string;
export function Quote(props: Props): JSX.Element | null {
const {
conversationColor,
customColor,
isStoryReply,
onClose,
text,
bodyRanges,
authorTitle,
conversationTitle,
isFromMe,
i18n,
payment,
isViewOnce,
isGiftBadge,
rawAttachment,
isIncoming,
moduleClassName,
referencedMessageNotFound,
doubleCheckMissingQuoteReference,
onClick,
isCompose,
reactionEmoji,
} = props;
const [imageBroken, setImageBroken] = useState(false);
constructor(props: Props) {
super(props);
this.state = {
imageBroken: false,
};
this.getClassName = getClassNamesFor('module-quote', props.moduleClassName);
}
override componentDidMount(): void {
const { doubleCheckMissingQuoteReference, referencedMessageNotFound } =
this.props;
const getClassName = getClassNamesFor('module-quote', moduleClassName);
useEffect(() => {
if (referencedMessageNotFound) {
doubleCheckMissingQuoteReference?.();
}
}
public handleKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
): void => {
const { onClick } = this.props;
}, [referencedMessageNotFound, doubleCheckMissingQuoteReference]);
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
// This is important to ensure that using this quote to navigate to the referenced
// message doesn't also trigger its parent message's keydown.
if (onClick && (event.key === 'Enter' || event.key === ' ')) {
@ -171,42 +177,35 @@ export class Quote extends React.Component<Props, State> {
event.stopPropagation();
onClick();
}
};
public handleClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
const { onClick } = this.props;
}
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
if (onClick) {
event.preventDefault();
event.stopPropagation();
onClick();
}
};
}
public handleImageError = (): void => {
function handleImageError() {
window.console.info(
'Message: Image failed to load; failing over to placeholder'
);
this.setState({
imageBroken: true,
});
};
setImageBroken(true);
}
public renderImage(
function renderImage(
url: string,
icon: string | undefined,
isGiftBadge?: boolean
asGiftBadge?: boolean
): JSX.Element {
const { isIncoming } = this.props;
const iconElement = icon ? (
<div className={this.getClassName('__icon-container__inner')}>
<div
className={this.getClassName('__icon-container__circle-background')}
>
<div className={getClassName('__icon-container__inner')}>
<div className={getClassName('__icon-container__circle-background')}>
<div
className={classNames(
this.getClassName('__icon-container__icon'),
this.getClassName(`__icon-container__icon--${icon}`)
getClassName('__icon-container__icon'),
getClassName(`__icon-container__icon--${icon}`)
)}
/>
</div>
@ -216,30 +215,28 @@ export class Quote extends React.Component<Props, State> {
return (
<ThumbnailImage
className={classNames(
this.getClassName('__icon-container'),
getClassName('__icon-container'),
isIncoming === false &&
isGiftBadge &&
this.getClassName('__icon-container__outgoing-gift-badge')
asGiftBadge &&
getClassName('__icon-container__outgoing-gift-badge')
)}
src={url}
onError={this.handleImageError}
onError={handleImageError}
>
{iconElement}
</ThumbnailImage>
);
}
public renderIcon(icon: string): JSX.Element {
function renderIcon(icon: string) {
return (
<div className={this.getClassName('__icon-container')}>
<div className={this.getClassName('__icon-container__inner')}>
<div
className={this.getClassName('__icon-container__circle-background')}
>
<div className={getClassName('__icon-container')}>
<div className={getClassName('__icon-container__inner')}>
<div className={getClassName('__icon-container__circle-background')}>
<div
className={classNames(
this.getClassName('__icon-container__icon'),
this.getClassName(`__icon-container__icon--${icon}`)
getClassName('__icon-container__icon'),
getClassName(`__icon-container__icon--${icon}`)
)}
/>
</div>
@ -248,8 +245,7 @@ export class Quote extends React.Component<Props, State> {
);
}
public renderGenericFile(): JSX.Element | null {
const { rawAttachment, isIncoming } = this.props;
function renderGenericFile() {
const attachment = getAttachment(rawAttachment);
if (!attachment) {
@ -268,14 +264,12 @@ export class Quote extends React.Component<Props, State> {
}
return (
<div className={this.getClassName('__generic-file')}>
<div className={this.getClassName('__generic-file__icon')} />
<div className={getClassName('__generic-file')}>
<div className={getClassName('__generic-file__icon')} />
<div
className={classNames(
this.getClassName('__generic-file__text'),
isIncoming
? this.getClassName('__generic-file__text--incoming')
: null
getClassName('__generic-file__text'),
isIncoming ? getClassName('__generic-file__text--incoming') : null
)}
>
{fileName}
@ -284,10 +278,7 @@ export class Quote extends React.Component<Props, State> {
);
}
public renderPayment(): JSX.Element | null {
const { payment, authorTitle, conversationTitle, isFromMe, i18n } =
this.props;
function renderPayment() {
if (payment == null) {
return null;
}
@ -306,13 +297,11 @@ export class Quote extends React.Component<Props, State> {
);
}
public renderIconContainer(): JSX.Element | null {
const { isGiftBadge, isViewOnce, i18n, rawAttachment } = this.props;
const { imageBroken } = this.state;
function renderIconContainer() {
const attachment = getAttachment(rawAttachment);
if (isGiftBadge) {
return this.renderImage('images/gift-thumbnail.svg', undefined, true);
return renderImage('images/gift-thumbnail.svg', undefined, true);
}
if (!attachment) {
@ -323,12 +312,12 @@ export class Quote extends React.Component<Props, State> {
const url = getUrl(thumbnail);
if (isViewOnce) {
return this.renderIcon('view-once');
return renderIcon('view-once');
}
if (textAttachment) {
return (
<div className={this.getClassName('__icon-container')}>
<div className={getClassName('__icon-container')}>
<TextAttachment
i18n={i18n}
isThumbnail
@ -340,39 +329,29 @@ export class Quote extends React.Component<Props, State> {
if (GoogleChrome.isVideoTypeSupported(contentType)) {
return url && !imageBroken
? this.renderImage(url, 'play')
: this.renderIcon('movie');
? renderImage(url, 'play')
: renderIcon('movie');
}
if (GoogleChrome.isImageTypeSupported(contentType)) {
return url && !imageBroken
? this.renderImage(url, undefined)
: this.renderIcon('image');
? renderImage(url, undefined)
: renderIcon('image');
}
if (MIME.isAudio(contentType)) {
return this.renderIcon('microphone');
return renderIcon('microphone');
}
return null;
}
public renderText(): JSX.Element | null {
const {
bodyRanges,
isGiftBadge,
i18n,
text,
rawAttachment,
isIncoming,
isViewOnce,
} = this.props;
function renderText() {
if (text && !isGiftBadge) {
return (
<div
dir="auto"
className={classNames(
this.getClassName('__primary__text'),
isIncoming ? this.getClassName('__primary__text--incoming') : null
getClassName('__primary__text'),
isIncoming ? getClassName('__primary__text--incoming') : null
)}
>
<MessageBody
@ -410,10 +389,8 @@ export class Quote extends React.Component<Props, State> {
return (
<div
className={classNames(
this.getClassName('__primary__type-label'),
isIncoming
? this.getClassName('__primary__type-label--incoming')
: null
getClassName('__primary__type-label'),
isIncoming ? getClassName('__primary__type-label--incoming') : null
)}
>
{typeLabel}
@ -424,9 +401,7 @@ export class Quote extends React.Component<Props, State> {
return null;
}
public renderClose(): JSX.Element | null {
const { i18n, onClose } = this.props;
function renderClose() {
if (!onClose) {
return null;
}
@ -448,12 +423,12 @@ export class Quote extends React.Component<Props, State> {
// We need the container to give us the flexibility to implement the iOS design.
return (
<div className={this.getClassName('__close-container')}>
<div className={getClassName('__close-container')}>
<div
tabIndex={0}
// We can't be a button because the overall quote is a button; can't nest them
role="button"
className={this.getClassName('__close-button')}
className={getClassName('__close-button')}
aria-label={i18n('icu:close')}
onKeyDown={keyDownHandler}
onClick={clickHandler}
@ -462,10 +437,7 @@ export class Quote extends React.Component<Props, State> {
);
}
public renderAuthor(): JSX.Element {
const { authorTitle, i18n, isFromMe, isIncoming, isStoryReply } =
this.props;
function renderAuthor() {
const title = isFromMe ? (
i18n('icu:you')
) : (
@ -482,8 +454,8 @@ export class Quote extends React.Component<Props, State> {
return (
<div
className={classNames(
this.getClassName('__primary__author'),
isIncoming ? this.getClassName('__primary__author--incoming') : null
getClassName('__primary__author'),
isIncoming ? getClassName('__primary__author--incoming') : null
)}
>
{author}
@ -491,16 +463,7 @@ export class Quote extends React.Component<Props, State> {
);
}
public renderReferenceWarning(): JSX.Element | null {
const {
conversationColor,
customColor,
i18n,
isIncoming,
isStoryReply,
referencedMessageNotFound,
} = this.props;
function renderReferenceWarning() {
if (!referencedMessageNotFound || isStoryReply) {
return null;
}
@ -508,26 +471,28 @@ export class Quote extends React.Component<Props, State> {
return (
<div
className={classNames(
this.getClassName('__reference-warning'),
getClassName('__reference-warning'),
isIncoming
? this.getClassName(`--incoming-${conversationColor}`)
: this.getClassName(`--outgoing-${conversationColor}`)
? getClassName(`--incoming-${conversationColor}`)
: getClassName(`--outgoing-${conversationColor}`)
)}
style={{ ...getCustomColorStyle(customColor, true) }}
style={{
...getCustomColorStyle(customColor, true),
}}
>
<div
className={classNames(
this.getClassName('__reference-warning__icon'),
getClassName('__reference-warning__icon'),
isIncoming
? this.getClassName('__reference-warning__icon--incoming')
? getClassName('__reference-warning__icon--incoming')
: null
)}
/>
<div
className={classNames(
this.getClassName('__reference-warning__text'),
getClassName('__reference-warning__text'),
isIncoming
? this.getClassName('__reference-warning__text--incoming')
? getClassName('__reference-warning__text--incoming')
: null
)}
>
@ -537,75 +502,61 @@ export class Quote extends React.Component<Props, State> {
);
}
public override render(): JSX.Element | null {
const {
conversationColor,
customColor,
isCompose,
isIncoming,
onClick,
rawAttachment,
reactionEmoji,
referencedMessageNotFound,
} = this.props;
if (!validateQuote(this.props)) {
return null;
}
let colorClassName: string;
let directionClassName: string;
if (isCompose) {
directionClassName = this.getClassName('--compose');
colorClassName = this.getClassName(`--compose-${conversationColor}`);
} else if (isIncoming) {
directionClassName = this.getClassName('--incoming');
colorClassName = this.getClassName(`--incoming-${conversationColor}`);
} else {
directionClassName = this.getClassName('--outgoing');
colorClassName = this.getClassName(`--outgoing-${conversationColor}`);
}
return (
<div className={this.getClassName('__container')}>
<button
type="button"
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
className={classNames(
this.getClassName(''),
directionClassName,
colorClassName,
!onClick && this.getClassName('--no-click'),
referencedMessageNotFound &&
this.getClassName('--with-reference-warning')
)}
style={{ ...getCustomColorStyle(customColor, true) }}
>
<div className={this.getClassName('__primary')}>
{this.renderAuthor()}
{this.renderGenericFile()}
{this.renderPayment()}
{this.renderText()}
</div>
{reactionEmoji && (
<div
className={
rawAttachment
? this.getClassName('__reaction-emoji')
: this.getClassName('__reaction-emoji--story-unavailable')
}
>
<Emojify text={reactionEmoji} />
</div>
)}
{this.renderIconContainer()}
{this.renderClose()}
</button>
{this.renderReferenceWarning()}
</div>
);
if (!validateQuote(props)) {
return null;
}
let colorClassName: string;
let directionClassName: string;
if (isCompose) {
directionClassName = getClassName('--compose');
colorClassName = getClassName(`--compose-${conversationColor}`);
} else if (isIncoming) {
directionClassName = getClassName('--incoming');
colorClassName = getClassName(`--incoming-${conversationColor}`);
} else {
directionClassName = getClassName('--outgoing');
colorClassName = getClassName(`--outgoing-${conversationColor}`);
}
return (
<div className={getClassName('__container')}>
<button
type="button"
onClick={handleClick}
onKeyDown={handleKeyDown}
className={classNames(
getClassName(''),
directionClassName,
colorClassName,
!onClick && getClassName('--no-click'),
referencedMessageNotFound && getClassName('--with-reference-warning')
)}
style={{ ...getCustomColorStyle(customColor, true) }}
>
<div className={getClassName('__primary')}>
{renderAuthor()}
{renderGenericFile()}
{renderPayment()}
{renderText()}
</div>
{reactionEmoji && (
<div
className={
rawAttachment
? getClassName('__reaction-emoji')
: getClassName('__reaction-emoji--story-unavailable')
}
>
<Emojify text={reactionEmoji} />
</div>
)}
{renderIconContainer()}
{renderClose()}
</button>
{renderReferenceWarning()}
</div>
);
}
function ThumbnailImage({

View file

@ -184,200 +184,195 @@ export type PropsType = PropsLocalType &
| 'shouldHideMetadata'
>;
export class TimelineItem extends React.PureComponent<PropsType> {
public override render(): JSX.Element | null {
const {
containerElementRef,
conversationId,
getPreferredBadge,
i18n,
id,
isNextItemCallingNotification,
isTargeted,
item,
platform,
renderUniversalTimerNotification,
returnToActiveCall,
targetMessage,
shouldCollapseAbove,
shouldCollapseBelow,
shouldHideMetadata,
shouldRenderDateHeader,
startCallingLobby,
theme,
...reducedProps
} = this.props;
export function TimelineItem({
containerElementRef,
conversationId,
getPreferredBadge,
i18n,
id,
isNextItemCallingNotification,
isTargeted,
item,
platform,
renderUniversalTimerNotification,
returnToActiveCall,
targetMessage,
shouldCollapseAbove,
shouldCollapseBelow,
shouldHideMetadata,
shouldRenderDateHeader,
startCallingLobby,
theme,
...reducedProps
}: PropsType): JSX.Element | null {
if (!item) {
// This can happen under normal conditions.
//
// `<Timeline>` and `<TimelineItem>` are connected to Redux separately. If a
// timeline item is removed from Redux, `<TimelineItem>` might re-render before
// `<Timeline>` does, which means we'll try to render nothing. This should resolve
// itself quickly, as soon as `<Timeline>` re-renders.
return null;
}
if (!item) {
// This can happen under normal conditions.
//
// `<Timeline>` and `<TimelineItem>` are connected to Redux separately. If a
// timeline item is removed from Redux, `<TimelineItem>` might re-render before
// `<Timeline>` does, which means we'll try to render nothing. This should resolve
// itself quickly, as soon as `<Timeline>` re-renders.
return null;
}
let itemContents: ReactChild;
if (item.type === 'message') {
itemContents = (
<TimelineMessage
{...reducedProps}
{...item.data}
isTargeted={isTargeted}
targetMessage={targetMessage}
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={shouldHideMetadata}
containerElementRef={containerElementRef}
getPreferredBadge={getPreferredBadge}
platform={platform}
i18n={i18n}
theme={theme}
/>
);
} else {
let notification;
let itemContents: ReactChild;
if (item.type === 'message') {
itemContents = (
<TimelineMessage
if (item.type === 'unsupportedMessage') {
notification = (
<UnsupportedMessage {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'callHistory') {
notification = (
<CallingNotification
conversationId={conversationId}
i18n={i18n}
isNextItemCallingNotification={isNextItemCallingNotification}
returnToActiveCall={returnToActiveCall}
startCallingLobby={startCallingLobby}
{...item.data}
/>
);
} else if (item.type === 'chatSessionRefreshed') {
notification = (
<ChatSessionRefreshedNotification {...reducedProps} i18n={i18n} />
);
} else if (item.type === 'deliveryIssue') {
notification = (
<DeliveryIssueNotification
{...item.data}
{...reducedProps}
i18n={i18n}
/>
);
} else if (item.type === 'timerNotification') {
notification = (
<TimerNotification {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'universalTimerNotification') {
notification = renderUniversalTimerNotification();
} else if (item.type === 'contactRemovedNotification') {
notification = (
<SystemMessage
icon="info"
contents={i18n('icu:ContactRemovedNotification__text')}
/>
);
} else if (item.type === 'changeNumberNotification') {
notification = (
<ChangeNumberNotification
{...reducedProps}
{...item.data}
isTargeted={isTargeted}
targetMessage={targetMessage}
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={shouldHideMetadata}
containerElementRef={containerElementRef}
getPreferredBadge={getPreferredBadge}
platform={platform}
i18n={i18n}
/>
);
} else if (item.type === 'safetyNumberNotification') {
notification = (
<SafetyNumberNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'verificationNotification') {
notification = (
<VerificationNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'groupNotification') {
notification = (
<GroupNotification {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'groupV2Change') {
notification = (
<GroupV2Change {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'groupV1Migration') {
notification = (
<GroupV1Migration
{...reducedProps}
{...item.data}
i18n={i18n}
getPreferredBadge={getPreferredBadge}
theme={theme}
/>
);
} else if (item.type === 'conversationMerge') {
notification = (
<ConversationMergeNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'resetSessionNotification') {
notification = <ResetSessionNotification {...reducedProps} i18n={i18n} />;
} else if (item.type === 'profileChange') {
notification = (
<ProfileChangeNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'paymentEvent') {
notification = (
<PaymentEventNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else {
let notification;
if (item.type === 'unsupportedMessage') {
notification = (
<UnsupportedMessage {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'callHistory') {
notification = (
<CallingNotification
conversationId={conversationId}
i18n={i18n}
isNextItemCallingNotification={isNextItemCallingNotification}
returnToActiveCall={returnToActiveCall}
startCallingLobby={startCallingLobby}
{...item.data}
/>
);
} else if (item.type === 'chatSessionRefreshed') {
notification = (
<ChatSessionRefreshedNotification {...reducedProps} i18n={i18n} />
);
} else if (item.type === 'deliveryIssue') {
notification = (
<DeliveryIssueNotification
{...item.data}
{...reducedProps}
i18n={i18n}
/>
);
} else if (item.type === 'timerNotification') {
notification = (
<TimerNotification {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'universalTimerNotification') {
notification = renderUniversalTimerNotification();
} else if (item.type === 'contactRemovedNotification') {
notification = (
<SystemMessage
icon="info"
contents={i18n('icu:ContactRemovedNotification__text')}
/>
);
} else if (item.type === 'changeNumberNotification') {
notification = (
<ChangeNumberNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'safetyNumberNotification') {
notification = (
<SafetyNumberNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'verificationNotification') {
notification = (
<VerificationNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'groupNotification') {
notification = (
<GroupNotification {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'groupV2Change') {
notification = (
<GroupV2Change {...reducedProps} {...item.data} i18n={i18n} />
);
} else if (item.type === 'groupV1Migration') {
notification = (
<GroupV1Migration
{...reducedProps}
{...item.data}
i18n={i18n}
getPreferredBadge={getPreferredBadge}
theme={theme}
/>
);
} else if (item.type === 'conversationMerge') {
notification = (
<ConversationMergeNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'resetSessionNotification') {
notification = (
<ResetSessionNotification {...reducedProps} i18n={i18n} />
);
} else if (item.type === 'profileChange') {
notification = (
<ProfileChangeNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'paymentEvent') {
notification = (
<PaymentEventNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else {
// Weird, yes, but the idea is to get a compile error when we aren't comprehensive
// with our if/else checks above, but also log out the type we don't understand
// if we encounter it at runtime.
const unknownItem: never = item;
const asItem = unknownItem as TimelineItemType;
throw new Error(`TimelineItem: Unknown type: ${asItem.type}`);
}
itemContents = (
<InlineNotificationWrapper
id={id}
conversationId={conversationId}
isTargeted={isTargeted}
targetMessage={targetMessage}
>
{notification}
</InlineNotificationWrapper>
);
// Weird, yes, but the idea is to get a compile error when we aren't comprehensive
// with our if/else checks above, but also log out the type we don't understand
// if we encounter it at runtime.
const unknownItem: never = item;
const asItem = unknownItem as TimelineItemType;
throw new Error(`TimelineItem: Unknown type: ${asItem.type}`);
}
if (shouldRenderDateHeader) {
return (
<>
<TimelineDateHeader i18n={i18n} timestamp={item.timestamp} />
{itemContents}
</>
);
}
return itemContents;
itemContents = (
<InlineNotificationWrapper
id={id}
conversationId={conversationId}
isTargeted={isTargeted}
targetMessage={targetMessage}
>
{notification}
</InlineNotificationWrapper>
);
}
if (shouldRenderDateHeader) {
return (
<>
<TimelineDateHeader i18n={i18n} timestamp={item.timestamp} />
{itemContents}
</>
);
}
return itemContents;
}

View file

@ -24,56 +24,56 @@ type PropsHousekeeping = {
export type Props = PropsData & PropsHousekeeping;
export class VerificationNotification extends React.Component<Props> {
public renderContents(): JSX.Element {
const { contact, isLocal, type, i18n } = this.props;
function VerificationNotificationContents({
contact,
isLocal,
type,
i18n,
}: Props) {
const name = (
<ContactName
key="external-1"
title={contact.title}
module="module-verification-notification__contact"
/>
);
const name = (
<ContactName
key="external-1"
title={contact.title}
module="module-verification-notification__contact"
/>
);
switch (type) {
case 'markVerified':
return isLocal ? (
<Intl
id="icu:youMarkedAsVerified"
components={{ name }}
i18n={i18n}
/>
) : (
<Intl
id="icu:youMarkedAsVerifiedOtherDevice"
components={{ name }}
i18n={i18n}
/>
);
case 'markNotVerified':
return isLocal ? (
<Intl
id="icu:youMarkedAsNotVerified"
components={{ name }}
i18n={i18n}
/>
) : (
<Intl
id="icu:youMarkedAsNotVerifiedOtherDevice"
components={{ name }}
i18n={i18n}
/>
);
default:
throw missingCaseError(type);
}
}
public override render(): JSX.Element {
const { type } = this.props;
const icon = type === 'markVerified' ? 'verified' : 'verified-not';
return <SystemMessage icon={icon} contents={this.renderContents()} />;
switch (type) {
case 'markVerified':
return isLocal ? (
<Intl id="icu:youMarkedAsVerified" components={{ name }} i18n={i18n} />
) : (
<Intl
id="icu:youMarkedAsVerifiedOtherDevice"
components={{ name }}
i18n={i18n}
/>
);
case 'markNotVerified':
return isLocal ? (
<Intl
id="icu:youMarkedAsNotVerified"
components={{ name }}
i18n={i18n}
/>
) : (
<Intl
id="icu:youMarkedAsNotVerifiedOtherDevice"
components={{ name }}
i18n={i18n}
/>
);
default:
throw missingCaseError(type);
}
}
export function VerificationNotification(props: Props): JSX.Element {
const { type } = props;
return (
<SystemMessage
icon={type === 'markVerified' ? 'verified' : 'verified-not'}
contents={<VerificationNotificationContents {...props} />}
/>
);
}

View file

@ -18,28 +18,20 @@ type Props = {
shouldShowSeparator?: boolean;
};
export class DocumentListItem extends React.Component<Props> {
public override render(): JSX.Element {
const { shouldShowSeparator = true } = this.props;
return (
<div
className={classNames(
'module-document-list-item',
shouldShowSeparator
? 'module-document-list-item--with-separator'
: null
)}
>
{this.renderContent()}
</div>
);
}
private renderContent() {
const { fileName, fileSize, onClick, timestamp } = this.props;
return (
export function DocumentListItem({
shouldShowSeparator = true,
fileName,
fileSize,
onClick,
timestamp,
}: Props): JSX.Element {
return (
<div
className={classNames(
'module-document-list-item',
shouldShowSeparator ? 'module-document-list-item--with-separator' : null
)}
>
<button
type="button"
className="module-document-list-item__content"
@ -60,6 +52,6 @@ export class DocumentListItem extends React.Component<Props> {
{moment(timestamp).format('ddd, MMM D, Y')}
</div>
</button>
);
}
</div>
);
}

View file

@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../../../util/setupI18n';
@ -31,13 +30,8 @@ const createProps = (
const createMediaItem = (
overrideProps: Partial<MediaItemType> = {}
): MediaItemType => ({
thumbnailObjectUrl: text(
'thumbnailObjectUrl',
overrideProps.thumbnailObjectUrl || ''
),
contentType: stringToMIMEType(
text('contentType', overrideProps.contentType || '')
),
thumbnailObjectUrl: overrideProps.thumbnailObjectUrl || '',
contentType: overrideProps.contentType || stringToMIMEType(''),
index: 0,
attachment: {} as AttachmentType, // attachment not useful in the component
message: {

View file

@ -1,7 +1,7 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useCallback, useState } from 'react';
import classNames from 'classnames';
import type { ReadonlyDeep } from 'type-fest';
@ -19,110 +19,87 @@ export type Props = {
i18n: LocalizerType;
};
type State = {
imageBroken: boolean;
};
function MediaGridItemContent(props: Props) {
const { mediaItem, i18n } = props;
const { attachment, contentType } = mediaItem;
export class MediaGridItem extends React.Component<Props, State> {
private readonly onImageErrorBound: () => void;
const [imageBroken, setImageBroken] = useState(false);
constructor(props: Props) {
super(props);
this.state = {
imageBroken: false,
};
this.onImageErrorBound = this.onImageError.bind(this);
}
public onImageError(): void {
const handleImageError = useCallback(() => {
log.info(
'MediaGridItem: Image failed to load; failing over to placeholder'
);
this.setState({
imageBroken: true,
});
setImageBroken(true);
}, []);
if (!attachment) {
return null;
}
public renderContent(): JSX.Element | null {
const { mediaItem, i18n } = this.props;
const { imageBroken } = this.state;
const { attachment, contentType } = mediaItem;
if (!attachment) {
return null;
}
if (contentType && isImageTypeSupported(contentType)) {
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-image'
)}
/>
);
}
if (contentType && isImageTypeSupported(contentType)) {
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return (
<img
alt={i18n('icu:lightboxImageAlt')}
className="module-media-grid-item__image"
src={mediaItem.thumbnailObjectUrl}
onError={this.onImageErrorBound}
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-image'
)}
/>
);
}
if (contentType && isVideoTypeSupported(contentType)) {
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-video'
)}
/>
);
}
return (
<div className="module-media-grid-item__image-container">
<img
alt={i18n('icu:lightboxImageAlt')}
className="module-media-grid-item__image"
src={mediaItem.thumbnailObjectUrl}
onError={this.onImageErrorBound}
/>
<div className="module-media-grid-item__circle-overlay">
<div className="module-media-grid-item__play-overlay" />
</div>
</div>
);
}
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-generic'
)}
<img
alt={i18n('icu:lightboxImageAlt')}
className="module-media-grid-item__image"
src={mediaItem.thumbnailObjectUrl}
onError={handleImageError}
/>
);
}
public override render(): JSX.Element {
const { onClick } = this.props;
if (contentType && isVideoTypeSupported(contentType)) {
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-video'
)}
/>
);
}
return (
<button
type="button"
className="module-media-grid-item"
onClick={onClick}
>
{this.renderContent()}
</button>
<div className="module-media-grid-item__image-container">
<img
alt={i18n('icu:lightboxImageAlt')}
className="module-media-grid-item__image"
src={mediaItem.thumbnailObjectUrl}
onError={handleImageError}
/>
<div className="module-media-grid-item__circle-overlay">
<div className="module-media-grid-item__play-overlay" />
</div>
</div>
);
}
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-generic'
)}
/>
);
}
export function MediaGridItem(props: Props): JSX.Element {
const { onClick } = props;
return (
<button type="button" className="module-media-grid-item" onClick={onClick}>
<MediaGridItemContent {...props} />
</button>
);
}

View file

@ -2147,14 +2147,6 @@
"reasonCategory": "usageTrusted",
"updated": "2022-11-11T17:11:07.659Z"
},
{
"rule": "React-createRef",
"path": "ts/components/MainHeader.tsx",
"line": " public containerRef: React.RefObject<HTMLDivElement> = React.createRef();",
"reasonCategory": "usageTrusted",
"updated": "2022-06-14T22:04:43.988Z",
"reasonDetail": "Handling outside click"
},
{
"rule": "React-useRef",
"path": "ts/components/MediaQualitySelector.tsx",
@ -2359,14 +2351,6 @@
"reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/InlineNotificationWrapper.tsx",
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
"reasonCategory": "usageTrusted",
"updated": "2019-11-06T19:56:38.557Z",
"reasonDetail": "Used to manage focus"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
@ -2544,5 +2528,29 @@
"line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');",
"reasonCategory": "usageTrusted",
"updated": "2021-09-17T21:02:59.414Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/InlineNotificationWrapper.tsx",
"line": " const focusRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-04-12T15:51:28.066Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/MessageDetail.tsx",
"line": " const focusRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-04-12T15:51:28.066Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/MessageDetail.tsx",
"line": " const messageContainerRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-04-12T15:51:28.066Z",
"reasonDetail": "<optional>"
}
]