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