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 // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { VirtualElement } from '@popperjs/core'; import React, { useEffect, useState } from 'react';
import React from 'react'; import { usePopper } from 'react-popper';
import { Manager, Popper, Reference } from 'react-popper';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { showSettings } from '../shims/Whisper'; import { showSettings } from '../shims/Whisper';
@ -38,240 +37,183 @@ export type PropsType = {
toggleStoriesView: () => unknown; toggleStoriesView: () => unknown;
}; };
type StateType = { export function MainHeader({
showingAvatarPopup: boolean; areStoriesEnabled,
popperRoot: HTMLDivElement | null; avatarPath,
outsideClickDestructor?: () => void; badge,
virtualElement: { color,
getBoundingClientRect: () => DOMRect; 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/ const [showAvatarPopup, setShowAvatarPopup] = useState(false);
// 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> { const popper = usePopper(targetElement, popperElement, {
public containerRef: React.RefObject<HTMLDivElement> = React.createRef(); placement: 'bottom-start',
strategy: 'fixed',
modifiers: [
{
name: 'offset',
options: {
offset: [null, 4],
},
},
],
});
constructor(props: PropsType) { useEffect(() => {
super(props); const div = document.createElement('div');
document.body.appendChild(div);
this.state = { setPortalElement(div);
showingAvatarPopup: false, return () => {
popperRoot: null, div.remove();
virtualElement: generateVirtualElement(0, 0), setPortalElement(null);
}; };
} }, []);
public showAvatarPopup = (ev: React.MouseEvent): void => { useEffect(() => {
const popperRoot = document.createElement('div'); return handleOutsideClick(
document.body.appendChild(popperRoot);
const outsideClickDestructor = handleOutsideClick(
() => { () => {
const { showingAvatarPopup } = this.state; if (!showAvatarPopup) {
if (!showingAvatarPopup) {
return false; return false;
} }
setShowAvatarPopup(false);
this.hideAvatarPopup();
return true; return true;
}, },
{ {
containerElements: [popperRoot, this.containerRef], containerElements: [portalElement, targetElement],
name: 'MainHeader.showAvatarPopup', name: 'MainHeader.showAvatarPopup',
} }
); );
}, [portalElement, targetElement, showAvatarPopup]);
this.setState({ useEffect(() => {
showingAvatarPopup: true, function handleGlobalKeyDown(event: KeyboardEvent) {
popperRoot, if (showAvatarPopup && event.key === 'Escape') {
outsideClickDestructor, setShowAvatarPopup(false);
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);
} }
}; document.addEventListener('keydown', handleGlobalKeyDown, true);
return () => {
document.removeEventListener('keydown', handleGlobalKeyDown, true);
};
}, [showAvatarPopup]);
public override componentDidMount(): void { return (
const useCapture = true; <div className="module-main-header">
document.addEventListener('keydown', this.handleGlobalKeyDown, useCapture); <div
} className="module-main-header__avatar--container"
ref={setTargetElement}
public override componentWillUnmount(): void { >
const { popperRoot, outsideClickDestructor } = this.state; <Avatar
acceptedMessageRequest
const useCapture = true; avatarPath={avatarPath}
outsideClickDestructor?.(); badge={badge}
document.removeEventListener( className="module-main-header__avatar"
'keydown', color={color}
this.handleGlobalKeyDown, conversationType="direct"
useCapture i18n={i18n}
); isMe
phoneNumber={phoneNumber}
if (popperRoot && document.body.contains(popperRoot)) { profileName={profileName}
document.body.removeChild(popperRoot); theme={theme}
} title={title}
} // `sharedGroupNames` makes no sense for yourself, but
// `<Avatar>` needs it to determine blurring.
public handleGlobalKeyDown = (event: KeyboardEvent): void => { sharedGroupNames={[]}
const { showingAvatarPopup } = this.state; size={AvatarSize.TWENTY_EIGHT}
const { key } = event; onClick={() => {
setShowAvatarPopup(true);
if (showingAvatarPopup && key === 'Escape') { }}
this.hideAvatarPopup(); />
} {hasPendingUpdate && (
}; <div className="module-main-header__avatar--badged" />
)}
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>
</div> </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 // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { text } from '@storybook/addon-knobs';
import type { Props } from './AddNewLines'; import type { Props } from './AddNewLines';
import { AddNewLines } from './AddNewLines'; import { AddNewLines } from './AddNewLines';
@ -14,7 +11,7 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
renderNonNewLine: overrideProps.renderNonNewLine, renderNonNewLine: overrideProps.renderNonNewLine,
text: text('text', overrideProps.text || ''), text: overrideProps.text || '',
}); });
export function AllNewlines(): JSX.Element { export function AllNewlines(): JSX.Element {

View file

@ -13,43 +13,39 @@ export type Props = {
const defaultRenderNonNewLine: RenderTextCallbackType = ({ text }) => text; const defaultRenderNonNewLine: RenderTextCallbackType = ({ text }) => text;
export class AddNewLines extends React.Component<Props> { export function AddNewLines({
public override render(): text,
| JSX.Element renderNonNewLine = defaultRenderNonNewLine,
| string }: Props): JSX.Element {
| null const results: Array<JSX.Element | string> = [];
| Array<JSX.Element | string | null> { const FIND_NEWLINES = /\n/g;
const { text, renderNonNewLine = defaultRenderNonNewLine } = this.props;
const results: Array<JSX.Element | string> = [];
const FIND_NEWLINES = /\n/g;
let match = FIND_NEWLINES.exec(text); let match = FIND_NEWLINES.exec(text);
let last = 0; let last = 0;
let count = 1; let count = 1;
if (!match) { if (!match) {
return renderNonNewLine({ text, key: 0 }); 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;
} }
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 * as React from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { boolean } from '@storybook/addon-knobs';
import type { Props } from './ContactDetail'; import type { Props } from './ContactDetail';
import { ContactDetail } from './ContactDetail'; import { ContactDetail } from './ContactDetail';
@ -23,10 +22,7 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
contact: overrideProps.contact || {}, contact: overrideProps.contact || {},
hasSignalAccount: boolean( hasSignalAccount: overrideProps.hasSignalAccount || false,
'hasSignalAccount',
overrideProps.hasSignalAccount || false
),
i18n, i18n,
onSendMessage: action('onSendMessage'), onSendMessage: action('onSendMessage'),
}); });

View file

@ -73,164 +73,97 @@ function getLabelForAddress(
} }
} }
export class ContactDetail extends React.Component<Props> { export function ContactDetail({
public renderSendMessage({ contact,
hasSignalAccount, hasSignalAccount,
i18n, i18n,
onSendMessage, onSendMessage,
}: { }: Props): JSX.Element {
hasSignalAccount: boolean; // We don't want the overall click handler for this element to fire, so we stop
i18n: LocalizerType; // propagation before handing control to the caller's callback.
onSendMessage: () => void; const onClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
}): JSX.Element | null { e.stopPropagation();
if (!hasSignalAccount) { onSendMessage();
return null; };
}
// We don't want the overall click handler for this element to fire, so we stop const isIncoming = false;
// propagation before handing control to the caller's callback. const module = 'contact-detail';
const onClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
onSendMessage();
};
return ( return (
<button <div className="module-contact-detail">
type="button" <div className="module-contact-detail__avatar">
className="module-contact-detail__send-message" {renderAvatar({ contact, i18n, size: 80 })}
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> </div>
); {renderName({ contact, isIncoming, module })}
} {renderContactShorthand({ contact, isIncoming, module })}
public renderAddressLineTwo(address: PostalAddress): JSX.Element | null { {hasSignalAccount && (
if (address.city || address.region || address.postcode) { <button
return ( type="button"
<div> className="module-contact-detail__send-message"
{address.city} {address.region} {address.postcode} onClick={onClick}
</div> >
); <div className="module-contact-detail__send-message__inner">
} <div className="module-contact-detail__send-message__bubble-icon" />
{i18n('icu:sendMessageToContact')}
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> </div>
{this.renderAddressLine(address.street)} </button>
{this.renderPOBox(address.pobox, i18n)} )}
{this.renderAddressLine(address.neighborhood)}
{this.renderAddressLineTwo(address)}
{this.renderAddressLine(address.country)}
</div>
);
});
}
public override render(): JSX.Element { {contact.number?.map((phone: Phone) => {
const { contact, hasSignalAccount, i18n, onSendMessage } = this.props; return (
const isIncoming = false; <div
const module = 'contact-detail'; 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 ( {contact.email?.map((email: Email) => {
<div className="module-contact-detail"> return (
<div className="module-contact-detail__avatar"> <div
{renderAvatar({ contact, i18n, size: 80 })} key={email.value}
</div> className="module-contact-detail__additional-contact"
{renderName({ contact, isIncoming, module })} >
{renderContactShorthand({ contact, isIncoming, module })} <div className="module-contact-detail__additional-contact__type">
{this.renderSendMessage({ hasSignalAccount, i18n, onSendMessage })} {getLabelForEmail(email, i18n)}
{this.renderPhone(contact.number, i18n)} </div>
{this.renderEmail(contact.email, i18n)} {email.value}
{this.renderAddresses(contact.address, i18n)} </div>
</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 // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { text } from '@storybook/addon-knobs';
import type { Props } from './Emojify'; import type { Props } from './Emojify';
import { Emojify } from './Emojify'; import { Emojify } from './Emojify';
@ -15,7 +12,7 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
renderNonEmoji: overrideProps.renderNonEmoji, renderNonEmoji: overrideProps.renderNonEmoji,
sizeClass: overrideProps.sizeClass, sizeClass: overrideProps.sizeClass,
text: text('text', overrideProps.text || ''), text: overrideProps.text || '',
}); });
export function EmojiOnly(): JSX.Element { export function EmojiOnly(): JSX.Element {

View file

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

View file

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

View file

@ -1,11 +1,10 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useEffect, useReducer } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { getIncrement, getTimerBucket } from '../../util/timer'; import { getIncrement, getTimerBucket } from '../../util/timer';
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
export type Props = { export type Props = {
deletedForEveryone?: boolean; deletedForEveryone?: boolean;
@ -17,65 +16,43 @@ export type Props = {
withTapToViewExpired?: boolean; withTapToViewExpired?: boolean;
}; };
export class ExpireTimer extends React.Component<Props> { export function ExpireTimer({
private interval: NodeJS.Timeout | null; deletedForEveryone,
direction,
expirationLength,
expirationTimestamp,
withImageNoCaption,
withSticker,
withTapToViewExpired,
}: Props): JSX.Element {
const [, forceUpdate] = useReducer(() => ({}), {});
constructor(props: Props) { useEffect(() => {
super(props);
this.interval = null;
}
public override componentDidMount(): void {
const { expirationLength } = this.props;
const increment = getIncrement(expirationLength); const increment = getIncrement(expirationLength);
const updateFrequency = Math.max(increment, 500); const updateFrequency = Math.max(increment, 500);
const interval = setInterval(forceUpdate, updateFrequency);
const update = () => { return () => {
this.setState({ clearInterval(interval);
// Used to trigger renders
// eslint-disable-next-line react/no-unused-state
lastUpdated: Date.now(),
});
}; };
this.interval = setInterval(update, updateFrequency); }, [expirationLength]);
}
public override componentWillUnmount(): void { const bucket = getTimerBucket(expirationTimestamp, expirationLength);
clearTimeoutIfNecessary(this.interval);
}
public override render(): JSX.Element { return (
const { <div
deletedForEveryone, className={classNames(
direction, 'module-expire-timer',
expirationLength, `module-expire-timer--${bucket}`,
expirationTimestamp, direction ? `module-expire-timer--${direction}` : null,
withImageNoCaption, deletedForEveryone ? 'module-expire-timer--deleted-for-everyone' : null,
withSticker, withTapToViewExpired
withTapToViewExpired, ? `module-expire-timer--${direction}-with-tap-to-view-expired`
} = this.props; : null,
direction && withImageNoCaption
const bucket = getTimerBucket(expirationTimestamp, expirationLength); ? 'module-expire-timer--with-image-no-caption'
: null,
return ( withSticker ? 'module-expire-timer--with-sticker' : null
<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 type Props = PropsData & PropsHousekeeping;
export class GroupNotification extends React.Component<Props> { function GroupNotificationChange({
public renderChange( change,
change: Change, from,
from: ConversationType i18n,
): JSX.Element | string | null | undefined { }: {
const { contacts, type, newName } = change; change: Change;
const { i18n } = this.props; from: ConversationType;
i18n: LocalizerType;
}): JSX.Element | null {
const { contacts, type, newName } = change;
const otherPeople: Array<JSX.Element> = compact( const otherPeople: Array<JSX.Element> = compact(
(contacts || []).map(contact => { (contacts || []).map(contact => {
if (contact.isMe) { if (contact.isMe) {
return null; return null;
} }
return ( return (
<span <span
key={`external-${contact.id}`} key={`external-${contact.id}`}
className="module-group-notification__contact" className="module-group-notification__contact"
> >
<ContactName title={contact.title} /> <ContactName title={contact.title} />
</span> </span>
); );
}) })
); );
const otherPeopleWithCommas: Array<JSX.Element | string> = compact( const otherPeopleWithCommas: Array<JSX.Element | string> = compact(
flatten( flatten(
otherPeople.map((person, index) => [index > 0 ? ', ' : null, person]) otherPeople.map((person, index) => [index > 0 ? ', ' : null, person])
) )
); );
const contactsIncludesMe = (contacts || []).length !== otherPeople.length; const contactsIncludesMe = (contacts || []).length !== otherPeople.length;
switch (type) { switch (type) {
case 'name': case 'name':
return ( return (
<Intl <Intl
i18n={i18n} i18n={i18n}
id="icu:titleIsNow" id="icu:titleIsNow"
components={{ name: newName || '' }} components={{ name: newName || '' }}
/> />
); );
case 'avatar': case 'avatar':
return <Intl i18n={i18n} id="icu:updatedGroupAvatar" />; return <Intl i18n={i18n} id="icu:updatedGroupAvatar" />;
case 'add': case 'add':
if (!contacts || !contacts.length) { if (!contacts || !contacts.length) {
throw new Error('Group update is missing contacts'); throw new Error('Group update is missing contacts');
} }
return ( 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 = (
<> <>
<p>{fromLabel}</p> {otherPeople.length > 0 && (
{changes.map((change, i) => ( <>
// eslint-disable-next-line react/no-array-index-key {otherPeople.length === 1 ? (
<p key={i} className="module-group-notification__change"> <Intl
{this.renderChange(change, from)} i18n={i18n}
</p> 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 * as React from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { boolean, number, text } from '@storybook/addon-knobs';
import { pngUrl } from '../../storybook/Fixtures'; import { pngUrl } from '../../storybook/Fixtures';
import type { Props } from './Image'; import type { Props } from './Image';
@ -24,7 +23,7 @@ export default {
}; };
const createProps = (overrideProps: Partial<Props> = {}): Props => ({ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
alt: text('alt', overrideProps.alt || ''), alt: overrideProps.alt || '',
attachment: attachment:
overrideProps.attachment || overrideProps.attachment ||
fakeAttachment({ fakeAttachment({
@ -32,42 +31,27 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
fileName: 'sax.png', fileName: 'sax.png',
url: pngUrl, url: pngUrl,
}), }),
blurHash: text('blurHash', overrideProps.blurHash || ''), blurHash: overrideProps.blurHash || '',
bottomOverlay: boolean('bottomOverlay', overrideProps.bottomOverlay || false), bottomOverlay: overrideProps.bottomOverlay || false,
closeButton: boolean('closeButton', overrideProps.closeButton || false), closeButton: overrideProps.closeButton || false,
curveBottomLeft: number( curveBottomLeft: overrideProps.curveBottomLeft || CurveType.None,
'curveBottomLeft', curveBottomRight: overrideProps.curveBottomRight || CurveType.None,
overrideProps.curveBottomLeft || CurveType.None curveTopLeft: overrideProps.curveTopLeft || CurveType.None,
), curveTopRight: overrideProps.curveTopRight || CurveType.None,
curveBottomRight: number( darkOverlay: overrideProps.darkOverlay || false,
'curveBottomRight', height: overrideProps.height || 100,
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),
i18n, i18n,
noBackground: boolean('noBackground', overrideProps.noBackground || false), noBackground: overrideProps.noBackground || false,
noBorder: boolean('noBorder', overrideProps.noBorder || false), noBorder: overrideProps.noBorder || false,
onClick: action('onClick'), onClick: action('onClick'),
onClickClose: action('onClickClose'), onClickClose: action('onClickClose'),
onError: action('onError'), onError: action('onError'),
overlayText: text('overlayText', overrideProps.overlayText || ''), overlayText: overrideProps.overlayText || '',
playIconOverlay: boolean( playIconOverlay: overrideProps.playIconOverlay || false,
'playIconOverlay', tabIndex: overrideProps.tabIndex || 0,
overrideProps.playIconOverlay || false theme: overrideProps.theme || ('light' as ThemeType),
), url: 'url' in overrideProps ? overrideProps.url || '' : pngUrl,
tabIndex: number('tabIndex', overrideProps.tabIndex || 0), width: overrideProps.width || 100,
theme: text('theme', overrideProps.theme || 'light') as ThemeType,
url: text('url', 'url' in overrideProps ? overrideProps.url || '' : pngUrl),
width: number('width', overrideProps.width || 100),
}); });
export function UrlWithHeightWidth(): JSX.Element { export function UrlWithHeightWidth(): JSX.Element {

View file

@ -1,7 +1,7 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useCallback, useMemo } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Blurhash } from 'react-blurhash'; import { Blurhash } from 'react-blurhash';
@ -55,237 +55,220 @@ export type Props = {
onError?: () => void; onError?: () => void;
}; };
export class Image extends React.Component<Props> { export function Image({
private canClick() { alt,
const { onClick, attachment } = this.props; attachment,
const { pending } = attachment || { pending: true }; 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 => { const curveStyles = {
if (!this.canClick()) { borderTopLeftRadius: curveTopLeft || CurveType.None,
event.preventDefault(); borderTopRightRadius: curveTopRight || CurveType.None,
event.stopPropagation(); borderBottomLeftRadius: curveBottomLeft || CurveType.None,
borderBottomRightRadius: curveBottomRight || CurveType.None,
return;
}
const { onClick, attachment } = this.props;
if (onClick) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
}
}; };
public handleKeyDown = ( const canClick = useMemo(() => {
event: React.KeyboardEvent<HTMLButtonElement> return onClick != null && !pending;
): void => { }, [pending, onClick]);
if (!this.canClick()) {
event.preventDefault();
event.stopPropagation();
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')) { if (onClick) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onClick(attachment);
}
};
public renderPending = (): JSX.Element => { onClick(attachment);
const { blurHash, height, i18n, width } = this.props; }
},
[attachment, canClick, onClick]
);
if (blurHash) { const handleKeyDown = useCallback(
return ( (event: React.KeyboardEvent<HTMLButtonElement>) => {
<div className="module-image__download-pending"> if (!canClick) {
<Blurhash event.preventDefault();
hash={blurHash} event.stopPropagation();
width={width}
height={height} return;
style={{ display: 'block' }} }
/>
<div className="module-image__download-pending--spinner-container"> if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
<div event.preventDefault();
className="module-image__download-pending--spinner" event.stopPropagation();
title={i18n('icu:loading')} onClick(attachment);
> }
<Spinner moduleClassName="module-image-spinner" svgSize="small" /> },
[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> </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 <div
className="module-image__bottom-overlay" className="module-image__loading-placeholder"
style={{ style={{
borderBottomLeftRadius: curveBottomLeft || CurveType.None, height: `${height}px`,
borderBottomRightRadius: curveBottomRight || CurveType.None, width: `${width}px`,
lineHeight: `${height}px`,
textAlign: 'center',
}} }}
/> title={i18n('icu:loading')}
) : 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} <Spinner svgSize="normal" />
</div> </div>
) : null} )
{overlay} ) : url ? (
{closeButton ? ( <img
<button onError={onError}
type="button" className="module-image__image"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => { alt={alt}
e.preventDefault(); height={height}
e.stopPropagation(); 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) { if (onClickClose) {
onClickClose(attachment); onClickClose(attachment);
} }
}} }}
className="module-image__close-button" className="module-image__close-button"
title={i18n('icu:remove-attachment')} title={i18n('icu:remove-attachment')}
aria-label={i18n('icu:remove-attachment')} aria-label={i18n('icu:remove-attachment')}
/> />
) : null} ) : null}
</div> </div>
); );
/* eslint-enable no-nested-ternary */ /* eslint-enable no-nested-ternary */
}
} }

View file

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

View file

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

View file

@ -323,77 +323,71 @@ export const SUPPORTED_PROTOCOLS = /^(http|https):/i;
const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text; const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text;
export class Linkify extends React.Component<Props> { export function Linkify(props: Props): JSX.Element {
public override render(): const { text, renderNonLink = defaultRenderNonLink } = props;
| JSX.Element
| string
| null
| Array<JSX.Element | string | null> {
const { text, renderNonLink = defaultRenderNonLink } = this.props;
if (!shouldLinkifyMessage(text)) { if (!shouldLinkifyMessage(text)) {
return renderNonLink({ text, key: 1 }); 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<{ if (type === 'emoji') {
chunk: string; return { chunk, matchData: [] };
matchData: ReadonlyArray<LinkifyIt.Match>; }
}> = splitByEmoji(text).map(({ type, value: chunk }) => {
if (type === 'text') { throw missingCaseError(type);
return { chunk, matchData: linkify.match(chunk) || [] }; });
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') { const { url, text: originalText } = match;
return { chunk, matchData: [] }; count += 1;
} if (SUPPORTED_PROTOCOLS.test(url) && !isLinkSneaky(url)) {
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;
results.push( results.push(
renderNonLink({ <a key={count} href={url}>
text: chunk.slice(chunkLastIndex), {originalText}
key: count, </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 // SPDX-License-Identifier: AGPL-3.0-only
import type { ReactChild, ReactNode } from 'react'; import type { ReactChild, ReactNode } from 'react';
import React from 'react'; import React, { useEffect, useRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { noop } from 'lodash'; import { noop } from 'lodash';
@ -106,22 +106,54 @@ const _keyForError = (error: Error): string => {
return `${error.name}-${error.message}`; return `${error.name}-${error.message}`;
}; };
export class MessageDetail extends React.Component<Props> { export function MessageDetail({
private readonly focusRef = React.createRef<HTMLDivElement>(); contacts,
private readonly messageContainerRef = React.createRef<HTMLDivElement>(); 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(() => {
// When this component is created, it's initially not part of the DOM, and then it's const timer = setTimeout(() => {
// added off-screen and animated in. This ensures that the focus takes. // When this component is created, it's initially not part of the DOM, and then it's
setTimeout(() => { // added off-screen and animated in. This ensures that the focus takes.
if (this.focusRef.current) { focusRef.current?.focus();
this.focusRef.current.focus();
}
}); });
} return () => {
clearTimeout(timer);
};
}, []);
public renderAvatar(contact: Contact): JSX.Element { function renderAvatar(contact: Contact): JSX.Element {
const { getPreferredBadge, i18n, theme } = this.props;
const { const {
acceptedMessageRequest, acceptedMessageRequest,
avatarPath, avatarPath,
@ -155,9 +187,8 @@ export class MessageDetail extends React.Component<Props> {
); );
} }
public renderContact(contact: Contact): JSX.Element { function renderContact(contact: Contact): JSX.Element {
const { i18n, toggleSafetyNumberModal } = this.props; const contactErrors = contact.errors || [];
const errors = contact.errors || [];
const errorComponent = contact.isOutgoingKeyError ? ( const errorComponent = contact.isOutgoingKeyError ? (
<div className="module-message-detail__contact__error-buttons"> <div className="module-message-detail__contact__error-buttons">
@ -176,17 +207,17 @@ export class MessageDetail extends React.Component<Props> {
return ( return (
<div key={contact.id} className="module-message-detail__contact"> <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__text">
<div className="module-message-detail__contact__name"> <div className="module-message-detail__contact__name">
<ContactName title={contact.title} /> <ContactName title={contact.title} />
</div> </div>
{errors.map(error => ( {contactErrors.map(contactError => (
<div <div
key={_keyForError(error)} key={_keyForError(contactError)}
className="module-message-detail__contact__error" className="module-message-detail__contact__error"
> >
{error.message} {contactError.message}
</div> </div>
))} ))}
</div> </div>
@ -204,11 +235,9 @@ export class MessageDetail extends React.Component<Props> {
); );
} }
private renderContactGroupHeaderText( function renderContactGroupHeaderText(
sendStatus: undefined | SendStatus sendStatus: undefined | SendStatus
): string { ): string {
const { i18n } = this.props;
if (sendStatus === undefined) { if (sendStatus === undefined) {
return i18n('icu:from'); return i18n('icu:from');
} }
@ -231,19 +260,19 @@ export class MessageDetail extends React.Component<Props> {
} }
} }
private renderContactGroup( function renderContactGroup(
sendStatus: undefined | SendStatus, sendStatus: undefined | SendStatus,
contacts: undefined | ReadonlyArray<Contact> statusContacts: undefined | ReadonlyArray<Contact>
): ReactNode { ): ReactNode {
if (!contacts || !contacts.length) { if (!statusContacts || !statusContacts.length) {
return null; return null;
} }
const sortedContacts = [...contacts].sort((a, b) => const sortedContacts = [...statusContacts].sort((a, b) =>
contactSortCollator.compare(a.title, b.title) contactSortCollator.compare(a.title, b.title)
); );
const headerText = this.renderContactGroupHeaderText(sendStatus); const headerText = renderContactGroupHeaderText(sendStatus);
return ( return (
<div key={headerText} className="module-message-detail__contact-group"> <div key={headerText} className="module-message-detail__contact-group">
@ -256,16 +285,14 @@ export class MessageDetail extends React.Component<Props> {
> >
{headerText} {headerText}
</div> </div>
{sortedContacts.map(contact => this.renderContact(contact))} {sortedContacts.map(contact => renderContact(contact))}
</div> </div>
); );
} }
private renderContacts(): ReactChild { function renderContacts(): ReactChild {
// This assumes that the list either contains one sender (a status of `undefined`) or // 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. // 1+ contacts with `SendStatus`es, but it doesn't check that assumption.
const { contacts } = this.props;
const contactsBySendStatus = groupBy(contacts, contact => contact.status); const contactsBySendStatus = groupBy(contacts, contact => contact.status);
return ( return (
@ -279,181 +306,135 @@ export class MessageDetail extends React.Component<Props> {
SendStatus.Sent, SendStatus.Sent,
SendStatus.Pending, SendStatus.Pending,
].map(sendStatus => ].map(sendStatus =>
this.renderContactGroup( renderContactGroup(sendStatus, contactsBySendStatus.get(sendStatus))
sendStatus,
contactsBySendStatus.get(sendStatus)
)
)} )}
</div> </div>
); );
} }
public override render(): JSX.Element { const timeRemaining = message.expirationTimestamp
const { ? DurationInSeconds.fromMillis(message.expirationTimestamp - Date.now())
errors, : undefined;
message,
receivedAt,
sentAt,
checkForAccount, return (
clearTargetedMessage, // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
contactNameColor, <div className="module-message-detail" tabIndex={0} ref={focusRef}>
doubleCheckMissingQuoteReference, <div
getPreferredBadge, className="module-message-detail__message-container"
i18n, ref={messageContainerRef}
interactionMode, >
kickOffAttachmentDownload, <Message
markAttachmentAsCorrupted, {...message}
messageExpanded, renderingContext="conversation/MessageDetail"
openGiftBadge, checkForAccount={checkForAccount}
platform, clearTargetedMessage={clearTargetedMessage}
pushPanelForConversation, contactNameColor={contactNameColor}
renderAudioAttachment, containerElementRef={messageContainerRef}
saveAttachment, containerWidthBreakpoint={WidthBreakpoint.Wide}
showContactModal, renderMenu={undefined}
showConversation, disableScroll
showExpiredIncomingTapToViewToast, displayLimit={Number.MAX_SAFE_INTEGER}
showExpiredOutgoingTapToViewToast, showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
showLightbox, doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
showLightboxForViewOnceMedia, getPreferredBadge={getPreferredBadge}
showSpoiler, i18n={i18n}
startConversation, interactionMode={interactionMode}
theme, kickOffAttachmentDownload={kickOffAttachmentDownload}
viewStory, markAttachmentAsCorrupted={markAttachmentAsCorrupted}
} = this.props; messageExpanded={messageExpanded}
openGiftBadge={openGiftBadge}
const timeRemaining = message.expirationTimestamp platform={platform}
? DurationInSeconds.fromMillis(message.expirationTimestamp - Date.now()) pushPanelForConversation={pushPanelForConversation}
: undefined; renderAudioAttachment={renderAudioAttachment}
saveAttachment={saveAttachment}
return ( shouldCollapseAbove={false}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex shouldCollapseBelow={false}
<div className="module-message-detail" tabIndex={0} ref={this.focusRef}> shouldHideMetadata={false}
<div showConversation={showConversation}
className="module-message-detail__message-container" showSpoiler={showSpoiler}
ref={this.messageContainerRef} scrollToQuotedMessage={() => {
> log.warn('MessageDetail: scrollToQuotedMessage called!');
<Message }}
{...message} showContactModal={showContactModal}
renderingContext="conversation/MessageDetail" showExpiredIncomingTapToViewToast={showExpiredIncomingTapToViewToast}
checkForAccount={checkForAccount} showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
clearTargetedMessage={clearTargetedMessage} showLightbox={showLightbox}
contactNameColor={contactNameColor} startConversation={startConversation}
containerElementRef={this.messageContainerRef} theme={theme}
containerWidthBreakpoint={WidthBreakpoint.Wide} viewStory={viewStory}
renderMenu={undefined} onToggleSelect={noop}
disableScroll onReplyToMessage={noop}
displayLimit={Number.MAX_SAFE_INTEGER} />
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia} </div>
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference} <table className="module-message-detail__info">
getPreferredBadge={getPreferredBadge} <tbody>
i18n={i18n} {(errors || []).map(error => (
interactionMode={interactionMode} <tr key={_keyForError(error)}>
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>
<td className="module-message-detail__label"> <td className="module-message-detail__label">
{i18n('icu:sent')} {i18n('icu:error')}
</td> </td>
<td> <td>
<ContextMenu {' '}
i18n={i18n} <span className="error-message">{error.message}</span>{' '}
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>
</td> </td>
</tr> </tr>
{receivedAt && message.direction === 'incoming' ? ( ))}
<tr> <tr>
<td className="module-message-detail__label"> <td className="module-message-detail__label">{i18n('icu:sent')}</td>
{i18n('icu:received')} <td>
</td> <ContextMenu
<td> i18n={i18n}
<Time timestamp={receivedAt}> menuOptions={[
{formatDateTimeLong(i18n, receivedAt)} {
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>{' '} </Time>{' '}
<span className="module-message-detail__unix-timestamp"> <span className="module-message-detail__unix-timestamp">
({receivedAt}) ({sentAt})
</span> </span>
</td> </>
</tr> </ContextMenu>
) : null} </td>
{timeRemaining && timeRemaining > 0 && ( </tr>
<tr> {receivedAt && message.direction === 'incoming' ? (
<td className="module-message-detail__label"> <tr>
{i18n('icu:MessageDetail--disappears-in')} <td className="module-message-detail__label">
</td> {i18n('icu:received')}
<td> </td>
{formatRelativeTime(i18n, timeRemaining, { <td>
largest: 2, <Time timestamp={receivedAt}>
})} {formatDateTimeLong(i18n, receivedAt)}
</td> </Time>{' '}
</tr> <span className="module-message-detail__unix-timestamp">
)} ({receivedAt})
</tbody> </span>
</table> </td>
{this.renderContacts()} </tr>
</div> ) : 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; doubleCheckMissingQuoteReference?: () => unknown;
}; };
type State = {
imageBroken: boolean;
};
export type QuotedAttachmentType = Pick< export type QuotedAttachmentType = Pick<
AttachmentType, AttachmentType,
'contentType' | 'fileName' | 'isVoiceMessage' | 'thumbnail' | 'textAttachment' 'contentType' | 'fileName' | 'isVoiceMessage' | 'thumbnail' | 'textAttachment'
@ -139,31 +135,41 @@ function getTypeLabel({
return MIME.isAudio(contentType) ? i18n('icu:audio') : undefined; return MIME.isAudio(contentType) ? i18n('icu:audio') : undefined;
} }
export class Quote extends React.Component<Props, State> { export function Quote(props: Props): JSX.Element | null {
private getClassName: (modifier?: string) => string; 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) { const getClassName = getClassNamesFor('module-quote', moduleClassName);
super(props);
this.state = {
imageBroken: false,
};
this.getClassName = getClassNamesFor('module-quote', props.moduleClassName);
}
override componentDidMount(): void {
const { doubleCheckMissingQuoteReference, referencedMessageNotFound } =
this.props;
useEffect(() => {
if (referencedMessageNotFound) { if (referencedMessageNotFound) {
doubleCheckMissingQuoteReference?.(); doubleCheckMissingQuoteReference?.();
} }
} }, [referencedMessageNotFound, doubleCheckMissingQuoteReference]);
public handleKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
): void => {
const { onClick } = this.props;
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
// This is important to ensure that using this quote to navigate to the referenced // This is important to ensure that using this quote to navigate to the referenced
// message doesn't also trigger its parent message's keydown. // message doesn't also trigger its parent message's keydown.
if (onClick && (event.key === 'Enter' || event.key === ' ')) { if (onClick && (event.key === 'Enter' || event.key === ' ')) {
@ -171,42 +177,35 @@ export class Quote extends React.Component<Props, State> {
event.stopPropagation(); event.stopPropagation();
onClick(); onClick();
} }
}; }
public handleClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
const { onClick } = this.props;
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
if (onClick) { if (onClick) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
onClick(); onClick();
} }
}; }
public handleImageError = (): void => { function handleImageError() {
window.console.info( window.console.info(
'Message: Image failed to load; failing over to placeholder' 'Message: Image failed to load; failing over to placeholder'
); );
this.setState({ setImageBroken(true);
imageBroken: true, }
});
};
public renderImage( function renderImage(
url: string, url: string,
icon: string | undefined, icon: string | undefined,
isGiftBadge?: boolean asGiftBadge?: boolean
): JSX.Element { ): JSX.Element {
const { isIncoming } = this.props;
const iconElement = icon ? ( const iconElement = icon ? (
<div className={this.getClassName('__icon-container__inner')}> <div className={getClassName('__icon-container__inner')}>
<div <div className={getClassName('__icon-container__circle-background')}>
className={this.getClassName('__icon-container__circle-background')}
>
<div <div
className={classNames( className={classNames(
this.getClassName('__icon-container__icon'), getClassName('__icon-container__icon'),
this.getClassName(`__icon-container__icon--${icon}`) getClassName(`__icon-container__icon--${icon}`)
)} )}
/> />
</div> </div>
@ -216,30 +215,28 @@ export class Quote extends React.Component<Props, State> {
return ( return (
<ThumbnailImage <ThumbnailImage
className={classNames( className={classNames(
this.getClassName('__icon-container'), getClassName('__icon-container'),
isIncoming === false && isIncoming === false &&
isGiftBadge && asGiftBadge &&
this.getClassName('__icon-container__outgoing-gift-badge') getClassName('__icon-container__outgoing-gift-badge')
)} )}
src={url} src={url}
onError={this.handleImageError} onError={handleImageError}
> >
{iconElement} {iconElement}
</ThumbnailImage> </ThumbnailImage>
); );
} }
public renderIcon(icon: string): JSX.Element { function renderIcon(icon: string) {
return ( return (
<div className={this.getClassName('__icon-container')}> <div className={getClassName('__icon-container')}>
<div className={this.getClassName('__icon-container__inner')}> <div className={getClassName('__icon-container__inner')}>
<div <div className={getClassName('__icon-container__circle-background')}>
className={this.getClassName('__icon-container__circle-background')}
>
<div <div
className={classNames( className={classNames(
this.getClassName('__icon-container__icon'), getClassName('__icon-container__icon'),
this.getClassName(`__icon-container__icon--${icon}`) getClassName(`__icon-container__icon--${icon}`)
)} )}
/> />
</div> </div>
@ -248,8 +245,7 @@ export class Quote extends React.Component<Props, State> {
); );
} }
public renderGenericFile(): JSX.Element | null { function renderGenericFile() {
const { rawAttachment, isIncoming } = this.props;
const attachment = getAttachment(rawAttachment); const attachment = getAttachment(rawAttachment);
if (!attachment) { if (!attachment) {
@ -268,14 +264,12 @@ export class Quote extends React.Component<Props, State> {
} }
return ( return (
<div className={this.getClassName('__generic-file')}> <div className={getClassName('__generic-file')}>
<div className={this.getClassName('__generic-file__icon')} /> <div className={getClassName('__generic-file__icon')} />
<div <div
className={classNames( className={classNames(
this.getClassName('__generic-file__text'), getClassName('__generic-file__text'),
isIncoming isIncoming ? getClassName('__generic-file__text--incoming') : null
? this.getClassName('__generic-file__text--incoming')
: null
)} )}
> >
{fileName} {fileName}
@ -284,10 +278,7 @@ export class Quote extends React.Component<Props, State> {
); );
} }
public renderPayment(): JSX.Element | null { function renderPayment() {
const { payment, authorTitle, conversationTitle, isFromMe, i18n } =
this.props;
if (payment == null) { if (payment == null) {
return null; return null;
} }
@ -306,13 +297,11 @@ export class Quote extends React.Component<Props, State> {
); );
} }
public renderIconContainer(): JSX.Element | null { function renderIconContainer() {
const { isGiftBadge, isViewOnce, i18n, rawAttachment } = this.props;
const { imageBroken } = this.state;
const attachment = getAttachment(rawAttachment); const attachment = getAttachment(rawAttachment);
if (isGiftBadge) { if (isGiftBadge) {
return this.renderImage('images/gift-thumbnail.svg', undefined, true); return renderImage('images/gift-thumbnail.svg', undefined, true);
} }
if (!attachment) { if (!attachment) {
@ -323,12 +312,12 @@ export class Quote extends React.Component<Props, State> {
const url = getUrl(thumbnail); const url = getUrl(thumbnail);
if (isViewOnce) { if (isViewOnce) {
return this.renderIcon('view-once'); return renderIcon('view-once');
} }
if (textAttachment) { if (textAttachment) {
return ( return (
<div className={this.getClassName('__icon-container')}> <div className={getClassName('__icon-container')}>
<TextAttachment <TextAttachment
i18n={i18n} i18n={i18n}
isThumbnail isThumbnail
@ -340,39 +329,29 @@ export class Quote extends React.Component<Props, State> {
if (GoogleChrome.isVideoTypeSupported(contentType)) { if (GoogleChrome.isVideoTypeSupported(contentType)) {
return url && !imageBroken return url && !imageBroken
? this.renderImage(url, 'play') ? renderImage(url, 'play')
: this.renderIcon('movie'); : renderIcon('movie');
} }
if (GoogleChrome.isImageTypeSupported(contentType)) { if (GoogleChrome.isImageTypeSupported(contentType)) {
return url && !imageBroken return url && !imageBroken
? this.renderImage(url, undefined) ? renderImage(url, undefined)
: this.renderIcon('image'); : renderIcon('image');
} }
if (MIME.isAudio(contentType)) { if (MIME.isAudio(contentType)) {
return this.renderIcon('microphone'); return renderIcon('microphone');
} }
return null; return null;
} }
public renderText(): JSX.Element | null { function renderText() {
const {
bodyRanges,
isGiftBadge,
i18n,
text,
rawAttachment,
isIncoming,
isViewOnce,
} = this.props;
if (text && !isGiftBadge) { if (text && !isGiftBadge) {
return ( return (
<div <div
dir="auto" dir="auto"
className={classNames( className={classNames(
this.getClassName('__primary__text'), getClassName('__primary__text'),
isIncoming ? this.getClassName('__primary__text--incoming') : null isIncoming ? getClassName('__primary__text--incoming') : null
)} )}
> >
<MessageBody <MessageBody
@ -410,10 +389,8 @@ export class Quote extends React.Component<Props, State> {
return ( return (
<div <div
className={classNames( className={classNames(
this.getClassName('__primary__type-label'), getClassName('__primary__type-label'),
isIncoming isIncoming ? getClassName('__primary__type-label--incoming') : null
? this.getClassName('__primary__type-label--incoming')
: null
)} )}
> >
{typeLabel} {typeLabel}
@ -424,9 +401,7 @@ export class Quote extends React.Component<Props, State> {
return null; return null;
} }
public renderClose(): JSX.Element | null { function renderClose() {
const { i18n, onClose } = this.props;
if (!onClose) { if (!onClose) {
return null; 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. // We need the container to give us the flexibility to implement the iOS design.
return ( return (
<div className={this.getClassName('__close-container')}> <div className={getClassName('__close-container')}>
<div <div
tabIndex={0} tabIndex={0}
// We can't be a button because the overall quote is a button; can't nest them // We can't be a button because the overall quote is a button; can't nest them
role="button" role="button"
className={this.getClassName('__close-button')} className={getClassName('__close-button')}
aria-label={i18n('icu:close')} aria-label={i18n('icu:close')}
onKeyDown={keyDownHandler} onKeyDown={keyDownHandler}
onClick={clickHandler} onClick={clickHandler}
@ -462,10 +437,7 @@ export class Quote extends React.Component<Props, State> {
); );
} }
public renderAuthor(): JSX.Element { function renderAuthor() {
const { authorTitle, i18n, isFromMe, isIncoming, isStoryReply } =
this.props;
const title = isFromMe ? ( const title = isFromMe ? (
i18n('icu:you') i18n('icu:you')
) : ( ) : (
@ -482,8 +454,8 @@ export class Quote extends React.Component<Props, State> {
return ( return (
<div <div
className={classNames( className={classNames(
this.getClassName('__primary__author'), getClassName('__primary__author'),
isIncoming ? this.getClassName('__primary__author--incoming') : null isIncoming ? getClassName('__primary__author--incoming') : null
)} )}
> >
{author} {author}
@ -491,16 +463,7 @@ export class Quote extends React.Component<Props, State> {
); );
} }
public renderReferenceWarning(): JSX.Element | null { function renderReferenceWarning() {
const {
conversationColor,
customColor,
i18n,
isIncoming,
isStoryReply,
referencedMessageNotFound,
} = this.props;
if (!referencedMessageNotFound || isStoryReply) { if (!referencedMessageNotFound || isStoryReply) {
return null; return null;
} }
@ -508,26 +471,28 @@ export class Quote extends React.Component<Props, State> {
return ( return (
<div <div
className={classNames( className={classNames(
this.getClassName('__reference-warning'), getClassName('__reference-warning'),
isIncoming isIncoming
? this.getClassName(`--incoming-${conversationColor}`) ? getClassName(`--incoming-${conversationColor}`)
: this.getClassName(`--outgoing-${conversationColor}`) : getClassName(`--outgoing-${conversationColor}`)
)} )}
style={{ ...getCustomColorStyle(customColor, true) }} style={{
...getCustomColorStyle(customColor, true),
}}
> >
<div <div
className={classNames( className={classNames(
this.getClassName('__reference-warning__icon'), getClassName('__reference-warning__icon'),
isIncoming isIncoming
? this.getClassName('__reference-warning__icon--incoming') ? getClassName('__reference-warning__icon--incoming')
: null : null
)} )}
/> />
<div <div
className={classNames( className={classNames(
this.getClassName('__reference-warning__text'), getClassName('__reference-warning__text'),
isIncoming isIncoming
? this.getClassName('__reference-warning__text--incoming') ? getClassName('__reference-warning__text--incoming')
: null : null
)} )}
> >
@ -537,75 +502,61 @@ export class Quote extends React.Component<Props, State> {
); );
} }
public override render(): JSX.Element | null { if (!validateQuote(props)) {
const { return null;
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>
);
} }
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({ function ThumbnailImage({

View file

@ -184,200 +184,195 @@ export type PropsType = PropsLocalType &
| 'shouldHideMetadata' | 'shouldHideMetadata'
>; >;
export class TimelineItem extends React.PureComponent<PropsType> { export function TimelineItem({
public override render(): JSX.Element | null { containerElementRef,
const { conversationId,
containerElementRef, getPreferredBadge,
conversationId, i18n,
getPreferredBadge, id,
i18n, isNextItemCallingNotification,
id, isTargeted,
isNextItemCallingNotification, item,
isTargeted, platform,
item, renderUniversalTimerNotification,
platform, returnToActiveCall,
renderUniversalTimerNotification, targetMessage,
returnToActiveCall, shouldCollapseAbove,
targetMessage, shouldCollapseBelow,
shouldCollapseAbove, shouldHideMetadata,
shouldCollapseBelow, shouldRenderDateHeader,
shouldHideMetadata, startCallingLobby,
shouldRenderDateHeader, theme,
startCallingLobby, ...reducedProps
theme, }: PropsType): JSX.Element | null {
...reducedProps if (!item) {
} = this.props; // 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) { let itemContents: ReactChild;
// This can happen under normal conditions. if (item.type === 'message') {
// itemContents = (
// `<Timeline>` and `<TimelineItem>` are connected to Redux separately. If a <TimelineMessage
// timeline item is removed from Redux, `<TimelineItem>` might re-render before {...reducedProps}
// `<Timeline>` does, which means we'll try to render nothing. This should resolve {...item.data}
// itself quickly, as soon as `<Timeline>` re-renders. isTargeted={isTargeted}
return null; 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 === 'unsupportedMessage') {
if (item.type === 'message') { notification = (
itemContents = ( <UnsupportedMessage {...reducedProps} {...item.data} i18n={i18n} />
<TimelineMessage );
} 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} {...reducedProps}
{...item.data} {...item.data}
isTargeted={isTargeted}
targetMessage={targetMessage}
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={shouldHideMetadata}
containerElementRef={containerElementRef}
getPreferredBadge={getPreferredBadge}
platform={platform}
i18n={i18n} 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} 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 { } else {
let notification; // 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 (item.type === 'unsupportedMessage') { // if we encounter it at runtime.
notification = ( const unknownItem: never = item;
<UnsupportedMessage {...reducedProps} {...item.data} i18n={i18n} /> const asItem = unknownItem as TimelineItemType;
); throw new Error(`TimelineItem: Unknown type: ${asItem.type}`);
} 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>
);
} }
if (shouldRenderDateHeader) { itemContents = (
return ( <InlineNotificationWrapper
<> id={id}
<TimelineDateHeader i18n={i18n} timestamp={item.timestamp} /> conversationId={conversationId}
{itemContents} isTargeted={isTargeted}
</> targetMessage={targetMessage}
); >
} {notification}
return itemContents; </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 type Props = PropsData & PropsHousekeeping;
export class VerificationNotification extends React.Component<Props> { function VerificationNotificationContents({
public renderContents(): JSX.Element { contact,
const { contact, isLocal, type, i18n } = this.props; isLocal,
type,
i18n,
}: Props) {
const name = (
<ContactName
key="external-1"
title={contact.title}
module="module-verification-notification__contact"
/>
);
const name = ( switch (type) {
<ContactName case 'markVerified':
key="external-1" return isLocal ? (
title={contact.title} <Intl id="icu:youMarkedAsVerified" components={{ name }} i18n={i18n} />
module="module-verification-notification__contact" ) : (
/> <Intl
); id="icu:youMarkedAsVerifiedOtherDevice"
components={{ name }}
switch (type) { i18n={i18n}
case 'markVerified': />
return isLocal ? ( );
<Intl case 'markNotVerified':
id="icu:youMarkedAsVerified" return isLocal ? (
components={{ name }} <Intl
i18n={i18n} id="icu:youMarkedAsNotVerified"
/> components={{ name }}
) : ( i18n={i18n}
<Intl />
id="icu:youMarkedAsVerifiedOtherDevice" ) : (
components={{ name }} <Intl
i18n={i18n} id="icu:youMarkedAsNotVerifiedOtherDevice"
/> components={{ name }}
); i18n={i18n}
case 'markNotVerified': />
return isLocal ? ( );
<Intl default:
id="icu:youMarkedAsNotVerified" throw missingCaseError(type);
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()} />;
} }
} }
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; shouldShowSeparator?: boolean;
}; };
export class DocumentListItem extends React.Component<Props> { export function DocumentListItem({
public override render(): JSX.Element { shouldShowSeparator = true,
const { shouldShowSeparator = true } = this.props; fileName,
fileSize,
return ( onClick,
<div timestamp,
className={classNames( }: Props): JSX.Element {
'module-document-list-item', return (
shouldShowSeparator <div
? 'module-document-list-item--with-separator' className={classNames(
: null '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 (
<button <button
type="button" type="button"
className="module-document-list-item__content" className="module-document-list-item__content"
@ -60,6 +52,6 @@ export class DocumentListItem extends React.Component<Props> {
{moment(timestamp).format('ddd, MMM D, Y')} {moment(timestamp).format('ddd, MMM D, Y')}
</div> </div>
</button> </button>
); </div>
} );
} }

View file

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

View file

@ -1,7 +1,7 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useCallback, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
@ -19,110 +19,87 @@ export type Props = {
i18n: LocalizerType; i18n: LocalizerType;
}; };
type State = { function MediaGridItemContent(props: Props) {
imageBroken: boolean; const { mediaItem, i18n } = props;
}; const { attachment, contentType } = mediaItem;
export class MediaGridItem extends React.Component<Props, State> { const [imageBroken, setImageBroken] = useState(false);
private readonly onImageErrorBound: () => void;
constructor(props: Props) { const handleImageError = useCallback(() => {
super(props);
this.state = {
imageBroken: false,
};
this.onImageErrorBound = this.onImageError.bind(this);
}
public onImageError(): void {
log.info( log.info(
'MediaGridItem: Image failed to load; failing over to placeholder' 'MediaGridItem: Image failed to load; failing over to placeholder'
); );
this.setState({ setImageBroken(true);
imageBroken: true, }, []);
});
if (!attachment) {
return null;
} }
public renderContent(): JSX.Element | null { if (contentType && isImageTypeSupported(contentType)) {
const { mediaItem, i18n } = this.props; if (imageBroken || !mediaItem.thumbnailObjectUrl) {
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'
)}
/>
);
}
return ( return (
<img <div
alt={i18n('icu:lightboxImageAlt')} className={classNames(
className="module-media-grid-item__image" 'module-media-grid-item__icon',
src={mediaItem.thumbnailObjectUrl} 'module-media-grid-item__icon-image'
onError={this.onImageErrorBound} )}
/> />
); );
} }
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 ( return (
<div <img
className={classNames( alt={i18n('icu:lightboxImageAlt')}
'module-media-grid-item__icon', className="module-media-grid-item__image"
'module-media-grid-item__icon-generic' src={mediaItem.thumbnailObjectUrl}
)} onError={handleImageError}
/> />
); );
} }
public override render(): JSX.Element { if (contentType && isVideoTypeSupported(contentType)) {
const { onClick } = this.props; if (imageBroken || !mediaItem.thumbnailObjectUrl) {
return (
<div
className={classNames(
'module-media-grid-item__icon',
'module-media-grid-item__icon-video'
)}
/>
);
}
return ( return (
<button <div className="module-media-grid-item__image-container">
type="button" <img
className="module-media-grid-item" alt={i18n('icu:lightboxImageAlt')}
onClick={onClick} className="module-media-grid-item__image"
> src={mediaItem.thumbnailObjectUrl}
{this.renderContent()} onError={handleImageError}
</button> />
<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", "reasonCategory": "usageTrusted",
"updated": "2022-11-11T17:11:07.659Z" "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", "rule": "React-useRef",
"path": "ts/components/MediaQualitySelector.tsx", "path": "ts/components/MediaQualitySelector.tsx",
@ -2359,14 +2351,6 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z" "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", "rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx", "path": "ts/components/conversation/Message.tsx",
@ -2544,5 +2528,29 @@
"line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');", "line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');",
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-09-17T21:02:59.414Z" "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>"
} }
] ]