Calls Tab & Group Call Disposition
This commit is contained in:
parent
620e85ca01
commit
1eaabb6734
139 changed files with 9182 additions and 2721 deletions
|
@ -25,9 +25,7 @@ type PropsType = {
|
|||
registerSingleDevice: (number: string, code: string) => Promise<void>;
|
||||
renderCallManager: () => JSX.Element;
|
||||
renderGlobalModalContainer: () => JSX.Element;
|
||||
isShowingStoriesView: boolean;
|
||||
i18n: LocalizerType;
|
||||
renderStories: (closeView: () => unknown) => JSX.Element;
|
||||
hasSelectedStoryData: boolean;
|
||||
renderStoryViewer: (closeView: () => unknown) => JSX.Element;
|
||||
renderLightbox: () => JSX.Element | null;
|
||||
|
@ -53,7 +51,6 @@ type PropsType = {
|
|||
titleBarDoubleClick: () => void;
|
||||
toast?: AnyToast;
|
||||
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
||||
toggleStoriesView: () => unknown;
|
||||
viewStory: ViewStoryActionCreatorType;
|
||||
renderInbox: () => JSX.Element;
|
||||
};
|
||||
|
@ -69,7 +66,6 @@ export function App({
|
|||
i18n,
|
||||
isFullScreen,
|
||||
isMaximized,
|
||||
isShowingStoriesView,
|
||||
menuOptions,
|
||||
onUndoArchive,
|
||||
openFileInFolder,
|
||||
|
@ -81,13 +77,11 @@ export function App({
|
|||
renderGlobalModalContainer,
|
||||
renderInbox,
|
||||
renderLightbox,
|
||||
renderStories,
|
||||
renderStoryViewer,
|
||||
requestVerification,
|
||||
theme,
|
||||
titleBarDoubleClick,
|
||||
toast,
|
||||
toggleStoriesView,
|
||||
viewStory,
|
||||
}: PropsType): JSX.Element {
|
||||
let contents;
|
||||
|
@ -183,7 +177,6 @@ export function App({
|
|||
{renderGlobalModalContainer()}
|
||||
{renderCallManager()}
|
||||
{renderLightbox()}
|
||||
{isShowingStoriesView && renderStories(toggleStoriesView)}
|
||||
{hasSelectedStoryData &&
|
||||
renderStoryViewer(() => viewStory({ closeViewer: true }))}
|
||||
</div>
|
||||
|
|
|
@ -12,6 +12,7 @@ import React, { useEffect, useState } from 'react';
|
|||
import classNames from 'classnames';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { filterDOMProps } from '@react-aria/utils';
|
||||
import type { AvatarColorType } from '../types/Colors';
|
||||
import type { BadgeType } from '../badges/types';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
|
@ -239,7 +240,7 @@ export function Avatar({
|
|||
if (onClick) {
|
||||
contents = (
|
||||
<button
|
||||
{...ariaProps}
|
||||
{...filterDOMProps(ariaProps)}
|
||||
className={contentsClassName}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
|
|
|
@ -46,8 +46,6 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
|
||||
onEditProfile: action('onEditProfile'),
|
||||
onStartUpdate: action('startUpdate'),
|
||||
onViewArchive: action('onViewArchive'),
|
||||
onViewPreferences: action('onViewPreferences'),
|
||||
phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''),
|
||||
profileName: text('profileName', overrideProps.profileName || ''),
|
||||
sharedGroupNames: [],
|
||||
|
|
|
@ -19,8 +19,6 @@ export type Props = {
|
|||
|
||||
onEditProfile: () => unknown;
|
||||
onStartUpdate: () => unknown;
|
||||
onViewPreferences: () => unknown;
|
||||
onViewArchive: () => unknown;
|
||||
|
||||
// Matches Popper's RefHandler type
|
||||
innerRef?: React.Ref<HTMLDivElement>;
|
||||
|
@ -35,8 +33,6 @@ export function AvatarPopup(props: Props): JSX.Element {
|
|||
name,
|
||||
onEditProfile,
|
||||
onStartUpdate,
|
||||
onViewArchive,
|
||||
onViewPreferences,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
style,
|
||||
|
@ -70,54 +66,27 @@ export function AvatarPopup(props: Props): JSX.Element {
|
|||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
<hr className="module-avatar-popup__divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="module-avatar-popup__item"
|
||||
onClick={onViewPreferences}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-avatar-popup__item__icon',
|
||||
'module-avatar-popup__item__icon-settings'
|
||||
)}
|
||||
/>
|
||||
<div className="module-avatar-popup__item__text">
|
||||
{i18n('icu:mainMenuSettings')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="module-avatar-popup__item"
|
||||
onClick={onViewArchive}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-avatar-popup__item__icon',
|
||||
'module-avatar-popup__item__icon-archive'
|
||||
)}
|
||||
/>
|
||||
<div className="module-avatar-popup__item__text">
|
||||
{i18n('icu:avatarMenuViewArchive')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{hasPendingUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="module-avatar-popup__item"
|
||||
onClick={onStartUpdate}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-avatar-popup__item__icon',
|
||||
'module-avatar-popup__item__icon--update'
|
||||
)}
|
||||
/>
|
||||
<div className="module-avatar-popup__item__text">
|
||||
{i18n('icu:avatarMenuUpdateAvailable')}
|
||||
</div>
|
||||
<div className="module-avatar-popup__item--badge" />
|
||||
</button>
|
||||
<>
|
||||
<hr className="module-avatar-popup__divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="module-avatar-popup__item"
|
||||
onClick={onStartUpdate}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-avatar-popup__item__icon',
|
||||
'module-avatar-popup__item__icon--update'
|
||||
)}
|
||||
/>
|
||||
<div className="module-avatar-popup__item__text">
|
||||
{i18n('icu:avatarMenuUpdateAvailable')}
|
||||
</div>
|
||||
<div className="module-avatar-popup__item--badge" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -33,6 +33,7 @@ export enum ButtonVariant {
|
|||
|
||||
export enum ButtonIconType {
|
||||
audio = 'audio',
|
||||
message = 'message',
|
||||
muted = 'muted',
|
||||
search = 'search',
|
||||
unmuted = 'unmuted',
|
||||
|
|
476
ts/components/CallsList.tsx
Normal file
476
ts/components/CallsList.tsx
Normal file
|
@ -0,0 +1,476 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ChangeEvent } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { Index, IndexRange, ListRowProps } from 'react-virtualized';
|
||||
import { InfiniteLoader, List } from 'react-virtualized';
|
||||
import classNames from 'classnames';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import { ListTile } from './ListTile';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import type {
|
||||
CallHistoryFilterOptions,
|
||||
CallHistoryGroup,
|
||||
CallHistoryPagination,
|
||||
} from '../types/CallDisposition';
|
||||
import {
|
||||
CallHistoryFilterStatus,
|
||||
CallDirection,
|
||||
CallType,
|
||||
DirectCallStatus,
|
||||
GroupCallStatus,
|
||||
isSameCallHistoryGroup,
|
||||
} from '../types/CallDisposition';
|
||||
import { formatDateTimeShort } from '../util/timestamp';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import * as log from '../logging/log';
|
||||
import { refMerger } from '../util/refMerger';
|
||||
import { drop } from '../util/drop';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { UserText } from './UserText';
|
||||
import { Intl } from './Intl';
|
||||
import { NavSidebarSearchHeader } from './NavSidebar';
|
||||
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||
import { formatCallHistoryGroup } from '../util/callDisposition';
|
||||
|
||||
function Timestamp({
|
||||
i18n,
|
||||
timestamp,
|
||||
}: {
|
||||
i18n: LocalizerType;
|
||||
timestamp: number;
|
||||
}): JSX.Element {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 1_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dateTime = useMemo(() => {
|
||||
return new Date(timestamp).toISOString();
|
||||
}, [timestamp]);
|
||||
|
||||
const formatted = useMemo(() => {
|
||||
void now; // Use this as a dep so we update
|
||||
return formatDateTimeShort(i18n, timestamp);
|
||||
}, [i18n, timestamp, now]);
|
||||
|
||||
return <time dateTime={dateTime}>{formatted}</time>;
|
||||
}
|
||||
|
||||
type SearchResults = Readonly<{
|
||||
count: number;
|
||||
items: ReadonlyArray<CallHistoryGroup>;
|
||||
}>;
|
||||
|
||||
type SearchState = Readonly<{
|
||||
state: 'init' | 'pending' | 'rejected' | 'fulfilled';
|
||||
// Note these fields shouldnt be updated until the search is fulfilled or rejected.
|
||||
options: null | { query: string; status: CallHistoryFilterStatus };
|
||||
results: null | SearchResults;
|
||||
}>;
|
||||
|
||||
const defaultInitState: SearchState = {
|
||||
state: 'init',
|
||||
options: null,
|
||||
results: null,
|
||||
};
|
||||
|
||||
const defaultPendingState: SearchState = {
|
||||
state: 'pending',
|
||||
options: null,
|
||||
results: {
|
||||
count: 100,
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
|
||||
type CallsListProps = Readonly<{
|
||||
getCallHistoryGroupsCount: (
|
||||
options: CallHistoryFilterOptions
|
||||
) => Promise<number>;
|
||||
getCallHistoryGroups: (
|
||||
options: CallHistoryFilterOptions,
|
||||
pagination: CallHistoryPagination
|
||||
) => Promise<Array<CallHistoryGroup>>;
|
||||
getConversation: (id: string) => ConversationType | void;
|
||||
i18n: LocalizerType;
|
||||
selectedCallHistoryGroup: CallHistoryGroup | null;
|
||||
onSelectCallHistoryGroup: (
|
||||
conversationId: string,
|
||||
selectedCallHistoryGroup: CallHistoryGroup
|
||||
) => void;
|
||||
}>;
|
||||
|
||||
function rowHeight() {
|
||||
return ListTile.heightCompact;
|
||||
}
|
||||
|
||||
export function CallsList({
|
||||
getCallHistoryGroupsCount,
|
||||
getCallHistoryGroups,
|
||||
getConversation,
|
||||
i18n,
|
||||
selectedCallHistoryGroup,
|
||||
onSelectCallHistoryGroup,
|
||||
}: CallsListProps): JSX.Element {
|
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||
const listRef = useRef<List>(null);
|
||||
const [queryInput, setQueryInput] = useState('');
|
||||
const [status, setStatus] = useState(CallHistoryFilterStatus.All);
|
||||
const [searchState, setSearchState] = useState(defaultInitState);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function search() {
|
||||
const options = {
|
||||
query: queryInput.toLowerCase().normalize().trim(),
|
||||
status,
|
||||
};
|
||||
|
||||
let timer = setTimeout(() => {
|
||||
setSearchState(prevSearchState => {
|
||||
if (prevSearchState.state === 'init') {
|
||||
return defaultPendingState;
|
||||
}
|
||||
return prevSearchState;
|
||||
});
|
||||
timer = setTimeout(() => {
|
||||
// Show loading indicator after a delay
|
||||
setSearchState(defaultPendingState);
|
||||
}, 300);
|
||||
}, 50);
|
||||
|
||||
let results: SearchResults | null = null;
|
||||
|
||||
try {
|
||||
const [count, items] = await Promise.all([
|
||||
getCallHistoryGroupsCount(options),
|
||||
getCallHistoryGroups(options, {
|
||||
offset: 0,
|
||||
limit: 100, // preloaded rows
|
||||
}),
|
||||
]);
|
||||
results = { count, items };
|
||||
} catch (error) {
|
||||
log.error('CallsList#fetchTotal error fetching', error);
|
||||
}
|
||||
|
||||
// Clear the loading indicator timeout
|
||||
clearTimeout(timer);
|
||||
|
||||
// Ignore old requests
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only commit the new search state once the results are ready
|
||||
setSearchState({
|
||||
state: results == null ? 'rejected' : 'fulfilled',
|
||||
options,
|
||||
results,
|
||||
});
|
||||
infiniteLoaderRef.current?.resetLoadMoreRowsCache(true);
|
||||
listRef.current?.scrollToPosition(0);
|
||||
}
|
||||
|
||||
drop(search());
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [getCallHistoryGroupsCount, getCallHistoryGroups, queryInput, status]);
|
||||
|
||||
const loadMoreRows = useCallback(
|
||||
async (props: IndexRange) => {
|
||||
const { state, options } = searchState;
|
||||
if (state !== 'fulfilled') {
|
||||
return;
|
||||
}
|
||||
strictAssert(
|
||||
options != null,
|
||||
'options should never be null when status is fulfilled'
|
||||
);
|
||||
|
||||
let { startIndex, stopIndex } = props;
|
||||
|
||||
if (startIndex > stopIndex) {
|
||||
// flip
|
||||
[startIndex, stopIndex] = [stopIndex, startIndex];
|
||||
}
|
||||
|
||||
const offset = startIndex;
|
||||
const limit = stopIndex - startIndex + 1;
|
||||
|
||||
try {
|
||||
const groups = await getCallHistoryGroups(options, { offset, limit });
|
||||
|
||||
if (searchState.options !== options) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchState(prevSearchState => {
|
||||
strictAssert(
|
||||
prevSearchState.results != null,
|
||||
'results should never be null here'
|
||||
);
|
||||
const newItems = prevSearchState.results.items.slice();
|
||||
newItems.splice(startIndex, stopIndex, ...groups);
|
||||
return {
|
||||
...prevSearchState,
|
||||
results: {
|
||||
...prevSearchState.results,
|
||||
items: newItems,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('CallsList#loadMoreRows error fetching', error);
|
||||
}
|
||||
},
|
||||
[getCallHistoryGroups, searchState]
|
||||
);
|
||||
|
||||
const isRowLoaded = useCallback(
|
||||
(props: Index) => {
|
||||
return searchState.results?.items[props.index] != null;
|
||||
},
|
||||
[searchState]
|
||||
);
|
||||
|
||||
const rowRenderer = useCallback(
|
||||
({ key, index, style }: ListRowProps) => {
|
||||
const item = searchState.results?.items.at(index) ?? null;
|
||||
const conversation = item != null ? getConversation(item.peerId) : null;
|
||||
|
||||
if (
|
||||
searchState.state === 'pending' ||
|
||||
item == null ||
|
||||
conversation == null
|
||||
) {
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
<ListTile
|
||||
leading={<div className="CallsList__LoadingAvatar" />}
|
||||
title={
|
||||
<span className="CallsList__LoadingText CallsList__LoadingText--title" />
|
||||
}
|
||||
subtitle={
|
||||
<span className="CallsList__LoadingText CallsList__LoadingText--subtitle" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSelected =
|
||||
selectedCallHistoryGroup != null &&
|
||||
isSameCallHistoryGroup(item, selectedCallHistoryGroup);
|
||||
|
||||
const wasMissed =
|
||||
item.direction === CallDirection.Incoming &&
|
||||
(item.status === DirectCallStatus.Missed ||
|
||||
item.status === GroupCallStatus.Missed);
|
||||
|
||||
let statusText;
|
||||
if (wasMissed) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
|
||||
} else if (item.type === CallType.Group) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
|
||||
} else if (item.direction === CallDirection.Outgoing) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--Outgoing');
|
||||
} else if (item.direction === CallDirection.Incoming) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--Incoming');
|
||||
} else {
|
||||
strictAssert(false, 'Cannot format call');
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={style}
|
||||
className={classNames('CallsList__Item', {
|
||||
'CallsList__Item--selected': isSelected,
|
||||
})}
|
||||
>
|
||||
<ListTile
|
||||
moduleClassName="CallsList__ItemTile"
|
||||
aria-selected={isSelected}
|
||||
leading={
|
||||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={conversation.avatarPath}
|
||||
conversationType="group"
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
title={conversation.title}
|
||||
sharedGroupNames={[]}
|
||||
size={AvatarSize.THIRTY_TWO}
|
||||
badge={undefined}
|
||||
className="CallsList__ItemAvatar"
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
<span
|
||||
className={classNames('CallsList__ItemIcon', {
|
||||
'CallsList__ItemIcon--Phone': item.type === CallType.Audio,
|
||||
'CallsList__ItemIcon--Video': item.type !== CallType.Audio,
|
||||
})}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<span
|
||||
className="CallsList__ItemTitle"
|
||||
data-call={formatCallHistoryGroup(item)}
|
||||
>
|
||||
<UserText text={conversation.title} />
|
||||
</span>
|
||||
}
|
||||
subtitle={
|
||||
<span
|
||||
className={classNames('CallsList__ItemCallInfo', {
|
||||
'CallsList__ItemCallInfo--missed': wasMissed,
|
||||
})}
|
||||
>
|
||||
{item.children.length > 1 ? `(${item.children.length}) ` : ''}
|
||||
{statusText} ·{' '}
|
||||
<Timestamp i18n={i18n} timestamp={item.timestamp} />
|
||||
</span>
|
||||
}
|
||||
onClick={() => {
|
||||
onSelectCallHistoryGroup(conversation.id, item);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
searchState,
|
||||
getConversation,
|
||||
selectedCallHistoryGroup,
|
||||
onSelectCallHistoryGroup,
|
||||
i18n,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSearchInputChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
setQueryInput(event.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchInputClear = useCallback(() => {
|
||||
setQueryInput('');
|
||||
}, []);
|
||||
|
||||
const handleStatusToggle = useCallback(() => {
|
||||
setStatus(prevStatus => {
|
||||
return prevStatus === CallHistoryFilterStatus.All
|
||||
? CallHistoryFilterStatus.Missed
|
||||
: CallHistoryFilterStatus.All;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filteringByMissed = status === CallHistoryFilterStatus.Missed;
|
||||
|
||||
const hasEmptyResults = searchState.results?.count === 0;
|
||||
const currentQuery = searchState.options?.query ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavSidebarSearchHeader>
|
||||
<SearchInput
|
||||
i18n={i18n}
|
||||
placeholder={i18n('icu:CallsList__SearchInputPlaceholder')}
|
||||
onChange={handleSearchInputChange}
|
||||
onClear={handleSearchInputClear}
|
||||
value={queryInput}
|
||||
/>
|
||||
<button
|
||||
className={classNames('CallsList__ToggleFilterByMissed', {
|
||||
'CallsList__ToggleFilterByMissed--pressed': filteringByMissed,
|
||||
})}
|
||||
type="button"
|
||||
aria-pressed={filteringByMissed}
|
||||
aria-roledescription={i18n(
|
||||
'icu:CallsList__ToggleFilterByMissed__RoleDescription'
|
||||
)}
|
||||
onClick={handleStatusToggle}
|
||||
>
|
||||
<span className="CallsList__ToggleFilterByMissedLabel">
|
||||
{i18n('icu:CallsList__ToggleFilterByMissedLabel')}
|
||||
</span>
|
||||
</button>
|
||||
</NavSidebarSearchHeader>
|
||||
|
||||
{hasEmptyResults && (
|
||||
<p className="CallsList__EmptyState">
|
||||
{currentQuery === '' ? (
|
||||
i18n('icu:CallsList__EmptyState--noQuery')
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:CallsList__EmptyState--hasQuery"
|
||||
components={{
|
||||
query: <UserText text={currentQuery} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SizeObserver>
|
||||
{(ref, size) => {
|
||||
return (
|
||||
<div className="CallsList__ListContainer" ref={ref}>
|
||||
{size != null && (
|
||||
<InfiniteLoader
|
||||
ref={infiniteLoaderRef}
|
||||
isRowLoaded={isRowLoaded}
|
||||
loadMoreRows={loadMoreRows}
|
||||
rowCount={searchState.results?.count}
|
||||
minimumBatchSize={100}
|
||||
threshold={30}
|
||||
>
|
||||
{({ onRowsRendered, registerChild }) => {
|
||||
return (
|
||||
<List
|
||||
className={classNames('CallsList__List', {
|
||||
'CallsList__List--loading':
|
||||
searchState.state === 'pending',
|
||||
})}
|
||||
ref={refMerger(listRef, registerChild)}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
rowCount={searchState.results?.count ?? 0}
|
||||
rowHeight={rowHeight}
|
||||
rowRenderer={rowRenderer}
|
||||
onRowsRendered={onRowsRendered}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</InfiniteLoader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SizeObserver>
|
||||
</>
|
||||
);
|
||||
}
|
266
ts/components/CallsNewCall.tsx
Normal file
266
ts/components/CallsNewCall.tsx
Normal file
|
@ -0,0 +1,266 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ChangeEvent } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { partition } from 'lodash';
|
||||
import type { ListRowProps } from 'react-virtualized';
|
||||
import { List } from 'react-virtualized';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
|
||||
import { NavSidebarSearchHeader } from './NavSidebar';
|
||||
import { ListTile } from './ListTile';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { UserText } from './UserText';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { Intl } from './Intl';
|
||||
import type { ActiveCallStateType } from '../state/ducks/calling';
|
||||
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||
|
||||
type CallsNewCallProps = Readonly<{
|
||||
activeCall: ActiveCallStateType | undefined;
|
||||
allConversations: ReadonlyArray<ConversationType>;
|
||||
i18n: LocalizerType;
|
||||
onSelectConversation: (conversationId: string) => void;
|
||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||
regionCode: string | undefined;
|
||||
}>;
|
||||
|
||||
type Row =
|
||||
| { kind: 'header'; title: string }
|
||||
| { kind: 'conversation'; conversation: ConversationType };
|
||||
|
||||
export function CallsNewCall({
|
||||
activeCall,
|
||||
allConversations,
|
||||
i18n,
|
||||
onSelectConversation,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
regionCode,
|
||||
}: CallsNewCallProps): JSX.Element {
|
||||
const [queryInput, setQueryInput] = useState('');
|
||||
|
||||
const query = useMemo(() => {
|
||||
return queryInput.toLowerCase().normalize().trim();
|
||||
}, [queryInput]);
|
||||
|
||||
const activeConversations = useMemo(() => {
|
||||
return allConversations.filter(conversation => {
|
||||
return conversation.activeAt != null && conversation.isArchived !== true;
|
||||
});
|
||||
}, [allConversations]);
|
||||
|
||||
const filteredConversations = useMemo(() => {
|
||||
if (query === '') {
|
||||
return activeConversations;
|
||||
}
|
||||
return filterAndSortConversationsByRecent(
|
||||
activeConversations,
|
||||
query,
|
||||
regionCode
|
||||
);
|
||||
}, [activeConversations, query, regionCode]);
|
||||
|
||||
const [groupConversations, directConversations] = useMemo(() => {
|
||||
return partition(filteredConversations, conversation => {
|
||||
return conversation.type === 'group';
|
||||
});
|
||||
}, [filteredConversations]);
|
||||
|
||||
const handleSearchInputChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
setQueryInput(event.currentTarget.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchInputClear = useCallback(() => {
|
||||
setQueryInput('');
|
||||
}, []);
|
||||
|
||||
const rows = useMemo((): ReadonlyArray<Row> => {
|
||||
let result: Array<Row> = [];
|
||||
if (directConversations.length > 0) {
|
||||
result.push({
|
||||
kind: 'header',
|
||||
title: 'Contacts',
|
||||
});
|
||||
result = result.concat(
|
||||
directConversations.map(conversation => {
|
||||
return {
|
||||
kind: 'conversation',
|
||||
conversation,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
if (groupConversations.length > 0) {
|
||||
result.push({
|
||||
kind: 'header',
|
||||
title: 'Groups',
|
||||
});
|
||||
result = result.concat(
|
||||
groupConversations.map((conversation): Row => {
|
||||
return {
|
||||
kind: 'conversation',
|
||||
conversation,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [directConversations, groupConversations]);
|
||||
|
||||
const isRowLoaded = useCallback(
|
||||
({ index }) => {
|
||||
return rows.at(index) != null;
|
||||
},
|
||||
[rows]
|
||||
);
|
||||
|
||||
const rowHeight = useCallback(
|
||||
({ index }) => {
|
||||
if (rows.at(index)?.kind === 'conversation') {
|
||||
return ListTile.heightCompact;
|
||||
}
|
||||
// Height of .CallsNewCall__ListHeaderItem
|
||||
return 40;
|
||||
},
|
||||
[rows]
|
||||
);
|
||||
|
||||
const rowRenderer = useCallback(
|
||||
({ key, index, style }: ListRowProps) => {
|
||||
const item = rows.at(index);
|
||||
strictAssert(item != null, 'Rendered non-existent row');
|
||||
|
||||
if (item.kind === 'header') {
|
||||
return (
|
||||
<div key={key} style={style} className="CallsNewCall__ListHeaderItem">
|
||||
{item.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const callButtonsDisabled = activeCall != null;
|
||||
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
<ListTile
|
||||
leading={
|
||||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={item.conversation.avatarPath}
|
||||
conversationType="group"
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
title={item.conversation.title}
|
||||
sharedGroupNames={[]}
|
||||
size={AvatarSize.THIRTY_TWO}
|
||||
badge={undefined}
|
||||
/>
|
||||
}
|
||||
title={<UserText text={item.conversation.title} />}
|
||||
trailing={
|
||||
<div className="CallsNewCall__ItemActions">
|
||||
{item.conversation.type === 'direct' && (
|
||||
<button
|
||||
type="button"
|
||||
className="CallsNewCall__ItemActionButton"
|
||||
aria-disabled={callButtonsDisabled}
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
if (!callButtonsDisabled) {
|
||||
onOutgoingAudioCallInConversation(item.conversation.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Phone" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="CallsNewCall__ItemActionButton"
|
||||
aria-disabled={callButtonsDisabled}
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
if (!callButtonsDisabled) {
|
||||
onOutgoingVideoCallInConversation(item.conversation.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Video" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
onClick={() => {
|
||||
onSelectConversation(item.conversation.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
rows,
|
||||
i18n,
|
||||
activeCall,
|
||||
onSelectConversation,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavSidebarSearchHeader>
|
||||
<SearchInput
|
||||
i18n={i18n}
|
||||
placeholder="Search"
|
||||
onChange={handleSearchInputChange}
|
||||
onClear={handleSearchInputClear}
|
||||
value={queryInput}
|
||||
/>
|
||||
</NavSidebarSearchHeader>
|
||||
{rows.length === 0 && (
|
||||
<div className="CallsNewCall__EmptyState">
|
||||
{query === '' ? (
|
||||
i18n('icu:CallsNewCall__EmptyState--noQuery')
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:CallsNewCall__EmptyState--hasQuery"
|
||||
components={{
|
||||
query: <UserText text={query} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<SizeObserver>
|
||||
{(ref, size) => {
|
||||
return (
|
||||
<div ref={ref} className="CallsNewCall__ListContainer">
|
||||
{size != null && (
|
||||
<List
|
||||
className="CallsNewCall__List"
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
isRowLoaded={isRowLoaded}
|
||||
rowCount={rows.length}
|
||||
rowHeight={rowHeight}
|
||||
rowRenderer={rowRenderer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SizeObserver>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
264
ts/components/CallsTab.tsx
Normal file
264
ts/components/CallsTab.tsx
Normal file
|
@ -0,0 +1,264 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
|
||||
import { CallsList } from './CallsList';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type {
|
||||
CallHistoryFilterOptions,
|
||||
CallHistoryGroup,
|
||||
CallHistoryPagination,
|
||||
} from '../types/CallDisposition';
|
||||
import { CallsNewCall } from './CallsNewCall';
|
||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||
import type { ActiveCallStateType } from '../state/ducks/calling';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
|
||||
enum CallsTabSidebarView {
|
||||
CallsListView,
|
||||
NewCallView,
|
||||
}
|
||||
|
||||
type CallsTabProps = Readonly<{
|
||||
activeCall: ActiveCallStateType | undefined;
|
||||
allConversations: ReadonlyArray<ConversationType>;
|
||||
getCallHistoryGroupsCount: (
|
||||
options: CallHistoryFilterOptions
|
||||
) => Promise<number>;
|
||||
getCallHistoryGroups: (
|
||||
options: CallHistoryFilterOptions,
|
||||
pagination: CallHistoryPagination
|
||||
) => Promise<Array<CallHistoryGroup>>;
|
||||
getConversation: (id: string) => ConversationType | void;
|
||||
i18n: LocalizerType;
|
||||
navTabsCollapsed: boolean;
|
||||
onClearCallHistory: () => void;
|
||||
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||
preferredLeftPaneWidth: number;
|
||||
renderConversationDetails: (
|
||||
conversationId: string,
|
||||
callHistoryGroup: CallHistoryGroup | null
|
||||
) => JSX.Element;
|
||||
regionCode: string | undefined;
|
||||
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
|
||||
}>;
|
||||
|
||||
export function CallsTab({
|
||||
activeCall,
|
||||
allConversations,
|
||||
getCallHistoryGroupsCount,
|
||||
getCallHistoryGroups,
|
||||
getConversation,
|
||||
i18n,
|
||||
navTabsCollapsed,
|
||||
onClearCallHistory,
|
||||
onToggleNavTabsCollapse,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
preferredLeftPaneWidth,
|
||||
renderConversationDetails,
|
||||
regionCode,
|
||||
savePreferredLeftPaneWidth,
|
||||
}: CallsTabProps): JSX.Element {
|
||||
const [sidebarView, setSidebarView] = useState(
|
||||
CallsTabSidebarView.CallsListView
|
||||
);
|
||||
const [selected, setSelected] = useState<{
|
||||
conversationId: string;
|
||||
callHistoryGroup: CallHistoryGroup | null;
|
||||
} | null>(null);
|
||||
const [
|
||||
confirmClearCallHistoryDialogOpen,
|
||||
setConfirmClearCallHistoryDialogOpen,
|
||||
] = useState(false);
|
||||
|
||||
const updateSidebarView = useCallback(
|
||||
(newSidebarView: CallsTabSidebarView) => {
|
||||
setSidebarView(newSidebarView);
|
||||
setSelected(null);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSelectCallHistoryGroup = useCallback(
|
||||
(conversationId: string, callHistoryGroup: CallHistoryGroup) => {
|
||||
setSelected({
|
||||
conversationId,
|
||||
callHistoryGroup,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSelectConversation = useCallback((conversationId: string) => {
|
||||
setSelected({ conversationId, callHistoryGroup: null });
|
||||
}, []);
|
||||
|
||||
useEscapeHandling(
|
||||
sidebarView === CallsTabSidebarView.NewCallView
|
||||
? () => {
|
||||
updateSidebarView(CallsTabSidebarView.CallsListView);
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
const handleOpenClearCallHistoryDialog = useCallback(() => {
|
||||
setConfirmClearCallHistoryDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseClearCallHistoryDialog = useCallback(() => {
|
||||
setConfirmClearCallHistoryDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOutgoingAudioCallInConversation = useCallback(
|
||||
(conversationId: string) => {
|
||||
onOutgoingAudioCallInConversation(conversationId);
|
||||
updateSidebarView(CallsTabSidebarView.CallsListView);
|
||||
},
|
||||
[updateSidebarView, onOutgoingAudioCallInConversation]
|
||||
);
|
||||
|
||||
const handleOutgoingVideoCallInConversation = useCallback(
|
||||
(conversationId: string) => {
|
||||
onOutgoingVideoCallInConversation(conversationId);
|
||||
updateSidebarView(CallsTabSidebarView.CallsListView);
|
||||
},
|
||||
[updateSidebarView, onOutgoingVideoCallInConversation]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="CallsTab">
|
||||
<NavSidebar
|
||||
i18n={i18n}
|
||||
title={
|
||||
sidebarView === CallsTabSidebarView.CallsListView
|
||||
? i18n('icu:CallsTab__HeaderTitle--CallsList')
|
||||
: i18n('icu:CallsTab__HeaderTitle--NewCall')
|
||||
}
|
||||
navTabsCollapsed={navTabsCollapsed}
|
||||
onBack={
|
||||
sidebarView === CallsTabSidebarView.NewCallView
|
||||
? () => {
|
||||
updateSidebarView(CallsTabSidebarView.CallsListView);
|
||||
}
|
||||
: null
|
||||
}
|
||||
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||
requiresFullWidth
|
||||
preferredLeftPaneWidth={preferredLeftPaneWidth}
|
||||
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||
actions={
|
||||
<>
|
||||
{sidebarView === CallsTabSidebarView.CallsListView && (
|
||||
<>
|
||||
<NavSidebarActionButton
|
||||
icon={<span className="CallsTab__NewCallActionIcon" />}
|
||||
label={i18n('icu:CallsTab__NewCallActionLabel')}
|
||||
onClick={() => {
|
||||
updateSidebarView(CallsTabSidebarView.NewCallView);
|
||||
}}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'CallsTab__ClearCallHistoryIcon',
|
||||
label: i18n('icu:CallsTab__ClearCallHistoryLabel'),
|
||||
onClick: handleOpenClearCallHistoryDialog,
|
||||
},
|
||||
]}
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
portalToRoot
|
||||
>
|
||||
{({ openMenu, onKeyDown }) => {
|
||||
return (
|
||||
<NavSidebarActionButton
|
||||
onClick={openMenu}
|
||||
onKeyDown={onKeyDown}
|
||||
icon={<span className="CallsTab__MoreActionsIcon" />}
|
||||
label={i18n('icu:CallsTab__MoreActionsLabel')}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ContextMenu>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{sidebarView === CallsTabSidebarView.CallsListView && (
|
||||
<CallsList
|
||||
key={CallsTabSidebarView.CallsListView}
|
||||
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
||||
getCallHistoryGroups={getCallHistoryGroups}
|
||||
getConversation={getConversation}
|
||||
i18n={i18n}
|
||||
selectedCallHistoryGroup={selected?.callHistoryGroup ?? null}
|
||||
onSelectCallHistoryGroup={handleSelectCallHistoryGroup}
|
||||
/>
|
||||
)}
|
||||
{sidebarView === CallsTabSidebarView.NewCallView && (
|
||||
<CallsNewCall
|
||||
key={CallsTabSidebarView.NewCallView}
|
||||
activeCall={activeCall}
|
||||
allConversations={allConversations}
|
||||
i18n={i18n}
|
||||
regionCode={regionCode}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onOutgoingAudioCallInConversation={
|
||||
handleOutgoingAudioCallInConversation
|
||||
}
|
||||
onOutgoingVideoCallInConversation={
|
||||
handleOutgoingVideoCallInConversation
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</NavSidebar>
|
||||
{selected == null ? (
|
||||
<div className="CallsTab__EmptyState">
|
||||
{i18n('icu:CallsTab__EmptyStateText')}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="CallsTab__ConversationCallDetails"
|
||||
// Force scrolling to top when a new conversation is selected.
|
||||
key={selected.conversationId}
|
||||
>
|
||||
{renderConversationDetails(
|
||||
selected.conversationId,
|
||||
selected.callHistoryGroup
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{confirmClearCallHistoryDialogOpen && (
|
||||
<ConfirmationDialog
|
||||
dialogName="CallsTab__ConfirmClearCallHistory"
|
||||
i18n={i18n}
|
||||
onClose={handleCloseClearCallHistoryDialog}
|
||||
title={i18n('icu:CallsTab__ConfirmClearCallHistory__Title')}
|
||||
actions={[
|
||||
{
|
||||
style: 'negative',
|
||||
text: i18n(
|
||||
'icu:CallsTab__ConfirmClearCallHistory__ConfirmButton'
|
||||
),
|
||||
action: onClearCallHistory,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{i18n('icu:CallsTab__ConfirmClearCallHistory__Body')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
68
ts/components/ChatsTab.tsx
Normal file
68
ts/components/ChatsTab.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { Environment, getEnvironment } from '../environment';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import type { NavTabPanelProps } from './NavTabs';
|
||||
import { WhatsNewLink } from './WhatsNewLink';
|
||||
|
||||
type ChatsTabProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
navTabsCollapsed: boolean;
|
||||
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||
prevConversationId: string | undefined;
|
||||
renderConversationView: () => JSX.Element;
|
||||
renderLeftPane: (props: NavTabPanelProps) => JSX.Element;
|
||||
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
|
||||
selectedConversationId: string | undefined;
|
||||
showWhatsNewModal: () => unknown;
|
||||
}>;
|
||||
|
||||
export function ChatsTab({
|
||||
i18n,
|
||||
navTabsCollapsed,
|
||||
onToggleNavTabsCollapse,
|
||||
prevConversationId,
|
||||
renderConversationView,
|
||||
renderLeftPane,
|
||||
renderMiniPlayer,
|
||||
selectedConversationId,
|
||||
showWhatsNewModal,
|
||||
}: ChatsTabProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div id="LeftPane">
|
||||
{renderLeftPane({
|
||||
collapsed: navTabsCollapsed,
|
||||
onToggleCollapse: onToggleNavTabsCollapse,
|
||||
})}
|
||||
</div>
|
||||
<div className="Inbox__conversation-stack">
|
||||
<div id="toast" />
|
||||
{selectedConversationId && (
|
||||
<div
|
||||
className="Inbox__conversation"
|
||||
id={`conversation-${selectedConversationId}`}
|
||||
>
|
||||
{renderConversationView()}
|
||||
</div>
|
||||
)}
|
||||
{!prevConversationId && (
|
||||
<div className="Inbox__no-conversation-open">
|
||||
{renderMiniPlayer({ shouldFlow: false })}
|
||||
<div className="module-splash-screen__logo module-img--128 module-logo-blue" />
|
||||
<h3>
|
||||
{getEnvironment() !== Environment.Staging
|
||||
? i18n('icu:welcomeToSignal')
|
||||
: 'THIS IS A STAGING DESKTOP'}
|
||||
</h3>
|
||||
<p>
|
||||
<WhatsNewLink i18n={i18n} showWhatsNewModal={showWhatsNewModal} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -291,13 +291,18 @@ export function ContextMenu<T>({
|
|||
let buttonNode: JSX.Element;
|
||||
|
||||
if (typeof children === 'function') {
|
||||
buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({
|
||||
openMenu: onClick || handleClick,
|
||||
onKeyDown: handleKeyDown,
|
||||
isMenuShowing,
|
||||
ref: setReferenceElement,
|
||||
menuNode,
|
||||
});
|
||||
buttonNode = (
|
||||
<>
|
||||
{(children as (props: RenderButtonProps) => JSX.Element)({
|
||||
openMenu: onClick || handleClick,
|
||||
onKeyDown: handleKeyDown,
|
||||
isMenuShowing,
|
||||
ref: setReferenceElement,
|
||||
menuNode,
|
||||
})}
|
||||
{portalNode ? createPortal(menuNode, portalNode) : menuNode}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
buttonNode = (
|
||||
<div
|
||||
|
|
|
@ -12,7 +12,7 @@ import { assertDev } from '../util/assert';
|
|||
import type { ParsedE164Type } from '../util/libphonenumberInstance';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import { ScrollBehavior } from '../types/Util';
|
||||
import { getConversationListWidthBreakpoint } from './_util';
|
||||
import { getNavSidebarWidthBreakpoint } from './_util';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
|
||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||
|
@ -493,7 +493,7 @@ export function ConversationList({
|
|||
return null;
|
||||
}
|
||||
|
||||
const widthBreakpoint = getConversationListWidthBreakpoint(dimensions.width);
|
||||
const widthBreakpoint = getNavSidebarWidthBreakpoint(dimensions.width);
|
||||
|
||||
return (
|
||||
<ListView
|
||||
|
|
|
@ -84,10 +84,7 @@ const Template: Story<PropsType & { daysAgo?: number }> = ({
|
|||
{...args}
|
||||
firstEnvelopeTimestamp={firstEnvelopeTimestamp}
|
||||
envelopeTimestamp={envelopeTimestamp}
|
||||
renderConversationView={() => <div />}
|
||||
renderCustomizingPreferredReactionsModal={() => <div />}
|
||||
renderLeftPane={() => <div />}
|
||||
renderMiniPlayer={() => <div />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,19 +3,10 @@
|
|||
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import { SECOND, DAY } from '../util/durations';
|
||||
import { ToastStickerPackInstallFailed } from './ToastStickerPackInstallFailed';
|
||||
import { WhatsNewLink } from './WhatsNewLink';
|
||||
import { showToast } from '../util/showToast';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { TargetedMessageSource } from '../state/ducks/conversationsEnums';
|
||||
import { usePrevious } from '../hooks/usePrevious';
|
||||
import { Environment, getEnvironment } from '../environment';
|
||||
import type { SmartNavTabsProps } from '../state/smart/NavTabs';
|
||||
|
||||
export type PropsType = {
|
||||
firstEnvelopeTimestamp: number | undefined;
|
||||
|
@ -23,18 +14,13 @@ export type PropsType = {
|
|||
hasInitialLoadCompleted: boolean;
|
||||
i18n: LocalizerType;
|
||||
isCustomizingPreferredReactions: boolean;
|
||||
onConversationClosed: (id: string, reason: string) => unknown;
|
||||
onConversationOpened: (id: string, messageId?: string) => unknown;
|
||||
renderConversationView: () => JSX.Element;
|
||||
navTabsCollapsed: boolean;
|
||||
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => unknown;
|
||||
renderCallsTab: () => JSX.Element;
|
||||
renderChatsTab: () => JSX.Element;
|
||||
renderCustomizingPreferredReactionsModal: () => JSX.Element;
|
||||
renderLeftPane: () => JSX.Element;
|
||||
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
|
||||
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
||||
selectedConversationId?: string;
|
||||
targetedMessage?: string;
|
||||
targetedMessageSource?: TargetedMessageSource;
|
||||
showConversation: ShowConversationType;
|
||||
showWhatsNewModal: () => unknown;
|
||||
renderNavTabs: (props: SmartNavTabsProps) => JSX.Element;
|
||||
renderStoriesTab: () => JSX.Element;
|
||||
};
|
||||
|
||||
export function Inbox({
|
||||
|
@ -43,27 +29,17 @@ export function Inbox({
|
|||
hasInitialLoadCompleted,
|
||||
i18n,
|
||||
isCustomizingPreferredReactions,
|
||||
onConversationClosed,
|
||||
onConversationOpened,
|
||||
renderConversationView,
|
||||
navTabsCollapsed,
|
||||
onToggleNavTabsCollapse,
|
||||
renderCallsTab,
|
||||
renderChatsTab,
|
||||
renderCustomizingPreferredReactionsModal,
|
||||
renderLeftPane,
|
||||
renderMiniPlayer,
|
||||
scrollToMessage,
|
||||
selectedConversationId,
|
||||
targetedMessage,
|
||||
targetedMessageSource,
|
||||
showConversation,
|
||||
showWhatsNewModal,
|
||||
renderNavTabs,
|
||||
renderStoriesTab,
|
||||
}: PropsType): JSX.Element {
|
||||
const [internalHasInitialLoadCompleted, setInternalHasInitialLoadCompleted] =
|
||||
useState(hasInitialLoadCompleted);
|
||||
|
||||
const prevConversationId = usePrevious(
|
||||
selectedConversationId,
|
||||
selectedConversationId
|
||||
);
|
||||
|
||||
const now = useMemo(() => Date.now(), []);
|
||||
const midnight = useMemo(() => {
|
||||
const date = new Date(now);
|
||||
|
@ -74,80 +50,6 @@ export function Inbox({
|
|||
return date.getTime();
|
||||
}, [now]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevConversationId !== selectedConversationId) {
|
||||
if (prevConversationId) {
|
||||
onConversationClosed(prevConversationId, 'opened another conversation');
|
||||
}
|
||||
|
||||
if (selectedConversationId) {
|
||||
onConversationOpened(selectedConversationId, targetedMessage);
|
||||
}
|
||||
} else if (
|
||||
selectedConversationId &&
|
||||
targetedMessage &&
|
||||
targetedMessageSource !== TargetedMessageSource.Focus
|
||||
) {
|
||||
scrollToMessage(selectedConversationId, targetedMessage);
|
||||
}
|
||||
|
||||
if (!selectedConversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = window.ConversationController.get(
|
||||
selectedConversationId
|
||||
);
|
||||
strictAssert(conversation, 'Conversation must be found');
|
||||
|
||||
conversation.setMarkedUnread(false);
|
||||
}, [
|
||||
onConversationClosed,
|
||||
onConversationOpened,
|
||||
prevConversationId,
|
||||
scrollToMessage,
|
||||
selectedConversationId,
|
||||
targetedMessage,
|
||||
targetedMessageSource,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
function refreshConversation({
|
||||
newId,
|
||||
oldId,
|
||||
}: {
|
||||
newId: string;
|
||||
oldId: string;
|
||||
}) {
|
||||
if (prevConversationId === oldId) {
|
||||
showConversation({ conversationId: newId });
|
||||
}
|
||||
}
|
||||
|
||||
// Close current opened conversation to reload the group information once
|
||||
// linked.
|
||||
function unload() {
|
||||
if (!prevConversationId) {
|
||||
return;
|
||||
}
|
||||
onConversationClosed(prevConversationId, 'force unload requested');
|
||||
}
|
||||
|
||||
function packInstallFailed() {
|
||||
showToast(ToastStickerPackInstallFailed);
|
||||
}
|
||||
|
||||
window.Whisper.events.on('pack-install-failed', packInstallFailed);
|
||||
window.Whisper.events.on('refreshConversation', refreshConversation);
|
||||
window.Whisper.events.on('setupAsNewDevice', unload);
|
||||
|
||||
return () => {
|
||||
window.Whisper.events.off('pack-install-failed', packInstallFailed);
|
||||
window.Whisper.events.off('refreshConversation', refreshConversation);
|
||||
window.Whisper.events.off('setupAsNewDevice', unload);
|
||||
};
|
||||
}, [onConversationClosed, prevConversationId, showConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (internalHasInitialLoadCompleted) {
|
||||
return;
|
||||
|
@ -186,12 +88,6 @@ export function Inbox({
|
|||
setInternalHasInitialLoadCompleted(hasInitialLoadCompleted);
|
||||
}, [hasInitialLoadCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedConversationId) {
|
||||
window.SignalCI?.handleEvent('empty-inbox:rendered', null);
|
||||
}
|
||||
}, [selectedConversationId]);
|
||||
|
||||
if (!internalHasInitialLoadCompleted) {
|
||||
let loadingProgress = 0;
|
||||
if (
|
||||
|
@ -264,37 +160,13 @@ export function Inbox({
|
|||
<>
|
||||
<div className="Inbox">
|
||||
<div className="module-title-bar-drag-area" />
|
||||
|
||||
<div id="LeftPane">{renderLeftPane()}</div>
|
||||
|
||||
<div className="Inbox__conversation-stack">
|
||||
<div id="toast" />
|
||||
{selectedConversationId && (
|
||||
<div
|
||||
className="Inbox__conversation"
|
||||
id={`conversation-${selectedConversationId}`}
|
||||
>
|
||||
{renderConversationView()}
|
||||
</div>
|
||||
)}
|
||||
{!prevConversationId && (
|
||||
<div className="Inbox__no-conversation-open">
|
||||
{renderMiniPlayer({ shouldFlow: false })}
|
||||
<div className="module-splash-screen__logo module-img--128 module-logo-blue" />
|
||||
<h3>
|
||||
{getEnvironment() !== Environment.Staging
|
||||
? i18n('icu:welcomeToSignal')
|
||||
: 'THIS IS A STAGING DESKTOP'}
|
||||
</h3>
|
||||
<p>
|
||||
<WhatsNewLink
|
||||
i18n={i18n}
|
||||
showWhatsNewModal={showWhatsNewModal}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderNavTabs({
|
||||
navTabsCollapsed,
|
||||
onToggleNavTabsCollapse,
|
||||
renderChatsTab,
|
||||
renderCallsTab,
|
||||
renderStoriesTab,
|
||||
})}
|
||||
</div>
|
||||
{activeModal}
|
||||
</>
|
||||
|
|
|
@ -165,6 +165,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
|||
),
|
||||
isUpdateDownloaded,
|
||||
isContactManagementEnabled,
|
||||
navTabsCollapsed: boolean('navTabsCollapsed', false),
|
||||
|
||||
setChallengeStatus: action('setChallengeStatus'),
|
||||
lookupConversationWithoutUuid: makeFakeLookupConversationWithoutUuid(),
|
||||
|
@ -179,7 +180,6 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
|||
'onOutgoingVideoCallInConversation'
|
||||
),
|
||||
removeConversation: action('removeConversation'),
|
||||
renderMainHeader: () => <div />,
|
||||
renderMessageSearchResult: (id: string) => (
|
||||
<MessageSearchResult
|
||||
body="Lorem ipsum wow"
|
||||
|
@ -273,6 +273,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
|||
toggleConversationInChooseMembers: action(
|
||||
'toggleConversationInChooseMembers'
|
||||
),
|
||||
toggleNavTabsCollapse: action('toggleNavTabsCollapse'),
|
||||
updateSearchTerm: action('updateSearchTerm'),
|
||||
|
||||
...overrideProps,
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
// Copyright 2019 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useEffect, useCallback, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useCallback, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { clamp, isNumber, noop } from 'lodash';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
import type { LeftPaneHelper, ToFindType } from './leftPane/LeftPaneHelper';
|
||||
import { FindDirection } from './leftPane/LeftPaneHelper';
|
||||
|
@ -27,15 +27,8 @@ import { usePrevious } from '../hooks/usePrevious';
|
|||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import type { DurationInSeconds } from '../util/durations';
|
||||
import type { WidthBreakpoint } from './_util';
|
||||
import { getConversationListWidthBreakpoint } from './_util';
|
||||
import { getNavSidebarWidthBreakpoint } from './_util';
|
||||
import * as KeyboardLayout from '../services/keyboardLayout';
|
||||
import {
|
||||
MIN_WIDTH,
|
||||
SNAP_WIDTH,
|
||||
MIN_FULL_WIDTH,
|
||||
MAX_WIDTH,
|
||||
getWidthFromPreferredWidth,
|
||||
} from '../util/leftPaneWidth';
|
||||
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
|
||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||
import type { PropsType as UnsupportedOSDialogPropsType } from '../state/smart/UnsupportedOSDialog';
|
||||
|
@ -50,6 +43,12 @@ import type {
|
|||
SaveAvatarToDiskActionType,
|
||||
} from '../types/Avatar';
|
||||
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||
import {
|
||||
NavSidebar,
|
||||
NavSidebarActionButton,
|
||||
NavSidebarSearchHeader,
|
||||
} from './NavSidebar';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
|
||||
export enum LeftPaneMode {
|
||||
Inbox,
|
||||
|
@ -114,6 +113,7 @@ export type PropsType = {
|
|||
composeReplaceAvatar: ReplaceAvatarActionType;
|
||||
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
|
||||
createGroup: () => void;
|
||||
navTabsCollapsed: boolean;
|
||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||
removeConversation: (conversationId: string) => void;
|
||||
|
@ -132,10 +132,10 @@ export type PropsType = {
|
|||
startSettingGroupMetadata: () => void;
|
||||
toggleComposeEditingAvatar: () => unknown;
|
||||
toggleConversationInChooseMembers: (conversationId: string) => void;
|
||||
toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||
updateSearchTerm: (_: string) => void;
|
||||
|
||||
// Render Props
|
||||
renderMainHeader: () => JSX.Element;
|
||||
renderMessageSearchResult: (id: string) => JSX.Element;
|
||||
renderNetworkStatus: (
|
||||
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||
|
@ -178,14 +178,15 @@ export function LeftPane({
|
|||
isUpdateDownloaded,
|
||||
isContactManagementEnabled,
|
||||
modeSpecificProps,
|
||||
navTabsCollapsed,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
|
||||
preferredWidthFromStorage,
|
||||
removeConversation,
|
||||
renderCaptchaDialog,
|
||||
renderCrashReportDialog,
|
||||
renderExpiredBuildDialog,
|
||||
renderMainHeader,
|
||||
renderMessageSearchResult,
|
||||
renderNetworkStatus,
|
||||
renderUnsupportedOSDialog,
|
||||
|
@ -195,6 +196,7 @@ export function LeftPane({
|
|||
searchInConversation,
|
||||
selectedConversationId,
|
||||
targetedMessageId,
|
||||
toggleNavTabsCollapse,
|
||||
setChallengeStatus,
|
||||
setComposeGroupAvatar,
|
||||
setComposeGroupExpireTimer,
|
||||
|
@ -215,12 +217,6 @@ export function LeftPane({
|
|||
unsupportedOSDialogType,
|
||||
updateSearchTerm,
|
||||
}: PropsType): JSX.Element {
|
||||
const [preferredWidth, setPreferredWidth] = useState(
|
||||
// This clamp is present just in case we get a bogus value from storage.
|
||||
clamp(preferredWidthFromStorage, MIN_WIDTH, MAX_WIDTH)
|
||||
);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
const previousModeSpecificProps = usePrevious(
|
||||
modeSpecificProps,
|
||||
modeSpecificProps
|
||||
|
@ -421,76 +417,6 @@ export function LeftPane({
|
|||
startSearch,
|
||||
]);
|
||||
|
||||
const requiresFullWidth = helper.requiresFullWidth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
let width: number;
|
||||
|
||||
const isRTL = i18n.getLocaleDirection() === 'rtl';
|
||||
const x = isRTL ? window.innerWidth - event.clientX : event.clientX;
|
||||
|
||||
if (requiresFullWidth) {
|
||||
width = Math.max(x, MIN_FULL_WIDTH);
|
||||
} else if (x < SNAP_WIDTH) {
|
||||
width = MIN_WIDTH;
|
||||
} else {
|
||||
width = clamp(x, MIN_FULL_WIDTH, MAX_WIDTH);
|
||||
}
|
||||
setPreferredWidth(Math.min(width, MAX_WIDTH));
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const stopResizing = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
document.body.addEventListener('mousemove', onMouseMove);
|
||||
document.body.addEventListener('mouseup', stopResizing);
|
||||
document.body.addEventListener('mouseleave', stopResizing);
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener('mousemove', onMouseMove);
|
||||
document.body.removeEventListener('mouseup', stopResizing);
|
||||
document.body.removeEventListener('mouseleave', stopResizing);
|
||||
};
|
||||
}, [i18n, isResizing, requiresFullWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
document.body.classList.add('is-resizing-left-pane');
|
||||
return () => {
|
||||
document.body.classList.remove('is-resizing-left-pane');
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResizing || preferredWidth === preferredWidthFromStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
savePreferredLeftPaneWidth(preferredWidth);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [
|
||||
isResizing,
|
||||
preferredWidth,
|
||||
preferredWidthFromStorage,
|
||||
savePreferredLeftPaneWidth,
|
||||
]);
|
||||
|
||||
const preRowsNode = helper.getPreRowsNode({
|
||||
clearConversationSearch,
|
||||
clearGroupCreationError,
|
||||
|
@ -553,11 +479,7 @@ export function LeftPane({
|
|||
// It also ensures that we scroll to the top when switching views.
|
||||
const listKey = preRowsNode ? 1 : 0;
|
||||
|
||||
const width = getWidthFromPreferredWidth(preferredWidth, {
|
||||
requiresFullWidth,
|
||||
});
|
||||
|
||||
const widthBreakpoint = getConversationListWidthBreakpoint(width);
|
||||
const widthBreakpoint = getNavSidebarWidthBreakpoint(300);
|
||||
|
||||
const commonDialogProps = {
|
||||
i18n,
|
||||
|
@ -614,127 +536,171 @@ export function LeftPane({
|
|||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={classNames(
|
||||
'module-left-pane',
|
||||
isResizing && 'module-left-pane--is-resizing',
|
||||
`module-left-pane--width-${widthBreakpoint}`,
|
||||
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers &&
|
||||
'module-left-pane--mode-choose-group-members',
|
||||
modeSpecificProps.mode === LeftPaneMode.Compose &&
|
||||
'module-left-pane--mode-compose'
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
|
||||
<div className="module-left-pane__header">
|
||||
{helper.getHeaderContents({
|
||||
i18n,
|
||||
showInbox,
|
||||
startComposing,
|
||||
showChooseGroupMembers,
|
||||
}) || renderMainHeader()}
|
||||
</div>
|
||||
{helper.getSearchInput({
|
||||
clearConversationSearch,
|
||||
clearSearch,
|
||||
i18n,
|
||||
onChangeComposeSearchTerm: event => {
|
||||
setComposeSearchTerm(event.target.value);
|
||||
},
|
||||
updateSearchTerm,
|
||||
showConversation,
|
||||
})}
|
||||
<div className="module-left-pane__dialogs">
|
||||
{dialogs.map(({ key, dialog }) => (
|
||||
<React.Fragment key={key}>{dialog}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
|
||||
<SizeObserver>
|
||||
{(ref, size) => (
|
||||
<div className="module-left-pane__list--measure" ref={ref}>
|
||||
<div className="module-left-pane__list--wrapper">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="module-left-pane__list"
|
||||
data-supertab
|
||||
key={listKey}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ConversationList
|
||||
dimensions={{
|
||||
width,
|
||||
height: size?.height || 0,
|
||||
}}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
getRow={getRow}
|
||||
i18n={i18n}
|
||||
onClickArchiveButton={showArchivedConversations}
|
||||
onClickContactCheckbox={(
|
||||
conversationId: string,
|
||||
disabledReason: undefined | ContactCheckboxDisabledReason
|
||||
) => {
|
||||
switch (disabledReason) {
|
||||
case undefined:
|
||||
toggleConversationInChooseMembers(conversationId);
|
||||
break;
|
||||
case ContactCheckboxDisabledReason.AlreadyAdded:
|
||||
case ContactCheckboxDisabledReason.MaximumContactsSelected:
|
||||
// These are no-ops.
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(disabledReason);
|
||||
}
|
||||
}}
|
||||
showUserNotFoundModal={showUserNotFoundModal}
|
||||
setIsFetchingUUID={setIsFetchingUUID}
|
||||
lookupConversationWithoutUuid={lookupConversationWithoutUuid}
|
||||
showConversation={showConversation}
|
||||
blockConversation={blockConversation}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onOutgoingAudioCallInConversation={
|
||||
onOutgoingAudioCallInConversation
|
||||
}
|
||||
onOutgoingVideoCallInConversation={
|
||||
onOutgoingVideoCallInConversation
|
||||
}
|
||||
removeConversation={
|
||||
isContactManagementEnabled ? removeConversation : undefined
|
||||
}
|
||||
renderMessageSearchResult={renderMessageSearchResult}
|
||||
rowCount={helper.getRowCount()}
|
||||
scrollBehavior={scrollBehavior}
|
||||
scrollToRowIndex={rowIndexToScrollTo}
|
||||
scrollable={isScrollable}
|
||||
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
|
||||
showChooseGroupMembers={showChooseGroupMembers}
|
||||
theme={theme}
|
||||
<NavSidebar
|
||||
title="Chats"
|
||||
hideHeader={
|
||||
modeSpecificProps.mode === LeftPaneMode.Archive ||
|
||||
modeSpecificProps.mode === LeftPaneMode.Compose ||
|
||||
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers ||
|
||||
modeSpecificProps.mode === LeftPaneMode.SetGroupMetadata
|
||||
}
|
||||
i18n={i18n}
|
||||
navTabsCollapsed={navTabsCollapsed}
|
||||
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
||||
preferredLeftPaneWidth={preferredWidthFromStorage}
|
||||
requiresFullWidth={false}
|
||||
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||
actions={
|
||||
<>
|
||||
<NavSidebarActionButton
|
||||
label={i18n('icu:newConversation')}
|
||||
icon={<span className="module-left-pane__startComposingIcon" />}
|
||||
onClick={startComposing}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
label: i18n('icu:avatarMenuViewArchive'),
|
||||
onClick: showArchivedConversations,
|
||||
},
|
||||
]}
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
portalToRoot
|
||||
>
|
||||
{({ openMenu, onKeyDown }) => {
|
||||
return (
|
||||
<NavSidebarActionButton
|
||||
onClick={openMenu}
|
||||
onKeyDown={onKeyDown}
|
||||
icon={<span className="module-left-pane__moreActionsIcon" />}
|
||||
label="More Actions"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ContextMenu>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<nav
|
||||
className={classNames(
|
||||
'module-left-pane',
|
||||
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers &&
|
||||
'module-left-pane--mode-choose-group-members',
|
||||
modeSpecificProps.mode === LeftPaneMode.Compose &&
|
||||
'module-left-pane--mode-compose'
|
||||
)}
|
||||
>
|
||||
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
|
||||
<div className="module-left-pane__header">
|
||||
{helper.getHeaderContents({
|
||||
i18n,
|
||||
showInbox,
|
||||
startComposing,
|
||||
showChooseGroupMembers,
|
||||
})}
|
||||
</div>
|
||||
<NavSidebarSearchHeader>
|
||||
{helper.getSearchInput({
|
||||
clearConversationSearch,
|
||||
clearSearch,
|
||||
i18n,
|
||||
onChangeComposeSearchTerm: event => {
|
||||
setComposeSearchTerm(event.target.value);
|
||||
},
|
||||
updateSearchTerm,
|
||||
showConversation,
|
||||
})}
|
||||
</NavSidebarSearchHeader>
|
||||
<div className="module-left-pane__dialogs">
|
||||
{dialogs.map(({ key, dialog }) => (
|
||||
<React.Fragment key={key}>{dialog}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
|
||||
<SizeObserver>
|
||||
{(ref, size) => (
|
||||
<div className="module-left-pane__list--measure" ref={ref}>
|
||||
<div className="module-left-pane__list--wrapper">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="module-left-pane__list"
|
||||
data-supertab
|
||||
key={listKey}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ConversationList
|
||||
dimensions={size ?? undefined}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
getRow={getRow}
|
||||
i18n={i18n}
|
||||
onClickArchiveButton={showArchivedConversations}
|
||||
onClickContactCheckbox={(
|
||||
conversationId: string,
|
||||
disabledReason: undefined | ContactCheckboxDisabledReason
|
||||
) => {
|
||||
switch (disabledReason) {
|
||||
case undefined:
|
||||
toggleConversationInChooseMembers(conversationId);
|
||||
break;
|
||||
case ContactCheckboxDisabledReason.AlreadyAdded:
|
||||
case ContactCheckboxDisabledReason.MaximumContactsSelected:
|
||||
// These are no-ops.
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(disabledReason);
|
||||
}
|
||||
}}
|
||||
showUserNotFoundModal={showUserNotFoundModal}
|
||||
setIsFetchingUUID={setIsFetchingUUID}
|
||||
lookupConversationWithoutUuid={
|
||||
lookupConversationWithoutUuid
|
||||
}
|
||||
showConversation={showConversation}
|
||||
blockConversation={blockConversation}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onOutgoingAudioCallInConversation={
|
||||
onOutgoingAudioCallInConversation
|
||||
}
|
||||
onOutgoingVideoCallInConversation={
|
||||
onOutgoingVideoCallInConversation
|
||||
}
|
||||
removeConversation={
|
||||
isContactManagementEnabled
|
||||
? removeConversation
|
||||
: undefined
|
||||
}
|
||||
renderMessageSearchResult={renderMessageSearchResult}
|
||||
rowCount={helper.getRowCount()}
|
||||
scrollBehavior={scrollBehavior}
|
||||
scrollToRowIndex={rowIndexToScrollTo}
|
||||
scrollable={isScrollable}
|
||||
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
|
||||
showChooseGroupMembers={showChooseGroupMembers}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SizeObserver>
|
||||
{footerContents && (
|
||||
<div className="module-left-pane__footer">{footerContents}</div>
|
||||
)}
|
||||
</SizeObserver>
|
||||
{footerContents && (
|
||||
<div className="module-left-pane__footer">{footerContents}</div>
|
||||
)}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="module-left-pane__resize-grab-area"
|
||||
onMouseDown={() => {
|
||||
setIsResizing(true);
|
||||
}}
|
||||
/>
|
||||
{challengeStatus !== 'idle' &&
|
||||
renderCaptchaDialog({
|
||||
onSkip() {
|
||||
setChallengeStatus('idle');
|
||||
},
|
||||
})}
|
||||
{crashReportCount > 0 && renderCrashReportDialog()}
|
||||
</nav>
|
||||
|
||||
{challengeStatus !== 'idle' &&
|
||||
renderCaptchaDialog({
|
||||
onSkip() {
|
||||
setChallengeStatus('idle');
|
||||
},
|
||||
})}
|
||||
{crashReportCount > 0 && renderCrashReportDialog()}
|
||||
</nav>
|
||||
</NavSidebar>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Meta, Story } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { PropsType } from './MainHeader';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { MainHeader } from './MainHeader';
|
||||
import { ThemeType } from '../types/Util';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
title: 'Components/MainHeader',
|
||||
component: MainHeader,
|
||||
argTypes: {
|
||||
areStoriesEnabled: {
|
||||
defaultValue: false,
|
||||
},
|
||||
avatarPath: {
|
||||
defaultValue: undefined,
|
||||
},
|
||||
hasPendingUpdate: {
|
||||
defaultValue: false,
|
||||
},
|
||||
i18n: {
|
||||
defaultValue: i18n,
|
||||
},
|
||||
name: {
|
||||
defaultValue: undefined,
|
||||
},
|
||||
phoneNumber: {
|
||||
defaultValue: undefined,
|
||||
},
|
||||
showArchivedConversations: { action: true },
|
||||
startComposing: { action: true },
|
||||
startUpdate: { action: true },
|
||||
theme: {
|
||||
defaultValue: ThemeType.light,
|
||||
},
|
||||
title: {
|
||||
defaultValue: '',
|
||||
},
|
||||
toggleProfileEditor: { action: true },
|
||||
toggleStoriesView: { action: true },
|
||||
unreadStoriesCount: {
|
||||
defaultValue: 0,
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
const Template: Story<PropsType> = args => <MainHeader {...args} />;
|
||||
|
||||
export const Basic = Template.bind({});
|
||||
Basic.args = {};
|
||||
|
||||
export const Name = Template.bind({});
|
||||
{
|
||||
const { name, title } = getDefaultConversation();
|
||||
Name.args = {
|
||||
name,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
export const PhoneNumber = Template.bind({});
|
||||
{
|
||||
const { name, e164: phoneNumber } = getDefaultConversation();
|
||||
PhoneNumber.args = {
|
||||
name,
|
||||
phoneNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export const UpdateAvailable = Template.bind({});
|
||||
UpdateAvailable.args = {
|
||||
hasPendingUpdate: true,
|
||||
};
|
||||
|
||||
export const Stories = Template.bind({});
|
||||
Stories.args = {
|
||||
areStoriesEnabled: true,
|
||||
unreadStoriesCount: 6,
|
||||
};
|
||||
|
||||
export const StoriesOverflow = Template.bind({});
|
||||
StoriesOverflow.args = {
|
||||
areStoriesEnabled: true,
|
||||
unreadStoriesCount: 69,
|
||||
};
|
|
@ -1,228 +0,0 @@
|
|||
// Copyright 2018 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { showSettings } from '../shims/Whisper';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { AvatarPopup } from './AvatarPopup';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { AvatarColorType } from '../types/Colors';
|
||||
import type { BadgeType } from '../badges/types';
|
||||
import { handleOutsideClick } from '../util/handleOutsideClick';
|
||||
|
||||
const EMPTY_OBJECT = Object.freeze(Object.create(null));
|
||||
|
||||
export type PropsType = {
|
||||
areStoriesEnabled: boolean;
|
||||
avatarPath?: string;
|
||||
badge?: BadgeType;
|
||||
color?: AvatarColorType;
|
||||
hasPendingUpdate: boolean;
|
||||
i18n: LocalizerType;
|
||||
isMe?: boolean;
|
||||
isVerified?: boolean;
|
||||
name?: string;
|
||||
phoneNumber?: string;
|
||||
profileName?: string;
|
||||
theme: ThemeType;
|
||||
title: string;
|
||||
hasFailedStorySends?: boolean;
|
||||
unreadStoriesCount: number;
|
||||
|
||||
showArchivedConversations: () => void;
|
||||
startComposing: () => void;
|
||||
startUpdate: () => unknown;
|
||||
toggleProfileEditor: () => void;
|
||||
toggleStoriesView: () => unknown;
|
||||
};
|
||||
|
||||
export function MainHeader({
|
||||
areStoriesEnabled,
|
||||
avatarPath,
|
||||
badge,
|
||||
color,
|
||||
hasFailedStorySends,
|
||||
hasPendingUpdate,
|
||||
i18n,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
showArchivedConversations,
|
||||
startComposing,
|
||||
startUpdate,
|
||||
theme,
|
||||
title,
|
||||
toggleProfileEditor,
|
||||
toggleStoriesView,
|
||||
unreadStoriesCount,
|
||||
}: PropsType): JSX.Element {
|
||||
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
||||
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
const [showAvatarPopup, setShowAvatarPopup] = useState(false);
|
||||
|
||||
const popper = usePopper(targetElement, popperElement, {
|
||||
placement: 'bottom-start',
|
||||
strategy: 'fixed',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [null, 4],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
setPortalElement(div);
|
||||
return () => {
|
||||
div.remove();
|
||||
setPortalElement(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return handleOutsideClick(
|
||||
() => {
|
||||
if (!showAvatarPopup) {
|
||||
return false;
|
||||
}
|
||||
setShowAvatarPopup(false);
|
||||
return true;
|
||||
},
|
||||
{
|
||||
containerElements: [portalElement, targetElement],
|
||||
name: 'MainHeader.showAvatarPopup',
|
||||
}
|
||||
);
|
||||
}, [portalElement, targetElement, showAvatarPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleGlobalKeyDown(event: KeyboardEvent) {
|
||||
if (showAvatarPopup && event.key === 'Escape') {
|
||||
setShowAvatarPopup(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
};
|
||||
}, [showAvatarPopup]);
|
||||
|
||||
return (
|
||||
<div className="module-main-header">
|
||||
<div
|
||||
className="module-main-header__avatar--container"
|
||||
data-supertab
|
||||
ref={setTargetElement}
|
||||
>
|
||||
<Avatar
|
||||
aria-expanded={showAvatarPopup}
|
||||
aria-owns="MainHeader__AvatarPopup"
|
||||
acceptedMessageRequest
|
||||
avatarPath={avatarPath}
|
||||
badge={badge}
|
||||
className="module-main-header__avatar"
|
||||
color={color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
theme={theme}
|
||||
title={title}
|
||||
// `sharedGroupNames` makes no sense for yourself, but
|
||||
// `<Avatar>` needs it to determine blurring.
|
||||
sharedGroupNames={[]}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
onClick={() => {
|
||||
setShowAvatarPopup(true);
|
||||
}}
|
||||
/>
|
||||
{hasPendingUpdate && (
|
||||
<div className="module-main-header__avatar--badged" />
|
||||
)}
|
||||
</div>
|
||||
{showAvatarPopup &&
|
||||
portalElement != null &&
|
||||
createPortal(
|
||||
<div
|
||||
id="MainHeader__AvatarPopup"
|
||||
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}
|
||||
// See the comment above about `sharedGroupNames`.
|
||||
sharedGroupNames={[]}
|
||||
onEditProfile={() => {
|
||||
toggleProfileEditor();
|
||||
setShowAvatarPopup(false);
|
||||
}}
|
||||
onStartUpdate={() => {
|
||||
startUpdate();
|
||||
setShowAvatarPopup(false);
|
||||
}}
|
||||
onViewPreferences={() => {
|
||||
showSettings();
|
||||
setShowAvatarPopup(false);
|
||||
}}
|
||||
onViewArchive={() => {
|
||||
showArchivedConversations();
|
||||
setShowAvatarPopup(false);
|
||||
}}
|
||||
style={EMPTY_OBJECT}
|
||||
/>
|
||||
</div>,
|
||||
portalElement
|
||||
)}
|
||||
<div className="module-main-header__icon-container" data-supertab>
|
||||
{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>
|
||||
);
|
||||
}
|
219
ts/components/NavSidebar.tsx
Normal file
219
ts/components/NavSidebar.tsx
Normal file
|
@ -0,0 +1,219 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useMove } from 'react-aria';
|
||||
import { NavTabsToggle } from './NavTabs';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import {
|
||||
MAX_WIDTH,
|
||||
MIN_FULL_WIDTH,
|
||||
MIN_WIDTH,
|
||||
getWidthFromPreferredWidth,
|
||||
} from '../util/leftPaneWidth';
|
||||
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util';
|
||||
|
||||
export function NavSidebarActionButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: ReactNode;
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="NavSidebar__ActionButton"
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{icon}
|
||||
<span className="NavSidebar__ActionButtonLabel">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export type NavSidebarProps = Readonly<{
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
i18n: LocalizerType;
|
||||
hideHeader?: boolean;
|
||||
navTabsCollapsed: boolean;
|
||||
onBack?: (() => void) | null;
|
||||
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
|
||||
preferredLeftPaneWidth: number;
|
||||
requiresFullWidth: boolean;
|
||||
savePreferredLeftPaneWidth: (width: number) => void;
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
enum DragState {
|
||||
INITIAL,
|
||||
DRAGGING,
|
||||
DRAGEND,
|
||||
}
|
||||
|
||||
export function NavSidebar({
|
||||
actions,
|
||||
children,
|
||||
hideHeader,
|
||||
i18n,
|
||||
navTabsCollapsed,
|
||||
onBack,
|
||||
onToggleNavTabsCollapse,
|
||||
preferredLeftPaneWidth,
|
||||
requiresFullWidth,
|
||||
savePreferredLeftPaneWidth,
|
||||
title,
|
||||
}: NavSidebarProps): JSX.Element {
|
||||
const [dragState, setDragState] = useState(DragState.INITIAL);
|
||||
|
||||
const [preferredWidth, setPreferredWidth] = useState(() => {
|
||||
return getWidthFromPreferredWidth(preferredLeftPaneWidth, {
|
||||
requiresFullWidth,
|
||||
});
|
||||
});
|
||||
|
||||
const width = getWidthFromPreferredWidth(preferredWidth, {
|
||||
requiresFullWidth,
|
||||
});
|
||||
|
||||
const widthBreakpoint = getNavSidebarWidthBreakpoint(width);
|
||||
|
||||
// `useMove` gives us keyboard and mouse dragging support.
|
||||
const { moveProps } = useMove({
|
||||
onMoveStart() {
|
||||
setDragState(DragState.DRAGGING);
|
||||
},
|
||||
onMoveEnd() {
|
||||
setDragState(DragState.DRAGEND);
|
||||
},
|
||||
onMove(event) {
|
||||
const { deltaX, shiftKey, pointerType } = event;
|
||||
const isKeyboard = pointerType === 'keyboard';
|
||||
const increment = isKeyboard && shiftKey ? 10 : 1;
|
||||
setPreferredWidth(prevWidth => {
|
||||
// Jump minimize for keyboard users
|
||||
if (isKeyboard && prevWidth === MIN_FULL_WIDTH && deltaX < 0) {
|
||||
return MIN_WIDTH;
|
||||
}
|
||||
// Jump maximize for keyboard users
|
||||
if (isKeyboard && prevWidth === MIN_WIDTH && deltaX > 0) {
|
||||
return MIN_FULL_WIDTH;
|
||||
}
|
||||
return prevWidth + deltaX * increment;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Save the preferred width when the drag ends. We can't do this in onMoveEnd
|
||||
// because the width is not updated yet.
|
||||
if (dragState === DragState.DRAGEND) {
|
||||
setPreferredWidth(width);
|
||||
savePreferredLeftPaneWidth(width);
|
||||
setDragState(DragState.INITIAL);
|
||||
}
|
||||
}, [
|
||||
dragState,
|
||||
preferredLeftPaneWidth,
|
||||
preferredWidth,
|
||||
savePreferredLeftPaneWidth,
|
||||
width,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// This effect helps keep the pointer `col-resize` even when you drag past the handle.
|
||||
const className = 'NavSidebar__document--draggingHandle';
|
||||
if (dragState === DragState.DRAGGING) {
|
||||
document.body.classList.add(className);
|
||||
return () => {
|
||||
document.body.classList.remove(className);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [dragState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="navigation"
|
||||
className={classNames('NavSidebar', {
|
||||
'NavSidebar--narrow': widthBreakpoint === WidthBreakpoint.Narrow,
|
||||
})}
|
||||
style={{ width }}
|
||||
>
|
||||
{!hideHeader && (
|
||||
<div className="NavSidebar__Header">
|
||||
{onBack == null && navTabsCollapsed && (
|
||||
<NavTabsToggle
|
||||
i18n={i18n}
|
||||
navTabsCollapsed={navTabsCollapsed}
|
||||
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={classNames('NavSidebar__HeaderContent', {
|
||||
'NavSidebar__HeaderContent--navTabsCollapsed': navTabsCollapsed,
|
||||
'NavSidebar__HeaderContent--withBackButton': onBack != null,
|
||||
})}
|
||||
>
|
||||
{onBack != null && (
|
||||
<button
|
||||
type="button"
|
||||
role="link"
|
||||
onClick={onBack}
|
||||
className="NavSidebar__BackButton"
|
||||
>
|
||||
<span className="NavSidebar__BackButtonLabel">
|
||||
{i18n('icu:NavSidebar__BackButtonLabel')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<h1
|
||||
className={classNames('NavSidebar__HeaderTitle', {
|
||||
'NavSidebar__HeaderTitle--withBackButton': onBack != null,
|
||||
})}
|
||||
aria-live="assertive"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{actions && (
|
||||
<div className="NavSidebar__HeaderActions">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="NavSidebar__Content">{children}</div>
|
||||
|
||||
{/* eslint-disable-next-line jsx-a11y/role-supports-aria-props -- See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/separator_role#focusable_separator */}
|
||||
<div
|
||||
className={classNames('NavSidebar__DragHandle', {
|
||||
'NavSidebar__DragHandle--dragging': dragState === DragState.DRAGGING,
|
||||
})}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={MIN_WIDTH}
|
||||
aria-valuemax={preferredLeftPaneWidth}
|
||||
aria-valuenow={MAX_WIDTH}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/separator_role#focusable_separator
|
||||
tabIndex={0}
|
||||
{...moveProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavSidebarSearchHeader({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return <div className="NavSidebarSearchHeader">{children}</div>;
|
||||
}
|
352
ts/components/NavTabs.tsx
Normal file
352
ts/components/NavTabs.tsx
Normal file
|
@ -0,0 +1,352 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Key, ReactNode } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'react-aria-components';
|
||||
import classNames from 'classnames';
|
||||
import { usePopper } from 'react-popper';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { BadgeType } from '../badges/types';
|
||||
import { AvatarPopup } from './AvatarPopup';
|
||||
import { handleOutsideClick } from '../util/handleOutsideClick';
|
||||
import type { UnreadStats } from '../state/selectors/conversations';
|
||||
import { NavTab } from '../state/ducks/nav';
|
||||
|
||||
type NavTabProps = Readonly<{
|
||||
badge?: ReactNode;
|
||||
iconClassName: string;
|
||||
id: NavTab;
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
function NavTabsItem({ badge, iconClassName, id, label }: NavTabProps) {
|
||||
return (
|
||||
<Tab id={id} data-testid={`NavTabsItem--${id}`} className="NavTabs__Item">
|
||||
<span className="NavTabs__ItemLabel">{label}</span>
|
||||
<span className="NavTabs__ItemButton">
|
||||
<span className="NavTabs__ItemContent">
|
||||
<span
|
||||
role="presentation"
|
||||
className={`NavTabs__ItemIcon ${iconClassName}`}
|
||||
/>
|
||||
{badge && <span className="NavTabs__ItemBadge">{badge}</span>}
|
||||
</span>
|
||||
</span>
|
||||
</Tab>
|
||||
);
|
||||
}
|
||||
|
||||
export type NavTabPanelProps = Readonly<{
|
||||
collapsed: boolean;
|
||||
onToggleCollapse(collapsed: boolean): void;
|
||||
}>;
|
||||
|
||||
export type NavTabsToggleProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
navTabsCollapsed: boolean;
|
||||
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
|
||||
}>;
|
||||
|
||||
export function NavTabsToggle({
|
||||
i18n,
|
||||
navTabsCollapsed,
|
||||
onToggleNavTabsCollapse,
|
||||
}: NavTabsToggleProps): JSX.Element {
|
||||
function handleToggle() {
|
||||
onToggleNavTabsCollapse(!navTabsCollapsed);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="NavTabs__Item NavTabs__Toggle"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span className="NavTabs__ItemButton">
|
||||
<span
|
||||
role="presentation"
|
||||
className="NavTabs__ItemIcon NavTabs__ItemIcon--Menu"
|
||||
/>
|
||||
<span className="NavTabs__ItemLabel">
|
||||
{navTabsCollapsed
|
||||
? i18n('icu:NavTabsToggle__showTabs')
|
||||
: i18n('icu:NavTabsToggle__hideTabs')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export type NavTabsProps = Readonly<{
|
||||
badge: BadgeType | undefined;
|
||||
hasFailedStorySends: boolean;
|
||||
hasPendingUpdate: boolean;
|
||||
i18n: LocalizerType;
|
||||
me: ConversationType;
|
||||
navTabsCollapsed: boolean;
|
||||
onShowSettings: () => void;
|
||||
onStartUpdate: () => unknown;
|
||||
onNavTabSelected(tab: NavTab): void;
|
||||
onToggleNavTabsCollapse(collapsed: boolean): void;
|
||||
onToggleProfileEditor: () => void;
|
||||
renderCallsTab(props: NavTabPanelProps): JSX.Element;
|
||||
renderChatsTab(props: NavTabPanelProps): JSX.Element;
|
||||
renderStoriesTab(props: NavTabPanelProps): JSX.Element;
|
||||
selectedNavTab: NavTab;
|
||||
storiesEnabled: boolean;
|
||||
theme: ThemeType;
|
||||
unreadConversationsStats: UnreadStats;
|
||||
unreadStoriesCount: number;
|
||||
}>;
|
||||
|
||||
export function NavTabs({
|
||||
badge,
|
||||
hasFailedStorySends,
|
||||
hasPendingUpdate,
|
||||
i18n,
|
||||
me,
|
||||
navTabsCollapsed,
|
||||
onShowSettings,
|
||||
onStartUpdate,
|
||||
onNavTabSelected,
|
||||
onToggleNavTabsCollapse,
|
||||
onToggleProfileEditor,
|
||||
renderCallsTab,
|
||||
renderChatsTab,
|
||||
renderStoriesTab,
|
||||
selectedNavTab,
|
||||
storiesEnabled,
|
||||
theme,
|
||||
unreadConversationsStats,
|
||||
unreadStoriesCount,
|
||||
}: NavTabsProps): JSX.Element {
|
||||
function handleSelectionChange(key: Key) {
|
||||
onNavTabSelected(key as NavTab);
|
||||
}
|
||||
|
||||
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
||||
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
const [showAvatarPopup, setShowAvatarPopup] = useState(false);
|
||||
|
||||
const popper = usePopper(targetElement, popperElement, {
|
||||
placement: 'bottom-start',
|
||||
strategy: 'fixed',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [null, 4],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
setPortalElement(div);
|
||||
return () => {
|
||||
div.remove();
|
||||
setPortalElement(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return handleOutsideClick(
|
||||
() => {
|
||||
if (!showAvatarPopup) {
|
||||
return false;
|
||||
}
|
||||
setShowAvatarPopup(false);
|
||||
return true;
|
||||
},
|
||||
{
|
||||
containerElements: [portalElement, targetElement],
|
||||
name: 'MainHeader.showAvatarPopup',
|
||||
}
|
||||
);
|
||||
}, [portalElement, targetElement, showAvatarPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleGlobalKeyDown(event: KeyboardEvent) {
|
||||
if (showAvatarPopup && event.key === 'Escape') {
|
||||
setShowAvatarPopup(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||
};
|
||||
}, [showAvatarPopup]);
|
||||
|
||||
return (
|
||||
<Tabs orientation="vertical" className="NavTabs__Container">
|
||||
<nav
|
||||
className={classNames('NavTabs', {
|
||||
'NavTabs--collapsed': navTabsCollapsed,
|
||||
})}
|
||||
>
|
||||
<NavTabsToggle
|
||||
i18n={i18n}
|
||||
navTabsCollapsed={navTabsCollapsed}
|
||||
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||
/>
|
||||
<TabList
|
||||
className="NavTabs__TabList"
|
||||
selectedKey={selectedNavTab}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
>
|
||||
<NavTabsItem
|
||||
id={NavTab.Chats}
|
||||
label="Chats"
|
||||
iconClassName="NavTabs__ItemIcon--Chats"
|
||||
badge={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
unreadConversationsStats.unreadCount > 0 ? (
|
||||
<>
|
||||
<span className="NavTabs__ItemIconLabel">
|
||||
{i18n('icu:NavTabs__ItemIconLabel--UnreadCount', {
|
||||
count: unreadConversationsStats.unreadCount,
|
||||
})}
|
||||
</span>
|
||||
<span aria-hidden>
|
||||
{unreadConversationsStats.unreadCount}
|
||||
</span>
|
||||
</>
|
||||
) : unreadConversationsStats.markedUnread ? (
|
||||
<span className="NavTabs__ItemIconLabel">
|
||||
{i18n('icu:NavTabs__ItemIconLabel--MarkedUnread')}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<NavTabsItem
|
||||
id={NavTab.Calls}
|
||||
label="Calls"
|
||||
iconClassName="NavTabs__ItemIcon--Calls"
|
||||
/>
|
||||
{storiesEnabled && (
|
||||
<NavTabsItem
|
||||
id={NavTab.Stories}
|
||||
label="Stories"
|
||||
iconClassName="NavTabs__ItemIcon--Stories"
|
||||
badge={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
hasFailedStorySends
|
||||
? '!'
|
||||
: unreadStoriesCount > 0
|
||||
? unreadStoriesCount
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TabList>
|
||||
<div className="NavTabs__Misc">
|
||||
<button
|
||||
type="button"
|
||||
className="NavTabs__Item"
|
||||
onClick={onShowSettings}
|
||||
>
|
||||
<span className="NavTabs__ItemButton">
|
||||
<span
|
||||
role="presentation"
|
||||
className="NavTabs__ItemIcon NavTabs__ItemIcon--Settings"
|
||||
/>
|
||||
<span className="NavTabs__ItemLabel">
|
||||
{i18n('icu:NavTabs__ItemLabel--Settings')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="NavTabs__Item"
|
||||
data-supertab
|
||||
onClick={() => {
|
||||
setShowAvatarPopup(true);
|
||||
}}
|
||||
aria-label={i18n('icu:NavTabs__ItemLabel--Profile')}
|
||||
>
|
||||
<span className="NavTabs__ItemButton" ref={setTargetElement}>
|
||||
<span className="NavTabs__ItemContent">
|
||||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={me.avatarPath}
|
||||
badge={badge}
|
||||
className="module-main-header__avatar"
|
||||
color={me.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe
|
||||
phoneNumber={me.phoneNumber}
|
||||
profileName={me.profileName}
|
||||
theme={theme}
|
||||
title={me.title}
|
||||
// `sharedGroupNames` makes no sense for yourself, but
|
||||
// `<Avatar>` needs it to determine blurring.
|
||||
sharedGroupNames={[]}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
/>
|
||||
{hasPendingUpdate && <div className="NavTabs__AvatarBadge" />}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{showAvatarPopup &&
|
||||
portalElement != null &&
|
||||
createPortal(
|
||||
<div
|
||||
id="MainHeader__AvatarPopup"
|
||||
ref={setPopperElement}
|
||||
style={{ ...popper.styles.popper, zIndex: 10 }}
|
||||
{...popper.attributes.popper}
|
||||
>
|
||||
<AvatarPopup
|
||||
acceptedMessageRequest
|
||||
badge={badge}
|
||||
i18n={i18n}
|
||||
isMe
|
||||
color={me.color}
|
||||
conversationType="direct"
|
||||
name={me.name}
|
||||
phoneNumber={me.phoneNumber}
|
||||
profileName={me.profileName}
|
||||
theme={theme}
|
||||
title={me.title}
|
||||
avatarPath={me.avatarPath}
|
||||
hasPendingUpdate={hasPendingUpdate}
|
||||
// See the comment above about `sharedGroupNames`.
|
||||
sharedGroupNames={[]}
|
||||
onEditProfile={() => {
|
||||
onToggleProfileEditor();
|
||||
setShowAvatarPopup(false);
|
||||
}}
|
||||
onStartUpdate={() => {
|
||||
onStartUpdate();
|
||||
setShowAvatarPopup(false);
|
||||
}}
|
||||
style={{}}
|
||||
/>
|
||||
</div>,
|
||||
portalElement
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<TabPanels>
|
||||
<TabPanel id={NavTab.Chats} className="NavTabs__TabPanel">
|
||||
{renderChatsTab}
|
||||
</TabPanel>
|
||||
<TabPanel id={NavTab.Calls} className="NavTabs__TabPanel">
|
||||
{renderCallsTab}
|
||||
</TabPanel>
|
||||
<TabPanel id={NavTab.Stories} className="NavTabs__TabPanel">
|
||||
{renderStoriesTab}
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
|
@ -7,7 +7,6 @@ import type { LocalizerType } from '../types/Util';
|
|||
import { Button, ButtonVariant } from './Button';
|
||||
import { Intl } from './Intl';
|
||||
import { Modal } from './Modal';
|
||||
import { STORIES_COLOR_THEME } from './Stories';
|
||||
|
||||
export type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
|
@ -24,7 +23,6 @@ export function SignalConnectionsModal({
|
|||
hasXButton
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
theme={STORIES_COLOR_THEME}
|
||||
>
|
||||
<div className="SignalConnectionsModal">
|
||||
<i className="SignalConnectionsModal__icon" />
|
||||
|
|
|
@ -7,7 +7,6 @@ import React, { useState, useCallback } from 'react';
|
|||
import type { LocalizerType } from '../types/Util';
|
||||
import type { ShowToastAction } from '../state/ducks/toast';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { Theme } from '../util/theme';
|
||||
import { ToastType } from '../types/Toast';
|
||||
import {
|
||||
isVideoGoodForStories,
|
||||
|
@ -109,7 +108,6 @@ export function StoriesAddStoryButton({
|
|||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
theme={Theme.Dark}
|
||||
>
|
||||
{children}
|
||||
</ContextMenu>
|
||||
|
|
|
@ -10,18 +10,15 @@ import type {
|
|||
ShowConversationType,
|
||||
} from '../state/ducks/conversations';
|
||||
import type { ConversationStoryType, MyStoryType } from '../types/Stories';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { ShowToastAction } from '../state/ducks/toast';
|
||||
import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { MyStoryButton } from './MyStoryButton';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { StoriesAddStoryButton } from './StoriesAddStoryButton';
|
||||
import { StoryListItem } from './StoryListItem';
|
||||
import { Theme } from '../util/theme';
|
||||
import { isNotNil } from '../util/isNotNil';
|
||||
import { useRestoreFocus } from '../hooks/useRestoreFocus';
|
||||
import { NavSidebarSearchHeader } from './NavSidebar';
|
||||
|
||||
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
|
||||
getFn: (story, path) => {
|
||||
|
@ -70,8 +67,8 @@ export type PropsType = {
|
|||
showConversation: ShowConversationType;
|
||||
showToast: ShowToastAction;
|
||||
stories: Array<ConversationStoryType>;
|
||||
theme: ThemeType;
|
||||
toggleHideStories: (conversationId: string) => unknown;
|
||||
toggleStoriesView: () => unknown;
|
||||
viewUserStories: ViewUserStoriesActionCreatorType;
|
||||
};
|
||||
|
||||
|
@ -84,14 +81,13 @@ export function StoriesPane({
|
|||
myStories,
|
||||
onAddStory,
|
||||
onMyStoriesClicked,
|
||||
onStoriesSettings,
|
||||
onMediaPlaybackStart,
|
||||
queueStoryDownload,
|
||||
showConversation,
|
||||
showToast,
|
||||
stories,
|
||||
theme,
|
||||
toggleHideStories,
|
||||
toggleStoriesView,
|
||||
viewUserStories,
|
||||
}: PropsType): JSX.Element {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
@ -106,55 +102,18 @@ export function StoriesPane({
|
|||
setRenderedStories(stories);
|
||||
}
|
||||
}, [searchTerm, stories]);
|
||||
|
||||
const [focusRef] = useRestoreFocus();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="Stories__pane__header">
|
||||
<button
|
||||
ref={focusRef}
|
||||
aria-label={i18n('icu:back')}
|
||||
className="Stories__pane__header--back"
|
||||
onClick={toggleStoriesView}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
/>
|
||||
<div className="Stories__pane__header--title">
|
||||
{i18n('icu:Stories__title')}
|
||||
</div>
|
||||
<StoriesAddStoryButton
|
||||
<NavSidebarSearchHeader>
|
||||
<SearchInput
|
||||
i18n={i18n}
|
||||
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
||||
moduleClassName="Stories__pane__add-story"
|
||||
onAddStory={onAddStory}
|
||||
showToast={showToast}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
label: i18n('icu:StoriesSettings__context-menu'),
|
||||
onClick: () => onStoriesSettings(),
|
||||
},
|
||||
]}
|
||||
moduleClassName="Stories__pane__settings"
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
onChange={event => {
|
||||
setSearchTerm(event.target.value);
|
||||
}}
|
||||
theme={Theme.Dark}
|
||||
placeholder={i18n('icu:search')}
|
||||
value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
<SearchInput
|
||||
i18n={i18n}
|
||||
moduleClassName="Stories__search"
|
||||
onChange={event => {
|
||||
setSearchTerm(event.target.value);
|
||||
}}
|
||||
placeholder={i18n('icu:search')}
|
||||
value={searchTerm}
|
||||
/>
|
||||
</NavSidebarSearchHeader>
|
||||
<div className="Stories__pane__list">
|
||||
<MyStoryButton
|
||||
i18n={i18n}
|
||||
|
@ -178,12 +137,12 @@ export function StoriesPane({
|
|||
key={story.storyView.timestamp}
|
||||
onGoToConversation={conversationId => {
|
||||
showConversation({ conversationId });
|
||||
toggleStoriesView();
|
||||
}}
|
||||
onHideStory={toggleHideStories}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
story={story.storyView}
|
||||
theme={theme}
|
||||
viewUserStories={viewUserStories}
|
||||
/>
|
||||
))}
|
||||
|
@ -191,6 +150,7 @@ export function StoriesPane({
|
|||
<>
|
||||
<button
|
||||
className={classNames('Stories__hidden-stories', {
|
||||
'Stories__hidden-stories--collapsed': !isShowingHiddenStories,
|
||||
'Stories__hidden-stories--expanded': isShowingHiddenStories,
|
||||
})}
|
||||
onClick={() => setIsShowingHiddenStories(!isShowingHiddenStories)}
|
||||
|
@ -209,12 +169,12 @@ export function StoriesPane({
|
|||
key={story.storyView.timestamp}
|
||||
onGoToConversation={conversationId => {
|
||||
showConversation({ conversationId });
|
||||
toggleStoriesView();
|
||||
}}
|
||||
onHideStory={toggleHideStories}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
story={story.storyView}
|
||||
theme={theme}
|
||||
viewUserStories={viewUserStories}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -69,7 +69,6 @@ export type PropsType = {
|
|||
setMyStoriesToAllSignalConnections: () => unknown;
|
||||
storyViewReceiptsEnabled: boolean;
|
||||
toggleSignalConnectionsModal: () => unknown;
|
||||
toggleStoriesView: () => void;
|
||||
setStoriesDisabled: (value: boolean) => void;
|
||||
getConversationByUuid: (uuid: UUIDStringType) => ConversationType | undefined;
|
||||
};
|
||||
|
@ -256,7 +255,6 @@ export function StoriesSettingsModal({
|
|||
setMyStoriesToAllSignalConnections,
|
||||
storyViewReceiptsEnabled,
|
||||
toggleSignalConnectionsModal,
|
||||
toggleStoriesView,
|
||||
setStoriesDisabled,
|
||||
getConversationByUuid,
|
||||
}: PropsType): JSX.Element {
|
||||
|
@ -463,7 +461,6 @@ export function StoriesSettingsModal({
|
|||
variant={ButtonVariant.SecondaryDestructive}
|
||||
onClick={async () => {
|
||||
setStoriesDisabled(true);
|
||||
toggleStoriesView();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -4,8 +4,8 @@
|
|||
import type { Meta, Story } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { PropsType } from './Stories';
|
||||
import { Stories } from './Stories';
|
||||
import type { PropsType } from './StoriesTab';
|
||||
import { StoriesTab } from './StoriesTab';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
|
@ -18,8 +18,8 @@ import * as durations from '../util/durations';
|
|||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
title: 'Components/Stories',
|
||||
component: Stories,
|
||||
title: 'Components/StoriesTab',
|
||||
component: StoriesTab,
|
||||
argTypes: {
|
||||
deleteStoryForEveryone: { action: true },
|
||||
getPreferredBadge: { action: true },
|
||||
|
@ -63,7 +63,7 @@ export default {
|
|||
} as Meta;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
const Template: Story<PropsType> = args => <Stories {...args} />;
|
||||
const Template: Story<PropsType> = args => <StoriesTab {...args} />;
|
||||
|
||||
export const Blank = Template.bind({});
|
||||
Blank.args = {};
|
|
@ -2,7 +2,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import type {
|
||||
ConversationType,
|
||||
ShowConversationType,
|
||||
|
@ -12,7 +11,7 @@ import type {
|
|||
MyStoryType,
|
||||
StoryViewType,
|
||||
} from '../types/Stories';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { ShowToastAction } from '../state/ducks/toast';
|
||||
import type {
|
||||
|
@ -22,9 +21,9 @@ import type {
|
|||
} from '../state/ducks/stories';
|
||||
import { MyStories } from './MyStories';
|
||||
import { StoriesPane } from './StoriesPane';
|
||||
import { Theme, themeClassName } from '../util/theme';
|
||||
import { getWidthFromPreferredWidth } from '../util/leftPaneWidth';
|
||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||
import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
|
||||
import { StoriesAddStoryButton } from './StoriesAddStoryButton';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
|
||||
export type PropsType = {
|
||||
addStoryData: AddStoryData;
|
||||
|
@ -38,90 +37,132 @@ export type PropsType = {
|
|||
maxAttachmentSizeInKb: number;
|
||||
me: ConversationType;
|
||||
myStories: Array<MyStoryType>;
|
||||
navTabsCollapsed: boolean;
|
||||
onForwardStory: (storyId: string) => unknown;
|
||||
onSaveStory: (story: StoryViewType) => unknown;
|
||||
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||
onMediaPlaybackStart: () => void;
|
||||
preferredLeftPaneWidth: number;
|
||||
preferredWidthFromStorage: number;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
renderStoryCreator: () => JSX.Element;
|
||||
retryMessageSend: (messageId: string) => unknown;
|
||||
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
|
||||
setAddStoryData: (data: AddStoryData) => unknown;
|
||||
showConversation: ShowConversationType;
|
||||
showStoriesSettings: () => unknown;
|
||||
showToast: ShowToastAction;
|
||||
stories: Array<ConversationStoryType>;
|
||||
theme: ThemeType;
|
||||
toggleHideStories: (conversationId: string) => unknown;
|
||||
toggleStoriesView: () => unknown;
|
||||
viewStory: ViewStoryActionCreatorType;
|
||||
viewUserStories: ViewUserStoriesActionCreatorType;
|
||||
};
|
||||
|
||||
export const STORIES_COLOR_THEME = Theme.Dark;
|
||||
|
||||
export function Stories({
|
||||
export function StoriesTab({
|
||||
addStoryData,
|
||||
deleteStoryForEveryone,
|
||||
getPreferredBadge,
|
||||
hasViewReceiptSetting,
|
||||
hiddenStories,
|
||||
i18n,
|
||||
isStoriesSettingsVisible,
|
||||
isViewingStory,
|
||||
maxAttachmentSizeInKb,
|
||||
me,
|
||||
myStories,
|
||||
navTabsCollapsed,
|
||||
onForwardStory,
|
||||
onSaveStory,
|
||||
onToggleNavTabsCollapse,
|
||||
onMediaPlaybackStart,
|
||||
preferredWidthFromStorage,
|
||||
preferredLeftPaneWidth,
|
||||
queueStoryDownload,
|
||||
renderStoryCreator,
|
||||
retryMessageSend,
|
||||
savePreferredLeftPaneWidth,
|
||||
setAddStoryData,
|
||||
showConversation,
|
||||
showStoriesSettings,
|
||||
showToast,
|
||||
stories,
|
||||
theme,
|
||||
toggleHideStories,
|
||||
toggleStoriesView,
|
||||
viewStory,
|
||||
viewUserStories,
|
||||
}: PropsType): JSX.Element {
|
||||
const width = getWidthFromPreferredWidth(preferredWidthFromStorage, {
|
||||
requiresFullWidth: true,
|
||||
});
|
||||
|
||||
const [isMyStories, setIsMyStories] = useState(false);
|
||||
|
||||
// only handle ESC if not showing a child that handles their own ESC
|
||||
useEscapeHandling(
|
||||
(isMyStories && myStories.length) ||
|
||||
isViewingStory ||
|
||||
isStoriesSettingsVisible ||
|
||||
addStoryData
|
||||
? undefined
|
||||
: toggleStoriesView
|
||||
);
|
||||
function onAddStory(file?: File) {
|
||||
if (file) {
|
||||
setAddStoryData({ type: 'Media', file });
|
||||
} else {
|
||||
setAddStoryData({ type: 'Text' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('Stories', themeClassName(STORIES_COLOR_THEME))}>
|
||||
<div className="Stories">
|
||||
{addStoryData && renderStoryCreator()}
|
||||
<div className="Stories__pane" style={{ width }}>
|
||||
{isMyStories && myStories.length ? (
|
||||
<MyStories
|
||||
hasViewReceiptSetting={hasViewReceiptSetting}
|
||||
i18n={i18n}
|
||||
myStories={myStories}
|
||||
onBack={() => setIsMyStories(false)}
|
||||
onDelete={deleteStoryForEveryone}
|
||||
onForward={onForwardStory}
|
||||
onSave={onSaveStory}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
retryMessageSend={retryMessageSend}
|
||||
viewStory={viewStory}
|
||||
/>
|
||||
) : (
|
||||
{isMyStories && myStories.length ? (
|
||||
<MyStories
|
||||
hasViewReceiptSetting={hasViewReceiptSetting}
|
||||
i18n={i18n}
|
||||
myStories={myStories}
|
||||
onBack={() => setIsMyStories(false)}
|
||||
onDelete={deleteStoryForEveryone}
|
||||
onForward={onForwardStory}
|
||||
onSave={onSaveStory}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
retryMessageSend={retryMessageSend}
|
||||
viewStory={viewStory}
|
||||
/>
|
||||
) : (
|
||||
<NavSidebar
|
||||
title="Stories"
|
||||
i18n={i18n}
|
||||
navTabsCollapsed={navTabsCollapsed}
|
||||
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||
preferredLeftPaneWidth={preferredLeftPaneWidth}
|
||||
requiresFullWidth
|
||||
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||
actions={
|
||||
<>
|
||||
<StoriesAddStoryButton
|
||||
i18n={i18n}
|
||||
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
||||
moduleClassName="Stories__pane__add-story"
|
||||
onAddStory={onAddStory}
|
||||
showToast={showToast}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
label: i18n('icu:StoriesSettings__context-menu'),
|
||||
onClick: showStoriesSettings,
|
||||
},
|
||||
]}
|
||||
moduleClassName="Stories__pane__settings"
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
portalToRoot
|
||||
>
|
||||
{({ openMenu, onKeyDown }) => {
|
||||
return (
|
||||
<NavSidebarActionButton
|
||||
onClick={openMenu}
|
||||
onKeyDown={onKeyDown}
|
||||
icon={<span className="StoriesTab__MoreActionsIcon" />}
|
||||
label={i18n('icu:StoriesTab__MoreActionsLabel')}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ContextMenu>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StoriesPane
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
hiddenStories={hiddenStories}
|
||||
|
@ -129,11 +170,7 @@ export function Stories({
|
|||
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
||||
me={me}
|
||||
myStories={myStories}
|
||||
onAddStory={file =>
|
||||
file
|
||||
? setAddStoryData({ type: 'Media', file })
|
||||
: setAddStoryData({ type: 'Text' })
|
||||
}
|
||||
onAddStory={onAddStory}
|
||||
onMyStoriesClicked={() => {
|
||||
if (myStories.length) {
|
||||
setIsMyStories(true);
|
||||
|
@ -147,12 +184,12 @@ export function Stories({
|
|||
showConversation={showConversation}
|
||||
showToast={showToast}
|
||||
stories={stories}
|
||||
theme={theme}
|
||||
toggleHideStories={toggleHideStories}
|
||||
toggleStoriesView={toggleStoriesView}
|
||||
viewUserStories={viewUserStories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</NavSidebar>
|
||||
)}
|
||||
<div className="Stories__placeholder">
|
||||
<div className="Stories__placeholder__stories" />
|
||||
{i18n('icu:Stories__placeholder--text')}
|
|
@ -4,6 +4,7 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { get, has } from 'lodash';
|
||||
|
||||
import { createPortal } from 'react-dom';
|
||||
import type {
|
||||
AttachmentType,
|
||||
InMemoryAttachmentDraftType,
|
||||
|
@ -26,6 +27,22 @@ import { TextStoryCreator } from './TextStoryCreator';
|
|||
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
|
||||
import type { DraftBodyRanges } from '../types/BodyRange';
|
||||
|
||||
function usePortalElement(testid: string): HTMLDivElement | null {
|
||||
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const div = document.createElement('div');
|
||||
div.dataset.testid = testid;
|
||||
document.body.appendChild(div);
|
||||
setElement(div);
|
||||
return () => {
|
||||
document.body.removeChild(div);
|
||||
};
|
||||
}, [testid]);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
export type PropsType = {
|
||||
debouncedMaybeGrabLinkPreview: (
|
||||
message: string,
|
||||
|
@ -119,7 +136,9 @@ export function StoryCreator({
|
|||
skinTone,
|
||||
toggleGroupsForStorySend,
|
||||
toggleSignalConnectionsModal,
|
||||
}: PropsType): JSX.Element {
|
||||
}: PropsType): JSX.Element | null {
|
||||
const portalElement = usePortalElement('StoryCreatorPortal');
|
||||
|
||||
const [draftAttachment, setDraftAttachment] = useState<
|
||||
AttachmentType | undefined
|
||||
>();
|
||||
|
@ -173,97 +192,100 @@ export function StoryCreator({
|
|||
}
|
||||
}, [draftAttachment, sendStoryModalOpenStateChanged]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{draftAttachment && isReadyToSend && (
|
||||
<SendStoryModal
|
||||
draftAttachment={draftAttachment}
|
||||
candidateConversations={candidateConversations}
|
||||
distributionLists={distributionLists}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
groupConversations={groupConversations}
|
||||
groupStories={groupStories}
|
||||
hasFirstStoryPostExperience={hasFirstStoryPostExperience}
|
||||
ourConversationId={ourConversationId}
|
||||
i18n={i18n}
|
||||
me={me}
|
||||
onClose={() => setDraftAttachment(undefined)}
|
||||
onDeleteList={onDeleteList}
|
||||
onDistributionListCreated={onDistributionListCreated}
|
||||
onHideMyStoriesFrom={onHideMyStoriesFrom}
|
||||
onRemoveMembers={onRemoveMembers}
|
||||
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
|
||||
onSelectedStoryList={onSelectedStoryList}
|
||||
onSend={(listIds, groupIds) => {
|
||||
onSend(listIds, groupIds, draftAttachment, bodyRanges);
|
||||
setDraftAttachment(undefined);
|
||||
}}
|
||||
onViewersUpdated={onViewersUpdated}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
setMyStoriesToAllSignalConnections={
|
||||
setMyStoriesToAllSignalConnections
|
||||
}
|
||||
signalConnections={signalConnections}
|
||||
toggleGroupsForStorySend={toggleGroupsForStorySend}
|
||||
mostRecentActiveStoryTimestampByGroupOrDistributionList={
|
||||
mostRecentActiveStoryTimestampByGroupOrDistributionList
|
||||
}
|
||||
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
|
||||
/>
|
||||
)}
|
||||
{draftAttachment && !isReadyToSend && attachmentUrl && (
|
||||
<MediaEditor
|
||||
doneButtonLabel={i18n('icu:next2')}
|
||||
i18n={i18n}
|
||||
imageSrc={attachmentUrl}
|
||||
installedPacks={installedPacks}
|
||||
isSending={isSending}
|
||||
onClose={onClose}
|
||||
supportsCaption
|
||||
renderCompositionTextArea={renderCompositionTextArea}
|
||||
imageToBlurHash={imageToBlurHash}
|
||||
onDone={({
|
||||
contentType,
|
||||
data,
|
||||
blurHash,
|
||||
caption,
|
||||
captionBodyRanges,
|
||||
}) => {
|
||||
setDraftAttachment({
|
||||
...draftAttachment,
|
||||
contentType,
|
||||
data,
|
||||
size: data.byteLength,
|
||||
blurHash,
|
||||
caption,
|
||||
});
|
||||
setBodyRanges(captionBodyRanges);
|
||||
setIsReadyToSend(true);
|
||||
}}
|
||||
recentStickers={recentStickers}
|
||||
/>
|
||||
)}
|
||||
{!file && (
|
||||
<TextStoryCreator
|
||||
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
|
||||
i18n={i18n}
|
||||
isSending={isSending}
|
||||
linkPreview={linkPreview}
|
||||
onClose={onClose}
|
||||
onDone={textAttachment => {
|
||||
setDraftAttachment({
|
||||
contentType: TEXT_ATTACHMENT,
|
||||
textAttachment,
|
||||
size: textAttachment.text?.length || 0,
|
||||
});
|
||||
setIsReadyToSend(true);
|
||||
}}
|
||||
onUseEmoji={onUseEmoji}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
recentEmojis={recentEmojis}
|
||||
skinTone={skinTone}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return portalElement != null
|
||||
? createPortal(
|
||||
<>
|
||||
{draftAttachment && isReadyToSend && (
|
||||
<SendStoryModal
|
||||
draftAttachment={draftAttachment}
|
||||
candidateConversations={candidateConversations}
|
||||
distributionLists={distributionLists}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
groupConversations={groupConversations}
|
||||
groupStories={groupStories}
|
||||
hasFirstStoryPostExperience={hasFirstStoryPostExperience}
|
||||
ourConversationId={ourConversationId}
|
||||
i18n={i18n}
|
||||
me={me}
|
||||
onClose={() => setDraftAttachment(undefined)}
|
||||
onDeleteList={onDeleteList}
|
||||
onDistributionListCreated={onDistributionListCreated}
|
||||
onHideMyStoriesFrom={onHideMyStoriesFrom}
|
||||
onRemoveMembers={onRemoveMembers}
|
||||
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
|
||||
onSelectedStoryList={onSelectedStoryList}
|
||||
onSend={(listIds, groupIds) => {
|
||||
onSend(listIds, groupIds, draftAttachment, bodyRanges);
|
||||
setDraftAttachment(undefined);
|
||||
}}
|
||||
onViewersUpdated={onViewersUpdated}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
setMyStoriesToAllSignalConnections={
|
||||
setMyStoriesToAllSignalConnections
|
||||
}
|
||||
signalConnections={signalConnections}
|
||||
toggleGroupsForStorySend={toggleGroupsForStorySend}
|
||||
mostRecentActiveStoryTimestampByGroupOrDistributionList={
|
||||
mostRecentActiveStoryTimestampByGroupOrDistributionList
|
||||
}
|
||||
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
|
||||
/>
|
||||
)}
|
||||
{draftAttachment && !isReadyToSend && attachmentUrl && (
|
||||
<MediaEditor
|
||||
doneButtonLabel={i18n('icu:next2')}
|
||||
i18n={i18n}
|
||||
imageSrc={attachmentUrl}
|
||||
installedPacks={installedPacks}
|
||||
isSending={isSending}
|
||||
onClose={onClose}
|
||||
supportsCaption
|
||||
renderCompositionTextArea={renderCompositionTextArea}
|
||||
imageToBlurHash={imageToBlurHash}
|
||||
onDone={({
|
||||
contentType,
|
||||
data,
|
||||
blurHash,
|
||||
caption,
|
||||
captionBodyRanges,
|
||||
}) => {
|
||||
setDraftAttachment({
|
||||
...draftAttachment,
|
||||
contentType,
|
||||
data,
|
||||
size: data.byteLength,
|
||||
blurHash,
|
||||
caption,
|
||||
});
|
||||
setBodyRanges(captionBodyRanges);
|
||||
setIsReadyToSend(true);
|
||||
}}
|
||||
recentStickers={recentStickers}
|
||||
/>
|
||||
)}
|
||||
{!file && (
|
||||
<TextStoryCreator
|
||||
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
|
||||
i18n={i18n}
|
||||
isSending={isSending}
|
||||
linkPreview={linkPreview}
|
||||
onClose={onClose}
|
||||
onDone={textAttachment => {
|
||||
setDraftAttachment({
|
||||
contentType: TEXT_ATTACHMENT,
|
||||
textAttachment,
|
||||
size: textAttachment.text?.length || 0,
|
||||
});
|
||||
setIsReadyToSend(true);
|
||||
}}
|
||||
onUseEmoji={onUseEmoji}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
recentEmojis={recentEmojis}
|
||||
skinTone={skinTone}
|
||||
/>
|
||||
)}
|
||||
</>,
|
||||
portalElement
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import React, { useState } from 'react';
|
|||
import classNames from 'classnames';
|
||||
import type { ConversationStoryType, StoryViewType } from '../types/Stories';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
|
@ -16,7 +16,6 @@ import { StoryViewTargetType, HasStories } from '../types/Stories';
|
|||
|
||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||
import { StoryImage } from './StoryImage';
|
||||
import { ThemeType } from '../types/Util';
|
||||
import { getAvatarColor } from '../types/Colors';
|
||||
|
||||
export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
|
||||
|
@ -30,6 +29,7 @@ export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
|
|||
queueStoryDownload: (storyId: string) => unknown;
|
||||
onMediaPlaybackStart: () => void;
|
||||
story: StoryViewType;
|
||||
theme: ThemeType;
|
||||
viewUserStories: ViewUserStoriesActionCreatorType;
|
||||
};
|
||||
|
||||
|
@ -45,6 +45,7 @@ function StoryListItemAvatar({
|
|||
profileName,
|
||||
sharedGroupNames,
|
||||
title,
|
||||
theme,
|
||||
}: Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
|
@ -59,6 +60,7 @@ function StoryListItemAvatar({
|
|||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
i18n: LocalizerType;
|
||||
isMe?: boolean;
|
||||
theme: ThemeType;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Avatar
|
||||
|
@ -73,7 +75,7 @@ function StoryListItemAvatar({
|
|||
sharedGroupNames={sharedGroupNames}
|
||||
size={AvatarSize.FORTY_EIGHT}
|
||||
storyRing={avatarStoryRing}
|
||||
theme={ThemeType.dark}
|
||||
theme={theme}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
|
@ -92,6 +94,7 @@ export function StoryListItem({
|
|||
onMediaPlaybackStart,
|
||||
queueStoryDownload,
|
||||
story,
|
||||
theme,
|
||||
viewUserStories,
|
||||
}: PropsType): JSX.Element {
|
||||
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
|
||||
|
@ -167,6 +170,7 @@ export function StoryListItem({
|
|||
avatarStoryRing={avatarStoryRing}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
{...(group || sender)}
|
||||
/>
|
||||
<div className="StoryListItem__info">
|
||||
|
|
|
@ -26,6 +26,8 @@ function getToast(toastType: ToastType): AnyToast {
|
|||
return { toastType: ToastType.Blocked };
|
||||
case ToastType.BlockedGroup:
|
||||
return { toastType: ToastType.BlockedGroup };
|
||||
case ToastType.CallHistoryCleared:
|
||||
return { toastType: ToastType.CallHistoryCleared };
|
||||
case ToastType.CannotEditMessage:
|
||||
return { toastType: ToastType.CannotEditMessage };
|
||||
case ToastType.CannotForwardEmptyMessage:
|
||||
|
|
|
@ -68,6 +68,14 @@ export function ToastManager({
|
|||
return <Toast onClose={hideToast}>{i18n('icu:unblockGroupToSend')}</Toast>;
|
||||
}
|
||||
|
||||
if (toastType === ToastType.CallHistoryCleared) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>
|
||||
{i18n('icu:CallsTab__ToastCallHistoryCleared')}
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
if (toastType === ToastType.CannotEditMessage) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>
|
||||
|
|
|
@ -11,7 +11,6 @@ export enum WidthBreakpoint {
|
|||
Narrow = 'narrow',
|
||||
}
|
||||
|
||||
export const getConversationListWidthBreakpoint = (
|
||||
width: number
|
||||
): WidthBreakpoint =>
|
||||
width >= 150 ? WidthBreakpoint.Wide : WidthBreakpoint.Narrow;
|
||||
export function getNavSidebarWidthBreakpoint(width: number): WidthBreakpoint {
|
||||
return width >= 150 ? WidthBreakpoint.Wide : WidthBreakpoint.Narrow;
|
||||
}
|
||||
|
|
|
@ -7,9 +7,21 @@ import { action } from '@storybook/addon-actions';
|
|||
import { setupI18n } from '../../util/setupI18n';
|
||||
import enMessages from '../../../_locales/en/messages.json';
|
||||
import { CallMode } from '../../types/Calling';
|
||||
import { CallingNotification } from './CallingNotification';
|
||||
import type { CallingNotificationType } from '../../util/callingNotification';
|
||||
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
||||
import { CallingNotification, type PropsType } from './CallingNotification';
|
||||
import {
|
||||
getDefaultConversation,
|
||||
getDefaultGroup,
|
||||
} from '../../test-both/helpers/getDefaultConversation';
|
||||
import type { CallStatus } from '../../types/CallDisposition';
|
||||
import {
|
||||
CallType,
|
||||
CallDirection,
|
||||
GroupCallStatus,
|
||||
DirectCallStatus,
|
||||
} from '../../types/CallDisposition';
|
||||
import { UUID } from '../../types/UUID';
|
||||
import type { ConversationType } from '../../state/ducks/conversations';
|
||||
import { CallExternalState } from '../../util/callingNotification';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -17,15 +29,59 @@ export default {
|
|||
title: 'Components/Conversation/CallingNotification',
|
||||
};
|
||||
|
||||
const getCommonProps = () => ({
|
||||
conversationId: 'fake-conversation-id',
|
||||
i18n,
|
||||
isNextItemCallingNotification: false,
|
||||
messageId: 'fake-message-id',
|
||||
now: Date.now(),
|
||||
returnToActiveCall: action('returnToActiveCall'),
|
||||
startCallingLobby: action('startCallingLobby'),
|
||||
});
|
||||
const getCommonProps = (options: {
|
||||
mode: CallMode;
|
||||
type?: CallType;
|
||||
direction?: CallDirection;
|
||||
status?: CallStatus;
|
||||
callCreator?: ConversationType | null;
|
||||
callExternalState?: CallExternalState;
|
||||
}): PropsType => {
|
||||
const {
|
||||
mode,
|
||||
type = mode === CallMode.Group ? CallType.Group : CallType.Audio,
|
||||
direction = CallDirection.Outgoing,
|
||||
status = mode === CallMode.Group
|
||||
? GroupCallStatus.GenericGroupCall
|
||||
: DirectCallStatus.Pending,
|
||||
callCreator = getDefaultConversation({
|
||||
uuid: UUID.generate().toString(),
|
||||
isMe: direction === CallDirection.Outgoing,
|
||||
}),
|
||||
callExternalState = CallExternalState.Active,
|
||||
} = options;
|
||||
|
||||
const conversation =
|
||||
mode === CallMode.Group ? getDefaultGroup() : getDefaultConversation();
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
i18n,
|
||||
isNextItemCallingNotification: false,
|
||||
returnToActiveCall: action('returnToActiveCall'),
|
||||
startCallingLobby: action('startCallingLobby'),
|
||||
callHistory: {
|
||||
callId: '123',
|
||||
peerId: conversation.id,
|
||||
ringerId: callCreator?.uuid ?? null,
|
||||
mode,
|
||||
type,
|
||||
direction,
|
||||
timestamp: Date.now(),
|
||||
status,
|
||||
},
|
||||
callCreator,
|
||||
callExternalState,
|
||||
maxDevices: mode === CallMode.Group ? 15 : 0,
|
||||
deviceCount:
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
mode === CallMode.Group
|
||||
? callExternalState === CallExternalState.Full
|
||||
? 15
|
||||
: 13
|
||||
: Infinity,
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
<CallingNotification
|
||||
|
@ -42,13 +98,12 @@ const getCommonProps = () => ({
|
|||
export function AcceptedIncomingAudioCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={1618894800000}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined={false}
|
||||
wasIncoming
|
||||
wasVideoCall={false}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Accepted,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -56,13 +111,13 @@ export function AcceptedIncomingAudioCall(): JSX.Element {
|
|||
export function AcceptedIncomingVideoCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={1618894800000}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined={false}
|
||||
wasIncoming
|
||||
wasVideoCall
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Accepted,
|
||||
callExternalState: CallExternalState.Ended,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -70,13 +125,12 @@ export function AcceptedIncomingVideoCall(): JSX.Element {
|
|||
export function DeclinedIncomingAudioCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={undefined}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined
|
||||
wasIncoming
|
||||
wasVideoCall={false}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -84,13 +138,12 @@ export function DeclinedIncomingAudioCall(): JSX.Element {
|
|||
export function DeclinedIncomingVideoCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={undefined}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined
|
||||
wasIncoming
|
||||
wasVideoCall
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -98,13 +151,12 @@ export function DeclinedIncomingVideoCall(): JSX.Element {
|
|||
export function AcceptedOutgoingAudioCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={1618894800000}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined={false}
|
||||
wasIncoming={false}
|
||||
wasVideoCall={false}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Accepted,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -112,13 +164,12 @@ export function AcceptedOutgoingAudioCall(): JSX.Element {
|
|||
export function AcceptedOutgoingVideoCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={1618894800000}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined={false}
|
||||
wasIncoming={false}
|
||||
wasVideoCall
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Accepted,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -126,13 +177,12 @@ export function AcceptedOutgoingVideoCall(): JSX.Element {
|
|||
export function DeclinedOutgoingAudioCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={undefined}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined
|
||||
wasIncoming={false}
|
||||
wasVideoCall={false}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -140,42 +190,37 @@ export function DeclinedOutgoingAudioCall(): JSX.Element {
|
|||
export function DeclinedOutgoingVideoCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={undefined}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined
|
||||
wasIncoming={false}
|
||||
wasVideoCall
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwoIncomingDirectCallsBackToBack(): JSX.Element {
|
||||
const call1: CallingNotificationType = {
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: true,
|
||||
wasVideoCall: true,
|
||||
wasDeclined: false,
|
||||
acceptedTime: 1618894800000,
|
||||
endedTime: 1618894800000,
|
||||
};
|
||||
const call2: CallingNotificationType = {
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: true,
|
||||
wasVideoCall: false,
|
||||
wasDeclined: false,
|
||||
endedTime: 1618894800000,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
{...call1}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Declined,
|
||||
callExternalState: CallExternalState.Ended,
|
||||
})}
|
||||
isNextItemCallingNotification
|
||||
/>
|
||||
<CallingNotification {...getCommonProps()} {...call2} />
|
||||
<CallingNotification
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -185,30 +230,26 @@ TwoIncomingDirectCallsBackToBack.story = {
|
|||
};
|
||||
|
||||
export function TwoOutgoingDirectCallsBackToBack(): JSX.Element {
|
||||
const call1: CallingNotificationType = {
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: false,
|
||||
wasVideoCall: true,
|
||||
wasDeclined: false,
|
||||
acceptedTime: 1618894800000,
|
||||
endedTime: 1618894800000,
|
||||
};
|
||||
const call2: CallingNotificationType = {
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: false,
|
||||
wasVideoCall: false,
|
||||
wasDeclined: false,
|
||||
endedTime: 1618894800000,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
{...call1}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Declined,
|
||||
callExternalState: CallExternalState.Ended,
|
||||
})}
|
||||
isNextItemCallingNotification
|
||||
/>
|
||||
<CallingNotification {...getCommonProps()} {...call2} />
|
||||
<CallingNotification
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -220,13 +261,13 @@ TwoOutgoingDirectCallsBackToBack.story = {
|
|||
export function GroupCallByUnknown(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
creator={undefined}
|
||||
deviceCount={15}
|
||||
ended={false}
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Incoming,
|
||||
status: GroupCallStatus.Accepted,
|
||||
callCreator: null,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -234,13 +275,12 @@ export function GroupCallByUnknown(): JSX.Element {
|
|||
export function GroupCallByYou(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
creator={getDefaultConversation({ isMe: true, title: 'Alicia' })}
|
||||
deviceCount={15}
|
||||
ended={false}
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: GroupCallStatus.Accepted,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -248,31 +288,28 @@ export function GroupCallByYou(): JSX.Element {
|
|||
export function GroupCallBySomeone(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
creator={getDefaultConversation({ isMe: false, title: 'Alicia' })}
|
||||
deviceCount={15}
|
||||
ended={false}
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Incoming,
|
||||
status: GroupCallStatus.GenericGroupCall,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupCallStartedBySomeoneWithALongName(): JSX.Element {
|
||||
const longName = '😤🪐🦆'.repeat(50);
|
||||
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
creator={getDefaultConversation({
|
||||
title: longName,
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Incoming,
|
||||
status: GroupCallStatus.GenericGroupCall,
|
||||
callCreator: getDefaultConversation({
|
||||
name: '😤🪐🦆'.repeat(50),
|
||||
}),
|
||||
})}
|
||||
deviceCount={15}
|
||||
ended={false}
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -284,12 +321,13 @@ GroupCallStartedBySomeoneWithALongName.story = {
|
|||
export function GroupCallActiveCallFull(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
deviceCount={16}
|
||||
ended={false}
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Incoming,
|
||||
status: GroupCallStatus.GenericGroupCall,
|
||||
callExternalState: CallExternalState.Full,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -301,12 +339,13 @@ GroupCallActiveCallFull.story = {
|
|||
export function GroupCallEnded(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
deviceCount={0}
|
||||
ended
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Incoming,
|
||||
status: GroupCallStatus.GenericGroupCall,
|
||||
callExternalState: CallExternalState.Ended,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,13 +12,19 @@ import type { LocalizerType } from '../../types/Util';
|
|||
import { CallMode } from '../../types/Calling';
|
||||
import type { CallingNotificationType } from '../../util/callingNotification';
|
||||
import {
|
||||
CallExternalState,
|
||||
getCallingIcon,
|
||||
getCallingNotificationText,
|
||||
} from '../../util/callingNotification';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { Tooltip, TooltipPlacement } from '../Tooltip';
|
||||
import * as log from '../../logging/log';
|
||||
import { assertDev } from '../../util/assert';
|
||||
import {
|
||||
CallDirection,
|
||||
CallType,
|
||||
DirectCallStatus,
|
||||
GroupCallStatus,
|
||||
} from '../../types/CallDisposition';
|
||||
|
||||
export type PropsActionsType = {
|
||||
returnToActiveCall: () => void;
|
||||
|
@ -34,35 +40,15 @@ type PropsHousekeeping = {
|
|||
isNextItemCallingNotification: boolean;
|
||||
};
|
||||
|
||||
type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping;
|
||||
export type PropsType = CallingNotificationType &
|
||||
PropsActionsType &
|
||||
PropsHousekeeping;
|
||||
|
||||
export const CallingNotification: React.FC<PropsType> = React.memo(
|
||||
function CallingNotificationInner(props) {
|
||||
const { i18n } = props;
|
||||
|
||||
let timestamp: number;
|
||||
let wasMissed = false;
|
||||
switch (props.callMode) {
|
||||
case CallMode.Direct: {
|
||||
const resolvedTime = props.acceptedTime ?? props.endedTime;
|
||||
assertDev(resolvedTime, 'Direct call must have accepted or ended time');
|
||||
timestamp = resolvedTime;
|
||||
wasMissed =
|
||||
props.wasIncoming && !props.acceptedTime && !props.wasDeclined;
|
||||
break;
|
||||
}
|
||||
case CallMode.Group:
|
||||
timestamp = props.startedTime;
|
||||
break;
|
||||
default:
|
||||
log.error(
|
||||
`CallingNotification missing case: ${missingCaseError(props)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon = getCallingIcon(props);
|
||||
|
||||
const { type, direction, status, timestamp } = props.callHistory;
|
||||
const icon = getCallingIcon(type, direction, status);
|
||||
return (
|
||||
<SystemMessage
|
||||
button={renderCallingNotificationButton(props)}
|
||||
|
@ -80,7 +66,12 @@ export const CallingNotification: React.FC<PropsType> = React.memo(
|
|||
</>
|
||||
}
|
||||
icon={icon}
|
||||
kind={wasMissed ? SystemMessageKind.Danger : SystemMessageKind.Normal}
|
||||
kind={
|
||||
status === DirectCallStatus.Missed ||
|
||||
status === GroupCallStatus.Missed
|
||||
? SystemMessageKind.Danger
|
||||
: SystemMessageKind.Normal
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -90,7 +81,6 @@ function renderCallingNotificationButton(
|
|||
props: Readonly<PropsType>
|
||||
): ReactNode {
|
||||
const {
|
||||
activeCallConversationId,
|
||||
conversationId,
|
||||
i18n,
|
||||
isNextItemCallingNotification,
|
||||
|
@ -106,55 +96,65 @@ function renderCallingNotificationButton(
|
|||
let disabledTooltipText: undefined | string;
|
||||
let onClick: () => void;
|
||||
|
||||
switch (props.callMode) {
|
||||
switch (props.callHistory.mode) {
|
||||
case CallMode.Direct: {
|
||||
const { wasIncoming, wasVideoCall } = props;
|
||||
buttonText = wasIncoming
|
||||
? i18n('icu:calling__call-back')
|
||||
: i18n('icu:calling__call-again');
|
||||
if (activeCallConversationId) {
|
||||
const { direction, type } = props.callHistory;
|
||||
buttonText =
|
||||
direction === CallDirection.Incoming
|
||||
? i18n('icu:calling__call-back')
|
||||
: i18n('icu:calling__call-again');
|
||||
if (
|
||||
props.callExternalState === CallExternalState.Joined ||
|
||||
props.callExternalState === CallExternalState.InOtherCall
|
||||
) {
|
||||
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
|
||||
onClick = noop;
|
||||
} else {
|
||||
onClick = () => {
|
||||
startCallingLobby({ conversationId, isVideoCall: wasVideoCall });
|
||||
startCallingLobby({
|
||||
conversationId,
|
||||
isVideoCall: type === CallType.Video,
|
||||
});
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CallMode.Group: {
|
||||
if (props.ended) {
|
||||
if (props.callExternalState === CallExternalState.Ended) {
|
||||
return null;
|
||||
}
|
||||
const { deviceCount, maxDevices } = props;
|
||||
if (activeCallConversationId) {
|
||||
if (activeCallConversationId === conversationId) {
|
||||
buttonText = i18n('icu:calling__return');
|
||||
onClick = returnToActiveCall;
|
||||
} else {
|
||||
buttonText = i18n('icu:calling__join');
|
||||
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
|
||||
onClick = noop;
|
||||
}
|
||||
} else if (deviceCount >= maxDevices) {
|
||||
if (props.callExternalState === CallExternalState.Joined) {
|
||||
buttonText = i18n('icu:calling__return');
|
||||
onClick = returnToActiveCall;
|
||||
} else if (props.callExternalState === CallExternalState.InOtherCall) {
|
||||
buttonText = i18n('icu:calling__join');
|
||||
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
|
||||
onClick = noop;
|
||||
} else if (props.callExternalState === CallExternalState.Full) {
|
||||
buttonText = i18n('icu:calling__call-is-full');
|
||||
disabledTooltipText = i18n(
|
||||
'icu:calling__call-notification__button__call-full-tooltip',
|
||||
{
|
||||
max: deviceCount,
|
||||
max: props.maxDevices,
|
||||
}
|
||||
);
|
||||
onClick = noop;
|
||||
} else {
|
||||
} else if (props.callExternalState === CallExternalState.Active) {
|
||||
buttonText = i18n('icu:calling__join');
|
||||
onClick = () => {
|
||||
startCallingLobby({ conversationId, isVideoCall: true });
|
||||
};
|
||||
} else {
|
||||
throw missingCaseError(props.callExternalState);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CallMode.None: {
|
||||
log.error('renderCallingNotificationButton: Call mode cant be none');
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
log.error(missingCaseError(props));
|
||||
log.error(missingCaseError(props.callHistory.mode));
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,13 @@ import { getDefaultConversation } from '../../../test-both/helpers/getDefaultCon
|
|||
import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid';
|
||||
import { ThemeType } from '../../../types/Util';
|
||||
import { DurationInSeconds } from '../../../util/durations';
|
||||
import { NavTab } from '../../../state/ducks/nav';
|
||||
import { CallMode } from '../../../types/Calling';
|
||||
import {
|
||||
CallDirection,
|
||||
CallType,
|
||||
DirectCallStatus,
|
||||
} from '../../../types/CallDisposition';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -79,6 +86,7 @@ const createProps = (
|
|||
metadata: {},
|
||||
member: getDefaultConversation(),
|
||||
})),
|
||||
selectedNavTab: NavTab.Chats,
|
||||
setDisappearingMessages: action('setDisappearingMessages'),
|
||||
showContactModal: action('showContactModal'),
|
||||
pushPanelForConversation: action('pushPanelForConversation'),
|
||||
|
@ -214,3 +222,32 @@ export const _11 = (): JSX.Element => (
|
|||
_11.story = {
|
||||
name: '1:1',
|
||||
};
|
||||
|
||||
function mins(n: number) {
|
||||
return DurationInSeconds.toMillis(DurationInSeconds.fromMinutes(n));
|
||||
}
|
||||
|
||||
export function WithCallHistoryGroup(): JSX.Element {
|
||||
const props = createProps();
|
||||
|
||||
return (
|
||||
<ConversationDetails
|
||||
{...props}
|
||||
callHistoryGroup={{
|
||||
peerId: props.conversation?.uuid ?? '',
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Accepted,
|
||||
timestamp: Date.now(),
|
||||
children: [
|
||||
{ callId: '123', timestamp: Date.now() },
|
||||
{ callId: '122', timestamp: Date.now() - mins(30) },
|
||||
{ callId: '121', timestamp: Date.now() - mins(45) },
|
||||
{ callId: '121', timestamp: Date.now() - mins(60) },
|
||||
],
|
||||
}}
|
||||
selectedNavTab={NavTab.Calls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Button, ButtonIconType, ButtonVariant } from '../../Button';
|
||||
import { Tooltip } from '../../Tooltip';
|
||||
import type {
|
||||
|
@ -52,6 +53,37 @@ import type {
|
|||
import { isConversationMuted } from '../../../util/isConversationMuted';
|
||||
import { ConversationDetailsGroups } from './ConversationDetailsGroups';
|
||||
import { PanelType } from '../../../types/Panels';
|
||||
import type { CallStatus } from '../../../types/CallDisposition';
|
||||
import {
|
||||
CallType,
|
||||
type CallHistoryGroup,
|
||||
CallDirection,
|
||||
DirectCallStatus,
|
||||
GroupCallStatus,
|
||||
} from '../../../types/CallDisposition';
|
||||
import { formatDate, formatTime } from '../../../util/timestamp';
|
||||
import { NavTab } from '../../../state/ducks/nav';
|
||||
|
||||
function describeCallHistory(
|
||||
i18n: LocalizerType,
|
||||
type: CallType,
|
||||
direction: CallDirection,
|
||||
status: CallStatus
|
||||
): string {
|
||||
if (status === DirectCallStatus.Missed || status === GroupCallStatus.Missed) {
|
||||
if (direction === CallDirection.Incoming) {
|
||||
return i18n('icu:CallHistory__Description--Missed', { type });
|
||||
}
|
||||
return i18n('icu:CallHistory__Description--Unanswered', { type });
|
||||
}
|
||||
if (
|
||||
status === DirectCallStatus.Declined ||
|
||||
status === GroupCallStatus.Declined
|
||||
) {
|
||||
return i18n('icu:CallHistory__Description--Declined', { type });
|
||||
}
|
||||
return i18n('icu:CallHistory__Description--Default', { type, direction });
|
||||
}
|
||||
|
||||
enum ModalState {
|
||||
NothingOpen,
|
||||
|
@ -65,6 +97,7 @@ enum ModalState {
|
|||
export type StateProps = {
|
||||
areWeASubscriber: boolean;
|
||||
badges?: ReadonlyArray<BadgeType>;
|
||||
callHistoryGroup?: CallHistoryGroup | null;
|
||||
canEditGroupInfo: boolean;
|
||||
canAddNewMembers: boolean;
|
||||
conversation?: ConversationType;
|
||||
|
@ -80,6 +113,7 @@ export type StateProps = {
|
|||
memberships: ReadonlyArray<GroupV2Membership>;
|
||||
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
|
||||
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
|
||||
selectedNavTab: NavTab;
|
||||
theme: ThemeType;
|
||||
userAvatarData: ReadonlyArray<AvatarDataType>;
|
||||
renderChooseGroupMembersModal: (
|
||||
|
@ -101,6 +135,7 @@ type ActionProps = {
|
|||
}
|
||||
) => unknown;
|
||||
blockConversation: (id: string) => void;
|
||||
|
||||
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
||||
getProfilesForConversation: (id: string) => unknown;
|
||||
leaveGroup: (conversationId: string) => void;
|
||||
|
@ -153,6 +188,7 @@ export function ConversationDetails({
|
|||
areWeASubscriber,
|
||||
badges,
|
||||
blockConversation,
|
||||
callHistoryGroup,
|
||||
canEditGroupInfo,
|
||||
canAddNewMembers,
|
||||
conversation,
|
||||
|
@ -180,6 +216,7 @@ export function ConversationDetails({
|
|||
replaceAvatar,
|
||||
saveAvatarToDisk,
|
||||
searchInConversation,
|
||||
selectedNavTab,
|
||||
setDisappearingMessages,
|
||||
setMuteExpiration,
|
||||
showContactModal,
|
||||
|
@ -364,6 +401,20 @@ export function ConversationDetails({
|
|||
/>
|
||||
|
||||
<div className="ConversationDetails__header-buttons">
|
||||
{selectedNavTab === NavTab.Calls && (
|
||||
<Button
|
||||
icon={ButtonIconType.message}
|
||||
onClick={() => {
|
||||
showConversation({
|
||||
conversationId: conversation?.id,
|
||||
switchToAssociatedView: true,
|
||||
});
|
||||
}}
|
||||
variant={ButtonVariant.Details}
|
||||
>
|
||||
{i18n('icu:ConversationDetails__HeaderButton--Message')}
|
||||
</Button>
|
||||
)}
|
||||
{!conversation.isMe && (
|
||||
<>
|
||||
<ConversationDetailsCallButton
|
||||
|
@ -397,17 +448,60 @@ export function ConversationDetails({
|
|||
>
|
||||
{isMuted ? i18n('icu:unmute') : i18n('icu:mute')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={ButtonIconType.search}
|
||||
onClick={() => {
|
||||
searchInConversation(conversation.id);
|
||||
}}
|
||||
variant={ButtonVariant.Details}
|
||||
>
|
||||
{i18n('icu:search')}
|
||||
</Button>
|
||||
{selectedNavTab !== NavTab.Calls && (
|
||||
<Button
|
||||
icon={ButtonIconType.search}
|
||||
onClick={() => {
|
||||
searchInConversation(conversation.id);
|
||||
}}
|
||||
variant={ButtonVariant.Details}
|
||||
>
|
||||
{i18n('icu:search')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{callHistoryGroup && (
|
||||
<PanelSection>
|
||||
<h2 className="ConversationDetails__CallHistoryGroup__header">
|
||||
{formatDate(i18n, callHistoryGroup.timestamp)}
|
||||
</h2>
|
||||
<ol className="ConversationDetails__CallHistoryGroup__List">
|
||||
{callHistoryGroup.children.map(child => {
|
||||
return (
|
||||
<li
|
||||
key={child.callId}
|
||||
className="ConversationDetails__CallHistoryGroup__Item"
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
'ConversationDetails__CallHistoryGroup__ItemIcon',
|
||||
{
|
||||
'ConversationDetails__CallHistoryGroup__ItemIcon--Audio':
|
||||
callHistoryGroup.type === CallType.Audio,
|
||||
'ConversationDetails__CallHistoryGroup__ItemIcon--Video':
|
||||
callHistoryGroup.type !== CallType.Audio,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<span className="ConversationDetails__CallHistoryGroup__ItemLabel">
|
||||
{describeCallHistory(
|
||||
i18n,
|
||||
callHistoryGroup.type,
|
||||
callHistoryGroup.direction,
|
||||
callHistoryGroup.status
|
||||
)}
|
||||
</span>
|
||||
<span className="ConversationDetails__CallHistoryGroup__ItemTimestamp">
|
||||
{formatTime(i18n, child.timestamp, Date.now(), false)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection>
|
||||
{!isGroup || canEditGroupInfo ? (
|
||||
<PanelRow
|
||||
|
@ -440,28 +534,30 @@ export function ConversationDetails({
|
|||
}
|
||||
/>
|
||||
) : null}
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('icu:showChatColorEditor')}
|
||||
icon={IconType.color}
|
||||
/>
|
||||
}
|
||||
label={i18n('icu:showChatColorEditor')}
|
||||
onClick={() => {
|
||||
pushPanelForConversation({
|
||||
type: PanelType.ChatColorEditor,
|
||||
});
|
||||
}}
|
||||
right={
|
||||
<div
|
||||
className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`}
|
||||
style={{
|
||||
...getCustomColorStyle(conversation.customColor),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{selectedNavTab === NavTab.Chats && (
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('icu:showChatColorEditor')}
|
||||
icon={IconType.color}
|
||||
/>
|
||||
}
|
||||
label={i18n('icu:showChatColorEditor')}
|
||||
onClick={() => {
|
||||
pushPanelForConversation({
|
||||
type: PanelType.ChatColorEditor,
|
||||
});
|
||||
}}
|
||||
right={
|
||||
<div
|
||||
className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`}
|
||||
style={{
|
||||
...getCustomColorStyle(conversation.customColor),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isGroup && (
|
||||
<PanelRow
|
||||
icon={
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue