Calls Tab & Group Call Disposition

This commit is contained in:
Jamie Kyle 2023-08-08 17:53:06 -07:00 committed by GitHub
parent 620e85ca01
commit 1eaabb6734
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
139 changed files with 9182 additions and 2721 deletions

View file

@ -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>

View file

@ -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}

View file

@ -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: [],

View file

@ -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>
);

View file

@ -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
View 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} &middot;{' '}
<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>
</>
);
}

View 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
View 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>
)}
</>
);
}

View 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>
</>
);
}

View file

@ -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

View file

@ -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

View file

@ -84,10 +84,7 @@ const Template: Story<PropsType & { daysAgo?: number }> = ({
{...args}
firstEnvelopeTimestamp={firstEnvelopeTimestamp}
envelopeTimestamp={envelopeTimestamp}
renderConversationView={() => <div />}
renderCustomizingPreferredReactionsModal={() => <div />}
renderLeftPane={() => <div />}
renderMiniPlayer={() => <div />}
/>
);
};

View file

@ -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}
</>

View file

@ -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,

View file

@ -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>
);
}

View file

@ -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,
};

View file

@ -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>
);
}

View 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
View 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>
);
}

View file

@ -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" />

View file

@ -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>

View file

@ -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}
/>
))}

View file

@ -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();
}}
>

View file

@ -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 = {};

View file

@ -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')}

View file

@ -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;
}

View file

@ -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">

View file

@ -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:

View file

@ -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}>

View file

@ -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;
}

View file

@ -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,
})}
/>
);
}

View file

@ -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;
}

View file

@ -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}
/>
);
}

View file

@ -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={