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,114 +37,7 @@ export type PropsType = {
toggleStoriesView: () => unknown;
};
type StateType = {
showingAvatarPopup: boolean;
popperRoot: HTMLDivElement | null;
outsideClickDestructor?: () => void;
virtualElement: {
getBoundingClientRect: () => DOMRect;
};
};
// 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),
};
}
export class MainHeader extends React.Component<PropsType, StateType> {
public containerRef: React.RefObject<HTMLDivElement> = React.createRef();
constructor(props: PropsType) {
super(props);
this.state = {
showingAvatarPopup: false,
popperRoot: null,
virtualElement: generateVirtualElement(0, 0),
};
}
public showAvatarPopup = (ev: React.MouseEvent): void => {
const popperRoot = document.createElement('div');
document.body.appendChild(popperRoot);
const outsideClickDestructor = handleOutsideClick(
() => {
const { showingAvatarPopup } = this.state;
if (!showingAvatarPopup) {
return false;
}
this.hideAvatarPopup();
return true;
},
{
containerElements: [popperRoot, this.containerRef],
name: 'MainHeader.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);
}
};
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 {
export function MainHeader({
areStoriesEnabled,
avatarPath,
badge,
@ -164,17 +56,69 @@ export class MainHeader extends React.Component<PropsType, StateType> {
toggleProfileEditor,
toggleStoriesView,
unreadStoriesCount,
} = this.props;
const { showingAvatarPopup, popperRoot } = this.state;
}: PropsType): JSX.Element {
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
const [showAvatarPopup, setShowAvatarPopup] = useState(false);
const popper = usePopper(targetElement, popperElement, {
placement: 'bottom-start',
strategy: 'fixed',
modifiers: [
{
name: 'offset',
options: {
offset: [null, 4],
},
},
],
});
useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setPortalElement(div);
return () => {
div.remove();
setPortalElement(null);
};
}, []);
useEffect(() => {
return handleOutsideClick(
() => {
if (!showAvatarPopup) {
return false;
}
setShowAvatarPopup(false);
return true;
},
{
containerElements: [portalElement, targetElement],
name: 'MainHeader.showAvatarPopup',
}
);
}, [portalElement, targetElement, showAvatarPopup]);
useEffect(() => {
function handleGlobalKeyDown(event: KeyboardEvent) {
if (showAvatarPopup && event.key === 'Escape') {
setShowAvatarPopup(false);
}
}
document.addEventListener('keydown', handleGlobalKeyDown, true);
return () => {
document.removeEventListener('keydown', handleGlobalKeyDown, true);
};
}, [showAvatarPopup]);
return (
<div className="module-main-header">
<Manager>
<Reference>
{({ ref }) => (
<div
className="module-main-header__avatar--container"
ref={this.containerRef}
ref={setTargetElement}
>
<Avatar
acceptedMessageRequest
@ -193,26 +137,27 @@ export class MainHeader extends React.Component<PropsType, StateType> {
// `<Avatar>` needs it to determine blurring.
sharedGroupNames={[]}
size={AvatarSize.TWENTY_EIGHT}
innerRef={ref}
onClick={this.showAvatarPopup}
onClick={() => {
setShowAvatarPopup(true);
}}
/>
{hasPendingUpdate && (
<div className="module-main-header__avatar--badged" />
)}
</div>
)}
</Reference>
{showingAvatarPopup && popperRoot
? createPortal(
<Popper referenceElement={this.state.virtualElement}>
{({ ref, style }) => (
{showAvatarPopup &&
portalElement != null &&
createPortal(
<div
ref={setPopperElement}
style={{ ...popper.styles.popper, zIndex: 10 }}
{...popper.attributes.popper}
>
<AvatarPopup
acceptedMessageRequest
badge={badge}
innerRef={ref}
i18n={i18n}
isMe
style={{ ...style, zIndex: 10 }}
color={color}
conversationType="direct"
name={name}
@ -227,23 +172,21 @@ export class MainHeader extends React.Component<PropsType, StateType> {
sharedGroupNames={[]}
onEditProfile={() => {
toggleProfileEditor();
this.hideAvatarPopup();
setShowAvatarPopup(false);
}}
onViewPreferences={() => {
showSettings();
this.hideAvatarPopup();
setShowAvatarPopup(false);
}}
onViewArchive={() => {
showArchivedConversations();
this.hideAvatarPopup();
setShowAvatarPopup(false);
}}
style={{}}
/>
</div>,
portalElement
)}
</Popper>,
popperRoot
)
: null}
</Manager>
<div className="module-main-header__icon-container">
{areStoriesEnabled && (
<button
@ -273,5 +216,4 @@ export class MainHeader extends React.Component<PropsType, StateType> {
</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,13 +13,10 @@ 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;
export function AddNewLines({
text,
renderNonNewLine = defaultRenderNonNewLine,
}: Props): JSX.Element {
const results: Array<JSX.Element | string> = [];
const FIND_NEWLINES = /\n/g;
@ -28,7 +25,7 @@ export class AddNewLines extends React.Component<Props> {
let count = 1;
if (!match) {
return renderNonNewLine({ text, key: 0 });
return <>{renderNonNewLine({ text, key: 0 })}</>;
}
while (match) {
@ -50,6 +47,5 @@ export class AddNewLines extends React.Component<Props> {
results.push(renderNonNewLine({ text: text.slice(last), key: count }));
}
return results;
}
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,20 +73,12 @@ function getLabelForAddress(
}
}
export class ContactDetail extends React.Component<Props> {
public renderSendMessage({
export function ContactDetail({
contact,
hasSignalAccount,
i18n,
onSendMessage,
}: {
hasSignalAccount: boolean;
i18n: LocalizerType;
onSendMessage: () => void;
}): JSX.Element | null {
if (!hasSignalAccount) {
return null;
}
}: 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 => {
@ -94,128 +86,6 @@ export class ContactDetail extends React.Component<Props> {
onSendMessage();
};
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}
</div>
);
}
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)}
</div>
{this.renderAddressLine(address.street)}
{this.renderPOBox(address.pobox, i18n)}
{this.renderAddressLine(address.neighborhood)}
{this.renderAddressLineTwo(address)}
{this.renderAddressLine(address.country)}
</div>
);
});
}
public override render(): JSX.Element {
const { contact, hasSignalAccount, i18n, onSendMessage } = this.props;
const isIncoming = false;
const module = 'contact-detail';
@ -226,11 +96,74 @@ export class ContactDetail extends React.Component<Props> {
</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)}
{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>
</button>
)}
{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>
);
})}
{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,16 +59,15 @@ 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 {
export function Emojify({
isInvisible,
renderNonEmoji = defaultRenderNonEmoji,
sizeClass,
text,
} = this.props;
return splitByEmoji(text).map(({ type, value: match }, index) => {
}: Props): JSX.Element {
return (
<>
{splitByEmoji(text).map(({ type, value: match }, index) => {
if (type === 'emoji') {
return getImageTag({ isInvisible, match, sizeClass, key: index });
}
@ -78,6 +77,7 @@ export class Emojify extends React.Component<Props> {
}
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,36 +16,7 @@ export type Props = {
withTapToViewExpired?: boolean;
};
export class ExpireTimer extends React.Component<Props> {
private interval: NodeJS.Timeout | null;
constructor(props: Props) {
super(props);
this.interval = null;
}
public override componentDidMount(): void {
const { expirationLength } = this.props;
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(),
});
};
this.interval = setInterval(update, updateFrequency);
}
public override componentWillUnmount(): void {
clearTimeoutIfNecessary(this.interval);
}
public override render(): JSX.Element {
const {
export function ExpireTimer({
deletedForEveryone,
direction,
expirationLength,
@ -54,7 +24,17 @@ export class ExpireTimer extends React.Component<Props> {
withImageNoCaption,
withSticker,
withTapToViewExpired,
} = this.props;
}: Props): JSX.Element {
const [, forceUpdate] = useReducer(() => ({}), {});
useEffect(() => {
const increment = getIncrement(expirationLength);
const updateFrequency = Math.max(increment, 500);
const interval = setInterval(forceUpdate, updateFrequency);
return () => {
clearInterval(interval);
};
}, [expirationLength]);
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
@ -64,9 +44,7 @@ export class ExpireTimer extends React.Component<Props> {
'module-expire-timer',
`module-expire-timer--${bucket}`,
direction ? `module-expire-timer--${direction}` : null,
deletedForEveryone
? 'module-expire-timer--deleted-for-everyone'
: null,
deletedForEveryone ? 'module-expire-timer--deleted-for-everyone' : null,
withTapToViewExpired
? `module-expire-timer--${direction}-with-tap-to-view-expired`
: null,
@ -77,5 +55,4 @@ export class ExpireTimer extends React.Component<Props> {
)}
/>
);
}
}

View file

@ -32,13 +32,16 @@ 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 {
function GroupNotificationChange({
change,
from,
i18n,
}: {
change: Change;
from: ConversationType;
i18n: LocalizerType;
}): JSX.Element | null {
const { contacts, type, newName } = change;
const { i18n } = this.props;
const otherPeople: Array<JSX.Element> = compact(
(contacts || []).map(contact => {
@ -107,7 +110,7 @@ export class GroupNotification extends React.Component<Props> {
);
case 'remove':
if (from && from.isMe) {
return i18n('icu:youLeftTheGroup');
return <>{i18n('icu:youLeftTheGroup')}</>;
}
if (!contacts || !contacts.length) {
@ -128,15 +131,17 @@ export class GroupNotification extends React.Component<Props> {
/>
);
case 'general':
return;
return null;
default:
throw missingCaseError(type);
}
}
public override render(): JSX.Element {
const { changes: rawChanges, i18n, from } = this.props;
}
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 : [];
@ -157,7 +162,9 @@ export class GroupNotification extends React.Component<Props> {
let contents: ReactNode;
if (isLeftOnly) {
contents = this.renderChange(firstChange, from);
contents = (
<GroupNotificationChange change={firstChange} from={from} i18n={i18n} />
);
} else {
contents = (
<>
@ -165,7 +172,7 @@ export class GroupNotification extends React.Component<Props> {
{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)}
<GroupNotificationChange change={change} from={from} i18n={i18n} />
</p>
))}
</>
@ -173,5 +180,4 @@ export class GroupNotification extends React.Component<Props> {
}
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,93 +55,7 @@ export type Props = {
onError?: () => void;
};
export class Image extends React.Component<Props> {
private canClick() {
const { onClick, attachment } = this.props;
const { pending } = attachment || { pending: true };
return Boolean(onClick && !pending);
}
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);
}
};
public handleKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
): void => {
if (!this.canClick()) {
event.preventDefault();
event.stopPropagation();
return;
}
const { onClick, attachment } = this.props;
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
}
};
public renderPending = (): JSX.Element => {
const { blurHash, height, i18n, width } = this.props;
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" />
</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 {
export function Image({
alt,
attachment,
blurHash,
@ -158,6 +72,7 @@ export class Image extends React.Component<Props> {
i18n,
noBackground,
noBorder,
onClick,
onClickClose,
onError,
overlayText,
@ -168,10 +83,8 @@ export class Image extends React.Component<Props> {
width = 0,
cropWidth = 0,
cropHeight = 0,
} = this.props;
}: Props): JSX.Element {
const { caption, pending } = attachment || { caption: null, pending: true };
const canClick = this.canClick();
const imgNotDownloaded = isDownloaded
? false
: !isDownloadedFunction(attachment);
@ -185,24 +98,46 @@ export class Image extends React.Component<Props> {
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;
const canClick = useMemo(() => {
return onClick != null && !pending;
}, [pending, onClick]);
const handleClick = useCallback(
(event: React.MouseEvent) => {
if (!canClick) {
event.preventDefault();
event.stopPropagation();
return;
}
if (onClick) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
}
},
[attachment, canClick, onClick]
);
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 (
@ -220,7 +155,40 @@ export class Image extends React.Component<Props> {
}}
>
{pending ? (
this.renderPending()
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
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>
)
) : url ? (
<img
onError={onError}
@ -267,7 +235,23 @@ export class Image extends React.Component<Props> {
{overlayText}
</div>
) : null}
{overlay}
{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"
@ -287,5 +271,4 @@ export class Image extends React.Component<Props> {
</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();
public setFocus = (): void => {
const container = this.focusRef.current;
export function InlineNotificationWrapper({
id,
conversationId,
isTargeted,
targetMessage,
children,
}: PropsType): JSX.Element {
const focusRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isTargeted) {
const container = focusRef.current;
if (container && !container.contains(document.activeElement)) {
container.focus();
}
};
public handleFocus = (): void => {
if (getInteractionMode() === 'keyboard') {
this.setTargeted();
}
};
}, [isTargeted]);
public setTargeted = (): void => {
const { id, conversationId, targetMessage } = this.props;
if (targetMessage) {
const handleFocus = useCallback(() => {
if (getInteractionMode() === 'keyboard') {
targetMessage(id, conversationId);
}
};
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;
}, [id, conversationId, targetMessage]);
return (
<div
className="module-inline-notification-wrapper"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
ref={this.focusRef}
onFocus={this.handleFocus}
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,16 +323,11 @@ 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 });
return <>{renderNonLink({ text, key: 1 })}</>;
}
const chunkData: Array<{
@ -394,6 +389,5 @@ export class Linkify extends React.Component<Props> {
}
});
return results;
}
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 {
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.
setTimeout(() => {
if (this.focusRef.current) {
this.focusRef.current.focus();
}
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,59 +306,22 @@ 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,
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" tabIndex={0} ref={focusRef}>
<div
className="module-message-detail__message-container"
ref={this.messageContainerRef}
ref={messageContainerRef}
>
<Message
{...message}
@ -339,7 +329,7 @@ export class MessageDetail extends React.Component<Props> {
checkForAccount={checkForAccount}
clearTargetedMessage={clearTargetedMessage}
contactNameColor={contactNameColor}
containerElementRef={this.messageContainerRef}
containerElementRef={messageContainerRef}
containerWidthBreakpoint={WidthBreakpoint.Wide}
renderMenu={undefined}
disableScroll
@ -366,12 +356,8 @@ export class MessageDetail extends React.Component<Props> {
log.warn('MessageDetail: scrollToQuotedMessage called!');
}}
showContactModal={showContactModal}
showExpiredIncomingTapToViewToast={
showExpiredIncomingTapToViewToast
}
showExpiredOutgoingTapToViewToast={
showExpiredOutgoingTapToViewToast
}
showExpiredIncomingTapToViewToast={showExpiredIncomingTapToViewToast}
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
showLightbox={showLightbox}
startConversation={startConversation}
theme={theme}
@ -394,9 +380,7 @@ export class MessageDetail extends React.Component<Props> {
</tr>
))}
<tr>
<td className="module-message-detail__label">
{i18n('icu:sent')}
</td>
<td className="module-message-detail__label">{i18n('icu:sent')}</td>
<td>
<ContextMenu
i18n={i18n}
@ -405,9 +389,7 @@ export class MessageDetail extends React.Component<Props> {
icon: 'StoryDetailsModal__copy-icon',
label: i18n('icu:StoryDetailsModal__copy-timestamp'),
onClick: () => {
void window.navigator.clipboard.writeText(
String(sentAt)
);
void window.navigator.clipboard.writeText(String(sentAt));
},
},
]}
@ -452,8 +434,7 @@ export class MessageDetail extends React.Component<Props> {
)}
</tbody>
</table>
{this.renderContacts()}
{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)) {
if (!validateQuote(props)) {
return null;
}
let colorClassName: string;
let directionClassName: string;
if (isCompose) {
directionClassName = this.getClassName('--compose');
colorClassName = this.getClassName(`--compose-${conversationColor}`);
directionClassName = getClassName('--compose');
colorClassName = getClassName(`--compose-${conversationColor}`);
} else if (isIncoming) {
directionClassName = this.getClassName('--incoming');
colorClassName = this.getClassName(`--incoming-${conversationColor}`);
directionClassName = getClassName('--incoming');
colorClassName = getClassName(`--incoming-${conversationColor}`);
} else {
directionClassName = this.getClassName('--outgoing');
colorClassName = this.getClassName(`--outgoing-${conversationColor}`);
directionClassName = getClassName('--outgoing');
colorClassName = getClassName(`--outgoing-${conversationColor}`);
}
return (
<div className={this.getClassName('__container')}>
<div className={getClassName('__container')}>
<button
type="button"
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={classNames(
this.getClassName(''),
getClassName(''),
directionClassName,
colorClassName,
!onClick && this.getClassName('--no-click'),
referencedMessageNotFound &&
this.getClassName('--with-reference-warning')
!onClick && getClassName('--no-click'),
referencedMessageNotFound && getClassName('--with-reference-warning')
)}
style={{ ...getCustomColorStyle(customColor, true) }}
>
<div className={this.getClassName('__primary')}>
{this.renderAuthor()}
{this.renderGenericFile()}
{this.renderPayment()}
{this.renderText()}
<div className={getClassName('__primary')}>
{renderAuthor()}
{renderGenericFile()}
{renderPayment()}
{renderText()}
</div>
{reactionEmoji && (
<div
className={
rawAttachment
? this.getClassName('__reaction-emoji')
: this.getClassName('__reaction-emoji--story-unavailable')
? getClassName('__reaction-emoji')
: getClassName('__reaction-emoji--story-unavailable')
}
>
<Emojify text={reactionEmoji} />
</div>
)}
{this.renderIconContainer()}
{this.renderClose()}
{renderIconContainer()}
{renderClose()}
</button>
{this.renderReferenceWarning()}
{renderReferenceWarning()}
</div>
);
}
}
function ThumbnailImage({

View file

@ -184,9 +184,7 @@ export type PropsType = PropsLocalType &
| 'shouldHideMetadata'
>;
export class TimelineItem extends React.PureComponent<PropsType> {
public override render(): JSX.Element | null {
const {
export function TimelineItem({
containerElementRef,
conversationId,
getPreferredBadge,
@ -206,8 +204,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
startCallingLobby,
theme,
...reducedProps
} = this.props;
}: PropsType): JSX.Element | null {
if (!item) {
// This can happen under normal conditions.
//
@ -330,9 +327,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
/>
);
} else if (item.type === 'resetSessionNotification') {
notification = (
<ResetSessionNotification {...reducedProps} i18n={i18n} />
);
notification = <ResetSessionNotification {...reducedProps} i18n={i18n} />;
} else if (item.type === 'profileChange') {
notification = (
<ProfileChangeNotification
@ -378,6 +373,6 @@ export class TimelineItem extends React.PureComponent<PropsType> {
</>
);
}
return itemContents;
}
}

View file

@ -24,10 +24,12 @@ 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"
@ -39,11 +41,7 @@ export class VerificationNotification extends React.Component<Props> {
switch (type) {
case 'markVerified':
return isLocal ? (
<Intl
id="icu:youMarkedAsVerified"
components={{ name }}
i18n={i18n}
/>
<Intl id="icu:youMarkedAsVerified" components={{ name }} i18n={i18n} />
) : (
<Intl
id="icu:youMarkedAsVerifiedOtherDevice"
@ -68,12 +66,14 @@ export class VerificationNotification extends React.Component<Props> {
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()} />;
}
}
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;
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
shouldShowSeparator ? 'module-document-list-item--with-separator' : null
)}
>
{this.renderContent()}
</div>
);
}
private renderContent() {
const { fileName, fileSize, onClick, timestamp } = this.props;
return (
<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,36 +19,18 @@ 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,
});
}
public renderContent(): JSX.Element | null {
const { mediaItem, i18n } = this.props;
const { imageBroken } = this.state;
const { attachment, contentType } = mediaItem;
setImageBroken(true);
}, []);
if (!attachment) {
return null;
@ -71,10 +53,11 @@ export class MediaGridItem extends React.Component<Props, State> {
alt={i18n('icu:lightboxImageAlt')}
className="module-media-grid-item__image"
src={mediaItem.thumbnailObjectUrl}
onError={this.onImageErrorBound}
onError={handleImageError}
/>
);
}
if (contentType && isVideoTypeSupported(contentType)) {
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return (
@ -93,7 +76,7 @@ export class MediaGridItem extends React.Component<Props, State> {
alt={i18n('icu:lightboxImageAlt')}
className="module-media-grid-item__image"
src={mediaItem.thumbnailObjectUrl}
onError={this.onImageErrorBound}
onError={handleImageError}
/>
<div className="module-media-grid-item__circle-overlay">
<div className="module-media-grid-item__play-overlay" />
@ -110,19 +93,13 @@ export class MediaGridItem extends React.Component<Props, State> {
)}
/>
);
}
public override render(): JSX.Element {
const { onClick } = this.props;
}
export function MediaGridItem(props: Props): JSX.Element {
const { onClick } = props;
return (
<button
type="button"
className="module-media-grid-item"
onClick={onClick}
>
{this.renderContent()}
<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>"
}
]