Migrate most React class components to function components
This commit is contained in:
parent
4c9baaef80
commit
558b5a4a38
23 changed files with 1444 additions and 1775 deletions
|
@ -1,9 +1,8 @@
|
|||
// Copyright 2018 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { VirtualElement } from '@popperjs/core';
|
||||
import React from 'react';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { showSettings } from '../shims/Whisper';
|
||||
|
@ -38,240 +37,183 @@ export type PropsType = {
|
|||
toggleStoriesView: () => unknown;
|
||||
};
|
||||
|
||||
type StateType = {
|
||||
showingAvatarPopup: boolean;
|
||||
popperRoot: HTMLDivElement | null;
|
||||
outsideClickDestructor?: () => void;
|
||||
virtualElement: {
|
||||
getBoundingClientRect: () => DOMRect;
|
||||
};
|
||||
};
|
||||
export function MainHeader({
|
||||
areStoriesEnabled,
|
||||
avatarPath,
|
||||
badge,
|
||||
color,
|
||||
hasFailedStorySends,
|
||||
hasPendingUpdate,
|
||||
i18n,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
showArchivedConversations,
|
||||
startComposing,
|
||||
startUpdate,
|
||||
theme,
|
||||
title,
|
||||
toggleProfileEditor,
|
||||
toggleStoriesView,
|
||||
unreadStoriesCount,
|
||||
}: PropsType): JSX.Element {
|
||||
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
||||
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
// https://popper.js.org/docs/v2/virtual-elements/
|
||||
// Generating a virtual element here so that we can make the menu pop up
|
||||
// right under the mouse cursor.
|
||||
function generateVirtualElement(x: number, y: number): VirtualElement {
|
||||
return {
|
||||
getBoundingClientRect: () => new DOMRect(x, y),
|
||||
};
|
||||
}
|
||||
const [showAvatarPopup, setShowAvatarPopup] = useState(false);
|
||||
|
||||
export class MainHeader extends React.Component<PropsType, StateType> {
|
||||
public containerRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
const popper = usePopper(targetElement, popperElement, {
|
||||
placement: 'bottom-start',
|
||||
strategy: 'fixed',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [null, 4],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
constructor(props: PropsType) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showingAvatarPopup: false,
|
||||
popperRoot: null,
|
||||
virtualElement: generateVirtualElement(0, 0),
|
||||
useEffect(() => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
setPortalElement(div);
|
||||
return () => {
|
||||
div.remove();
|
||||
setPortalElement(null);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
public showAvatarPopup = (ev: React.MouseEvent): void => {
|
||||
const popperRoot = document.createElement('div');
|
||||
document.body.appendChild(popperRoot);
|
||||
|
||||
const outsideClickDestructor = handleOutsideClick(
|
||||
useEffect(() => {
|
||||
return handleOutsideClick(
|
||||
() => {
|
||||
const { showingAvatarPopup } = this.state;
|
||||
if (!showingAvatarPopup) {
|
||||
if (!showAvatarPopup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.hideAvatarPopup();
|
||||
|
||||
setShowAvatarPopup(false);
|
||||
return true;
|
||||
},
|
||||
{
|
||||
containerElements: [popperRoot, this.containerRef],
|
||||
containerElements: [portalElement, targetElement],
|
||||
name: 'MainHeader.showAvatarPopup',
|
||||
}
|
||||
);
|
||||
}, [portalElement, targetElement, showAvatarPopup]);
|
||||
|
||||
this.setState({
|
||||
showingAvatarPopup: true,
|
||||
popperRoot,
|
||||
outsideClickDestructor,
|
||||
virtualElement: generateVirtualElement(ev.clientX, ev.clientY),
|
||||
});
|
||||
};
|
||||
|
||||
public hideAvatarPopup = (): void => {
|
||||
const { popperRoot, outsideClickDestructor } = this.state;
|
||||
|
||||
this.setState({
|
||||
showingAvatarPopup: false,
|
||||
popperRoot: null,
|
||||
outsideClickDestructor: undefined,
|
||||
});
|
||||
|
||||
outsideClickDestructor?.();
|
||||
|
||||
if (popperRoot && document.body.contains(popperRoot)) {
|
||||
document.body.removeChild(popperRoot);
|
||||
useEffect(() => {
|
||||
function handleGlobalKeyDown(event: KeyboardEvent) {
|
||||
if (showAvatarPopup && event.key === 'Escape') {
|
||||
setShowAvatarPopup(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
};
|
||||
}, [showAvatarPopup]);
|
||||
|
||||
public override componentDidMount(): void {
|
||||
const useCapture = true;
|
||||
document.addEventListener('keydown', this.handleGlobalKeyDown, useCapture);
|
||||
}
|
||||
|
||||
public override componentWillUnmount(): void {
|
||||
const { popperRoot, outsideClickDestructor } = this.state;
|
||||
|
||||
const useCapture = true;
|
||||
outsideClickDestructor?.();
|
||||
document.removeEventListener(
|
||||
'keydown',
|
||||
this.handleGlobalKeyDown,
|
||||
useCapture
|
||||
);
|
||||
|
||||
if (popperRoot && document.body.contains(popperRoot)) {
|
||||
document.body.removeChild(popperRoot);
|
||||
}
|
||||
}
|
||||
|
||||
public handleGlobalKeyDown = (event: KeyboardEvent): void => {
|
||||
const { showingAvatarPopup } = this.state;
|
||||
const { key } = event;
|
||||
|
||||
if (showingAvatarPopup && key === 'Escape') {
|
||||
this.hideAvatarPopup();
|
||||
}
|
||||
};
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const {
|
||||
areStoriesEnabled,
|
||||
avatarPath,
|
||||
badge,
|
||||
color,
|
||||
hasFailedStorySends,
|
||||
hasPendingUpdate,
|
||||
i18n,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
showArchivedConversations,
|
||||
startComposing,
|
||||
startUpdate,
|
||||
theme,
|
||||
title,
|
||||
toggleProfileEditor,
|
||||
toggleStoriesView,
|
||||
unreadStoriesCount,
|
||||
} = this.props;
|
||||
const { showingAvatarPopup, popperRoot } = this.state;
|
||||
|
||||
return (
|
||||
<div className="module-main-header">
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div
|
||||
className="module-main-header__avatar--container"
|
||||
ref={this.containerRef}
|
||||
>
|
||||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={avatarPath}
|
||||
badge={badge}
|
||||
className="module-main-header__avatar"
|
||||
color={color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
theme={theme}
|
||||
title={title}
|
||||
// `sharedGroupNames` makes no sense for yourself, but
|
||||
// `<Avatar>` needs it to determine blurring.
|
||||
sharedGroupNames={[]}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
innerRef={ref}
|
||||
onClick={this.showAvatarPopup}
|
||||
/>
|
||||
{hasPendingUpdate && (
|
||||
<div className="module-main-header__avatar--badged" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
{showingAvatarPopup && popperRoot
|
||||
? createPortal(
|
||||
<Popper referenceElement={this.state.virtualElement}>
|
||||
{({ ref, style }) => (
|
||||
<AvatarPopup
|
||||
acceptedMessageRequest
|
||||
badge={badge}
|
||||
innerRef={ref}
|
||||
i18n={i18n}
|
||||
isMe
|
||||
style={{ ...style, zIndex: 10 }}
|
||||
color={color}
|
||||
conversationType="direct"
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
theme={theme}
|
||||
title={title}
|
||||
avatarPath={avatarPath}
|
||||
hasPendingUpdate={hasPendingUpdate}
|
||||
startUpdate={startUpdate}
|
||||
// See the comment above about `sharedGroupNames`.
|
||||
sharedGroupNames={[]}
|
||||
onEditProfile={() => {
|
||||
toggleProfileEditor();
|
||||
this.hideAvatarPopup();
|
||||
}}
|
||||
onViewPreferences={() => {
|
||||
showSettings();
|
||||
this.hideAvatarPopup();
|
||||
}}
|
||||
onViewArchive={() => {
|
||||
showArchivedConversations();
|
||||
this.hideAvatarPopup();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popper>,
|
||||
popperRoot
|
||||
)
|
||||
: null}
|
||||
</Manager>
|
||||
<div className="module-main-header__icon-container">
|
||||
{areStoriesEnabled && (
|
||||
<button
|
||||
aria-label={i18n('icu:stories')}
|
||||
className="module-main-header__stories-icon"
|
||||
onClick={toggleStoriesView}
|
||||
title={i18n('icu:stories')}
|
||||
type="button"
|
||||
>
|
||||
{hasFailedStorySends && (
|
||||
<span className="module-main-header__stories-badge">!</span>
|
||||
)}
|
||||
{!hasFailedStorySends && unreadStoriesCount ? (
|
||||
<span className="module-main-header__stories-badge">
|
||||
{unreadStoriesCount}
|
||||
</span>
|
||||
) : undefined}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={i18n('icu:newConversation')}
|
||||
className="module-main-header__compose-icon"
|
||||
onClick={startComposing}
|
||||
title={i18n('icu:newConversation')}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="module-main-header">
|
||||
<div
|
||||
className="module-main-header__avatar--container"
|
||||
ref={setTargetElement}
|
||||
>
|
||||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={avatarPath}
|
||||
badge={badge}
|
||||
className="module-main-header__avatar"
|
||||
color={color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
theme={theme}
|
||||
title={title}
|
||||
// `sharedGroupNames` makes no sense for yourself, but
|
||||
// `<Avatar>` needs it to determine blurring.
|
||||
sharedGroupNames={[]}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
onClick={() => {
|
||||
setShowAvatarPopup(true);
|
||||
}}
|
||||
/>
|
||||
{hasPendingUpdate && (
|
||||
<div className="module-main-header__avatar--badged" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{showAvatarPopup &&
|
||||
portalElement != null &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={setPopperElement}
|
||||
style={{ ...popper.styles.popper, zIndex: 10 }}
|
||||
{...popper.attributes.popper}
|
||||
>
|
||||
<AvatarPopup
|
||||
acceptedMessageRequest
|
||||
badge={badge}
|
||||
i18n={i18n}
|
||||
isMe
|
||||
color={color}
|
||||
conversationType="direct"
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
theme={theme}
|
||||
title={title}
|
||||
avatarPath={avatarPath}
|
||||
hasPendingUpdate={hasPendingUpdate}
|
||||
startUpdate={startUpdate}
|
||||
// See the comment above about `sharedGroupNames`.
|
||||
sharedGroupNames={[]}
|
||||
onEditProfile={() => {
|
||||
toggleProfileEditor();
|
||||
setShowAvatarPopup(false);
|
||||
}}
|
||||
onViewPreferences={() => {
|
||||
showSettings();
|
||||
setShowAvatarPopup(false);
|
||||
}}
|
||||
onViewArchive={() => {
|
||||
showArchivedConversations();
|
||||
setShowAvatarPopup(false);
|
||||
}}
|
||||
style={{}}
|
||||
/>
|
||||
</div>,
|
||||
portalElement
|
||||
)}
|
||||
<div className="module-main-header__icon-container">
|
||||
{areStoriesEnabled && (
|
||||
<button
|
||||
aria-label={i18n('icu:stories')}
|
||||
className="module-main-header__stories-icon"
|
||||
onClick={toggleStoriesView}
|
||||
title={i18n('icu:stories')}
|
||||
type="button"
|
||||
>
|
||||
{hasFailedStorySends && (
|
||||
<span className="module-main-header__stories-badge">!</span>
|
||||
)}
|
||||
{!hasFailedStorySends && unreadStoriesCount ? (
|
||||
<span className="module-main-header__stories-badge">
|
||||
{unreadStoriesCount}
|
||||
</span>
|
||||
) : undefined}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={i18n('icu:newConversation')}
|
||||
className="module-main-header__compose-icon"
|
||||
onClick={startComposing}
|
||||
title={i18n('icu:newConversation')}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,9 +2,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { text } from '@storybook/addon-knobs';
|
||||
|
||||
import type { Props } from './AddNewLines';
|
||||
import { AddNewLines } from './AddNewLines';
|
||||
|
||||
|
@ -14,7 +11,7 @@ export default {
|
|||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
renderNonNewLine: overrideProps.renderNonNewLine,
|
||||
text: text('text', overrideProps.text || ''),
|
||||
text: overrideProps.text || '',
|
||||
});
|
||||
|
||||
export function AllNewlines(): JSX.Element {
|
||||
|
|
|
@ -13,43 +13,39 @@ export type Props = {
|
|||
|
||||
const defaultRenderNonNewLine: RenderTextCallbackType = ({ text }) => text;
|
||||
|
||||
export class AddNewLines extends React.Component<Props> {
|
||||
public override render():
|
||||
| JSX.Element
|
||||
| string
|
||||
| null
|
||||
| Array<JSX.Element | string | null> {
|
||||
const { text, renderNonNewLine = defaultRenderNonNewLine } = this.props;
|
||||
const results: Array<JSX.Element | string> = [];
|
||||
const FIND_NEWLINES = /\n/g;
|
||||
export function AddNewLines({
|
||||
text,
|
||||
renderNonNewLine = defaultRenderNonNewLine,
|
||||
}: Props): JSX.Element {
|
||||
const results: Array<JSX.Element | string> = [];
|
||||
const FIND_NEWLINES = /\n/g;
|
||||
|
||||
let match = FIND_NEWLINES.exec(text);
|
||||
let last = 0;
|
||||
let count = 1;
|
||||
let match = FIND_NEWLINES.exec(text);
|
||||
let last = 0;
|
||||
let count = 1;
|
||||
|
||||
if (!match) {
|
||||
return renderNonNewLine({ text, key: 0 });
|
||||
}
|
||||
|
||||
while (match) {
|
||||
if (last < match.index) {
|
||||
const textWithNoNewline = text.slice(last, match.index);
|
||||
count += 1;
|
||||
results.push(renderNonNewLine({ text: textWithNoNewline, key: count }));
|
||||
}
|
||||
|
||||
count += 1;
|
||||
results.push(<br key={count} />);
|
||||
|
||||
last = FIND_NEWLINES.lastIndex;
|
||||
match = FIND_NEWLINES.exec(text);
|
||||
}
|
||||
|
||||
if (last < text.length) {
|
||||
count += 1;
|
||||
results.push(renderNonNewLine({ text: text.slice(last), key: count }));
|
||||
}
|
||||
|
||||
return results;
|
||||
if (!match) {
|
||||
return <>{renderNonNewLine({ text, key: 0 })}</>;
|
||||
}
|
||||
|
||||
while (match) {
|
||||
if (last < match.index) {
|
||||
const textWithNoNewline = text.slice(last, match.index);
|
||||
count += 1;
|
||||
results.push(renderNonNewLine({ text: textWithNoNewline, key: count }));
|
||||
}
|
||||
|
||||
count += 1;
|
||||
results.push(<br key={count} />);
|
||||
|
||||
last = FIND_NEWLINES.lastIndex;
|
||||
match = FIND_NEWLINES.exec(text);
|
||||
}
|
||||
|
||||
if (last < text.length) {
|
||||
count += 1;
|
||||
results.push(renderNonNewLine({ text: text.slice(last), key: count }));
|
||||
}
|
||||
|
||||
return <>{results}</>;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { boolean } from '@storybook/addon-knobs';
|
||||
|
||||
import type { Props } from './ContactDetail';
|
||||
import { ContactDetail } from './ContactDetail';
|
||||
|
@ -23,10 +22,7 @@ export default {
|
|||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
contact: overrideProps.contact || {},
|
||||
hasSignalAccount: boolean(
|
||||
'hasSignalAccount',
|
||||
overrideProps.hasSignalAccount || false
|
||||
),
|
||||
hasSignalAccount: overrideProps.hasSignalAccount || false,
|
||||
i18n,
|
||||
onSendMessage: action('onSendMessage'),
|
||||
});
|
||||
|
|
|
@ -73,164 +73,97 @@ function getLabelForAddress(
|
|||
}
|
||||
}
|
||||
|
||||
export class ContactDetail extends React.Component<Props> {
|
||||
public renderSendMessage({
|
||||
hasSignalAccount,
|
||||
i18n,
|
||||
onSendMessage,
|
||||
}: {
|
||||
hasSignalAccount: boolean;
|
||||
i18n: LocalizerType;
|
||||
onSendMessage: () => void;
|
||||
}): JSX.Element | null {
|
||||
if (!hasSignalAccount) {
|
||||
return null;
|
||||
}
|
||||
export function ContactDetail({
|
||||
contact,
|
||||
hasSignalAccount,
|
||||
i18n,
|
||||
onSendMessage,
|
||||
}: Props): JSX.Element {
|
||||
// We don't want the overall click handler for this element to fire, so we stop
|
||||
// propagation before handing control to the caller's callback.
|
||||
const onClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
onSendMessage();
|
||||
};
|
||||
|
||||
// We don't want the overall click handler for this element to fire, so we stop
|
||||
// propagation before handing control to the caller's callback.
|
||||
const onClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
onSendMessage();
|
||||
};
|
||||
const isIncoming = false;
|
||||
const module = 'contact-detail';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-detail__send-message"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="module-contact-detail__send-message__inner">
|
||||
<div className="module-contact-detail__send-message__bubble-icon" />
|
||||
{i18n('icu:sendMessageToContact')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
public renderEmail(
|
||||
items: Array<Email> | undefined,
|
||||
i18n: LocalizerType
|
||||
): Array<JSX.Element> | undefined {
|
||||
if (!items || items.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items.map((item: Email) => {
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className="module-contact-detail__additional-contact"
|
||||
>
|
||||
<div className="module-contact-detail__additional-contact__type">
|
||||
{getLabelForEmail(item, i18n)}
|
||||
</div>
|
||||
{item.value}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public renderPhone(
|
||||
items: Array<Phone> | undefined,
|
||||
i18n: LocalizerType
|
||||
): Array<JSX.Element> | null | undefined {
|
||||
if (!items || items.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items.map((item: Phone) => {
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className="module-contact-detail__additional-contact"
|
||||
>
|
||||
<div className="module-contact-detail__additional-contact__type">
|
||||
{getLabelForPhone(item, i18n)}
|
||||
</div>
|
||||
{item.value}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public renderAddressLine(value: string | undefined): JSX.Element | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return <div>{value}</div>;
|
||||
}
|
||||
|
||||
public renderPOBox(
|
||||
poBox: string | undefined,
|
||||
i18n: LocalizerType
|
||||
): JSX.Element | null {
|
||||
if (!poBox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{i18n('icu:poBox')} {poBox}
|
||||
return (
|
||||
<div className="module-contact-detail">
|
||||
<div className="module-contact-detail__avatar">
|
||||
{renderAvatar({ contact, i18n, size: 80 })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{renderName({ contact, isIncoming, module })}
|
||||
{renderContactShorthand({ contact, isIncoming, module })}
|
||||
|
||||
public renderAddressLineTwo(address: PostalAddress): JSX.Element | null {
|
||||
if (address.city || address.region || address.postcode) {
|
||||
return (
|
||||
<div>
|
||||
{address.city} {address.region} {address.postcode}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public renderAddresses(
|
||||
addresses: Array<PostalAddress> | undefined,
|
||||
i18n: LocalizerType
|
||||
): Array<JSX.Element> | undefined {
|
||||
if (!addresses || addresses.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return addresses.map((address: PostalAddress, index: number) => {
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={index} className="module-contact-detail__additional-contact">
|
||||
<div className="module-contact-detail__additional-contact__type">
|
||||
{getLabelForAddress(address, i18n)}
|
||||
{hasSignalAccount && (
|
||||
<button
|
||||
type="button"
|
||||
className="module-contact-detail__send-message"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="module-contact-detail__send-message__inner">
|
||||
<div className="module-contact-detail__send-message__bubble-icon" />
|
||||
{i18n('icu:sendMessageToContact')}
|
||||
</div>
|
||||
{this.renderAddressLine(address.street)}
|
||||
{this.renderPOBox(address.pobox, i18n)}
|
||||
{this.renderAddressLine(address.neighborhood)}
|
||||
{this.renderAddressLineTwo(address)}
|
||||
{this.renderAddressLine(address.country)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const { contact, hasSignalAccount, i18n, onSendMessage } = this.props;
|
||||
const isIncoming = false;
|
||||
const module = 'contact-detail';
|
||||
{contact.number?.map((phone: Phone) => {
|
||||
return (
|
||||
<div
|
||||
key={phone.value}
|
||||
className="module-contact-detail__additional-contact"
|
||||
>
|
||||
<div className="module-contact-detail__additional-contact__type">
|
||||
{getLabelForPhone(phone, i18n)}
|
||||
</div>
|
||||
{phone.value}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
return (
|
||||
<div className="module-contact-detail">
|
||||
<div className="module-contact-detail__avatar">
|
||||
{renderAvatar({ contact, i18n, size: 80 })}
|
||||
</div>
|
||||
{renderName({ contact, isIncoming, module })}
|
||||
{renderContactShorthand({ contact, isIncoming, module })}
|
||||
{this.renderSendMessage({ hasSignalAccount, i18n, onSendMessage })}
|
||||
{this.renderPhone(contact.number, i18n)}
|
||||
{this.renderEmail(contact.email, i18n)}
|
||||
{this.renderAddresses(contact.address, i18n)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{contact.email?.map((email: Email) => {
|
||||
return (
|
||||
<div
|
||||
key={email.value}
|
||||
className="module-contact-detail__additional-contact"
|
||||
>
|
||||
<div className="module-contact-detail__additional-contact__type">
|
||||
{getLabelForEmail(email, i18n)}
|
||||
</div>
|
||||
{email.value}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{contact.address?.map((address: PostalAddress, index: number) => {
|
||||
return (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
className="module-contact-detail__additional-contact"
|
||||
>
|
||||
<div className="module-contact-detail__additional-contact__type">
|
||||
{getLabelForAddress(address, i18n)}
|
||||
</div>
|
||||
{address.street && <div>{address.street}</div>}
|
||||
{address.pobox && (
|
||||
<div>
|
||||
{i18n('icu:poBox')} {address.pobox}
|
||||
</div>
|
||||
)}
|
||||
{address.neighborhood && <div>{address.neighborhood}</div>}
|
||||
{(address.city || address.region || address.postcode) && (
|
||||
<div>
|
||||
{address.city} {address.region} {address.postcode}
|
||||
</div>
|
||||
)}
|
||||
{address.country && <div>{address.country}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,9 +2,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { text } from '@storybook/addon-knobs';
|
||||
|
||||
import type { Props } from './Emojify';
|
||||
import { Emojify } from './Emojify';
|
||||
|
||||
|
@ -15,7 +12,7 @@ export default {
|
|||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
renderNonEmoji: overrideProps.renderNonEmoji,
|
||||
sizeClass: overrideProps.sizeClass,
|
||||
text: text('text', overrideProps.text || ''),
|
||||
text: overrideProps.text || '',
|
||||
});
|
||||
|
||||
export function EmojiOnly(): JSX.Element {
|
||||
|
|
|
@ -59,25 +59,25 @@ export type Props = {
|
|||
|
||||
const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
|
||||
|
||||
export class Emojify extends React.Component<Props> {
|
||||
public override render(): null | Array<JSX.Element | string | null> {
|
||||
const {
|
||||
isInvisible,
|
||||
renderNonEmoji = defaultRenderNonEmoji,
|
||||
sizeClass,
|
||||
text,
|
||||
} = this.props;
|
||||
export function Emojify({
|
||||
isInvisible,
|
||||
renderNonEmoji = defaultRenderNonEmoji,
|
||||
sizeClass,
|
||||
text,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{splitByEmoji(text).map(({ type, value: match }, index) => {
|
||||
if (type === 'emoji') {
|
||||
return getImageTag({ isInvisible, match, sizeClass, key: index });
|
||||
}
|
||||
|
||||
return splitByEmoji(text).map(({ type, value: match }, index) => {
|
||||
if (type === 'emoji') {
|
||||
return getImageTag({ isInvisible, match, sizeClass, key: index });
|
||||
}
|
||||
if (type === 'text') {
|
||||
return renderNonEmoji({ text: match, key: index });
|
||||
}
|
||||
|
||||
if (type === 'text') {
|
||||
return renderNonEmoji({ text: match, key: index });
|
||||
}
|
||||
|
||||
throw missingCaseError(type);
|
||||
});
|
||||
}
|
||||
throw missingCaseError(type);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,9 +2,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { boolean, number } from '@storybook/addon-knobs';
|
||||
|
||||
import type { Props } from './ExpireTimer';
|
||||
import { ExpireTimer } from './ExpireTimer';
|
||||
|
||||
|
@ -14,23 +11,12 @@ export default {
|
|||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
direction: overrideProps.direction || 'outgoing',
|
||||
expirationLength: number(
|
||||
'expirationLength',
|
||||
overrideProps.expirationLength || 30 * 1000
|
||||
),
|
||||
expirationTimestamp: number(
|
||||
'expirationTimestamp',
|
||||
overrideProps.expirationTimestamp || Date.now() + 30 * 1000
|
||||
),
|
||||
withImageNoCaption: boolean(
|
||||
'withImageNoCaption',
|
||||
overrideProps.withImageNoCaption || false
|
||||
),
|
||||
withSticker: boolean('withSticker', overrideProps.withSticker || false),
|
||||
withTapToViewExpired: boolean(
|
||||
'withTapToViewExpired',
|
||||
overrideProps.withTapToViewExpired || false
|
||||
),
|
||||
expirationLength: overrideProps.expirationLength || 30 * 1000,
|
||||
expirationTimestamp:
|
||||
overrideProps.expirationTimestamp || Date.now() + 30 * 1000,
|
||||
withImageNoCaption: overrideProps.withImageNoCaption || false,
|
||||
withSticker: overrideProps.withSticker || false,
|
||||
withTapToViewExpired: overrideProps.withTapToViewExpired || false,
|
||||
});
|
||||
|
||||
export const _30Seconds = (): JSX.Element => {
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
// Copyright 2018 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { getIncrement, getTimerBucket } from '../../util/timer';
|
||||
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
|
||||
|
||||
export type Props = {
|
||||
deletedForEveryone?: boolean;
|
||||
|
@ -17,65 +16,43 @@ export type Props = {
|
|||
withTapToViewExpired?: boolean;
|
||||
};
|
||||
|
||||
export class ExpireTimer extends React.Component<Props> {
|
||||
private interval: NodeJS.Timeout | null;
|
||||
export function ExpireTimer({
|
||||
deletedForEveryone,
|
||||
direction,
|
||||
expirationLength,
|
||||
expirationTimestamp,
|
||||
withImageNoCaption,
|
||||
withSticker,
|
||||
withTapToViewExpired,
|
||||
}: Props): JSX.Element {
|
||||
const [, forceUpdate] = useReducer(() => ({}), {});
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
public override componentDidMount(): void {
|
||||
const { expirationLength } = this.props;
|
||||
useEffect(() => {
|
||||
const increment = getIncrement(expirationLength);
|
||||
const updateFrequency = Math.max(increment, 500);
|
||||
|
||||
const update = () => {
|
||||
this.setState({
|
||||
// Used to trigger renders
|
||||
// eslint-disable-next-line react/no-unused-state
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
const interval = setInterval(forceUpdate, updateFrequency);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
this.interval = setInterval(update, updateFrequency);
|
||||
}
|
||||
}, [expirationLength]);
|
||||
|
||||
public override componentWillUnmount(): void {
|
||||
clearTimeoutIfNecessary(this.interval);
|
||||
}
|
||||
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const {
|
||||
deletedForEveryone,
|
||||
direction,
|
||||
expirationLength,
|
||||
expirationTimestamp,
|
||||
withImageNoCaption,
|
||||
withSticker,
|
||||
withTapToViewExpired,
|
||||
} = this.props;
|
||||
|
||||
const bucket = getTimerBucket(expirationTimestamp, expirationLength);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-expire-timer',
|
||||
`module-expire-timer--${bucket}`,
|
||||
direction ? `module-expire-timer--${direction}` : null,
|
||||
deletedForEveryone
|
||||
? 'module-expire-timer--deleted-for-everyone'
|
||||
: null,
|
||||
withTapToViewExpired
|
||||
? `module-expire-timer--${direction}-with-tap-to-view-expired`
|
||||
: null,
|
||||
direction && withImageNoCaption
|
||||
? 'module-expire-timer--with-image-no-caption'
|
||||
: null,
|
||||
withSticker ? 'module-expire-timer--with-sticker' : null
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-expire-timer',
|
||||
`module-expire-timer--${bucket}`,
|
||||
direction ? `module-expire-timer--${direction}` : null,
|
||||
deletedForEveryone ? 'module-expire-timer--deleted-for-everyone' : null,
|
||||
withTapToViewExpired
|
||||
? `module-expire-timer--${direction}-with-tap-to-view-expired`
|
||||
: null,
|
||||
direction && withImageNoCaption
|
||||
? 'module-expire-timer--with-image-no-caption'
|
||||
: null,
|
||||
withSticker ? 'module-expire-timer--with-sticker' : null
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -32,146 +32,152 @@ type PropsHousekeeping = {
|
|||
|
||||
export type Props = PropsData & PropsHousekeeping;
|
||||
|
||||
export class GroupNotification extends React.Component<Props> {
|
||||
public renderChange(
|
||||
change: Change,
|
||||
from: ConversationType
|
||||
): JSX.Element | string | null | undefined {
|
||||
const { contacts, type, newName } = change;
|
||||
const { i18n } = this.props;
|
||||
function GroupNotificationChange({
|
||||
change,
|
||||
from,
|
||||
i18n,
|
||||
}: {
|
||||
change: Change;
|
||||
from: ConversationType;
|
||||
i18n: LocalizerType;
|
||||
}): JSX.Element | null {
|
||||
const { contacts, type, newName } = change;
|
||||
|
||||
const otherPeople: Array<JSX.Element> = compact(
|
||||
(contacts || []).map(contact => {
|
||||
if (contact.isMe) {
|
||||
return null;
|
||||
}
|
||||
const otherPeople: Array<JSX.Element> = compact(
|
||||
(contacts || []).map(contact => {
|
||||
if (contact.isMe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`external-${contact.id}`}
|
||||
className="module-group-notification__contact"
|
||||
>
|
||||
<ContactName title={contact.title} />
|
||||
</span>
|
||||
);
|
||||
})
|
||||
);
|
||||
const otherPeopleWithCommas: Array<JSX.Element | string> = compact(
|
||||
flatten(
|
||||
otherPeople.map((person, index) => [index > 0 ? ', ' : null, person])
|
||||
)
|
||||
);
|
||||
const contactsIncludesMe = (contacts || []).length !== otherPeople.length;
|
||||
return (
|
||||
<span
|
||||
key={`external-${contact.id}`}
|
||||
className="module-group-notification__contact"
|
||||
>
|
||||
<ContactName title={contact.title} />
|
||||
</span>
|
||||
);
|
||||
})
|
||||
);
|
||||
const otherPeopleWithCommas: Array<JSX.Element | string> = compact(
|
||||
flatten(
|
||||
otherPeople.map((person, index) => [index > 0 ? ', ' : null, person])
|
||||
)
|
||||
);
|
||||
const contactsIncludesMe = (contacts || []).length !== otherPeople.length;
|
||||
|
||||
switch (type) {
|
||||
case 'name':
|
||||
return (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:titleIsNow"
|
||||
components={{ name: newName || '' }}
|
||||
/>
|
||||
);
|
||||
case 'avatar':
|
||||
return <Intl i18n={i18n} id="icu:updatedGroupAvatar" />;
|
||||
case 'add':
|
||||
if (!contacts || !contacts.length) {
|
||||
throw new Error('Group update is missing contacts');
|
||||
}
|
||||
switch (type) {
|
||||
case 'name':
|
||||
return (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:titleIsNow"
|
||||
components={{ name: newName || '' }}
|
||||
/>
|
||||
);
|
||||
case 'avatar':
|
||||
return <Intl i18n={i18n} id="icu:updatedGroupAvatar" />;
|
||||
case 'add':
|
||||
if (!contacts || !contacts.length) {
|
||||
throw new Error('Group update is missing contacts');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{otherPeople.length > 0 && (
|
||||
<>
|
||||
{otherPeople.length === 1 ? (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:joinedTheGroup"
|
||||
components={{ name: otherPeopleWithCommas }}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:multipleJoinedTheGroup"
|
||||
components={{ names: otherPeopleWithCommas }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{contactsIncludesMe && (
|
||||
<div className="module-group-notification__change">
|
||||
<Intl i18n={i18n} id="icu:youJoinedTheGroup" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case 'remove':
|
||||
if (from && from.isMe) {
|
||||
return i18n('icu:youLeftTheGroup');
|
||||
}
|
||||
|
||||
if (!contacts || !contacts.length) {
|
||||
throw new Error('Group update is missing contacts');
|
||||
}
|
||||
|
||||
return contacts.length > 1 ? (
|
||||
<Intl
|
||||
id="icu:multipleLeftTheGroup"
|
||||
i18n={i18n}
|
||||
components={{ name: otherPeopleWithCommas }}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
id="icu:leftTheGroup"
|
||||
i18n={i18n}
|
||||
components={{ name: otherPeopleWithCommas }}
|
||||
/>
|
||||
);
|
||||
case 'general':
|
||||
return;
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
}
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const { changes: rawChanges, i18n, from } = this.props;
|
||||
|
||||
// This check is just to be extra careful, and can probably be removed.
|
||||
const changes: Array<Change> = Array.isArray(rawChanges) ? rawChanges : [];
|
||||
|
||||
// Leave messages are always from the person leaving, so we omit the fromLabel if
|
||||
// the change is a 'leave.'
|
||||
const firstChange: undefined | Change = changes[0];
|
||||
const isLeftOnly = changes.length === 1 && firstChange?.type === 'remove';
|
||||
|
||||
const fromLabel = from.isMe ? (
|
||||
<Intl i18n={i18n} id="icu:youUpdatedTheGroup" />
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:updatedTheGroup"
|
||||
components={{ name: <ContactName title={from.title} /> }}
|
||||
/>
|
||||
);
|
||||
|
||||
let contents: ReactNode;
|
||||
if (isLeftOnly) {
|
||||
contents = this.renderChange(firstChange, from);
|
||||
} else {
|
||||
contents = (
|
||||
return (
|
||||
<>
|
||||
<p>{fromLabel}</p>
|
||||
{changes.map((change, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<p key={i} className="module-group-notification__change">
|
||||
{this.renderChange(change, from)}
|
||||
</p>
|
||||
))}
|
||||
{otherPeople.length > 0 && (
|
||||
<>
|
||||
{otherPeople.length === 1 ? (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:joinedTheGroup"
|
||||
components={{ name: otherPeopleWithCommas }}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:multipleJoinedTheGroup"
|
||||
components={{ names: otherPeopleWithCommas }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{contactsIncludesMe && (
|
||||
<div className="module-group-notification__change">
|
||||
<Intl i18n={i18n} id="icu:youJoinedTheGroup" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'remove':
|
||||
if (from && from.isMe) {
|
||||
return <>{i18n('icu:youLeftTheGroup')}</>;
|
||||
}
|
||||
|
||||
return <SystemMessage contents={contents} icon="group" />;
|
||||
if (!contacts || !contacts.length) {
|
||||
throw new Error('Group update is missing contacts');
|
||||
}
|
||||
|
||||
return contacts.length > 1 ? (
|
||||
<Intl
|
||||
id="icu:multipleLeftTheGroup"
|
||||
i18n={i18n}
|
||||
components={{ name: otherPeopleWithCommas }}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
id="icu:leftTheGroup"
|
||||
i18n={i18n}
|
||||
components={{ name: otherPeopleWithCommas }}
|
||||
/>
|
||||
);
|
||||
case 'general':
|
||||
return null;
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
}
|
||||
|
||||
export function GroupNotification({
|
||||
changes: rawChanges,
|
||||
i18n,
|
||||
from,
|
||||
}: Props): JSX.Element {
|
||||
// This check is just to be extra careful, and can probably be removed.
|
||||
const changes: Array<Change> = Array.isArray(rawChanges) ? rawChanges : [];
|
||||
|
||||
// Leave messages are always from the person leaving, so we omit the fromLabel if
|
||||
// the change is a 'leave.'
|
||||
const firstChange: undefined | Change = changes[0];
|
||||
const isLeftOnly = changes.length === 1 && firstChange?.type === 'remove';
|
||||
|
||||
const fromLabel = from.isMe ? (
|
||||
<Intl i18n={i18n} id="icu:youUpdatedTheGroup" />
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:updatedTheGroup"
|
||||
components={{ name: <ContactName title={from.title} /> }}
|
||||
/>
|
||||
);
|
||||
|
||||
let contents: ReactNode;
|
||||
if (isLeftOnly) {
|
||||
contents = (
|
||||
<GroupNotificationChange change={firstChange} from={from} i18n={i18n} />
|
||||
);
|
||||
} else {
|
||||
contents = (
|
||||
<>
|
||||
<p>{fromLabel}</p>
|
||||
{changes.map((change, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<p key={i} className="module-group-notification__change">
|
||||
<GroupNotificationChange change={change} from={from} i18n={i18n} />
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <SystemMessage contents={contents} icon="group" />;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { boolean, number, text } from '@storybook/addon-knobs';
|
||||
|
||||
import { pngUrl } from '../../storybook/Fixtures';
|
||||
import type { Props } from './Image';
|
||||
|
@ -24,7 +23,7 @@ export default {
|
|||
};
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
alt: text('alt', overrideProps.alt || ''),
|
||||
alt: overrideProps.alt || '',
|
||||
attachment:
|
||||
overrideProps.attachment ||
|
||||
fakeAttachment({
|
||||
|
@ -32,42 +31,27 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
fileName: 'sax.png',
|
||||
url: pngUrl,
|
||||
}),
|
||||
blurHash: text('blurHash', overrideProps.blurHash || ''),
|
||||
bottomOverlay: boolean('bottomOverlay', overrideProps.bottomOverlay || false),
|
||||
closeButton: boolean('closeButton', overrideProps.closeButton || false),
|
||||
curveBottomLeft: number(
|
||||
'curveBottomLeft',
|
||||
overrideProps.curveBottomLeft || CurveType.None
|
||||
),
|
||||
curveBottomRight: number(
|
||||
'curveBottomRight',
|
||||
overrideProps.curveBottomRight || CurveType.None
|
||||
),
|
||||
curveTopLeft: number(
|
||||
'curveTopLeft',
|
||||
overrideProps.curveTopLeft || CurveType.None
|
||||
),
|
||||
curveTopRight: number(
|
||||
'curveTopRight',
|
||||
overrideProps.curveTopRight || CurveType.None
|
||||
),
|
||||
darkOverlay: boolean('darkOverlay', overrideProps.darkOverlay || false),
|
||||
height: number('height', overrideProps.height || 100),
|
||||
blurHash: overrideProps.blurHash || '',
|
||||
bottomOverlay: overrideProps.bottomOverlay || false,
|
||||
closeButton: overrideProps.closeButton || false,
|
||||
curveBottomLeft: overrideProps.curveBottomLeft || CurveType.None,
|
||||
curveBottomRight: overrideProps.curveBottomRight || CurveType.None,
|
||||
curveTopLeft: overrideProps.curveTopLeft || CurveType.None,
|
||||
curveTopRight: overrideProps.curveTopRight || CurveType.None,
|
||||
darkOverlay: overrideProps.darkOverlay || false,
|
||||
height: overrideProps.height || 100,
|
||||
i18n,
|
||||
noBackground: boolean('noBackground', overrideProps.noBackground || false),
|
||||
noBorder: boolean('noBorder', overrideProps.noBorder || false),
|
||||
noBackground: overrideProps.noBackground || false,
|
||||
noBorder: overrideProps.noBorder || false,
|
||||
onClick: action('onClick'),
|
||||
onClickClose: action('onClickClose'),
|
||||
onError: action('onError'),
|
||||
overlayText: text('overlayText', overrideProps.overlayText || ''),
|
||||
playIconOverlay: boolean(
|
||||
'playIconOverlay',
|
||||
overrideProps.playIconOverlay || false
|
||||
),
|
||||
tabIndex: number('tabIndex', overrideProps.tabIndex || 0),
|
||||
theme: text('theme', overrideProps.theme || 'light') as ThemeType,
|
||||
url: text('url', 'url' in overrideProps ? overrideProps.url || '' : pngUrl),
|
||||
width: number('width', overrideProps.width || 100),
|
||||
overlayText: overrideProps.overlayText || '',
|
||||
playIconOverlay: overrideProps.playIconOverlay || false,
|
||||
tabIndex: overrideProps.tabIndex || 0,
|
||||
theme: overrideProps.theme || ('light' as ThemeType),
|
||||
url: 'url' in overrideProps ? overrideProps.url || '' : pngUrl,
|
||||
width: overrideProps.width || 100,
|
||||
});
|
||||
|
||||
export function UrlWithHeightWidth(): JSX.Element {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2018 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
|
||||
|
@ -55,237 +55,220 @@ export type Props = {
|
|||
onError?: () => void;
|
||||
};
|
||||
|
||||
export class Image extends React.Component<Props> {
|
||||
private canClick() {
|
||||
const { onClick, attachment } = this.props;
|
||||
const { pending } = attachment || { pending: true };
|
||||
export function Image({
|
||||
alt,
|
||||
attachment,
|
||||
blurHash,
|
||||
bottomOverlay,
|
||||
className,
|
||||
closeButton,
|
||||
curveBottomLeft,
|
||||
curveBottomRight,
|
||||
curveTopLeft,
|
||||
curveTopRight,
|
||||
darkOverlay,
|
||||
isDownloaded,
|
||||
height = 0,
|
||||
i18n,
|
||||
noBackground,
|
||||
noBorder,
|
||||
onClick,
|
||||
onClickClose,
|
||||
onError,
|
||||
overlayText,
|
||||
playIconOverlay,
|
||||
tabIndex,
|
||||
theme,
|
||||
url,
|
||||
width = 0,
|
||||
cropWidth = 0,
|
||||
cropHeight = 0,
|
||||
}: Props): JSX.Element {
|
||||
const { caption, pending } = attachment || { caption: null, pending: true };
|
||||
const imgNotDownloaded = isDownloaded
|
||||
? false
|
||||
: !isDownloadedFunction(attachment);
|
||||
|
||||
return Boolean(onClick && !pending);
|
||||
}
|
||||
const resolvedBlurHash = blurHash || defaultBlurHash(theme);
|
||||
|
||||
public handleClick = (event: React.MouseEvent): void => {
|
||||
if (!this.canClick()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { onClick, attachment } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
onClick(attachment);
|
||||
}
|
||||
const curveStyles = {
|
||||
borderTopLeftRadius: curveTopLeft || CurveType.None,
|
||||
borderTopRightRadius: curveTopRight || CurveType.None,
|
||||
borderBottomLeftRadius: curveBottomLeft || CurveType.None,
|
||||
borderBottomRightRadius: curveBottomRight || CurveType.None,
|
||||
};
|
||||
|
||||
public handleKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLButtonElement>
|
||||
): void => {
|
||||
if (!this.canClick()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const canClick = useMemo(() => {
|
||||
return onClick != null && !pending;
|
||||
}, [pending, onClick]);
|
||||
|
||||
return;
|
||||
}
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
if (!canClick) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { onClick, attachment } = this.props;
|
||||
return;
|
||||
}
|
||||
|
||||
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick(attachment);
|
||||
}
|
||||
};
|
||||
if (onClick) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
public renderPending = (): JSX.Element => {
|
||||
const { blurHash, height, i18n, width } = this.props;
|
||||
onClick(attachment);
|
||||
}
|
||||
},
|
||||
[attachment, canClick, onClick]
|
||||
);
|
||||
|
||||
if (blurHash) {
|
||||
return (
|
||||
<div className="module-image__download-pending">
|
||||
<Blurhash
|
||||
hash={blurHash}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
<div className="module-image__download-pending--spinner-container">
|
||||
<div
|
||||
className="module-image__download-pending--spinner"
|
||||
title={i18n('icu:loading')}
|
||||
>
|
||||
<Spinner moduleClassName="module-image-spinner" svgSize="small" />
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (!canClick) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick(attachment);
|
||||
}
|
||||
},
|
||||
[attachment, canClick, onClick]
|
||||
);
|
||||
|
||||
/* eslint-disable no-nested-ternary */
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-image',
|
||||
className,
|
||||
!noBackground ? 'module-image--with-background' : null,
|
||||
cropWidth || cropHeight ? 'module-image--cropped' : null
|
||||
)}
|
||||
style={{
|
||||
width: width - cropWidth,
|
||||
height: height - cropHeight,
|
||||
...curveStyles,
|
||||
}}
|
||||
>
|
||||
{pending ? (
|
||||
blurHash ? (
|
||||
<div className="module-image__download-pending">
|
||||
<Blurhash
|
||||
hash={blurHash}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
<div className="module-image__download-pending--spinner-container">
|
||||
<div
|
||||
className="module-image__download-pending--spinner"
|
||||
title={i18n('icu:loading')}
|
||||
>
|
||||
<Spinner
|
||||
moduleClassName="module-image-spinner"
|
||||
svgSize="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="module-image__loading-placeholder"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${width}px`,
|
||||
lineHeight: `${height}px`,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
title={i18n('icu:loading')}
|
||||
>
|
||||
<Spinner svgSize="normal" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const {
|
||||
alt,
|
||||
attachment,
|
||||
blurHash,
|
||||
bottomOverlay,
|
||||
className,
|
||||
closeButton,
|
||||
curveBottomLeft,
|
||||
curveBottomRight,
|
||||
curveTopLeft,
|
||||
curveTopRight,
|
||||
darkOverlay,
|
||||
isDownloaded,
|
||||
height = 0,
|
||||
i18n,
|
||||
noBackground,
|
||||
noBorder,
|
||||
onClickClose,
|
||||
onError,
|
||||
overlayText,
|
||||
playIconOverlay,
|
||||
tabIndex,
|
||||
theme,
|
||||
url,
|
||||
width = 0,
|
||||
cropWidth = 0,
|
||||
cropHeight = 0,
|
||||
} = this.props;
|
||||
|
||||
const { caption, pending } = attachment || { caption: null, pending: true };
|
||||
const canClick = this.canClick();
|
||||
const imgNotDownloaded = isDownloaded
|
||||
? false
|
||||
: !isDownloadedFunction(attachment);
|
||||
|
||||
const resolvedBlurHash = blurHash || defaultBlurHash(theme);
|
||||
|
||||
const curveStyles = {
|
||||
borderTopLeftRadius: curveTopLeft || CurveType.None,
|
||||
borderTopRightRadius: curveTopRight || CurveType.None,
|
||||
borderBottomLeftRadius: curveBottomLeft || CurveType.None,
|
||||
borderBottomRightRadius: curveBottomRight || CurveType.None,
|
||||
};
|
||||
|
||||
const overlay = canClick ? (
|
||||
// Not sure what this button does.
|
||||
<button
|
||||
type="button"
|
||||
className={classNames('module-image__border-overlay', {
|
||||
'module-image__border-overlay--with-border': !noBorder,
|
||||
'module-image__border-overlay--with-click-handler': canClick,
|
||||
'module-image__border-overlay--dark': darkOverlay,
|
||||
'module-image--not-downloaded': imgNotDownloaded,
|
||||
})}
|
||||
style={curveStyles}
|
||||
onClick={this.handleClick}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{imgNotDownloaded ? <span /> : null}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
/* eslint-disable no-nested-ternary */
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-image',
|
||||
className,
|
||||
!noBackground ? 'module-image--with-background' : null,
|
||||
cropWidth || cropHeight ? 'module-image--cropped' : null
|
||||
)}
|
||||
style={{
|
||||
width: width - cropWidth,
|
||||
height: height - cropHeight,
|
||||
...curveStyles,
|
||||
}}
|
||||
>
|
||||
{pending ? (
|
||||
this.renderPending()
|
||||
) : url ? (
|
||||
<img
|
||||
onError={onError}
|
||||
className="module-image__image"
|
||||
alt={alt}
|
||||
height={height}
|
||||
width={width}
|
||||
src={url}
|
||||
/>
|
||||
) : resolvedBlurHash ? (
|
||||
<Blurhash
|
||||
hash={resolvedBlurHash}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
) : null}
|
||||
{caption ? (
|
||||
<img
|
||||
className="module-image__caption-icon"
|
||||
src="images/caption-shadow.svg"
|
||||
alt={i18n('icu:imageCaptionIconAlt')}
|
||||
/>
|
||||
) : null}
|
||||
{bottomOverlay ? (
|
||||
) : (
|
||||
<div
|
||||
className="module-image__bottom-overlay"
|
||||
className="module-image__loading-placeholder"
|
||||
style={{
|
||||
borderBottomLeftRadius: curveBottomLeft || CurveType.None,
|
||||
borderBottomRightRadius: curveBottomRight || CurveType.None,
|
||||
height: `${height}px`,
|
||||
width: `${width}px`,
|
||||
lineHeight: `${height}px`,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!pending && !imgNotDownloaded && playIconOverlay ? (
|
||||
<div className="module-image__play-overlay__circle">
|
||||
<div className="module-image__play-overlay__icon" />
|
||||
</div>
|
||||
) : null}
|
||||
{overlayText ? (
|
||||
<div
|
||||
className="module-image__text-container"
|
||||
style={{ lineHeight: `${height}px` }}
|
||||
title={i18n('icu:loading')}
|
||||
>
|
||||
{overlayText}
|
||||
<Spinner svgSize="normal" />
|
||||
</div>
|
||||
) : null}
|
||||
{overlay}
|
||||
{closeButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
)
|
||||
) : url ? (
|
||||
<img
|
||||
onError={onError}
|
||||
className="module-image__image"
|
||||
alt={alt}
|
||||
height={height}
|
||||
width={width}
|
||||
src={url}
|
||||
/>
|
||||
) : resolvedBlurHash ? (
|
||||
<Blurhash
|
||||
hash={resolvedBlurHash}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
) : null}
|
||||
{caption ? (
|
||||
<img
|
||||
className="module-image__caption-icon"
|
||||
src="images/caption-shadow.svg"
|
||||
alt={i18n('icu:imageCaptionIconAlt')}
|
||||
/>
|
||||
) : null}
|
||||
{bottomOverlay ? (
|
||||
<div
|
||||
className="module-image__bottom-overlay"
|
||||
style={{
|
||||
borderBottomLeftRadius: curveBottomLeft || CurveType.None,
|
||||
borderBottomRightRadius: curveBottomRight || CurveType.None,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!pending && !imgNotDownloaded && playIconOverlay ? (
|
||||
<div className="module-image__play-overlay__circle">
|
||||
<div className="module-image__play-overlay__icon" />
|
||||
</div>
|
||||
) : null}
|
||||
{overlayText ? (
|
||||
<div
|
||||
className="module-image__text-container"
|
||||
style={{ lineHeight: `${height}px` }}
|
||||
>
|
||||
{overlayText}
|
||||
</div>
|
||||
) : null}
|
||||
{canClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className={classNames('module-image__border-overlay', {
|
||||
'module-image__border-overlay--with-border': !noBorder,
|
||||
'module-image__border-overlay--with-click-handler': canClick,
|
||||
'module-image__border-overlay--dark': darkOverlay,
|
||||
'module-image--not-downloaded': imgNotDownloaded,
|
||||
})}
|
||||
style={curveStyles}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{imgNotDownloaded ? <span /> : null}
|
||||
</button>
|
||||
) : null}
|
||||
{closeButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (onClickClose) {
|
||||
onClickClose(attachment);
|
||||
}
|
||||
}}
|
||||
className="module-image__close-button"
|
||||
title={i18n('icu:remove-attachment')}
|
||||
aria-label={i18n('icu:remove-attachment')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
/* eslint-enable no-nested-ternary */
|
||||
}
|
||||
if (onClickClose) {
|
||||
onClickClose(attachment);
|
||||
}
|
||||
}}
|
||||
className="module-image__close-button"
|
||||
title={i18n('icu:remove-attachment')}
|
||||
aria-label={i18n('icu:remove-attachment')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
/* eslint-enable no-nested-ternary */
|
||||
}
|
||||
|
|
|
@ -1,69 +1,51 @@
|
|||
// Copyright 2019 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { getInteractionMode } from '../../services/InteractionMode';
|
||||
|
||||
export type PropsType = {
|
||||
type PropsType = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
isTargeted: boolean;
|
||||
targetMessage?: (messageId: string, conversationId: string) => unknown;
|
||||
targetMessage: (messageId: string, conversationId: string) => unknown;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export class InlineNotificationWrapper extends React.Component<PropsType> {
|
||||
public focusRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
export function InlineNotificationWrapper({
|
||||
id,
|
||||
conversationId,
|
||||
isTargeted,
|
||||
targetMessage,
|
||||
children,
|
||||
}: PropsType): JSX.Element {
|
||||
const focusRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
public setFocus = (): void => {
|
||||
const container = this.focusRef.current;
|
||||
|
||||
if (container && !container.contains(document.activeElement)) {
|
||||
container.focus();
|
||||
useEffect(() => {
|
||||
if (isTargeted) {
|
||||
const container = focusRef.current;
|
||||
if (container && !container.contains(document.activeElement)) {
|
||||
container.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [isTargeted]);
|
||||
|
||||
public handleFocus = (): void => {
|
||||
const handleFocus = useCallback(() => {
|
||||
if (getInteractionMode() === 'keyboard') {
|
||||
this.setTargeted();
|
||||
}
|
||||
};
|
||||
|
||||
public setTargeted = (): void => {
|
||||
const { id, conversationId, targetMessage } = this.props;
|
||||
|
||||
if (targetMessage) {
|
||||
targetMessage(id, conversationId);
|
||||
}
|
||||
};
|
||||
}, [id, conversationId, targetMessage]);
|
||||
|
||||
public override componentDidMount(): void {
|
||||
const { isTargeted } = this.props;
|
||||
if (isTargeted) {
|
||||
this.setFocus();
|
||||
}
|
||||
}
|
||||
|
||||
public override componentDidUpdate(prevProps: PropsType): void {
|
||||
const { isTargeted } = this.props;
|
||||
|
||||
if (!prevProps.isTargeted && isTargeted) {
|
||||
this.setFocus();
|
||||
}
|
||||
}
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const { children } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="module-inline-notification-wrapper"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
ref={this.focusRef}
|
||||
onFocus={this.handleFocus}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="module-inline-notification-wrapper"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
ref={focusRef}
|
||||
onFocus={handleFocus}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,8 +3,6 @@
|
|||
|
||||
import * as React from 'react';
|
||||
|
||||
import { text } from '@storybook/addon-knobs';
|
||||
|
||||
import type { Props } from './Linkify';
|
||||
import { Linkify } from './Linkify';
|
||||
|
||||
|
@ -14,7 +12,7 @@ export default {
|
|||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
renderNonLink: overrideProps.renderNonLink,
|
||||
text: text('text', overrideProps.text || ''),
|
||||
text: overrideProps.text || '',
|
||||
});
|
||||
|
||||
export function OnlyLink(): JSX.Element {
|
||||
|
|
|
@ -323,77 +323,71 @@ export const SUPPORTED_PROTOCOLS = /^(http|https):/i;
|
|||
|
||||
const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text;
|
||||
|
||||
export class Linkify extends React.Component<Props> {
|
||||
public override render():
|
||||
| JSX.Element
|
||||
| string
|
||||
| null
|
||||
| Array<JSX.Element | string | null> {
|
||||
const { text, renderNonLink = defaultRenderNonLink } = this.props;
|
||||
export function Linkify(props: Props): JSX.Element {
|
||||
const { text, renderNonLink = defaultRenderNonLink } = props;
|
||||
|
||||
if (!shouldLinkifyMessage(text)) {
|
||||
return renderNonLink({ text, key: 1 });
|
||||
if (!shouldLinkifyMessage(text)) {
|
||||
return <>{renderNonLink({ text, key: 1 })}</>;
|
||||
}
|
||||
|
||||
const chunkData: Array<{
|
||||
chunk: string;
|
||||
matchData: ReadonlyArray<LinkifyIt.Match>;
|
||||
}> = splitByEmoji(text).map(({ type, value: chunk }) => {
|
||||
if (type === 'text') {
|
||||
return { chunk, matchData: linkify.match(chunk) || [] };
|
||||
}
|
||||
|
||||
const chunkData: Array<{
|
||||
chunk: string;
|
||||
matchData: ReadonlyArray<LinkifyIt.Match>;
|
||||
}> = splitByEmoji(text).map(({ type, value: chunk }) => {
|
||||
if (type === 'text') {
|
||||
return { chunk, matchData: linkify.match(chunk) || [] };
|
||||
if (type === 'emoji') {
|
||||
return { chunk, matchData: [] };
|
||||
}
|
||||
|
||||
throw missingCaseError(type);
|
||||
});
|
||||
|
||||
const results: Array<JSX.Element | string> = [];
|
||||
let count = 1;
|
||||
|
||||
chunkData.forEach(({ chunk, matchData }) => {
|
||||
if (matchData.length === 0) {
|
||||
count += 1;
|
||||
results.push(renderNonLink({ text: chunk, key: count }));
|
||||
return;
|
||||
}
|
||||
|
||||
let chunkLastIndex = 0;
|
||||
matchData.forEach(match => {
|
||||
if (chunkLastIndex < match.index) {
|
||||
const textWithNoLink = chunk.slice(chunkLastIndex, match.index);
|
||||
count += 1;
|
||||
results.push(renderNonLink({ text: textWithNoLink, key: count }));
|
||||
}
|
||||
|
||||
if (type === 'emoji') {
|
||||
return { chunk, matchData: [] };
|
||||
}
|
||||
|
||||
throw missingCaseError(type);
|
||||
});
|
||||
|
||||
const results: Array<JSX.Element | string> = [];
|
||||
let count = 1;
|
||||
|
||||
chunkData.forEach(({ chunk, matchData }) => {
|
||||
if (matchData.length === 0) {
|
||||
count += 1;
|
||||
results.push(renderNonLink({ text: chunk, key: count }));
|
||||
return;
|
||||
}
|
||||
|
||||
let chunkLastIndex = 0;
|
||||
matchData.forEach(match => {
|
||||
if (chunkLastIndex < match.index) {
|
||||
const textWithNoLink = chunk.slice(chunkLastIndex, match.index);
|
||||
count += 1;
|
||||
results.push(renderNonLink({ text: textWithNoLink, key: count }));
|
||||
}
|
||||
|
||||
const { url, text: originalText } = match;
|
||||
count += 1;
|
||||
if (SUPPORTED_PROTOCOLS.test(url) && !isLinkSneaky(url)) {
|
||||
results.push(
|
||||
<a key={count} href={url}>
|
||||
{originalText}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
results.push(renderNonLink({ text: originalText, key: count }));
|
||||
}
|
||||
|
||||
chunkLastIndex = match.lastIndex;
|
||||
});
|
||||
|
||||
if (chunkLastIndex < chunk.length) {
|
||||
count += 1;
|
||||
const { url, text: originalText } = match;
|
||||
count += 1;
|
||||
if (SUPPORTED_PROTOCOLS.test(url) && !isLinkSneaky(url)) {
|
||||
results.push(
|
||||
renderNonLink({
|
||||
text: chunk.slice(chunkLastIndex),
|
||||
key: count,
|
||||
})
|
||||
<a key={count} href={url}>
|
||||
{originalText}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
results.push(renderNonLink({ text: originalText, key: count }));
|
||||
}
|
||||
|
||||
chunkLastIndex = match.lastIndex;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
if (chunkLastIndex < chunk.length) {
|
||||
count += 1;
|
||||
results.push(
|
||||
renderNonLink({
|
||||
text: chunk.slice(chunkLastIndex),
|
||||
key: count,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return <>{results}</>;
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReactChild, ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
|
@ -106,22 +106,54 @@ const _keyForError = (error: Error): string => {
|
|||
return `${error.name}-${error.message}`;
|
||||
};
|
||||
|
||||
export class MessageDetail extends React.Component<Props> {
|
||||
private readonly focusRef = React.createRef<HTMLDivElement>();
|
||||
private readonly messageContainerRef = React.createRef<HTMLDivElement>();
|
||||
export function MessageDetail({
|
||||
contacts,
|
||||
errors,
|
||||
message,
|
||||
receivedAt,
|
||||
sentAt,
|
||||
checkForAccount,
|
||||
clearTargetedMessage,
|
||||
contactNameColor,
|
||||
doubleCheckMissingQuoteReference,
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
interactionMode,
|
||||
kickOffAttachmentDownload,
|
||||
markAttachmentAsCorrupted,
|
||||
messageExpanded,
|
||||
openGiftBadge,
|
||||
platform,
|
||||
pushPanelForConversation,
|
||||
renderAudioAttachment,
|
||||
saveAttachment,
|
||||
showContactModal,
|
||||
showConversation,
|
||||
showExpiredIncomingTapToViewToast,
|
||||
showExpiredOutgoingTapToViewToast,
|
||||
showLightbox,
|
||||
showLightboxForViewOnceMedia,
|
||||
showSpoiler,
|
||||
startConversation,
|
||||
theme,
|
||||
toggleSafetyNumberModal,
|
||||
viewStory,
|
||||
}: Props): JSX.Element {
|
||||
const focusRef = useRef<HTMLDivElement>(null);
|
||||
const messageContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
public override componentDidMount(): void {
|
||||
// When this component is created, it's initially not part of the DOM, and then it's
|
||||
// added off-screen and animated in. This ensures that the focus takes.
|
||||
setTimeout(() => {
|
||||
if (this.focusRef.current) {
|
||||
this.focusRef.current.focus();
|
||||
}
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
// When this component is created, it's initially not part of the DOM, and then it's
|
||||
// added off-screen and animated in. This ensures that the focus takes.
|
||||
focusRef.current?.focus();
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
public renderAvatar(contact: Contact): JSX.Element {
|
||||
const { getPreferredBadge, i18n, theme } = this.props;
|
||||
function renderAvatar(contact: Contact): JSX.Element {
|
||||
const {
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
|
@ -155,9 +187,8 @@ export class MessageDetail extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
|
||||
public renderContact(contact: Contact): JSX.Element {
|
||||
const { i18n, toggleSafetyNumberModal } = this.props;
|
||||
const errors = contact.errors || [];
|
||||
function renderContact(contact: Contact): JSX.Element {
|
||||
const contactErrors = contact.errors || [];
|
||||
|
||||
const errorComponent = contact.isOutgoingKeyError ? (
|
||||
<div className="module-message-detail__contact__error-buttons">
|
||||
|
@ -176,17 +207,17 @@ export class MessageDetail extends React.Component<Props> {
|
|||
|
||||
return (
|
||||
<div key={contact.id} className="module-message-detail__contact">
|
||||
{this.renderAvatar(contact)}
|
||||
{renderAvatar(contact)}
|
||||
<div className="module-message-detail__contact__text">
|
||||
<div className="module-message-detail__contact__name">
|
||||
<ContactName title={contact.title} />
|
||||
</div>
|
||||
{errors.map(error => (
|
||||
{contactErrors.map(contactError => (
|
||||
<div
|
||||
key={_keyForError(error)}
|
||||
key={_keyForError(contactError)}
|
||||
className="module-message-detail__contact__error"
|
||||
>
|
||||
{error.message}
|
||||
{contactError.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -204,11 +235,9 @@ export class MessageDetail extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
|
||||
private renderContactGroupHeaderText(
|
||||
function renderContactGroupHeaderText(
|
||||
sendStatus: undefined | SendStatus
|
||||
): string {
|
||||
const { i18n } = this.props;
|
||||
|
||||
if (sendStatus === undefined) {
|
||||
return i18n('icu:from');
|
||||
}
|
||||
|
@ -231,19 +260,19 @@ export class MessageDetail extends React.Component<Props> {
|
|||
}
|
||||
}
|
||||
|
||||
private renderContactGroup(
|
||||
function renderContactGroup(
|
||||
sendStatus: undefined | SendStatus,
|
||||
contacts: undefined | ReadonlyArray<Contact>
|
||||
statusContacts: undefined | ReadonlyArray<Contact>
|
||||
): ReactNode {
|
||||
if (!contacts || !contacts.length) {
|
||||
if (!statusContacts || !statusContacts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sortedContacts = [...contacts].sort((a, b) =>
|
||||
const sortedContacts = [...statusContacts].sort((a, b) =>
|
||||
contactSortCollator.compare(a.title, b.title)
|
||||
);
|
||||
|
||||
const headerText = this.renderContactGroupHeaderText(sendStatus);
|
||||
const headerText = renderContactGroupHeaderText(sendStatus);
|
||||
|
||||
return (
|
||||
<div key={headerText} className="module-message-detail__contact-group">
|
||||
|
@ -256,16 +285,14 @@ export class MessageDetail extends React.Component<Props> {
|
|||
>
|
||||
{headerText}
|
||||
</div>
|
||||
{sortedContacts.map(contact => this.renderContact(contact))}
|
||||
{sortedContacts.map(contact => renderContact(contact))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderContacts(): ReactChild {
|
||||
function renderContacts(): ReactChild {
|
||||
// This assumes that the list either contains one sender (a status of `undefined`) or
|
||||
// 1+ contacts with `SendStatus`es, but it doesn't check that assumption.
|
||||
const { contacts } = this.props;
|
||||
|
||||
const contactsBySendStatus = groupBy(contacts, contact => contact.status);
|
||||
|
||||
return (
|
||||
|
@ -279,181 +306,135 @@ export class MessageDetail extends React.Component<Props> {
|
|||
SendStatus.Sent,
|
||||
SendStatus.Pending,
|
||||
].map(sendStatus =>
|
||||
this.renderContactGroup(
|
||||
sendStatus,
|
||||
contactsBySendStatus.get(sendStatus)
|
||||
)
|
||||
renderContactGroup(sendStatus, contactsBySendStatus.get(sendStatus))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const {
|
||||
errors,
|
||||
message,
|
||||
receivedAt,
|
||||
sentAt,
|
||||
const timeRemaining = message.expirationTimestamp
|
||||
? DurationInSeconds.fromMillis(message.expirationTimestamp - Date.now())
|
||||
: undefined;
|
||||
|
||||
checkForAccount,
|
||||
clearTargetedMessage,
|
||||
contactNameColor,
|
||||
doubleCheckMissingQuoteReference,
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
interactionMode,
|
||||
kickOffAttachmentDownload,
|
||||
markAttachmentAsCorrupted,
|
||||
messageExpanded,
|
||||
openGiftBadge,
|
||||
platform,
|
||||
pushPanelForConversation,
|
||||
renderAudioAttachment,
|
||||
saveAttachment,
|
||||
showContactModal,
|
||||
showConversation,
|
||||
showExpiredIncomingTapToViewToast,
|
||||
showExpiredOutgoingTapToViewToast,
|
||||
showLightbox,
|
||||
showLightboxForViewOnceMedia,
|
||||
showSpoiler,
|
||||
startConversation,
|
||||
theme,
|
||||
viewStory,
|
||||
} = this.props;
|
||||
|
||||
const timeRemaining = message.expirationTimestamp
|
||||
? DurationInSeconds.fromMillis(message.expirationTimestamp - Date.now())
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
<div className="module-message-detail" tabIndex={0} ref={this.focusRef}>
|
||||
<div
|
||||
className="module-message-detail__message-container"
|
||||
ref={this.messageContainerRef}
|
||||
>
|
||||
<Message
|
||||
{...message}
|
||||
renderingContext="conversation/MessageDetail"
|
||||
checkForAccount={checkForAccount}
|
||||
clearTargetedMessage={clearTargetedMessage}
|
||||
contactNameColor={contactNameColor}
|
||||
containerElementRef={this.messageContainerRef}
|
||||
containerWidthBreakpoint={WidthBreakpoint.Wide}
|
||||
renderMenu={undefined}
|
||||
disableScroll
|
||||
displayLimit={Number.MAX_SAFE_INTEGER}
|
||||
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
|
||||
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
interactionMode={interactionMode}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
|
||||
messageExpanded={messageExpanded}
|
||||
openGiftBadge={openGiftBadge}
|
||||
platform={platform}
|
||||
pushPanelForConversation={pushPanelForConversation}
|
||||
renderAudioAttachment={renderAudioAttachment}
|
||||
saveAttachment={saveAttachment}
|
||||
shouldCollapseAbove={false}
|
||||
shouldCollapseBelow={false}
|
||||
shouldHideMetadata={false}
|
||||
showConversation={showConversation}
|
||||
showSpoiler={showSpoiler}
|
||||
scrollToQuotedMessage={() => {
|
||||
log.warn('MessageDetail: scrollToQuotedMessage called!');
|
||||
}}
|
||||
showContactModal={showContactModal}
|
||||
showExpiredIncomingTapToViewToast={
|
||||
showExpiredIncomingTapToViewToast
|
||||
}
|
||||
showExpiredOutgoingTapToViewToast={
|
||||
showExpiredOutgoingTapToViewToast
|
||||
}
|
||||
showLightbox={showLightbox}
|
||||
startConversation={startConversation}
|
||||
theme={theme}
|
||||
viewStory={viewStory}
|
||||
onToggleSelect={noop}
|
||||
onReplyToMessage={noop}
|
||||
/>
|
||||
</div>
|
||||
<table className="module-message-detail__info">
|
||||
<tbody>
|
||||
{(errors || []).map(error => (
|
||||
<tr key={_keyForError(error)}>
|
||||
<td className="module-message-detail__label">
|
||||
{i18n('icu:error')}
|
||||
</td>
|
||||
<td>
|
||||
{' '}
|
||||
<span className="error-message">{error.message}</span>{' '}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
<div className="module-message-detail" tabIndex={0} ref={focusRef}>
|
||||
<div
|
||||
className="module-message-detail__message-container"
|
||||
ref={messageContainerRef}
|
||||
>
|
||||
<Message
|
||||
{...message}
|
||||
renderingContext="conversation/MessageDetail"
|
||||
checkForAccount={checkForAccount}
|
||||
clearTargetedMessage={clearTargetedMessage}
|
||||
contactNameColor={contactNameColor}
|
||||
containerElementRef={messageContainerRef}
|
||||
containerWidthBreakpoint={WidthBreakpoint.Wide}
|
||||
renderMenu={undefined}
|
||||
disableScroll
|
||||
displayLimit={Number.MAX_SAFE_INTEGER}
|
||||
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
|
||||
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
interactionMode={interactionMode}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
|
||||
messageExpanded={messageExpanded}
|
||||
openGiftBadge={openGiftBadge}
|
||||
platform={platform}
|
||||
pushPanelForConversation={pushPanelForConversation}
|
||||
renderAudioAttachment={renderAudioAttachment}
|
||||
saveAttachment={saveAttachment}
|
||||
shouldCollapseAbove={false}
|
||||
shouldCollapseBelow={false}
|
||||
shouldHideMetadata={false}
|
||||
showConversation={showConversation}
|
||||
showSpoiler={showSpoiler}
|
||||
scrollToQuotedMessage={() => {
|
||||
log.warn('MessageDetail: scrollToQuotedMessage called!');
|
||||
}}
|
||||
showContactModal={showContactModal}
|
||||
showExpiredIncomingTapToViewToast={showExpiredIncomingTapToViewToast}
|
||||
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
|
||||
showLightbox={showLightbox}
|
||||
startConversation={startConversation}
|
||||
theme={theme}
|
||||
viewStory={viewStory}
|
||||
onToggleSelect={noop}
|
||||
onReplyToMessage={noop}
|
||||
/>
|
||||
</div>
|
||||
<table className="module-message-detail__info">
|
||||
<tbody>
|
||||
{(errors || []).map(error => (
|
||||
<tr key={_keyForError(error)}>
|
||||
<td className="module-message-detail__label">
|
||||
{i18n('icu:sent')}
|
||||
{i18n('icu:error')}
|
||||
</td>
|
||||
<td>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'StoryDetailsModal__copy-icon',
|
||||
label: i18n('icu:StoryDetailsModal__copy-timestamp'),
|
||||
onClick: () => {
|
||||
void window.navigator.clipboard.writeText(
|
||||
String(sentAt)
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<>
|
||||
<Time timestamp={sentAt}>
|
||||
{formatDateTimeLong(i18n, sentAt)}
|
||||
</Time>{' '}
|
||||
<span className="module-message-detail__unix-timestamp">
|
||||
({sentAt})
|
||||
</span>
|
||||
</>
|
||||
</ContextMenu>
|
||||
{' '}
|
||||
<span className="error-message">{error.message}</span>{' '}
|
||||
</td>
|
||||
</tr>
|
||||
{receivedAt && message.direction === 'incoming' ? (
|
||||
<tr>
|
||||
<td className="module-message-detail__label">
|
||||
{i18n('icu:received')}
|
||||
</td>
|
||||
<td>
|
||||
<Time timestamp={receivedAt}>
|
||||
{formatDateTimeLong(i18n, receivedAt)}
|
||||
))}
|
||||
<tr>
|
||||
<td className="module-message-detail__label">{i18n('icu:sent')}</td>
|
||||
<td>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'StoryDetailsModal__copy-icon',
|
||||
label: i18n('icu:StoryDetailsModal__copy-timestamp'),
|
||||
onClick: () => {
|
||||
void window.navigator.clipboard.writeText(String(sentAt));
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<>
|
||||
<Time timestamp={sentAt}>
|
||||
{formatDateTimeLong(i18n, sentAt)}
|
||||
</Time>{' '}
|
||||
<span className="module-message-detail__unix-timestamp">
|
||||
({receivedAt})
|
||||
({sentAt})
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{timeRemaining && timeRemaining > 0 && (
|
||||
<tr>
|
||||
<td className="module-message-detail__label">
|
||||
{i18n('icu:MessageDetail--disappears-in')}
|
||||
</td>
|
||||
<td>
|
||||
{formatRelativeTime(i18n, timeRemaining, {
|
||||
largest: 2,
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{this.renderContacts()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</>
|
||||
</ContextMenu>
|
||||
</td>
|
||||
</tr>
|
||||
{receivedAt && message.direction === 'incoming' ? (
|
||||
<tr>
|
||||
<td className="module-message-detail__label">
|
||||
{i18n('icu:received')}
|
||||
</td>
|
||||
<td>
|
||||
<Time timestamp={receivedAt}>
|
||||
{formatDateTimeLong(i18n, receivedAt)}
|
||||
</Time>{' '}
|
||||
<span className="module-message-detail__unix-timestamp">
|
||||
({receivedAt})
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{timeRemaining && timeRemaining > 0 && (
|
||||
<tr>
|
||||
<td className="module-message-detail__label">
|
||||
{i18n('icu:MessageDetail--disappears-in')}
|
||||
</td>
|
||||
<td>
|
||||
{formatRelativeTime(i18n, timeRemaining, {
|
||||
largest: 2,
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{renderContacts()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -51,10 +51,6 @@ export type Props = {
|
|||
doubleCheckMissingQuoteReference?: () => unknown;
|
||||
};
|
||||
|
||||
type State = {
|
||||
imageBroken: boolean;
|
||||
};
|
||||
|
||||
export type QuotedAttachmentType = Pick<
|
||||
AttachmentType,
|
||||
'contentType' | 'fileName' | 'isVoiceMessage' | 'thumbnail' | 'textAttachment'
|
||||
|
@ -139,31 +135,41 @@ function getTypeLabel({
|
|||
return MIME.isAudio(contentType) ? i18n('icu:audio') : undefined;
|
||||
}
|
||||
|
||||
export class Quote extends React.Component<Props, State> {
|
||||
private getClassName: (modifier?: string) => string;
|
||||
export function Quote(props: Props): JSX.Element | null {
|
||||
const {
|
||||
conversationColor,
|
||||
customColor,
|
||||
isStoryReply,
|
||||
onClose,
|
||||
text,
|
||||
bodyRanges,
|
||||
authorTitle,
|
||||
conversationTitle,
|
||||
isFromMe,
|
||||
i18n,
|
||||
payment,
|
||||
isViewOnce,
|
||||
isGiftBadge,
|
||||
rawAttachment,
|
||||
isIncoming,
|
||||
moduleClassName,
|
||||
referencedMessageNotFound,
|
||||
doubleCheckMissingQuoteReference,
|
||||
onClick,
|
||||
isCompose,
|
||||
reactionEmoji,
|
||||
} = props;
|
||||
const [imageBroken, setImageBroken] = useState(false);
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
imageBroken: false,
|
||||
};
|
||||
this.getClassName = getClassNamesFor('module-quote', props.moduleClassName);
|
||||
}
|
||||
|
||||
override componentDidMount(): void {
|
||||
const { doubleCheckMissingQuoteReference, referencedMessageNotFound } =
|
||||
this.props;
|
||||
const getClassName = getClassNamesFor('module-quote', moduleClassName);
|
||||
|
||||
useEffect(() => {
|
||||
if (referencedMessageNotFound) {
|
||||
doubleCheckMissingQuoteReference?.();
|
||||
}
|
||||
}
|
||||
|
||||
public handleKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLButtonElement>
|
||||
): void => {
|
||||
const { onClick } = this.props;
|
||||
}, [referencedMessageNotFound, doubleCheckMissingQuoteReference]);
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
|
||||
// This is important to ensure that using this quote to navigate to the referenced
|
||||
// message doesn't also trigger its parent message's keydown.
|
||||
if (onClick && (event.key === 'Enter' || event.key === ' ')) {
|
||||
|
@ -171,42 +177,35 @@ export class Quote extends React.Component<Props, State> {
|
|||
event.stopPropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
public handleClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
const { onClick } = this.props;
|
||||
}
|
||||
|
||||
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
|
||||
if (onClick) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public handleImageError = (): void => {
|
||||
function handleImageError() {
|
||||
window.console.info(
|
||||
'Message: Image failed to load; failing over to placeholder'
|
||||
);
|
||||
this.setState({
|
||||
imageBroken: true,
|
||||
});
|
||||
};
|
||||
setImageBroken(true);
|
||||
}
|
||||
|
||||
public renderImage(
|
||||
function renderImage(
|
||||
url: string,
|
||||
icon: string | undefined,
|
||||
isGiftBadge?: boolean
|
||||
asGiftBadge?: boolean
|
||||
): JSX.Element {
|
||||
const { isIncoming } = this.props;
|
||||
const iconElement = icon ? (
|
||||
<div className={this.getClassName('__icon-container__inner')}>
|
||||
<div
|
||||
className={this.getClassName('__icon-container__circle-background')}
|
||||
>
|
||||
<div className={getClassName('__icon-container__inner')}>
|
||||
<div className={getClassName('__icon-container__circle-background')}>
|
||||
<div
|
||||
className={classNames(
|
||||
this.getClassName('__icon-container__icon'),
|
||||
this.getClassName(`__icon-container__icon--${icon}`)
|
||||
getClassName('__icon-container__icon'),
|
||||
getClassName(`__icon-container__icon--${icon}`)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
@ -216,30 +215,28 @@ export class Quote extends React.Component<Props, State> {
|
|||
return (
|
||||
<ThumbnailImage
|
||||
className={classNames(
|
||||
this.getClassName('__icon-container'),
|
||||
getClassName('__icon-container'),
|
||||
isIncoming === false &&
|
||||
isGiftBadge &&
|
||||
this.getClassName('__icon-container__outgoing-gift-badge')
|
||||
asGiftBadge &&
|
||||
getClassName('__icon-container__outgoing-gift-badge')
|
||||
)}
|
||||
src={url}
|
||||
onError={this.handleImageError}
|
||||
onError={handleImageError}
|
||||
>
|
||||
{iconElement}
|
||||
</ThumbnailImage>
|
||||
);
|
||||
}
|
||||
|
||||
public renderIcon(icon: string): JSX.Element {
|
||||
function renderIcon(icon: string) {
|
||||
return (
|
||||
<div className={this.getClassName('__icon-container')}>
|
||||
<div className={this.getClassName('__icon-container__inner')}>
|
||||
<div
|
||||
className={this.getClassName('__icon-container__circle-background')}
|
||||
>
|
||||
<div className={getClassName('__icon-container')}>
|
||||
<div className={getClassName('__icon-container__inner')}>
|
||||
<div className={getClassName('__icon-container__circle-background')}>
|
||||
<div
|
||||
className={classNames(
|
||||
this.getClassName('__icon-container__icon'),
|
||||
this.getClassName(`__icon-container__icon--${icon}`)
|
||||
getClassName('__icon-container__icon'),
|
||||
getClassName(`__icon-container__icon--${icon}`)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
@ -248,8 +245,7 @@ export class Quote extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
public renderGenericFile(): JSX.Element | null {
|
||||
const { rawAttachment, isIncoming } = this.props;
|
||||
function renderGenericFile() {
|
||||
const attachment = getAttachment(rawAttachment);
|
||||
|
||||
if (!attachment) {
|
||||
|
@ -268,14 +264,12 @@ export class Quote extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={this.getClassName('__generic-file')}>
|
||||
<div className={this.getClassName('__generic-file__icon')} />
|
||||
<div className={getClassName('__generic-file')}>
|
||||
<div className={getClassName('__generic-file__icon')} />
|
||||
<div
|
||||
className={classNames(
|
||||
this.getClassName('__generic-file__text'),
|
||||
isIncoming
|
||||
? this.getClassName('__generic-file__text--incoming')
|
||||
: null
|
||||
getClassName('__generic-file__text'),
|
||||
isIncoming ? getClassName('__generic-file__text--incoming') : null
|
||||
)}
|
||||
>
|
||||
{fileName}
|
||||
|
@ -284,10 +278,7 @@ export class Quote extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
public renderPayment(): JSX.Element | null {
|
||||
const { payment, authorTitle, conversationTitle, isFromMe, i18n } =
|
||||
this.props;
|
||||
|
||||
function renderPayment() {
|
||||
if (payment == null) {
|
||||
return null;
|
||||
}
|
||||
|
@ -306,13 +297,11 @@ export class Quote extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
public renderIconContainer(): JSX.Element | null {
|
||||
const { isGiftBadge, isViewOnce, i18n, rawAttachment } = this.props;
|
||||
const { imageBroken } = this.state;
|
||||
function renderIconContainer() {
|
||||
const attachment = getAttachment(rawAttachment);
|
||||
|
||||
if (isGiftBadge) {
|
||||
return this.renderImage('images/gift-thumbnail.svg', undefined, true);
|
||||
return renderImage('images/gift-thumbnail.svg', undefined, true);
|
||||
}
|
||||
|
||||
if (!attachment) {
|
||||
|
@ -323,12 +312,12 @@ export class Quote extends React.Component<Props, State> {
|
|||
const url = getUrl(thumbnail);
|
||||
|
||||
if (isViewOnce) {
|
||||
return this.renderIcon('view-once');
|
||||
return renderIcon('view-once');
|
||||
}
|
||||
|
||||
if (textAttachment) {
|
||||
return (
|
||||
<div className={this.getClassName('__icon-container')}>
|
||||
<div className={getClassName('__icon-container')}>
|
||||
<TextAttachment
|
||||
i18n={i18n}
|
||||
isThumbnail
|
||||
|
@ -340,39 +329,29 @@ export class Quote extends React.Component<Props, State> {
|
|||
|
||||
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
||||
return url && !imageBroken
|
||||
? this.renderImage(url, 'play')
|
||||
: this.renderIcon('movie');
|
||||
? renderImage(url, 'play')
|
||||
: renderIcon('movie');
|
||||
}
|
||||
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
||||
return url && !imageBroken
|
||||
? this.renderImage(url, undefined)
|
||||
: this.renderIcon('image');
|
||||
? renderImage(url, undefined)
|
||||
: renderIcon('image');
|
||||
}
|
||||
if (MIME.isAudio(contentType)) {
|
||||
return this.renderIcon('microphone');
|
||||
return renderIcon('microphone');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public renderText(): JSX.Element | null {
|
||||
const {
|
||||
bodyRanges,
|
||||
isGiftBadge,
|
||||
i18n,
|
||||
text,
|
||||
rawAttachment,
|
||||
isIncoming,
|
||||
isViewOnce,
|
||||
} = this.props;
|
||||
|
||||
function renderText() {
|
||||
if (text && !isGiftBadge) {
|
||||
return (
|
||||
<div
|
||||
dir="auto"
|
||||
className={classNames(
|
||||
this.getClassName('__primary__text'),
|
||||
isIncoming ? this.getClassName('__primary__text--incoming') : null
|
||||
getClassName('__primary__text'),
|
||||
isIncoming ? getClassName('__primary__text--incoming') : null
|
||||
)}
|
||||
>
|
||||
<MessageBody
|
||||
|
@ -410,10 +389,8 @@ export class Quote extends React.Component<Props, State> {
|
|||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
this.getClassName('__primary__type-label'),
|
||||
isIncoming
|
||||
? this.getClassName('__primary__type-label--incoming')
|
||||
: null
|
||||
getClassName('__primary__type-label'),
|
||||
isIncoming ? getClassName('__primary__type-label--incoming') : null
|
||||
)}
|
||||
>
|
||||
{typeLabel}
|
||||
|
@ -424,9 +401,7 @@ export class Quote extends React.Component<Props, State> {
|
|||
return null;
|
||||
}
|
||||
|
||||
public renderClose(): JSX.Element | null {
|
||||
const { i18n, onClose } = this.props;
|
||||
|
||||
function renderClose() {
|
||||
if (!onClose) {
|
||||
return null;
|
||||
}
|
||||
|
@ -448,12 +423,12 @@ export class Quote extends React.Component<Props, State> {
|
|||
|
||||
// We need the container to give us the flexibility to implement the iOS design.
|
||||
return (
|
||||
<div className={this.getClassName('__close-container')}>
|
||||
<div className={getClassName('__close-container')}>
|
||||
<div
|
||||
tabIndex={0}
|
||||
// We can't be a button because the overall quote is a button; can't nest them
|
||||
role="button"
|
||||
className={this.getClassName('__close-button')}
|
||||
className={getClassName('__close-button')}
|
||||
aria-label={i18n('icu:close')}
|
||||
onKeyDown={keyDownHandler}
|
||||
onClick={clickHandler}
|
||||
|
@ -462,10 +437,7 @@ export class Quote extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
public renderAuthor(): JSX.Element {
|
||||
const { authorTitle, i18n, isFromMe, isIncoming, isStoryReply } =
|
||||
this.props;
|
||||
|
||||
function renderAuthor() {
|
||||
const title = isFromMe ? (
|
||||
i18n('icu:you')
|
||||
) : (
|
||||
|
@ -482,8 +454,8 @@ export class Quote extends React.Component<Props, State> {
|
|||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
this.getClassName('__primary__author'),
|
||||
isIncoming ? this.getClassName('__primary__author--incoming') : null
|
||||
getClassName('__primary__author'),
|
||||
isIncoming ? getClassName('__primary__author--incoming') : null
|
||||
)}
|
||||
>
|
||||
{author}
|
||||
|
@ -491,16 +463,7 @@ export class Quote extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
public renderReferenceWarning(): JSX.Element | null {
|
||||
const {
|
||||
conversationColor,
|
||||
customColor,
|
||||
i18n,
|
||||
isIncoming,
|
||||
isStoryReply,
|
||||
referencedMessageNotFound,
|
||||
} = this.props;
|
||||
|
||||
function renderReferenceWarning() {
|
||||
if (!referencedMessageNotFound || isStoryReply) {
|
||||
return null;
|
||||
}
|
||||
|
@ -508,26 +471,28 @@ export class Quote extends React.Component<Props, State> {
|
|||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
this.getClassName('__reference-warning'),
|
||||
getClassName('__reference-warning'),
|
||||
isIncoming
|
||||
? this.getClassName(`--incoming-${conversationColor}`)
|
||||
: this.getClassName(`--outgoing-${conversationColor}`)
|
||||
? getClassName(`--incoming-${conversationColor}`)
|
||||
: getClassName(`--outgoing-${conversationColor}`)
|
||||
)}
|
||||
style={{ ...getCustomColorStyle(customColor, true) }}
|
||||
style={{
|
||||
...getCustomColorStyle(customColor, true),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
this.getClassName('__reference-warning__icon'),
|
||||
getClassName('__reference-warning__icon'),
|
||||
isIncoming
|
||||
? this.getClassName('__reference-warning__icon--incoming')
|
||||
? getClassName('__reference-warning__icon--incoming')
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
this.getClassName('__reference-warning__text'),
|
||||
getClassName('__reference-warning__text'),
|
||||
isIncoming
|
||||
? this.getClassName('__reference-warning__text--incoming')
|
||||
? getClassName('__reference-warning__text--incoming')
|
||||
: null
|
||||
)}
|
||||
>
|
||||
|
@ -537,75 +502,61 @@ export class Quote extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
public override render(): JSX.Element | null {
|
||||
const {
|
||||
conversationColor,
|
||||
customColor,
|
||||
isCompose,
|
||||
isIncoming,
|
||||
onClick,
|
||||
rawAttachment,
|
||||
reactionEmoji,
|
||||
referencedMessageNotFound,
|
||||
} = this.props;
|
||||
|
||||
if (!validateQuote(this.props)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let colorClassName: string;
|
||||
let directionClassName: string;
|
||||
if (isCompose) {
|
||||
directionClassName = this.getClassName('--compose');
|
||||
colorClassName = this.getClassName(`--compose-${conversationColor}`);
|
||||
} else if (isIncoming) {
|
||||
directionClassName = this.getClassName('--incoming');
|
||||
colorClassName = this.getClassName(`--incoming-${conversationColor}`);
|
||||
} else {
|
||||
directionClassName = this.getClassName('--outgoing');
|
||||
colorClassName = this.getClassName(`--outgoing-${conversationColor}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={this.getClassName('__container')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleClick}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
className={classNames(
|
||||
this.getClassName(''),
|
||||
directionClassName,
|
||||
colorClassName,
|
||||
!onClick && this.getClassName('--no-click'),
|
||||
referencedMessageNotFound &&
|
||||
this.getClassName('--with-reference-warning')
|
||||
)}
|
||||
style={{ ...getCustomColorStyle(customColor, true) }}
|
||||
>
|
||||
<div className={this.getClassName('__primary')}>
|
||||
{this.renderAuthor()}
|
||||
{this.renderGenericFile()}
|
||||
{this.renderPayment()}
|
||||
{this.renderText()}
|
||||
</div>
|
||||
{reactionEmoji && (
|
||||
<div
|
||||
className={
|
||||
rawAttachment
|
||||
? this.getClassName('__reaction-emoji')
|
||||
: this.getClassName('__reaction-emoji--story-unavailable')
|
||||
}
|
||||
>
|
||||
<Emojify text={reactionEmoji} />
|
||||
</div>
|
||||
)}
|
||||
{this.renderIconContainer()}
|
||||
{this.renderClose()}
|
||||
</button>
|
||||
{this.renderReferenceWarning()}
|
||||
</div>
|
||||
);
|
||||
if (!validateQuote(props)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let colorClassName: string;
|
||||
let directionClassName: string;
|
||||
if (isCompose) {
|
||||
directionClassName = getClassName('--compose');
|
||||
colorClassName = getClassName(`--compose-${conversationColor}`);
|
||||
} else if (isIncoming) {
|
||||
directionClassName = getClassName('--incoming');
|
||||
colorClassName = getClassName(`--incoming-${conversationColor}`);
|
||||
} else {
|
||||
directionClassName = getClassName('--outgoing');
|
||||
colorClassName = getClassName(`--outgoing-${conversationColor}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={getClassName('__container')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={classNames(
|
||||
getClassName(''),
|
||||
directionClassName,
|
||||
colorClassName,
|
||||
!onClick && getClassName('--no-click'),
|
||||
referencedMessageNotFound && getClassName('--with-reference-warning')
|
||||
)}
|
||||
style={{ ...getCustomColorStyle(customColor, true) }}
|
||||
>
|
||||
<div className={getClassName('__primary')}>
|
||||
{renderAuthor()}
|
||||
{renderGenericFile()}
|
||||
{renderPayment()}
|
||||
{renderText()}
|
||||
</div>
|
||||
{reactionEmoji && (
|
||||
<div
|
||||
className={
|
||||
rawAttachment
|
||||
? getClassName('__reaction-emoji')
|
||||
: getClassName('__reaction-emoji--story-unavailable')
|
||||
}
|
||||
>
|
||||
<Emojify text={reactionEmoji} />
|
||||
</div>
|
||||
)}
|
||||
{renderIconContainer()}
|
||||
{renderClose()}
|
||||
</button>
|
||||
{renderReferenceWarning()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThumbnailImage({
|
||||
|
|
|
@ -184,200 +184,195 @@ export type PropsType = PropsLocalType &
|
|||
| 'shouldHideMetadata'
|
||||
>;
|
||||
|
||||
export class TimelineItem extends React.PureComponent<PropsType> {
|
||||
public override render(): JSX.Element | null {
|
||||
const {
|
||||
containerElementRef,
|
||||
conversationId,
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
id,
|
||||
isNextItemCallingNotification,
|
||||
isTargeted,
|
||||
item,
|
||||
platform,
|
||||
renderUniversalTimerNotification,
|
||||
returnToActiveCall,
|
||||
targetMessage,
|
||||
shouldCollapseAbove,
|
||||
shouldCollapseBelow,
|
||||
shouldHideMetadata,
|
||||
shouldRenderDateHeader,
|
||||
startCallingLobby,
|
||||
theme,
|
||||
...reducedProps
|
||||
} = this.props;
|
||||
export function TimelineItem({
|
||||
containerElementRef,
|
||||
conversationId,
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
id,
|
||||
isNextItemCallingNotification,
|
||||
isTargeted,
|
||||
item,
|
||||
platform,
|
||||
renderUniversalTimerNotification,
|
||||
returnToActiveCall,
|
||||
targetMessage,
|
||||
shouldCollapseAbove,
|
||||
shouldCollapseBelow,
|
||||
shouldHideMetadata,
|
||||
shouldRenderDateHeader,
|
||||
startCallingLobby,
|
||||
theme,
|
||||
...reducedProps
|
||||
}: PropsType): JSX.Element | null {
|
||||
if (!item) {
|
||||
// This can happen under normal conditions.
|
||||
//
|
||||
// `<Timeline>` and `<TimelineItem>` are connected to Redux separately. If a
|
||||
// timeline item is removed from Redux, `<TimelineItem>` might re-render before
|
||||
// `<Timeline>` does, which means we'll try to render nothing. This should resolve
|
||||
// itself quickly, as soon as `<Timeline>` re-renders.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
// This can happen under normal conditions.
|
||||
//
|
||||
// `<Timeline>` and `<TimelineItem>` are connected to Redux separately. If a
|
||||
// timeline item is removed from Redux, `<TimelineItem>` might re-render before
|
||||
// `<Timeline>` does, which means we'll try to render nothing. This should resolve
|
||||
// itself quickly, as soon as `<Timeline>` re-renders.
|
||||
return null;
|
||||
}
|
||||
let itemContents: ReactChild;
|
||||
if (item.type === 'message') {
|
||||
itemContents = (
|
||||
<TimelineMessage
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
isTargeted={isTargeted}
|
||||
targetMessage={targetMessage}
|
||||
shouldCollapseAbove={shouldCollapseAbove}
|
||||
shouldCollapseBelow={shouldCollapseBelow}
|
||||
shouldHideMetadata={shouldHideMetadata}
|
||||
containerElementRef={containerElementRef}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
platform={platform}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
let notification;
|
||||
|
||||
let itemContents: ReactChild;
|
||||
if (item.type === 'message') {
|
||||
itemContents = (
|
||||
<TimelineMessage
|
||||
if (item.type === 'unsupportedMessage') {
|
||||
notification = (
|
||||
<UnsupportedMessage {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'callHistory') {
|
||||
notification = (
|
||||
<CallingNotification
|
||||
conversationId={conversationId}
|
||||
i18n={i18n}
|
||||
isNextItemCallingNotification={isNextItemCallingNotification}
|
||||
returnToActiveCall={returnToActiveCall}
|
||||
startCallingLobby={startCallingLobby}
|
||||
{...item.data}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'chatSessionRefreshed') {
|
||||
notification = (
|
||||
<ChatSessionRefreshedNotification {...reducedProps} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'deliveryIssue') {
|
||||
notification = (
|
||||
<DeliveryIssueNotification
|
||||
{...item.data}
|
||||
{...reducedProps}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'timerNotification') {
|
||||
notification = (
|
||||
<TimerNotification {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'universalTimerNotification') {
|
||||
notification = renderUniversalTimerNotification();
|
||||
} else if (item.type === 'contactRemovedNotification') {
|
||||
notification = (
|
||||
<SystemMessage
|
||||
icon="info"
|
||||
contents={i18n('icu:ContactRemovedNotification__text')}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'changeNumberNotification') {
|
||||
notification = (
|
||||
<ChangeNumberNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
isTargeted={isTargeted}
|
||||
targetMessage={targetMessage}
|
||||
shouldCollapseAbove={shouldCollapseAbove}
|
||||
shouldCollapseBelow={shouldCollapseBelow}
|
||||
shouldHideMetadata={shouldHideMetadata}
|
||||
containerElementRef={containerElementRef}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
platform={platform}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'safetyNumberNotification') {
|
||||
notification = (
|
||||
<SafetyNumberNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'verificationNotification') {
|
||||
notification = (
|
||||
<VerificationNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'groupNotification') {
|
||||
notification = (
|
||||
<GroupNotification {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'groupV2Change') {
|
||||
notification = (
|
||||
<GroupV2Change {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'groupV1Migration') {
|
||||
notification = (
|
||||
<GroupV1Migration
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'conversationMerge') {
|
||||
notification = (
|
||||
<ConversationMergeNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'resetSessionNotification') {
|
||||
notification = <ResetSessionNotification {...reducedProps} i18n={i18n} />;
|
||||
} else if (item.type === 'profileChange') {
|
||||
notification = (
|
||||
<ProfileChangeNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'paymentEvent') {
|
||||
notification = (
|
||||
<PaymentEventNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
let notification;
|
||||
|
||||
if (item.type === 'unsupportedMessage') {
|
||||
notification = (
|
||||
<UnsupportedMessage {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'callHistory') {
|
||||
notification = (
|
||||
<CallingNotification
|
||||
conversationId={conversationId}
|
||||
i18n={i18n}
|
||||
isNextItemCallingNotification={isNextItemCallingNotification}
|
||||
returnToActiveCall={returnToActiveCall}
|
||||
startCallingLobby={startCallingLobby}
|
||||
{...item.data}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'chatSessionRefreshed') {
|
||||
notification = (
|
||||
<ChatSessionRefreshedNotification {...reducedProps} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'deliveryIssue') {
|
||||
notification = (
|
||||
<DeliveryIssueNotification
|
||||
{...item.data}
|
||||
{...reducedProps}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'timerNotification') {
|
||||
notification = (
|
||||
<TimerNotification {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'universalTimerNotification') {
|
||||
notification = renderUniversalTimerNotification();
|
||||
} else if (item.type === 'contactRemovedNotification') {
|
||||
notification = (
|
||||
<SystemMessage
|
||||
icon="info"
|
||||
contents={i18n('icu:ContactRemovedNotification__text')}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'changeNumberNotification') {
|
||||
notification = (
|
||||
<ChangeNumberNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'safetyNumberNotification') {
|
||||
notification = (
|
||||
<SafetyNumberNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'verificationNotification') {
|
||||
notification = (
|
||||
<VerificationNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'groupNotification') {
|
||||
notification = (
|
||||
<GroupNotification {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'groupV2Change') {
|
||||
notification = (
|
||||
<GroupV2Change {...reducedProps} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'groupV1Migration') {
|
||||
notification = (
|
||||
<GroupV1Migration
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'conversationMerge') {
|
||||
notification = (
|
||||
<ConversationMergeNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'resetSessionNotification') {
|
||||
notification = (
|
||||
<ResetSessionNotification {...reducedProps} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'profileChange') {
|
||||
notification = (
|
||||
<ProfileChangeNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'paymentEvent') {
|
||||
notification = (
|
||||
<PaymentEventNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// Weird, yes, but the idea is to get a compile error when we aren't comprehensive
|
||||
// with our if/else checks above, but also log out the type we don't understand
|
||||
// if we encounter it at runtime.
|
||||
const unknownItem: never = item;
|
||||
const asItem = unknownItem as TimelineItemType;
|
||||
throw new Error(`TimelineItem: Unknown type: ${asItem.type}`);
|
||||
}
|
||||
|
||||
itemContents = (
|
||||
<InlineNotificationWrapper
|
||||
id={id}
|
||||
conversationId={conversationId}
|
||||
isTargeted={isTargeted}
|
||||
targetMessage={targetMessage}
|
||||
>
|
||||
{notification}
|
||||
</InlineNotificationWrapper>
|
||||
);
|
||||
// Weird, yes, but the idea is to get a compile error when we aren't comprehensive
|
||||
// with our if/else checks above, but also log out the type we don't understand
|
||||
// if we encounter it at runtime.
|
||||
const unknownItem: never = item;
|
||||
const asItem = unknownItem as TimelineItemType;
|
||||
throw new Error(`TimelineItem: Unknown type: ${asItem.type}`);
|
||||
}
|
||||
|
||||
if (shouldRenderDateHeader) {
|
||||
return (
|
||||
<>
|
||||
<TimelineDateHeader i18n={i18n} timestamp={item.timestamp} />
|
||||
{itemContents}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return itemContents;
|
||||
itemContents = (
|
||||
<InlineNotificationWrapper
|
||||
id={id}
|
||||
conversationId={conversationId}
|
||||
isTargeted={isTargeted}
|
||||
targetMessage={targetMessage}
|
||||
>
|
||||
{notification}
|
||||
</InlineNotificationWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldRenderDateHeader) {
|
||||
return (
|
||||
<>
|
||||
<TimelineDateHeader i18n={i18n} timestamp={item.timestamp} />
|
||||
{itemContents}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return itemContents;
|
||||
}
|
||||
|
|
|
@ -24,56 +24,56 @@ type PropsHousekeeping = {
|
|||
|
||||
export type Props = PropsData & PropsHousekeeping;
|
||||
|
||||
export class VerificationNotification extends React.Component<Props> {
|
||||
public renderContents(): JSX.Element {
|
||||
const { contact, isLocal, type, i18n } = this.props;
|
||||
function VerificationNotificationContents({
|
||||
contact,
|
||||
isLocal,
|
||||
type,
|
||||
i18n,
|
||||
}: Props) {
|
||||
const name = (
|
||||
<ContactName
|
||||
key="external-1"
|
||||
title={contact.title}
|
||||
module="module-verification-notification__contact"
|
||||
/>
|
||||
);
|
||||
|
||||
const name = (
|
||||
<ContactName
|
||||
key="external-1"
|
||||
title={contact.title}
|
||||
module="module-verification-notification__contact"
|
||||
/>
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case 'markVerified':
|
||||
return isLocal ? (
|
||||
<Intl
|
||||
id="icu:youMarkedAsVerified"
|
||||
components={{ name }}
|
||||
i18n={i18n}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
id="icu:youMarkedAsVerifiedOtherDevice"
|
||||
components={{ name }}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
case 'markNotVerified':
|
||||
return isLocal ? (
|
||||
<Intl
|
||||
id="icu:youMarkedAsNotVerified"
|
||||
components={{ name }}
|
||||
i18n={i18n}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
id="icu:youMarkedAsNotVerifiedOtherDevice"
|
||||
components={{ name }}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
}
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const { type } = this.props;
|
||||
const icon = type === 'markVerified' ? 'verified' : 'verified-not';
|
||||
|
||||
return <SystemMessage icon={icon} contents={this.renderContents()} />;
|
||||
switch (type) {
|
||||
case 'markVerified':
|
||||
return isLocal ? (
|
||||
<Intl id="icu:youMarkedAsVerified" components={{ name }} i18n={i18n} />
|
||||
) : (
|
||||
<Intl
|
||||
id="icu:youMarkedAsVerifiedOtherDevice"
|
||||
components={{ name }}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
case 'markNotVerified':
|
||||
return isLocal ? (
|
||||
<Intl
|
||||
id="icu:youMarkedAsNotVerified"
|
||||
components={{ name }}
|
||||
i18n={i18n}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
id="icu:youMarkedAsNotVerifiedOtherDevice"
|
||||
components={{ name }}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
}
|
||||
|
||||
export function VerificationNotification(props: Props): JSX.Element {
|
||||
const { type } = props;
|
||||
return (
|
||||
<SystemMessage
|
||||
icon={type === 'markVerified' ? 'verified' : 'verified-not'}
|
||||
contents={<VerificationNotificationContents {...props} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -18,28 +18,20 @@ type Props = {
|
|||
shouldShowSeparator?: boolean;
|
||||
};
|
||||
|
||||
export class DocumentListItem extends React.Component<Props> {
|
||||
public override render(): JSX.Element {
|
||||
const { shouldShowSeparator = true } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-document-list-item',
|
||||
shouldShowSeparator
|
||||
? 'module-document-list-item--with-separator'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderContent() {
|
||||
const { fileName, fileSize, onClick, timestamp } = this.props;
|
||||
|
||||
return (
|
||||
export function DocumentListItem({
|
||||
shouldShowSeparator = true,
|
||||
fileName,
|
||||
fileSize,
|
||||
onClick,
|
||||
timestamp,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-document-list-item',
|
||||
shouldShowSeparator ? 'module-document-list-item--with-separator' : null
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="module-document-list-item__content"
|
||||
|
@ -60,6 +52,6 @@ export class DocumentListItem extends React.Component<Props> {
|
|||
{moment(timestamp).format('ddd, MMM D, Y')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import { text } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { setupI18n } from '../../../util/setupI18n';
|
||||
|
@ -31,13 +30,8 @@ const createProps = (
|
|||
const createMediaItem = (
|
||||
overrideProps: Partial<MediaItemType> = {}
|
||||
): MediaItemType => ({
|
||||
thumbnailObjectUrl: text(
|
||||
'thumbnailObjectUrl',
|
||||
overrideProps.thumbnailObjectUrl || ''
|
||||
),
|
||||
contentType: stringToMIMEType(
|
||||
text('contentType', overrideProps.contentType || '')
|
||||
),
|
||||
thumbnailObjectUrl: overrideProps.thumbnailObjectUrl || '',
|
||||
contentType: overrideProps.contentType || stringToMIMEType(''),
|
||||
index: 0,
|
||||
attachment: {} as AttachmentType, // attachment not useful in the component
|
||||
message: {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2018 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
|
@ -19,110 +19,87 @@ export type Props = {
|
|||
i18n: LocalizerType;
|
||||
};
|
||||
|
||||
type State = {
|
||||
imageBroken: boolean;
|
||||
};
|
||||
function MediaGridItemContent(props: Props) {
|
||||
const { mediaItem, i18n } = props;
|
||||
const { attachment, contentType } = mediaItem;
|
||||
|
||||
export class MediaGridItem extends React.Component<Props, State> {
|
||||
private readonly onImageErrorBound: () => void;
|
||||
const [imageBroken, setImageBroken] = useState(false);
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
imageBroken: false,
|
||||
};
|
||||
|
||||
this.onImageErrorBound = this.onImageError.bind(this);
|
||||
}
|
||||
|
||||
public onImageError(): void {
|
||||
const handleImageError = useCallback(() => {
|
||||
log.info(
|
||||
'MediaGridItem: Image failed to load; failing over to placeholder'
|
||||
);
|
||||
this.setState({
|
||||
imageBroken: true,
|
||||
});
|
||||
setImageBroken(true);
|
||||
}, []);
|
||||
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
public renderContent(): JSX.Element | null {
|
||||
const { mediaItem, i18n } = this.props;
|
||||
const { imageBroken } = this.state;
|
||||
const { attachment, contentType } = mediaItem;
|
||||
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contentType && isImageTypeSupported(contentType)) {
|
||||
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-media-grid-item__icon',
|
||||
'module-media-grid-item__icon-image'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (contentType && isImageTypeSupported(contentType)) {
|
||||
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
|
||||
return (
|
||||
<img
|
||||
alt={i18n('icu:lightboxImageAlt')}
|
||||
className="module-media-grid-item__image"
|
||||
src={mediaItem.thumbnailObjectUrl}
|
||||
onError={this.onImageErrorBound}
|
||||
<div
|
||||
className={classNames(
|
||||
'module-media-grid-item__icon',
|
||||
'module-media-grid-item__icon-image'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (contentType && isVideoTypeSupported(contentType)) {
|
||||
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-media-grid-item__icon',
|
||||
'module-media-grid-item__icon-video'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-media-grid-item__image-container">
|
||||
<img
|
||||
alt={i18n('icu:lightboxImageAlt')}
|
||||
className="module-media-grid-item__image"
|
||||
src={mediaItem.thumbnailObjectUrl}
|
||||
onError={this.onImageErrorBound}
|
||||
/>
|
||||
<div className="module-media-grid-item__circle-overlay">
|
||||
<div className="module-media-grid-item__play-overlay" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-media-grid-item__icon',
|
||||
'module-media-grid-item__icon-generic'
|
||||
)}
|
||||
<img
|
||||
alt={i18n('icu:lightboxImageAlt')}
|
||||
className="module-media-grid-item__image"
|
||||
src={mediaItem.thumbnailObjectUrl}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const { onClick } = this.props;
|
||||
if (contentType && isVideoTypeSupported(contentType)) {
|
||||
if (imageBroken || !mediaItem.thumbnailObjectUrl) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-media-grid-item__icon',
|
||||
'module-media-grid-item__icon-video'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="module-media-grid-item"
|
||||
onClick={onClick}
|
||||
>
|
||||
{this.renderContent()}
|
||||
</button>
|
||||
<div className="module-media-grid-item__image-container">
|
||||
<img
|
||||
alt={i18n('icu:lightboxImageAlt')}
|
||||
className="module-media-grid-item__image"
|
||||
src={mediaItem.thumbnailObjectUrl}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
<div className="module-media-grid-item__circle-overlay">
|
||||
<div className="module-media-grid-item__play-overlay" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-media-grid-item__icon',
|
||||
'module-media-grid-item__icon-generic'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaGridItem(props: Props): JSX.Element {
|
||||
const { onClick } = props;
|
||||
return (
|
||||
<button type="button" className="module-media-grid-item" onClick={onClick}>
|
||||
<MediaGridItemContent {...props} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2147,14 +2147,6 @@
|
|||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2022-11-11T17:11:07.659Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/MainHeader.tsx",
|
||||
"line": " public containerRef: React.RefObject<HTMLDivElement> = React.createRef();",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2022-06-14T22:04:43.988Z",
|
||||
"reasonDetail": "Handling outside click"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/MediaQualitySelector.tsx",
|
||||
|
@ -2359,14 +2351,6 @@
|
|||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-07-30T16:57:33.618Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/conversation/InlineNotificationWrapper.tsx",
|
||||
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2019-11-06T19:56:38.557Z",
|
||||
"reasonDetail": "Used to manage focus"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
"path": "ts/components/conversation/Message.tsx",
|
||||
|
@ -2544,5 +2528,29 @@
|
|||
"line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-09-17T21:02:59.414Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/conversation/InlineNotificationWrapper.tsx",
|
||||
"line": " const focusRef = useRef<HTMLDivElement>(null);",
|
||||
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
|
||||
"updated": "2023-04-12T15:51:28.066Z",
|
||||
"reasonDetail": "<optional>"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/conversation/MessageDetail.tsx",
|
||||
"line": " const focusRef = useRef<HTMLDivElement>(null);",
|
||||
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
|
||||
"updated": "2023-04-12T15:51:28.066Z",
|
||||
"reasonDetail": "<optional>"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/conversation/MessageDetail.tsx",
|
||||
"line": " const messageContainerRef = useRef<HTMLDivElement>(null);",
|
||||
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
|
||||
"updated": "2023-04-12T15:51:28.066Z",
|
||||
"reasonDetail": "<optional>"
|
||||
}
|
||||
]
|
||||
|
|
Loading…
Add table
Reference in a new issue