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
139 changed files with 9182 additions and 2721 deletions

View File

@@ -3,7 +3,7 @@
import { Bytes } from './context/Bytes';
const bytes = window.SignalContext?.bytes || new Bytes();
const bytes = globalThis.window?.SignalContext?.bytes || new Bytes();
export function fromBase64(value: string): Uint8Array {
return bytes.fromBase64(value);

View File

@@ -7,13 +7,9 @@ import { HKDF } from '@signalapp/libsignal-client';
import * as Bytes from './Bytes';
import { calculateAgreement, generateKeyPair } from './Curve';
import * as log from './logging/log';
import { HashType, CipherType } from './types/Crypto';
import { ProfileDecryptError } from './types/errors';
import { UUID, UUID_BYTE_SIZE } from './types/UUID';
import type { UUIDStringType } from './types/UUID';
export { uuidToBytes } from './util/uuidToBytes';
import { getBytesSubarray } from './util/uuidToBytes';
export { HashType, CipherType };
@@ -199,12 +195,16 @@ export function decryptSymmetric(
const iv = getZeroes(IV_LENGTH);
const nonce = getFirstBytes(data, NONCE_LENGTH);
const ciphertext = getBytes(
const ciphertext = getBytesSubarray(
data,
NONCE_LENGTH,
data.byteLength - NONCE_LENGTH - MAC_LENGTH
);
const theirMac = getBytes(data, data.byteLength - MAC_LENGTH, MAC_LENGTH);
const theirMac = getBytesSubarray(
data,
data.byteLength - MAC_LENGTH,
MAC_LENGTH
);
const cipherKey = hmacSha256(key, nonce);
const macKey = hmacSha256(key, cipherKey);
@@ -353,52 +353,6 @@ export function getFirstBytes(data: Uint8Array, n: number): Uint8Array {
return data.subarray(0, n);
}
export function getBytes(
data: Uint8Array,
start: number,
n: number
): Uint8Array {
return data.subarray(start, start + n);
}
export function bytesToUuid(bytes: Uint8Array): undefined | UUIDStringType {
if (bytes.byteLength !== UUID_BYTE_SIZE) {
log.warn(
'bytesToUuid: received an Uint8Array of invalid length. ' +
'Returning undefined'
);
return undefined;
}
const uuids = splitUuids(bytes);
if (uuids.length === 1) {
return uuids[0] || undefined;
}
return undefined;
}
export function splitUuids(buffer: Uint8Array): Array<UUIDStringType | null> {
const uuids = new Array<UUIDStringType | null>();
for (let i = 0; i < buffer.byteLength; i += UUID_BYTE_SIZE) {
const bytes = getBytes(buffer, i, UUID_BYTE_SIZE);
const hex = Bytes.toHex(bytes);
const chunks = [
hex.substring(0, 8),
hex.substring(8, 12),
hex.substring(12, 16),
hex.substring(16, 20),
hex.substring(20),
];
const uuid = chunks.join('-');
if (uuid !== '00000000-0000-0000-0000-000000000000') {
uuids.push(UUID.cast(uuid));
} else {
uuids.push(null);
}
}
return uuids;
}
export function trimForDisplay(padded: Uint8Array): Uint8Array {
let paddingEnd = 0;
for (paddingEnd; paddingEnd < padded.length; paddingEnd += 1) {
@@ -588,7 +542,7 @@ export function decryptProfileName(
// SignalContext APIs
//
const { crypto } = window.SignalContext;
const { crypto } = globalThis.window?.SignalContext ?? {};
export function sign(key: Uint8Array, data: Uint8Array): Uint8Array {
return crypto.sign(key, data);

View File

@@ -8,8 +8,8 @@ import * as log from './logging/log';
import type { UUIDStringType } from './types/UUID';
import { parseIntOrThrow } from './util/parseIntOrThrow';
import { SECOND, HOUR } from './util/durations';
import { uuidToBytes } from './util/uuidToBytes';
import * as Bytes from './Bytes';
import { uuidToBytes } from './util/uuidToBytes';
import { HashType } from './types/Crypto';
import { getCountryCode } from './types/PhoneNumber';

View File

@@ -186,6 +186,11 @@ import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration'
import { makeLookup } from './util/makeLookup';
import { addGlobalKeyboardShortcuts } from './services/addGlobalKeyboardShortcuts';
import { createEventHandler } from './quill/signal-clipboard/util';
import { onCallLogEventSync } from './util/onCallLogEventSync';
import {
getCallsHistoryForRedux,
loadCallsHistory,
} from './services/callHistoryLoader';
export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@@ -434,6 +439,10 @@ export async function startApp(): Promise<void> {
'callEventSync',
queuedEventListener(onCallEventSync, false)
);
messageReceiver.addEventListener(
'callLogEventSync',
queuedEventListener(onCallLogEventSync, false)
);
});
ourProfileKeyService.initialize(window.storage);
@@ -1121,6 +1130,7 @@ export async function startApp(): Promise<void> {
loadInitialBadgesState(),
loadStories(),
loadDistributionLists(),
loadCallsHistory(),
window.textsecure.storage.protocol.hydrateCaches(),
(async () => {
mainWindowStats = await window.SignalContext.getMainWindowStats();
@@ -1174,6 +1184,7 @@ export async function startApp(): Promise<void> {
menuOptions,
stories: getStoriesForRedux(),
storyDistributionLists: getDistributionListsForRedux(),
callsHistory: getCallsHistoryForRedux(),
});
const store = window.Signal.State.createStore(initialState);
@@ -1193,6 +1204,10 @@ export async function startApp(): Promise<void> {
store.dispatch
),
badges: bindActionCreators(actionCreators.badges, store.dispatch),
callHistory: bindActionCreators(
actionCreators.callHistory,
store.dispatch
),
calling: bindActionCreators(actionCreators.calling, store.dispatch),
composer: bindActionCreators(actionCreators.composer, store.dispatch),
conversations: bindActionCreators(

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

View File

@@ -10,7 +10,6 @@ import * as KeyboardLayout from '../services/keyboardLayout';
import { getHasPanelOpen } from '../state/selectors/conversations';
import { isInFullScreenCall } from '../state/selectors/calling';
import { isShowingAnyModal } from '../state/selectors/globalModals';
import { shouldShowStoriesView } from '../state/selectors/stories';
type KeyboardShortcutHandlerType = (ev: KeyboardEvent) => boolean;
@@ -36,10 +35,6 @@ function useHasGlobalModal(): boolean {
return useSelector<StateType, boolean>(isShowingAnyModal);
}
function useHasStories(): boolean {
return useSelector<StateType, boolean>(shouldShowStoriesView);
}
function useHasCalling(): boolean {
return useSelector<StateType, boolean>(isInFullScreenCall);
}
@@ -47,10 +42,9 @@ function useHasCalling(): boolean {
function useHasAnyOverlay(): boolean {
const panels = useHasPanels();
const globalModal = useHasGlobalModal();
const stories = useHasStories();
const calling = useHasCalling();
return panels || globalModal || stories || calling;
return panels || globalModal || calling;
}
export function useActiveCallShortcuts(

3
ts/model-types.d.ts vendored
View File

@@ -7,7 +7,6 @@ import * as Backbone from 'backbone';
import type { GroupV2ChangeType } from './groups';
import type { DraftBodyRanges, RawBodyRange } from './types/BodyRange';
import type { CallHistoryDetailsFromDiskType } from './types/Calling';
import type { CustomColorType, ConversationColorType } from './types/Colors';
import type { DeviceType } from './textsecure/Types.d';
import type { SendMessageChallengeData } from './textsecure/Errors';
@@ -132,7 +131,7 @@ export type EditHistoryType = {
export type MessageAttributesType = {
bodyAttachment?: AttachmentType;
bodyRanges?: ReadonlyArray<RawBodyRange>;
callHistoryDetails?: CallHistoryDetailsFromDiskType;
callId?: string;
canReplyToStory?: boolean;
changedId?: string;
dataMessage?: Uint8Array | null;

View File

@@ -30,8 +30,6 @@ import { getAboutText } from '../util/getAboutText';
import { getAvatarPath } from '../util/avatarUtils';
import { getDraftPreview } from '../util/getDraftPreview';
import { hasDraft } from '../util/hasDraft';
import type { CallHistoryDetailsType } from '../types/Calling';
import { CallMode } from '../types/Calling';
import * as Conversation from '../types/Conversation';
import type { StickerType, StickerWithHydratedData } from '../types/Stickers';
import * as Stickers from '../types/Stickers';
@@ -55,7 +53,7 @@ import type {
} from '../types/Colors';
import type { MessageModel } from './messages';
import { getContact } from '../messages/helpers';
import { assertDev, strictAssert } from '../util/assert';
import { strictAssert } from '../util/assert';
import { isConversationMuted } from '../util/isConversationMuted';
import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
import {
@@ -63,10 +61,8 @@ import {
isConversationUnregistered,
isConversationUnregisteredAndStale,
} from '../util/isConversationUnregistered';
import { missingCaseError } from '../util/missingCaseError';
import { sniffImageMimeType } from '../util/sniffImageMimeType';
import { isValidE164 } from '../util/isValidE164';
import { canConversationBeUnarchived } from '../util/canConversationBeUnarchived';
import type { MIMEType } from '../types/MIME';
import { IMAGE_JPEG, IMAGE_WEBP } from '../types/MIME';
import { UUID, UUIDKind } from '../types/UUID';
@@ -160,7 +156,6 @@ import { ReceiptType } from '../types/Receipt';
import { getQuoteAttachment } from '../util/makeQuote';
import { deriveProfileKeyVersion } from '../util/zkgroup';
import { incrementMessageCounter } from '../util/incrementMessageCounter';
import { validateTransition } from '../util/callHistoryDetails';
import OS from '../util/os/osMain';
/* eslint-disable more/no-then */
@@ -256,8 +251,6 @@ export class ConversationModel extends window.Backbone
throttledUpdateSharedGroups?: () => Promise<void>;
private cachedLatestGroupCallEraId?: string;
private cachedIdenticon?: CachedIdenticon;
public isFetchingUUID?: boolean;
@@ -3069,181 +3062,6 @@ export class ConversationModel extends window.Backbone
}
}
async addCallHistory(
callHistoryDetails: CallHistoryDetailsType,
receivedAtCounter: number | undefined
): Promise<void> {
let timestamp: number;
let unread: boolean;
let detailsToSave: CallHistoryDetailsType;
switch (callHistoryDetails.callMode) {
case CallMode.Direct: {
const {
callId,
wasIncoming,
wasVideoCall,
wasDeclined,
acceptedTime,
endedTime,
} = callHistoryDetails;
log.info(
`addCallHistory: Conversation ID: ${this.id}, ` +
`Call ID: ${callId}, ` +
'Direct, ' +
`Incoming: ${wasIncoming}, ` +
`Video: ${wasVideoCall}, ` +
`Declined: ${wasDeclined}, ` +
`Accepted: ${acceptedTime}, ` +
`Ended: ${endedTime}`
);
const resolvedTime = acceptedTime ?? endedTime;
assertDev(resolvedTime, 'Direct call must have accepted or ended time');
timestamp = resolvedTime;
unread =
callHistoryDetails.wasIncoming &&
!callHistoryDetails.wasDeclined &&
!callHistoryDetails.acceptedTime;
detailsToSave = {
...callHistoryDetails,
callMode: CallMode.Direct,
};
break;
}
case CallMode.Group:
timestamp = callHistoryDetails.startedTime;
unread = false;
detailsToSave = callHistoryDetails;
break;
default:
throw missingCaseError(callHistoryDetails);
}
// This is sometimes called inside of another conversation queue job so if
// awaited it would block on this forever.
drop(
this.queueJob('addCallHistory', async () => {
// Force save if we're adding a new call history message for a direct call
let forceSave = true;
let previousMessage: MessageAttributesType | null = null;
if (callHistoryDetails.callMode === CallMode.Direct) {
const messageId =
await window.Signal.Data.getCallHistoryMessageByCallId(
this.id,
callHistoryDetails.callId
);
if (messageId != null) {
log.info(
`addCallHistory: Found existing call history message (Call ID: ${callHistoryDetails.callId}, Message ID: ${messageId})`
);
// We don't want to force save if we're updating an existing message
forceSave = false;
previousMessage =
(await window.Signal.Data.getMessageById(messageId)) ?? null;
} else {
log.info(
`addCallHistory: No existing call history message found (Call ID: ${callHistoryDetails.callId})`
);
}
}
if (
!validateTransition(
previousMessage?.callHistoryDetails,
callHistoryDetails,
log
)
) {
log.info("addCallHistory: Transition isn't valid, not saving");
return;
}
const message: MessageAttributesType = {
id: previousMessage?.id ?? generateGuid(),
conversationId: this.id,
type: 'call-history',
sent_at: timestamp,
timestamp,
received_at: receivedAtCounter || incrementMessageCounter(),
received_at_ms: timestamp,
readStatus: unread ? ReadStatus.Unread : ReadStatus.Read,
seenStatus: unread ? SeenStatus.Unseen : SeenStatus.NotApplicable,
callHistoryDetails,
};
const id = await window.Signal.Data.saveMessage(message, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
forceSave,
});
log.info(`addCallHistory: Saved call history message (ID: ${id})`);
const model = window.MessageController.register(
id,
new window.Whisper.Message({
...message,
id,
})
);
if (
detailsToSave.callMode === CallMode.Direct &&
!detailsToSave.wasIncoming
) {
this.incrementSentMessageCount();
} else {
this.incrementMessageCount();
}
this.trigger('newmessage', model);
void this.updateUnread();
this.set('active_at', timestamp);
if (canConversationBeUnarchived(this.attributes)) {
this.setArchived(false);
} else {
window.Signal.Data.updateConversation(this.attributes);
}
})
);
}
/**
* Adds a group call history message if one is needed. It won't add history messages for
* the same group call era ID.
*
* Resolves with `true` if a new message was added, and `false` otherwise.
*/
async updateCallHistoryForGroupCall(
eraId: string,
creatorUuid: string
): Promise<boolean> {
// We want to update the cache quickly in case this function is called multiple times.
const oldCachedEraId = this.cachedLatestGroupCallEraId;
this.cachedLatestGroupCallEraId = eraId;
const alreadyHasMessage =
(oldCachedEraId && oldCachedEraId === eraId) ||
(await window.Signal.Data.hasGroupCallHistoryMessage(this.id, eraId));
if (alreadyHasMessage) {
void this.updateLastMessage();
return false;
}
await this.addCallHistory(
{
callMode: CallMode.Group,
creatorUuid,
eraId,
startedTime: Date.now(),
},
undefined
);
return true;
}
async addProfileChange(
profileChange: unknown,
conversationId?: string

View File

@@ -147,7 +147,6 @@ import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import { getMessageById } from '../messages/getMessageById';
import { shouldDownloadStory } from '../util/shouldDownloadStory';
import { shouldShowStoriesView } from '../state/selectors/stories';
import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact';
import { SeenStatus } from '../MessageSeenStatus';
import { isNewReactionReplacingPrevious } from '../reactions/util';
@@ -172,6 +171,8 @@ import {
saveNewMessageBatcher,
} from '../util/messageBatcher';
import { normalizeUuid } from '../util/normalizeUuid';
import { getCallHistorySelector } from '../state/selectors/callHistory';
import { getConversationSelector } from '../state/selectors/conversations';
/* eslint-disable more/no-then */
@@ -715,9 +716,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (isCallHistory(attributes)) {
const state = window.reduxStore.getState();
const callingNotification = getPropsForCallHistory(attributes, {
conversationSelector: findAndFormatContact,
callSelector: getCallSelector(state),
activeCall: getActiveCall(state),
callHistorySelector: getCallHistorySelector(state),
conversationSelector: getConversationSelector(state),
});
if (callingNotification) {
return {
@@ -2837,11 +2839,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
let queueStoryForDownload = false;
if (isStory(message.attributes)) {
const isShowingStories = shouldShowStoriesView(reduxState);
queueStoryForDownload =
isShowingStories ||
(await shouldDownloadStory(conversation.attributes));
queueStoryForDownload = await shouldDownloadStory(
conversation.attributes
);
}
const shouldHoldOffDownload =

View File

@@ -0,0 +1,17 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import dataInterface from '../sql/Client';
import type { CallHistoryDetails } from '../types/CallDisposition';
import { strictAssert } from '../util/assert';
let callsHistoryData: ReadonlyArray<CallHistoryDetails>;
export async function loadCallsHistory(): Promise<void> {
callsHistoryData = await dataInterface.getAllCallHistory();
}
export function getCallsHistoryForRedux(): ReadonlyArray<CallHistoryDetails> {
strictAssert(callsHistoryData != null, 'callHistory has not been loaded');
return callsHistoryData;
}

View File

@@ -17,7 +17,6 @@ import {
AnswerMessage,
BusyMessage,
Call,
CallEndedReason,
CallingMessage,
CallLogLevel,
CallState,
@@ -39,8 +38,8 @@ import {
RingUpdate,
} from '@signalapp/ringrtc';
import { uniqBy, noop } from 'lodash';
import Long from 'long';
import Long from 'long';
import type {
ActionsType as CallingReduxActionsType,
GroupCallParticipantInfoType,
@@ -51,6 +50,7 @@ import { getConversationCallMode } from '../state/ducks/conversations';
import { isMe } from '../util/whatTypeOfConversation';
import type {
AvailableIODevicesType,
CallEndedReason,
MediaDeviceSettings,
PresentableSource,
PresentedSource,
@@ -74,11 +74,10 @@ import { UUID, UUIDKind } from '../types/UUID';
import * as Errors from '../types/errors';
import type { ConversationModel } from '../models/conversations';
import * as Bytes from '../Bytes';
import { uuidToBytes, bytesToUuid } from '../Crypto';
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
import { drop } from '../util/drop';
import { dropNull } from '../util/dropNull';
import { getOwn } from '../util/getOwn';
import { isNormalNumber } from '../util/isNormalNumber';
import * as durations from '../util/durations';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
import { handleMessageSend } from '../util/handleMessageSend';
@@ -107,6 +106,24 @@ import {
import * as log from '../logging/log';
import { assertDev, strictAssert } from '../util/assert';
import { sendContentMessageToGroup, sendToGroup } from '../util/sendToGroup';
import {
formatLocalDeviceState,
formatPeekInfo,
getPeerIdFromConversation,
getLocalCallEventFromCallEndedReason,
getCallDetailsFromEndedDirectCall,
getCallEventDetails,
getLocalCallEventFromGroupCall,
getLocalCallEventFromDirectCall,
getCallDetailsFromDirectCall,
getCallDetailsFromGroupCallMeta,
updateCallHistoryFromLocalEvent,
getGroupCallMeta,
getCallIdFromRing,
getLocalCallEventFromRingUpdate,
} from '../util/callDisposition';
import { isNormalNumber } from '../util/isNormalNumber';
import { LocalCallEvent } from '../types/CallDisposition';
const {
processGroupCallRingCancellation,
@@ -689,7 +706,51 @@ export class CallingClass {
{
onLocalDeviceStateChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
const { eraId } = groupCall.getPeekInfo() || {};
const peekInfo = groupCall.getPeekInfo() ?? null;
log.info(
'GroupCall#onLocalDeviceStateChanged',
formatLocalDeviceState(localDeviceState),
peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
const groupCallMeta = getGroupCallMeta(peekInfo);
if (groupCallMeta != null) {
try {
const localCallEvent = getLocalCallEventFromGroupCall(
groupCall,
groupCallMeta
);
if (localCallEvent != null && peekInfo != null) {
const conversation =
window.ConversationController.get(conversationId);
strictAssert(
conversation != null,
'GroupCall#onLocalDeviceStateChanged: Missing conversation'
);
const peerId = getPeerIdFromConversation(
conversation.attributes
);
const callDetails = getCallDetailsFromGroupCallMeta(
peerId,
groupCallMeta
);
const callEvent = getCallEventDetails(
callDetails,
localCallEvent
);
drop(updateCallHistoryFromLocalEvent(callEvent, null));
}
} catch (error) {
log.error(
'GroupCall#onLocalDeviceStateChanged: Error updating state',
Errors.toLogFormat(error)
);
}
}
if (
localDeviceState.connectionState === ConnectionState.NotConnected
@@ -703,10 +764,13 @@ export class CallingClass {
if (
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
eraId
peekInfo?.eraId != null
) {
updateMessageState = GroupCallUpdateMessageState.SentLeft;
void this.sendGroupCallUpdateMessage(conversationId, eraId);
void this.sendGroupCallUpdateMessage(
conversationId,
peekInfo?.eraId
);
}
} else {
this.callsByConversation[conversationId] = groupCall;
@@ -721,16 +785,28 @@ export class CallingClass {
if (
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
localDeviceState.joinState === JoinState.Joined &&
eraId
peekInfo?.eraId != null
) {
updateMessageState = GroupCallUpdateMessageState.SentJoin;
void this.sendGroupCallUpdateMessage(conversationId, eraId);
void this.sendGroupCallUpdateMessage(
conversationId,
peekInfo?.eraId
);
}
}
this.syncGroupCallToRedux(conversationId, groupCall);
},
onRemoteDeviceStatesChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
const peekInfo = groupCall.getPeekInfo();
log.info(
'GroupCall#onRemoteDeviceStatesChanged',
formatLocalDeviceState(localDeviceState),
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
this.syncGroupCallToRedux(conversationId, groupCall);
},
onAudioLevels: groupCall => {
@@ -748,7 +824,16 @@ export class CallingClass {
},
onPeekChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
const { eraId } = groupCall.getPeekInfo() || {};
const peekInfo = groupCall.getPeekInfo() ?? null;
log.info(
'GroupCall#onPeekChanged',
formatLocalDeviceState(localDeviceState),
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
const { eraId } = peekInfo ?? {};
if (
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
localDeviceState.connectionState !== ConnectionState.NotConnected &&
@@ -759,10 +844,7 @@ export class CallingClass {
void this.sendGroupCallUpdateMessage(conversationId, eraId);
}
void this.updateCallHistoryForGroupCall(
conversationId,
groupCall.getPeekInfo()
);
void this.updateCallHistoryForGroupCall(conversationId, peekInfo);
this.syncGroupCallToRedux(conversationId, groupCall);
},
async requestMembershipProof(groupCall) {
@@ -789,7 +871,17 @@ export class CallingClass {
requestGroupMembers: groupCall => {
groupCall.setGroupMembers(this.getGroupCallMembers(conversationId));
},
onEnded: noop,
onEnded: (groupCall, endedReason) => {
const localDeviceState = groupCall.getLocalDeviceState();
const peekInfo = groupCall.getPeekInfo();
log.info(
'GroupCall#onEnded',
endedReason,
formatLocalDeviceState(localDeviceState),
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
},
}
);
@@ -1567,12 +1659,23 @@ export class CallingClass {
await this.handleOutgoingSignaling(remoteUserId, message);
const ProtoOfferType = Proto.CallingMessage.Offer.Type;
await this.addCallHistoryForFailedIncomingCall(
conversation,
callingMessage.offer.type === ProtoOfferType.OFFER_VIDEO_CALL,
envelope.timestamp,
callId.toString()
const wasVideoCall =
callingMessage.offer.type ===
Proto.CallingMessage.Offer.Type.OFFER_VIDEO_CALL;
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromEndedDirectCall(
callId.toString(),
peerId,
peerId, // Incoming call
wasVideoCall,
envelope.timestamp
);
const localCallEvent = LocalCallEvent.Missed;
const callEvent = getCallEventDetails(callDetails, localCallEvent);
await updateCallHistoryFromLocalEvent(
callEvent,
envelope.receivedAtCounter
);
return;
@@ -1801,6 +1904,20 @@ export class CallingClass {
ringId,
});
}
const localEvent = getLocalCallEventFromRingUpdate(update);
if (localEvent != null) {
const callId = getCallIdFromRing(ringId);
const callDetails = getCallDetailsFromGroupCallMeta(groupId, {
callId,
ringerId: ringerUuid,
});
const callEvent = getCallEventDetails(
callDetails,
shouldRing ? LocalCallEvent.Ringing : LocalCallEvent.Started
);
await updateCallHistoryFromLocalEvent(callEvent, null);
}
}
private async handleOutgoingSignaling(
@@ -1865,8 +1982,6 @@ export class CallingClass {
);
return false;
}
const callId = Long.fromValue(call.callId).toString();
try {
// The peer must be 'trusted' before accepting a call from them.
// This is mostly the safety number check, unverified meaning that they were
@@ -1879,12 +1994,13 @@ export class CallingClass {
log.info(
`Peer is not trusted, ignoring incoming call for conversation: ${conversation.idForLogging()}`
);
await this.addCallHistoryForFailedIncomingCall(
conversation,
call.isVideoCall,
Date.now(),
callId
);
const localCallEvent = LocalCallEvent.Missed;
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromDirectCall(peerId, call);
const callEvent = getCallEventDetails(callDetails, localCallEvent);
await updateCallHistoryFromLocalEvent(callEvent, null);
return false;
}
@@ -1898,20 +2014,14 @@ export class CallingClass {
return true;
} catch (err) {
log.error(`Ignoring incoming call: ${Errors.toLogFormat(err)}`);
await this.addCallHistoryForFailedIncomingCall(
conversation,
call.isVideoCall,
Date.now(),
callId
);
return false;
}
}
private async handleAutoEndedIncomingCallRequest(
callId: CallId,
callIdValue: CallId,
remoteUserId: UserId,
reason: CallEndedReason,
callEndedReason: CallEndedReason,
ageInSeconds: number,
wasVideoCall: boolean,
receivedAtCounter: number | undefined
@@ -1921,22 +2031,28 @@ export class CallingClass {
return;
}
const callId = Long.fromValue(callIdValue).toString();
const peerId = getPeerIdFromConversation(conversation.attributes);
// This is extra defensive, just in case RingRTC passes us a bad value. (It probably
// won't.)
const ageInMilliseconds =
isNormalNumber(ageInSeconds) && ageInSeconds >= 0
? ageInSeconds * durations.SECOND
: 0;
const endedTime = Date.now() - ageInMilliseconds;
const timestamp = Date.now() - ageInMilliseconds;
await this.addCallHistoryForAutoEndedIncomingCall(
conversation,
reason,
endedTime,
const callDetails = getCallDetailsFromEndedDirectCall(
callId,
peerId,
remoteUserId,
wasVideoCall,
receivedAtCounter,
Long.fromValue(callId).toString()
timestamp
);
const localCallEvent =
getLocalCallEventFromCallEndedReason(callEndedReason);
const callEvent = getCallEventDetails(callDetails, localCallEvent);
await updateCallHistoryFromLocalEvent(callEvent, receivedAtCounter ?? null);
}
private attachToCall(conversation: ConversationModel, call: Call): void {
@@ -1947,44 +2063,26 @@ export class CallingClass {
return;
}
let acceptedTime: number | undefined;
// eslint-disable-next-line no-param-reassign
call.handleStateChanged = async () => {
if (call.state === CallState.Accepted) {
acceptedTime = acceptedTime || Date.now();
await this.addCallHistoryForAcceptedCall(
conversation,
call,
acceptedTime
);
} else if (call.state === CallState.Ended) {
try {
await this.addCallHistoryForEndedCall(
conversation,
call,
acceptedTime
);
} catch (error) {
log.error(
'Failed to add call history for ended call',
Errors.toLogFormat(error)
);
}
if (call.state === CallState.Ended) {
this.stopDeviceReselectionTimer();
this.lastMediaDeviceSettings = undefined;
delete this.callsByConversation[conversation.id];
}
const localCallEvent = getLocalCallEventFromDirectCall(call);
if (localCallEvent != null) {
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromDirectCall(peerId, call);
const callEvent = getCallEventDetails(callDetails, localCallEvent);
await updateCallHistoryFromLocalEvent(callEvent, null);
}
reduxInterface.callStateChange({
remoteUserId: call.remoteUserId,
callId: Long.fromValue(call.callId).toString(),
conversationId: conversation.id,
acceptedTime,
callState: call.state,
callEndedReason: call.endedReason,
isIncoming: call.isIncoming,
isVideoCall: call.isVideoCall,
title: conversation.getTitle(),
});
};
@@ -2137,154 +2235,55 @@ export class CallingClass {
return true;
}
private async addCallHistoryForAcceptedCall(
conversation: ConversationModel,
call: Call,
acceptedTime: number
) {
const callId = Long.fromValue(call.callId).toString();
try {
log.info('addCallHistoryForAcceptedCall: Adding call history');
await conversation.addCallHistory(
{
callId,
callMode: CallMode.Direct,
wasIncoming: call.isIncoming,
wasVideoCall: call.isVideoCall,
wasDeclined: false,
acceptedTime,
endedTime: undefined,
},
undefined
);
} catch (error) {
log.error(
'addCallHistoryForAcceptedCall: Failed to add call history',
Errors.toLogFormat(error)
);
}
}
private async addCallHistoryForEndedCall(
conversation: ConversationModel,
call: Call,
acceptedTimeParam: number | undefined
) {
let acceptedTime = acceptedTimeParam;
const { endedReason, isIncoming } = call;
const wasAccepted = Boolean(acceptedTime);
const isOutgoing = !isIncoming;
const wasDeclined =
!wasAccepted &&
(endedReason === CallEndedReason.Declined ||
endedReason === CallEndedReason.DeclinedOnAnotherDevice ||
(isIncoming && endedReason === CallEndedReason.LocalHangup) ||
(isOutgoing && endedReason === CallEndedReason.RemoteHangup) ||
(isOutgoing &&
endedReason === CallEndedReason.RemoteHangupNeedPermission));
if (call.endedReason === CallEndedReason.AcceptedOnAnotherDevice) {
acceptedTime = Date.now();
}
const callId = Long.fromValue(call.callId).toString();
await conversation.addCallHistory(
{
callId,
callMode: CallMode.Direct,
wasIncoming: call.isIncoming,
wasVideoCall: call.isVideoCall,
wasDeclined,
acceptedTime,
endedTime: Date.now(),
},
undefined
);
}
private async addCallHistoryForFailedIncomingCall(
conversation: ConversationModel,
wasVideoCall: boolean,
timestamp: number,
callId: string
) {
await conversation.addCallHistory(
{
callMode: CallMode.Direct,
wasIncoming: true,
wasVideoCall,
// Since the user didn't decline, make sure it shows up as a missed call instead
wasDeclined: false,
acceptedTime: undefined,
endedTime: timestamp,
callId,
},
undefined
);
}
private async addCallHistoryForAutoEndedIncomingCall(
conversation: ConversationModel,
reason: CallEndedReason,
endedTime: number,
wasVideoCall: boolean,
receivedAtCounter: number | undefined,
callId: string
) {
let wasDeclined = false;
let acceptedTime;
if (reason === CallEndedReason.AcceptedOnAnotherDevice) {
acceptedTime = endedTime;
} else if (reason === CallEndedReason.DeclinedOnAnotherDevice) {
wasDeclined = true;
}
// Otherwise it will show up as a missed call.
await conversation.addCallHistory(
{
callId,
callMode: CallMode.Direct,
wasIncoming: true,
wasVideoCall,
wasDeclined,
acceptedTime,
endedTime,
},
receivedAtCounter
);
}
public async updateCallHistoryForGroupCall(
conversationId: string,
peekInfo: undefined | PeekInfo
peekInfo: PeekInfo | null
): Promise<void> {
const groupCallMeta = getGroupCallMeta(peekInfo);
// If we don't have the necessary pieces to peek, bail. (It's okay if we don't.)
if (!peekInfo || !peekInfo.eraId || !peekInfo.creator) {
if (groupCallMeta == null) {
return;
}
const creatorUuid = bytesToUuid(peekInfo.creator);
if (!creatorUuid) {
log.error('updateCallHistoryForGroupCall(): bad creator UUID');
return;
}
const creatorConversation = window.ConversationController.get(creatorUuid);
const creatorConversation = window.ConversationController.get(
groupCallMeta.ringerId
);
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.error('updateCallHistoryForGroupCall(): could not find conversation');
log.error('maybeNotifyGroupCall(): could not find conversation');
return;
}
const isNewCall = await conversation.updateCallHistoryForGroupCall(
peekInfo.eraId,
creatorUuid
);
const prevMessageId =
await window.Signal.Data.getCallHistoryMessageByCallId({
conversationId: conversation.id,
callId: groupCallMeta.callId,
});
const isNewCall = prevMessageId == null;
const groupCall = this.getGroupCall(conversationId);
if (groupCall != null) {
const localCallEvent = getLocalCallEventFromGroupCall(
groupCall,
groupCallMeta
);
if (localCallEvent != null) {
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromGroupCallMeta(
peerId,
groupCallMeta
);
const callEvent = getCallEventDetails(callDetails, localCallEvent);
await updateCallHistoryFromLocalEvent(callEvent, null);
}
}
const wasStartedByMe = Boolean(
creatorConversation && isMe(creatorConversation.attributes)
);
const isAnybodyElseInGroupCall = Boolean(peekInfo.devices.length);
const isAnybodyElseInGroupCall = Boolean(peekInfo?.devices.length);
if (
isNewCall &&

View File

@@ -4,11 +4,8 @@
import { isEqual, isNumber } from 'lodash';
import Long from 'long';
import {
uuidToBytes,
bytesToUuid,
deriveMasterKeyFromGroupV1,
} from '../Crypto';
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
import { deriveMasterKeyFromGroupV1 } from '../Crypto';
import * as Bytes from '../Bytes';
import {
deriveGroupFields,

View File

@@ -11,8 +11,7 @@ import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { strictAssert } from '../util/assert';
import { sleep } from '../util/sleep';
import { getMinNickname, getMaxNickname } from '../util/Username';
import { bytesToUuid } from '../Crypto';
import { uuidToBytes } from '../util/uuidToBytes';
import { bytesToUuid, uuidToBytes } from '../util/uuidToBytes';
import type { UsernameReservationType } from '../types/Username';
import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username';
import * as Errors from '../types/errors';

View File

@@ -21,6 +21,12 @@ import type { ReadStatus } from '../messages/MessageReadStatus';
import type { RawBodyRange } from '../types/BodyRange';
import type { GetMessagesBetweenOptions } from './Server';
import type { MessageTimestamps } from '../state/ducks/conversations';
import type {
CallHistoryDetails,
CallHistoryFilter,
CallHistoryGroup,
CallHistoryPagination,
} from '../types/CallDisposition';
export type AdjacentMessagesByConversationOptionsType = Readonly<{
conversationId: string;
@@ -628,10 +634,22 @@ export type DataInterface = {
getLastConversationMessage(options: {
conversationId: string;
}): Promise<MessageType | undefined>;
getCallHistoryMessageByCallId(
conversationId: string,
callId: string
): Promise<string | void>;
getAllCallHistory: () => Promise<ReadonlyArray<CallHistoryDetails>>;
clearCallHistory: (beforeTimestamp: number) => Promise<Array<string>>;
getCallHistoryMessageByCallId(options: {
conversationId: string;
callId: string;
}): Promise<MessageType | undefined>;
getCallHistory(
callId: string,
peerId: string
): Promise<CallHistoryDetails | undefined>;
getCallHistoryGroupsCount(filter: CallHistoryFilter): Promise<number>;
getCallHistoryGroups(
filter: CallHistoryFilter,
pagination: CallHistoryPagination
): Promise<Array<CallHistoryGroup>>;
saveCallHistory(callHistory: CallHistoryDetails): Promise<void>;
hasGroupCallHistoryMessage: (
conversationId: string,
eraId: string

View File

@@ -10,6 +10,7 @@ import { randomBytes } from 'crypto';
import type { Database, Statement } from '@signalapp/better-sqlite3';
import SQL from '@signalapp/better-sqlite3';
import pProps from 'p-props';
import { z } from 'zod';
import type { Dictionary } from 'lodash';
import {
@@ -60,6 +61,7 @@ import type {
QueryFragment,
} from './util';
import {
sqlConstant,
sqlJoin,
sqlFragment,
sql,
@@ -142,6 +144,18 @@ import {
SNIPPET_RIGHT_PLACEHOLDER,
SNIPPET_TRUNCATION_PLACEHOLDER,
} from '../util/search';
import type {
CallHistoryDetails,
CallHistoryFilter,
CallHistoryGroup,
CallHistoryPagination,
} from '../types/CallDisposition';
import {
DirectCallStatus,
callHistoryGroupSchema,
CallHistoryFilterStatus,
callHistoryDetailsSchema,
} from '../types/CallDisposition';
type ConversationRow = Readonly<{
json: string;
@@ -288,7 +302,13 @@ const dataInterface: ServerInterface = {
getConversationRangeCenteredOnMessage,
getConversationMessageStats,
getLastConversationMessage,
getAllCallHistory,
clearCallHistory,
getCallHistoryMessageByCallId,
getCallHistory,
getCallHistoryGroupsCount,
getCallHistoryGroups,
saveCallHistory,
hasGroupCallHistoryMessage,
migrateConversationMessages,
getMessagesBetween,
@@ -1755,32 +1775,32 @@ async function searchMessages({
// Note: this groups the results by rowid, so even if one message mentions multiple
// matching UUIDs, we only return one to be highlighted
const [sqlQuery, params] = sql`
SELECT
SELECT
messages.rowid as rowid,
COALESCE(messages.json, ftsResults.json) as json,
COALESCE(messages.json, ftsResults.json) as json,
COALESCE(messages.sent_at, ftsResults.sent_at) as sent_at,
COALESCE(messages.received_at, ftsResults.received_at) as received_at,
ftsResults.ftsSnippet,
mentionUuid,
start as mentionStart,
ftsResults.ftsSnippet,
mentionUuid,
start as mentionStart,
length as mentionLength
FROM mentions
INNER JOIN messages
ON
messages.id = mentions.messageId
INNER JOIN messages
ON
messages.id = mentions.messageId
AND mentions.mentionUuid IN (
${sqlJoin(contactUuidsMatchingQuery, ', ')}
)
)
AND ${
conversationId
? sqlFragment`messages.conversationId = ${conversationId}`
: '1 IS 1'
}
AND messages.isViewOnce IS NOT 1
AND messages.isViewOnce IS NOT 1
AND messages.storyId IS NULL
FULL OUTER JOIN (
${ftsFragment}
) as ftsResults
) as ftsResults
USING (rowid)
GROUP BY rowid
ORDER BY received_at DESC, sent_at DESC
@@ -1910,6 +1930,7 @@ function saveMessageSync(
sourceUuid,
sourceDevice,
storyId,
callId,
type,
readStatus,
expireTimer,
@@ -1967,6 +1988,7 @@ function saveMessageSync(
sourceUuid: sourceUuid || null,
sourceDevice: sourceDevice || null,
storyId: storyId || null,
callId: callId || null,
type: type || null,
readStatus: readStatus ?? null,
seenStatus: seenStatus ?? SeenStatus.NotApplicable,
@@ -1999,6 +2021,7 @@ function saveMessageSync(
sourceUuid = $sourceUuid,
sourceDevice = $sourceDevice,
storyId = $storyId,
callId = $callId,
type = $type,
readStatus = $readStatus,
seenStatus = $seenStatus
@@ -2044,6 +2067,7 @@ function saveMessageSync(
sourceUuid,
sourceDevice,
storyId,
callId,
type,
readStatus,
seenStatus
@@ -2070,6 +2094,7 @@ function saveMessageSync(
$sourceUuid,
$sourceDevice,
$storyId,
$callId,
$type,
$readStatus,
$seenStatus
@@ -3224,30 +3249,366 @@ async function getConversationRangeCenteredOnMessage(
})();
}
async function getCallHistoryMessageByCallId(
conversationId: string,
callId: string
): Promise<string | void> {
async function getAllCallHistory(): Promise<ReadonlyArray<CallHistoryDetails>> {
const db = getInstance();
const [query] = sql`
SELECT * FROM callsHistory;
`;
return db.prepare(query).all();
}
async function clearCallHistory(
beforeTimestamp: number
): Promise<Array<string>> {
const db = getInstance();
return db.transaction(() => {
const whereMessages = sqlFragment`
WHERE messages.type IS 'call-history'
AND messages.sent_at <= ${beforeTimestamp};
`;
const [selectMessagesQuery, selectMessagesParams] = sql`
SELECT id FROM messages ${whereMessages}
`;
const [clearMessagesQuery, clearMessagesParams] = sql`
DELETE FROM messages ${whereMessages}
`;
const [clearCallsHistoryQuery, clearCallsHistoryParams] = sql`
UPDATE callsHistory
SET
status = ${DirectCallStatus.Deleted},
timestamp = ${Date.now()}
WHERE callsHistory.timestamp <= ${beforeTimestamp};
`;
const messageIds = db
.prepare(selectMessagesQuery)
.pluck()
.all(selectMessagesParams);
db.prepare(clearMessagesQuery).run(clearMessagesParams);
try {
db.prepare(clearCallsHistoryQuery).run(clearCallsHistoryParams);
} catch (error) {
logger.error(error, error.message);
throw error;
}
return messageIds;
})();
}
async function getCallHistoryMessageByCallId(options: {
conversationId: string;
callId: string;
}): Promise<MessageType | undefined> {
const db = getInstance();
const [query, params] = sql`
SELECT json
FROM messages
WHERE conversationId = ${options.conversationId}
AND type = 'call-history'
AND callId = ${options.callId}
`;
const row = db.prepare(query).get(params);
if (row == null) {
return;
}
return jsonToObject(row.json);
}
async function getCallHistory(
callId: string,
peerId: string
): Promise<CallHistoryDetails | undefined> {
const db = getInstance();
const id: string | void = db
.prepare<Query>(
`
SELECT id
FROM messages
WHERE conversationId = $conversationId
AND type = 'call-history'
AND callMode = 'Direct'
AND callId = $callId
`
)
.pluck()
.get({
conversationId,
callId,
});
const [query, params] = sql`
SELECT * FROM callsHistory
WHERE callId IS ${callId}
AND peerId IS ${peerId};
`;
return id;
const row = db.prepare(query).get(params);
if (row == null) {
return;
}
return callHistoryDetailsSchema.parse(row);
}
const MISSED = sqlConstant(DirectCallStatus.Missed);
const DELETED = sqlConstant(DirectCallStatus.Deleted);
const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
function getCallHistoryGroupDataSync(
db: Database,
isCount: boolean,
filter: CallHistoryFilter,
pagination: CallHistoryPagination
): unknown {
return db.transaction(() => {
const { limit, offset } = pagination;
const { status, conversationIds } = filter;
if (conversationIds != null) {
strictAssert(conversationIds.length > 0, "can't filter by empty array");
const [createTempTable] = sql`
CREATE TEMP TABLE temp_callHistory_filtered_conversations (
uuid TEXT,
groupId TEXT
);
`;
db.exec(createTempTable);
batchMultiVarQuery(db, conversationIds, ids => {
const idList = sqlJoin(
ids.map(id => sqlFragment`(${id})`),
','
);
const [insertQuery, insertParams] = sql`
INSERT INTO temp_callHistory_filtered_conversations
(uuid, groupId)
SELECT uuid, groupId
FROM conversations
WHERE conversations.id IN (${idList});
`;
db.prepare(insertQuery).run(insertParams);
});
}
const innerJoin =
conversationIds != null
? sqlFragment`
INNER JOIN temp_callHistory_filtered_conversations ON (
temp_callHistory_filtered_conversations.uuid IS c.peerId
OR temp_callHistory_filtered_conversations.groupId IS c.peerId
)
`
: sqlFragment``;
const filterClause =
status === CallHistoryFilterStatus.All
? sqlFragment`status IS NOT ${DELETED}`
: sqlFragment`status IS ${MISSED} AND status IS NOT ${DELETED}`;
const offsetLimit =
limit > 0 ? sqlFragment`LIMIT ${limit} OFFSET ${offset}` : sqlFragment``;
const projection = isCount
? sqlFragment`COUNT(*) AS count`
: sqlFragment`peerId, ringerId, mode, type, direction, status, timestamp, possibleChildren, inPeriod`;
const [query, params] = sql`
SELECT
${projection}
FROM (
-- 1. 'callAndGroupInfo': This section collects metadata to determine the
-- parent and children of each call. We can identify the real parents of calls
-- within the query, but we need to build the children at runtime.
WITH callAndGroupInfo AS (
SELECT
*,
-- 1a. 'possibleParent': This identifies the first call that _could_ be
-- considered the current call's parent. Note: The 'possibleParent' is not
-- necessarily the true parent if there is another call between them that
-- isn't a part of the group.
(
SELECT callId
FROM callsHistory
WHERE
callsHistory.direction IS c.direction
AND callsHistory.type IS c.type
AND callsHistory.peerId IS c.peerId
AND (callsHistory.timestamp - ${FOUR_HOURS_IN_MS}) <= c.timestamp
AND callsHistory.timestamp >= c.timestamp
-- Tracking Android & Desktop separately to make the queries easier to compare
-- Android Constraints:
AND (
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED})
)
-- Desktop Constraints:
AND callsHistory.status IS c.status
AND ${filterClause}
ORDER BY timestamp DESC
) as possibleParent,
-- 1b. 'possibleChildren': This identifies all possible calls that can
-- be grouped with the current call. Note: This current call is not
-- necessarily the parent, and not all possible children will end up as
-- children as they might have another parent
(
SELECT JSON_GROUP_ARRAY(
JSON_OBJECT(
'callId', callId,
'timestamp', timestamp
)
)
FROM callsHistory
WHERE
callsHistory.direction IS c.direction
AND callsHistory.type IS c.type
AND callsHistory.peerId IS c.peerId
AND (c.timestamp - ${FOUR_HOURS_IN_MS}) <= callsHistory.timestamp
AND c.timestamp >= callsHistory.timestamp
-- Tracking Android & Desktop separately to make the queries easier to compare
-- Android Constraints:
AND (
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED})
)
-- Desktop Constraints:
AND callsHistory.status IS c.status
AND ${filterClause}
ORDER BY timestamp DESC
) as possibleChildren,
-- 1c. 'inPeriod': This identifies all calls in a time period after the
-- current call. They may or may not be a part of the group.
(
SELECT GROUP_CONCAT(callId)
FROM callsHistory
WHERE
(c.timestamp - ${FOUR_HOURS_IN_MS}) <= callsHistory.timestamp
AND c.timestamp >= callsHistory.timestamp
AND ${filterClause}
) AS inPeriod
FROM callsHistory AS c
${innerJoin}
WHERE
${filterClause}
ORDER BY timestamp DESC
)
-- 2. 'isParent': We need to identify the true parent of the group in cases
-- where the previous call is not a part of the group.
SELECT
*,
CASE
WHEN LAG (possibleParent, 1, 0) OVER (
-- Note: This is an optimization assuming that we've already got 'timestamp DESC' ordering
-- from the query above. If we find that ordering isn't always correct, we can uncomment this:
-- ORDER BY timestamp DESC
) != possibleParent THEN callId
ELSE possibleParent
END AS parent
FROM callAndGroupInfo
) AS parentCallAndGroupInfo
WHERE parent = parentCallAndGroupInfo.callId
ORDER BY parentCallAndGroupInfo.timestamp DESC
${offsetLimit};
`;
const result = isCount
? db.prepare(query).pluck(true).get(params)
: db.prepare(query).all(params);
if (conversationIds != null) {
const [dropTempTableQuery] = sql`
DROP TABLE temp_callHistory_filtered_conversations;
`;
db.exec(dropTempTableQuery);
}
return result;
})();
}
const countSchema = z.number().int().nonnegative();
async function getCallHistoryGroupsCount(
filter: CallHistoryFilter
): Promise<number> {
const db = getInstance();
const result = getCallHistoryGroupDataSync(db, true, filter, {
limit: 0,
offset: 0,
});
return countSchema.parse(result);
}
const groupsDataSchema = z.array(
callHistoryGroupSchema.omit({ children: true }).extend({
possibleChildren: z.string(),
inPeriod: z.string(),
})
);
const possibleChildrenSchema = z.array(
callHistoryDetailsSchema.pick({
callId: true,
timestamp: true,
})
);
async function getCallHistoryGroups(
filter: CallHistoryFilter,
pagination: CallHistoryPagination
): Promise<Array<CallHistoryGroup>> {
const db = getInstance();
const groupsData = groupsDataSchema.parse(
getCallHistoryGroupDataSync(db, false, filter, pagination)
);
const taken = new Set<string>();
return groupsData
.map(groupData => {
return {
...groupData,
possibleChildren: possibleChildrenSchema.parse(
JSON.parse(groupData.possibleChildren)
),
inPeriod: new Set(groupData.inPeriod.split(',')),
};
})
.reverse()
.map(group => {
const { possibleChildren, inPeriod, ...rest } = group;
const children = [];
for (const child of possibleChildren) {
if (!taken.has(child.callId) && inPeriod.has(child.callId)) {
children.push(child);
taken.add(child.callId);
}
}
return callHistoryGroupSchema.parse({ ...rest, children });
})
.reverse();
}
async function saveCallHistory(callHistory: CallHistoryDetails): Promise<void> {
const db = getInstance();
const [insertQuery, insertParams] = sql`
INSERT OR REPLACE INTO callsHistory (
callId,
peerId,
ringerId,
mode,
type,
direction,
status,
timestamp
) VALUES (
${callHistory.callId},
${callHistory.peerId},
${callHistory.ringerId},
${callHistory.mode},
${callHistory.type},
${callHistory.direction},
${callHistory.status},
${callHistory.timestamp}
);
`;
db.prepare(insertQuery).run(insertParams);
}
async function hasGroupCallHistoryMessage(
@@ -5087,6 +5448,7 @@ async function removeAll(): Promise<void> {
DELETE FROM attachment_downloads;
DELETE FROM badgeImageFiles;
DELETE FROM badges;
DELETE FROM callsHistory;
DELETE FROM conversations;
DELETE FROM emojis;
DELETE FROM groupCallRingCancellations;

View File

@@ -0,0 +1,196 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import { callIdFromEra } from '@signalapp/ringrtc';
import Long from 'long';
import type { LoggerType } from '../../types/Logging';
import { sql } from '../util';
import { getOurUuid } from './41-uuid-keys';
import type { CallHistoryDetails } from '../../types/CallDisposition';
import {
DirectCallStatus,
CallDirection,
CallType,
GroupCallStatus,
callHistoryDetailsSchema,
} from '../../types/CallDisposition';
import { CallMode } from '../../types/Calling';
export default function updateToSchemaVersion87(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 87) {
return;
}
db.transaction(() => {
const ourUuid = getOurUuid(db);
const [modifySchema] = sql`
DROP TABLE IF EXISTS callsHistory;
CREATE TABLE callsHistory (
callId TEXT PRIMARY KEY,
peerId TEXT NOT NULL, -- conversation uuid | groupId | roomId
ringerId TEXT DEFAULT NULL, -- ringer uuid
mode TEXT NOT NULL, -- enum "Direct" | "Group"
type TEXT NOT NULL, -- enum "Audio" | "Video" | "Group"
direction TEXT NOT NULL, -- enum "Incoming" | "Outgoing
-- Direct: enum "Pending" | "Missed" | "Accepted" | "Deleted"
-- Group: enum "GenericGroupCall" | "OutgoingRing" | "Ringing" | "Joined" | "Missed" | "Declined" | "Accepted" | "Deleted"
status TEXT NOT NULL,
timestamp INTEGER NOT NULL,
UNIQUE (callId, peerId) ON CONFLICT FAIL
);
CREATE INDEX callsHistory_order on callsHistory (timestamp DESC);
CREATE INDEX callsHistory_byConversation ON callsHistory (peerId);
-- For 'getCallHistoryGroupData':
-- This index should target the subqueries for 'possible_parent' and 'possible_children'
CREATE INDEX callsHistory_callAndGroupInfo_optimize on callsHistory (
direction,
peerId,
timestamp DESC,
status
);
DROP INDEX IF EXISTS messages_call;
ALTER TABLE messages
DROP COLUMN callId;
ALTER TABLE messages
ADD COLUMN callId TEXT;
ALTER TABLE messages
DROP COLUMN callMode;
`;
db.exec(modifySchema);
const [selectQuery] = sql`
SELECT * FROM messages WHERE type = 'call-history';
`;
const rows = db.prepare(selectQuery).all();
for (const row of rows) {
const json = JSON.parse(row.json);
const details = json.callHistoryDetails;
const { conversationId: peerId } = row;
const { callMode } = details;
let callId: string;
let type: CallType;
let direction: CallDirection;
let status: GroupCallStatus | DirectCallStatus;
let timestamp: number;
let ringerId: string | null = null;
if (details.callMode === CallMode.Direct) {
callId = details.callId;
type = details.wasVideoCall ? CallType.Video : CallType.Audio;
direction = details.wasIncoming
? CallDirection.Incoming
: CallDirection.Outgoing;
if (details.acceptedTime != null) {
status = DirectCallStatus.Accepted;
} else {
status = details.wasDeclined
? DirectCallStatus.Declined
: DirectCallStatus.Missed;
}
timestamp = details.endedTime ?? details.acceptedTime ?? null;
} else if (details.callMode === CallMode.Group) {
callId = Long.fromValue(callIdFromEra(details.eraId)).toString();
type = CallType.Group;
direction =
details.creatorUuid === ourUuid
? CallDirection.Outgoing
: CallDirection.Incoming;
status = GroupCallStatus.GenericGroupCall;
timestamp = details.startedTime;
ringerId = details.creatorUuid;
} else {
logger.error(
`updateToSchemaVersion87: unknown callMode: ${details.callMode}`
);
continue;
}
if (callId == null) {
logger.error(
"updateToSchemaVersion87: callId doesn't exist, too old, skipping"
);
continue;
}
const callHistory: CallHistoryDetails = {
callId,
peerId,
ringerId,
mode: callMode,
type,
direction,
status,
timestamp,
};
const result = callHistoryDetailsSchema.safeParse(callHistory);
if (!result.success) {
logger.error(
`updateToSchemaVersion87: invalid callHistoryDetails (error: ${JSON.stringify(
result.error.format()
)}, input: ${JSON.stringify(json)}, output: ${JSON.stringify(
callHistory
)}))`
);
continue;
}
const [insertQuery, insertParams] = sql`
INSERT INTO callsHistory (
callId,
peerId,
ringerId,
mode,
type,
direction,
status,
timestamp
) VALUES (
${callHistory.callId},
${callHistory.peerId},
${callHistory.ringerId},
${callHistory.mode},
${callHistory.type},
${callHistory.direction},
${callHistory.status},
${callHistory.timestamp}
)
`;
db.prepare(insertQuery).run(insertParams);
const [updateQuery, updateParams] = sql`
UPDATE messages
SET json = JSON_PATCH(json, ${JSON.stringify({
callHistoryDetails: null, // delete
callId,
})})
WHERE id = ${row.id}
`;
db.prepare(updateQuery).run(updateParams);
}
db.pragma('user_version = 87');
})();
logger.info('updateToSchemaVersion87: success!');
}

View File

@@ -62,6 +62,7 @@ import updateToSchemaVersion83 from './83-mentions';
import updateToSchemaVersion84 from './84-all-mentions';
import updateToSchemaVersion85 from './85-add-kyber-keys';
import updateToSchemaVersion86 from './86-story-replies-index';
import updateToSchemaVersion87 from './87-calls-history-table';
function updateToSchemaVersion1(
currentVersion: number,
@@ -1994,6 +1995,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion84,
updateToSchemaVersion85,
updateToSchemaVersion86,
updateToSchemaVersion87,
];
export function updateSchema(db: Database, logger: LoggerType): void {

View File

@@ -36,7 +36,7 @@ export function jsonToObject<T>(json: string): T {
return JSON.parse(json);
}
export type QueryTemplateParam = string | number | undefined;
export type QueryTemplateParam = string | number | null | undefined;
export type QueryFragmentValue = QueryFragment | QueryTemplateParam;
export type QueryFragment = [
@@ -66,7 +66,7 @@ export function sqlFragment(
...values: ReadonlyArray<QueryFragmentValue>
): QueryFragment {
let query = '';
const params: Array<string | number | undefined> = [];
const params: Array<QueryTemplateParam> = [];
strings.forEach((string, index) => {
const value = values[index];
@@ -88,6 +88,20 @@ export function sqlFragment(
return [{ fragment: query }, params];
}
export function sqlConstant(value: QueryTemplateParam): QueryFragment {
let fragment;
if (value == null) {
fragment = 'NULL';
} else if (typeof value === 'number') {
fragment = `${value}`;
} else if (typeof value === 'boolean') {
fragment = `${value}`;
} else {
fragment = `'${value}'`;
}
return [{ fragment }, []];
}
/**
* Like `Array.prototype.join`, but for SQL fragments.
*/
@@ -96,7 +110,7 @@ export function sqlJoin(
separator: string
): QueryFragment {
let query = '';
const params: Array<string | number | undefined> = [];
const params: Array<QueryTemplateParam> = [];
items.forEach((item, index) => {
const [{ fragment }, fragmentParams] = sqlFragment`${item}`;
@@ -111,10 +125,7 @@ export function sqlJoin(
return [{ fragment: query }, params];
}
export type QueryTemplate = [
string,
ReadonlyArray<string | number | undefined>
];
export type QueryTemplate = [string, ReadonlyArray<QueryTemplateParam>];
/**
* You can use tagged template literals to build SQL queries
@@ -137,7 +148,7 @@ export type QueryTemplate = [
*/
export function sql(
strings: TemplateStringsArray,
...values: ReadonlyArray<QueryFragment | string | number | undefined>
...values: ReadonlyArray<QueryFragment | QueryTemplateParam>
): QueryTemplate {
const [{ fragment }, params] = sqlFragment(strings, ...values);
return [fragment, params];

View File

@@ -6,6 +6,7 @@ import { actions as app } from './ducks/app';
import { actions as audioPlayer } from './ducks/audioPlayer';
import { actions as audioRecorder } from './ducks/audioRecorder';
import { actions as badges } from './ducks/badges';
import { actions as callHistory } from './ducks/callHistory';
import { actions as calling } from './ducks/calling';
import { actions as composer } from './ducks/composer';
import { actions as conversations } from './ducks/conversations';
@@ -36,6 +37,7 @@ export const actionCreators: ReduxActions = {
audioPlayer,
audioRecorder,
badges,
callHistory,
calling,
composer,
conversations,

View File

@@ -0,0 +1,91 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReadonlyDeep } from 'type-fest';
import type { ThunkAction } from 'redux-thunk';
import type { StateType as RootStateType } from '../reducer';
import { clearCallHistoryDataAndSync } from '../../util/callDisposition';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
import type { ToastActionType } from './toast';
import { showToast } from './toast';
import { ToastType } from '../../types/Toast';
import type { CallHistoryDetails } from '../../types/CallDisposition';
export type CallHistoryState = ReadonlyDeep<{
// This informs the app that underlying call history data has changed.
edition: number;
callHistoryByCallId: Record<string, CallHistoryDetails>;
}>;
const CALL_HISTORY_CACHE = 'callHistory/CACHE';
const CALL_HISTORY_CLEAR = 'callHistory/CLEAR';
export type CallHistoryCache = ReadonlyDeep<{
type: typeof CALL_HISTORY_CACHE;
payload: CallHistoryDetails;
}>;
export type CallHistoryClear = ReadonlyDeep<{
type: typeof CALL_HISTORY_CLEAR;
}>;
export type CallHistoryAction = ReadonlyDeep<
CallHistoryCache | CallHistoryClear
>;
export function getEmptyState(): CallHistoryState {
return {
edition: 0,
callHistoryByCallId: {},
};
}
function cacheCallHistory(callHistory: CallHistoryDetails): CallHistoryCache {
return {
type: CALL_HISTORY_CACHE,
payload: callHistory,
};
}
function clearAllCallHistory(): ThunkAction<
void,
RootStateType,
unknown,
CallHistoryClear | ToastActionType
> {
return async dispatch => {
await clearCallHistoryDataAndSync();
dispatch({ type: CALL_HISTORY_CLEAR });
dispatch(showToast({ toastType: ToastType.CallHistoryCleared }));
};
}
export const actions = {
cacheCallHistory,
clearAllCallHistory,
};
export const useCallHistoryActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
export function reducer(
state: CallHistoryState = getEmptyState(),
action: CallHistoryAction
): CallHistoryState {
switch (action.type) {
case CALL_HISTORY_CLEAR:
return { ...state, edition: state.edition + 1, callHistoryByCallId: {} };
case CALL_HISTORY_CACHE:
return {
...state,
callHistoryByCallId: {
...state.callHistoryByCallId,
[action.payload.callId]: action.payload,
},
};
default:
return state;
}
}

View File

@@ -3,7 +3,6 @@
import { ipcRenderer } from 'electron';
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { CallEndedReason } from '@signalapp/ringrtc';
import {
hasScreenCapturePermission,
openSystemPreferences,
@@ -26,6 +25,7 @@ import type {
PresentableSource,
} from '../../types/Calling';
import {
CallEndedReason,
CallingDeviceType,
CallMode,
CallViewMode,
@@ -53,8 +53,6 @@ import { isDirectConversation } from '../../util/whatTypeOfConversation';
import { SHOW_TOAST } from './toast';
import { ToastType } from '../../types/Toast';
import type { ShowToastActionType } from './toast';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import MessageSender from '../../textsecure/SendMessage';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
import { isAnybodyElseInGroupCall } from './callingHelpers';
@@ -150,15 +148,10 @@ export type AcceptCallType = ReadonlyDeep<{
}>;
export type CallStateChangeType = ReadonlyDeep<{
remoteUserId: string; // TODO: Remove
callId: string; // TODO: Remove
conversationId: string;
acceptedTime?: number;
callState: CallState;
callEndedReason?: CallEndedReason;
isIncoming: boolean;
isVideoCall: boolean;
title: string;
}>;
export type CancelCallType = ReadonlyDeep<{
@@ -363,7 +356,7 @@ const doGroupCallPeek = (
// to only be peeking once.
await Promise.all([sleep(1000), waitForOnline(navigator, window)]);
let peekInfo;
let peekInfo = null;
try {
peekInfo = await calling.peekGroupCall(conversationId);
} catch (err) {
@@ -689,38 +682,18 @@ function callStateChange(
CallStateChangeFulfilledActionType
> {
return async dispatch => {
const {
callId,
callState,
isVideoCall,
isIncoming,
acceptedTime,
callEndedReason,
remoteUserId,
} = payload;
const { callState, acceptedTime, callEndedReason } = payload;
if (callState === CallState.Ended) {
ipcRenderer.send('close-screen-share-controller');
}
const isOutgoing = !isIncoming;
const wasAccepted = acceptedTime != null;
const isConnected = callState === CallState.Accepted; // "connected"
const isEnded = callState === CallState.Ended && callEndedReason != null;
const isLocalHangup = callEndedReason === CallEndedReason.LocalHangup;
const isRemoteHangup = callEndedReason === CallEndedReason.RemoteHangup;
const answered = isConnected && wasAccepted;
const notAnswered = isEnded && !wasAccepted;
const isOutgoingRemoteAccept = isOutgoing && isConnected && answered;
const isIncomingLocalAccept = isIncoming && isConnected && answered;
const isOutgoingLocalHangup = isOutgoing && isLocalHangup && notAnswered;
const isIncomingLocalHangup = isIncoming && isLocalHangup && notAnswered;
const isOutgoingRemoteHangup = isOutgoing && isRemoteHangup && notAnswered;
const isIncomingRemoteHangup = isIncoming && isRemoteHangup && notAnswered;
// Play the hangup noise if:
if (
// 1. I hungup (or declined)
@@ -733,37 +706,6 @@ function callStateChange(
await callingTones.playEndCall();
}
if (isIncomingRemoteHangup) {
// This is considered just another "missed" event
log.info(
`callStateChange: not syncing hangup from self (Call ID: ${callId}))`
);
} else if (
isOutgoingRemoteAccept ||
isIncomingLocalAccept ||
isOutgoingLocalHangup ||
isIncomingLocalHangup ||
isOutgoingRemoteHangup
) {
log.info(`callStateChange: syncing call event (Call ID: ${callId})`);
try {
await singleProtoJobQueue.add(
MessageSender.getCallEventSync(
remoteUserId,
callId,
isVideoCall,
isIncoming,
acceptedTime != null
)
);
} catch (error) {
log.error(
'callStateChange: Failed to queue sync message',
Errors.toLogFormat(error)
);
}
}
dispatch({
type: CALL_STATE_CHANGE_FULFILLED,
payload,
@@ -1326,10 +1268,12 @@ function onOutgoingVideoCallInConversation(
log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
);
startCallingLobby({
conversationId,
isVideoCall: true,
})(dispatch, getState, undefined);
dispatch(
startCallingLobby({
conversationId,
isVideoCall: true,
})
);
log.info('onOutgoingVideoCallInConversation: started the call');
} else {
log.info(

View File

@@ -159,6 +159,8 @@ import { ReceiptType } from '../../types/Receipt';
import { sortByMessageOrder } from '../../util/maybeForwardMessages';
import { Sound, SoundType } from '../../util/Sound';
import { canEditMessage } from '../../util/canEditMessage';
import type { ChangeNavTabActionType } from './nav';
import { CHANGE_NAV_TAB, NavTab, actions as navActions } from './nav';
// State
@@ -3911,14 +3913,18 @@ function showConversation({
void,
RootStateType,
unknown,
TargetedConversationChangedActionType
TargetedConversationChangedActionType | ChangeNavTabActionType
> {
return (dispatch, getState) => {
const { conversations } = getState();
const { conversations, nav } = getState();
if (nav.selectedNavTab !== NavTab.Chats) {
dispatch(navActions.changeNavTab(NavTab.Chats));
}
if (conversationId === conversations.selectedConversationId) {
if (conversationId && messageId) {
scrollToMessage(conversationId, messageId)(dispatch, getState, null);
dispatch(scrollToMessage(conversationId, messageId));
}
return;
@@ -4383,7 +4389,11 @@ function maybeUpdateSelectedMessageForDetails(
export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<ConversationActionType | StoryDistributionListsActionType>
action: Readonly<
| ConversationActionType
| StoryDistributionListsActionType
| ChangeNavTabActionType
>
): ConversationsStateType {
if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) {
return {
@@ -6168,5 +6178,31 @@ export function reducer(
};
}
if (
action.type === CHANGE_NAV_TAB &&
action.payload.selectedNavTab === NavTab.Chats
) {
const { messagesByConversation, selectedConversationId } = state;
if (selectedConversationId == null) {
return state;
}
const existingConversation = messagesByConversation[selectedConversationId];
if (existingConversation == null) {
return state;
}
return {
...state,
messagesByConversation: {
...messagesByConversation,
[selectedConversationId]: {
...existingConversation,
isNearBottom: true,
},
},
};
}
return state;
}

View File

@@ -25,7 +25,6 @@ import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig';
export type ItemsStateType = ReadonlyDeep<
{
[key: string]: unknown;
remoteConfig?: RemoteConfigType;
serverTimeSkew?: number;
} & Partial<
@@ -35,6 +34,7 @@ export type ItemsStateType = ReadonlyDeep<
| 'defaultConversationColor'
| 'customColors'
| 'preferredLeftPaneWidth'
| 'navTabsCollapsed'
| 'preferredReactionEmoji'
| 'areWeASubscriber'
| 'usernameLinkColor'
@@ -90,6 +90,7 @@ export const actions = {
resetDefaultChatColor,
savePreferredLeftPaneWidth,
setGlobalDefaultConversationColor,
toggleNavTabsCollapse,
onSetSkinTone,
putItem,
putItemExternal,
@@ -98,8 +99,9 @@ export const actions = {
resetItems,
};
export const useActions = (): BoundActionCreatorsMapObject<typeof actions> =>
useBoundActions(actions);
export const useItemsActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
function putItem<K extends keyof StorageAccessType>(
key: K,
@@ -292,6 +294,14 @@ function markHasCompletedSafetyNumberOnboarding(): ThunkAction<
};
}
function toggleNavTabsCollapse(
navTabsCollapsed: boolean
): ThunkAction<void, RootStateType, unknown, ItemPutAction> {
return dispatch => {
dispatch(putItem('navTabsCollapsed', navTabsCollapsed));
};
}
// Reducer
export function getEmptyState(): ItemsStateType {

69
ts/state/ducks/nav.ts Normal file
View File

@@ -0,0 +1,69 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReadonlyDeep } from 'type-fest';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
// Types
export enum NavTab {
Chats = 'Chats',
Calls = 'Calls',
Stories = 'Stories',
}
// State
export type NavStateType = ReadonlyDeep<{
selectedNavTab: NavTab;
}>;
// Actions
export const CHANGE_NAV_TAB = 'nav/CHANGE_NAV_TAB';
export type ChangeNavTabActionType = ReadonlyDeep<{
type: typeof CHANGE_NAV_TAB;
payload: { selectedNavTab: NavTab };
}>;
export type NavActionType = ReadonlyDeep<ChangeNavTabActionType>;
// Action Creators
function changeNavTab(selectedNavTab: NavTab): NavActionType {
return {
type: CHANGE_NAV_TAB,
payload: { selectedNavTab },
};
}
export const actions = {
changeNavTab,
};
export const useNavActions = (): BoundActionCreatorsMapObject<typeof actions> =>
useBoundActions(actions);
// Reducer
export function getEmptyState(): NavStateType {
return {
selectedNavTab: NavTab.Chats,
};
}
export function reducer(
state: Readonly<NavStateType> = getEmptyState(),
action: Readonly<NavActionType>
): NavStateType {
if (action.type === CHANGE_NAV_TAB) {
return {
...state,
selectedNavTab: action.payload.selectedNavTab,
};
}
return state;
}

View File

@@ -143,7 +143,6 @@ export type StoriesStateType = Readonly<{
addStoryData: AddStoryData;
hasAllStoriesUnmuted: boolean;
lastOpenedAtTimestamp: number | undefined;
openedAtTimestamp: number | undefined;
replyState?: Readonly<{
messageId: string;
replies: Array<MessageAttributesType>;
@@ -163,7 +162,8 @@ const QUEUE_STORY_DOWNLOAD = 'stories/QUEUE_STORY_DOWNLOAD';
const SEND_STORY_MODAL_OPEN_STATE_CHANGED =
'stories/SEND_STORY_MODAL_OPEN_STATE_CHANGED';
const STORY_CHANGED = 'stories/STORY_CHANGED';
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
const CLEAR_STORIES_TAB_STATE = 'stories/CLEAR_STORIES_TAB_STATE';
const MARK_STORIES_TAB_VIEWED = 'stories/MARK_STORIES_TAB_VIEWED';
const VIEW_STORY = 'stories/VIEW_STORY';
const STORY_REPLY_DELETED = 'stories/STORY_REPLY_DELETED';
const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES';
@@ -217,8 +217,12 @@ type StoryChangedActionType = ReadonlyDeep<{
payload: StoryDataType;
}>;
type ToggleViewActionType = ReadonlyDeep<{
type: typeof TOGGLE_VIEW;
type ClearStoriesTabStateActionType = ReadonlyDeep<{
type: typeof CLEAR_STORIES_TAB_STATE;
}>;
type MarkStoriesTabViewedActionType = ReadonlyDeep<{
type: typeof MARK_STORIES_TAB_VIEWED;
}>;
type ViewStoryActionType = ReadonlyDeep<{
@@ -262,7 +266,8 @@ export type StoriesActionType =
| QueueStoryDownloadActionType
| SendStoryModalOpenStateChanged
| StoryChangedActionType
| ToggleViewActionType
| ClearStoriesTabStateActionType
| MarkStoriesTabViewedActionType
| ViewStoryActionType
| StoryReplyDeletedActionType
| RemoveAllStoriesActionType
@@ -627,7 +632,7 @@ function sendStoryMessage(
> {
return async (dispatch, getState) => {
const { stories } = getState();
const { openedAtTimestamp, sendStoryModalData } = stories;
const { lastOpenedAtTimestamp, sendStoryModalData } = stories;
// Add spinners in the story creator
dispatch({
@@ -636,8 +641,8 @@ function sendStoryMessage(
});
assertDev(
openedAtTimestamp,
'sendStoryMessage: openedAtTimestamp is undefined, cannot send'
lastOpenedAtTimestamp,
'sendStoryMessage: lastOpenedAtTimestamp is undefined, cannot send'
);
assertDev(
sendStoryModalData,
@@ -649,7 +654,7 @@ function sendStoryMessage(
const result = await blockSendUntilConversationsAreVerified(
sendStoryModalData,
SafetyNumberChangeSource.Story,
Date.now() - openedAtTimestamp
Date.now() - lastOpenedAtTimestamp
);
if (!result) {
@@ -720,9 +725,15 @@ function sendStoryModalOpenStateChanged(
};
}
function toggleStoriesView(): ToggleViewActionType {
function clearStoriesTabState(): ClearStoriesTabStateActionType {
return {
type: TOGGLE_VIEW,
type: CLEAR_STORIES_TAB_STATE,
};
}
function markStoriesTabViewed(): MarkStoriesTabViewedActionType {
return {
type: MARK_STORIES_TAB_VIEWED,
};
}
@@ -1415,7 +1426,8 @@ export const actions = {
sendStoryMessage,
sendStoryModalOpenStateChanged,
storyChanged,
toggleStoriesView,
clearStoriesTabState,
markStoriesTabViewed,
verifyStoryListMembers,
viewUserStories,
viewStory,
@@ -1439,7 +1451,6 @@ export function getEmptyState(
): StoriesStateType {
return {
lastOpenedAtTimestamp: undefined,
openedAtTimestamp: undefined,
addStoryData: undefined,
stories: [],
hasAllStoriesUnmuted: false,
@@ -1451,20 +1462,22 @@ export function reducer(
state: Readonly<StoriesStateType> = getEmptyState(),
action: Readonly<StoriesActionType>
): StoriesStateType {
if (action.type === TOGGLE_VIEW) {
const isShowingStoriesView = Boolean(state.openedAtTimestamp);
if (action.type === MARK_STORIES_TAB_VIEWED) {
return {
...state,
lastOpenedAtTimestamp: !isShowingStoriesView
? state.openedAtTimestamp || Date.now()
: state.lastOpenedAtTimestamp,
openedAtTimestamp: isShowingStoriesView ? undefined : Date.now(),
lastOpenedAtTimestamp: Date.now(),
replyState: undefined,
sendStoryModalData: undefined,
selectedStoryData: isShowingStoriesView
? undefined
: state.selectedStoryData,
};
}
if (action.type === CLEAR_STORIES_TAB_STATE) {
return {
...state,
replyState: undefined,
sendStoryModalData: undefined,
selectedStoryData: undefined,
addStoryData: undefined,
};
}
@@ -1851,8 +1864,6 @@ export function reducer(
if (action.type === TARGETED_CONVERSATION_CHANGED) {
return {
...state,
lastOpenedAtTimestamp: state.openedAtTimestamp || Date.now(),
openedAtTimestamp: undefined,
replyState: undefined,
sendStoryModalData: undefined,
selectedStoryData: undefined,

View File

@@ -5,6 +5,7 @@ import { getEmptyState as accounts } from './ducks/accounts';
import { getEmptyState as app } from './ducks/app';
import { getEmptyState as audioPlayer } from './ducks/audioPlayer';
import { getEmptyState as audioRecorder } from './ducks/audioRecorder';
import { getEmptyState as callHistory } from './ducks/callHistory';
import { getEmptyState as calling } from './ducks/calling';
import { getEmptyState as composer } from './ducks/composer';
import { getEmptyState as conversations } from './ducks/conversations';
@@ -15,6 +16,7 @@ import { getEmptyState as inbox } from './ducks/inbox';
import { getEmptyState as lightbox } from './ducks/lightbox';
import { getEmptyState as linkPreviews } from './ducks/linkPreviews';
import { getEmptyState as mediaGallery } from './ducks/mediaGallery';
import { getEmptyState as nav } from './ducks/nav';
import { getEmptyState as network } from './ducks/network';
import { getEmptyState as preferredReactions } from './ducks/preferredReactions';
import { getEmptyState as safetyNumber } from './ducks/safetyNumber';
@@ -39,15 +41,18 @@ import { getInitialState as stickers } from '../types/Stickers';
import { getThemeType } from '../util/getThemeType';
import { getInteractionMode } from '../services/InteractionMode';
import { makeLookup } from '../util/makeLookup';
import type { CallHistoryDetails } from '../types/CallDisposition';
export function getInitialState({
badges,
callsHistory,
stories,
storyDistributionLists,
mainWindowStats,
menuOptions,
}: {
badges: BadgesStateType;
callsHistory: ReadonlyArray<CallHistoryDetails>;
stories: Array<StoryDataType>;
storyDistributionLists: Array<StoryDistributionListDataType>;
mainWindowStats: MainWindowStatsType;
@@ -88,6 +93,10 @@ export function getInitialState({
audioPlayer: audioPlayer(),
audioRecorder: audioRecorder(),
badges,
callHistory: {
...callHistory(),
callHistoryByCallId: makeLookup(callsHistory, 'callId'),
},
calling: calling(),
composer: composer(),
conversations: {
@@ -110,6 +119,7 @@ export function getInitialState({
lightbox: lightbox(),
linkPreviews: linkPreviews(),
mediaGallery: mediaGallery(),
nav: nav(),
network: network(),
preferredReactions: preferredReactions(),
safetyNumber: safetyNumber(),

View File

@@ -9,6 +9,7 @@ import { reducer as audioPlayer } from './ducks/audioPlayer';
import { reducer as audioRecorder } from './ducks/audioRecorder';
import { reducer as badges } from './ducks/badges';
import { reducer as calling } from './ducks/calling';
import { reducer as callHistory } from './ducks/callHistory';
import { reducer as composer } from './ducks/composer';
import { reducer as conversations } from './ducks/conversations';
import { reducer as crashReports } from './ducks/crashReports';
@@ -20,6 +21,7 @@ import { reducer as items } from './ducks/items';
import { reducer as lightbox } from './ducks/lightbox';
import { reducer as linkPreviews } from './ducks/linkPreviews';
import { reducer as mediaGallery } from './ducks/mediaGallery';
import { reducer as nav } from './ducks/nav';
import { reducer as network } from './ducks/network';
import { reducer as preferredReactions } from './ducks/preferredReactions';
import { reducer as safetyNumber } from './ducks/safetyNumber';
@@ -39,6 +41,7 @@ export const reducer = combineReducers({
audioRecorder,
badges,
calling,
callHistory,
composer,
conversations,
crashReports,
@@ -50,6 +53,7 @@ export const reducer = combineReducers({
lightbox,
linkPreviews,
mediaGallery,
nav,
network,
preferredReactions,
safetyNumber,

View File

@@ -0,0 +1,31 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import type { CallHistoryState } from '../ducks/callHistory';
import type { StateType } from '../reducer';
import type { CallHistoryDetails } from '../../types/CallDisposition';
import { getOwn } from '../../util/getOwn';
const getCallHistory = (state: StateType): CallHistoryState =>
state.callHistory;
export const getCallHistoryEdition = createSelector(
getCallHistory,
callHistory => {
return callHistory.edition;
}
);
export type CallHistorySelectorType = (
callId: string
) => CallHistoryDetails | void;
export const getCallHistorySelector = createSelector(
getCallHistory,
(callHistory): CallHistorySelectorType => {
return callId => {
return getOwn(callHistory.callHistoryByCallId, callId);
};
}
);

View File

@@ -308,16 +308,18 @@ export const getConversationComparator = createSelector(
_getConversationComparator
);
type LeftPaneLists = Readonly<{
conversations: ReadonlyArray<ConversationType>;
archivedConversations: ReadonlyArray<ConversationType>;
pinnedConversations: ReadonlyArray<ConversationType>;
}>;
export const _getLeftPaneLists = (
lookup: ConversationLookupType,
comparator: (left: ConversationType, right: ConversationType) => number,
selectedConversation?: string,
pinnedConversationIds?: ReadonlyArray<string>
): {
conversations: Array<ConversationType>;
archivedConversations: Array<ConversationType>;
pinnedConversations: Array<ConversationType>;
} => {
): LeftPaneLists => {
const conversations: Array<ConversationType> = [];
const archivedConversations: Array<ConversationType> = [];
const pinnedConversations: Array<ConversationType> = [];
@@ -529,6 +531,40 @@ export const getAllGroupsWithInviteAccess = createSelector(
})
);
export type UnreadStats = Readonly<{
unreadCount: number;
unreadMentionsCount: number;
markedUnread: boolean;
}>;
export const getAllConversationsUnreadStats = createSelector(
getLeftPaneLists,
(leftPaneLists: LeftPaneLists): UnreadStats => {
let unreadCount = 0;
let unreadMentionsCount = 0;
let markedUnread = false;
function count(conversations: ReadonlyArray<ConversationType>) {
conversations.forEach(conversation => {
if (conversation.unreadCount != null) {
unreadCount += conversation.unreadCount;
}
if (conversation.unreadMentionsCount != null) {
unreadMentionsCount += conversation.unreadMentionsCount;
}
if (conversation.markedUnread) {
markedUnread = true;
}
});
}
count(leftPaneLists.pinnedConversations);
count(leftPaneLists.conversations);
return { unreadCount, unreadMentionsCount, markedUnread };
}
);
/**
* getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
* composer and group members, a different list from your primary system contacts.

View File

@@ -312,3 +312,8 @@ export const getTextFormattingEnabled = createSelector(
getItems,
(state: ItemsStateType): boolean => Boolean(state.textFormatting ?? true)
);
export const getNavTabsCollapsed = createSelector(
getItems,
(state: ItemsStateType): boolean => Boolean(state.navTabsCollapsed ?? false)
);

View File

@@ -54,14 +54,13 @@ import { BodyRange, hydrateRanges } from '../../types/BodyRange';
import type { AssertProps } from '../../types/Util';
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { getMentionsRegex } from '../../types/Message';
import { CallMode } from '../../types/Calling';
import { SignalService as Proto } from '../../protobuf';
import type { AttachmentType } from '../../types/Attachment';
import { isVoiceMessage, canBeDownloaded } from '../../types/Attachment';
import { ReadStatus } from '../../messages/MessageReadStatus';
import type { CallingNotificationType } from '../../util/callingNotification';
import { missingCaseError } from '../../util/missingCaseError';
import { CallExternalState } from '../../util/callingNotification';
import { getRecipients } from '../../util/getRecipients';
import { getOwn } from '../../util/getOwn';
import { isNotNil } from '../../util/isNotNil';
@@ -128,6 +127,9 @@ import type { AnyPaymentEvent } from '../../types/Payment';
import { isPaymentNotificationEvent } from '../../types/Payment';
import { getTitleNoDefault, getNumber } from '../../util/getTitle';
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp';
import type { CallHistorySelectorType } from './callHistory';
import { CallMode } from '../../types/Calling';
import { CallDirection } from '../../types/CallDisposition';
export { isIncoming, isOutgoing, isStory };
@@ -166,6 +168,7 @@ export type GetPropsForBubbleOptions = Readonly<{
selectedMessageIds: ReadonlyArray<string> | undefined;
regionCode?: string;
callSelector: CallSelectorType;
callHistorySelector: CallHistorySelectorType;
activeCall?: CallStateType;
accountSelector: AccountSelectorType;
contactNameColorSelector: ContactNameColorSelectorType;
@@ -1307,69 +1310,79 @@ export function isCallHistory(message: MessageWithUIFieldsType): boolean {
export type GetPropsForCallHistoryOptions = Pick<
GetPropsForBubbleOptions,
'conversationSelector' | 'callSelector' | 'activeCall'
| 'callSelector'
| 'activeCall'
| 'callHistorySelector'
| 'conversationSelector'
| 'ourConversationId'
>;
export function getPropsForCallHistory(
message: MessageWithUIFieldsType,
{
conversationSelector,
callSelector,
callHistorySelector,
activeCall,
conversationSelector,
ourConversationId,
}: GetPropsForCallHistoryOptions
): CallingNotificationType {
const { callHistoryDetails } = message;
if (!callHistoryDetails) {
throw new Error('getPropsForCallHistory: Missing callHistoryDetails');
const { callId } = message;
strictAssert(callId != null, 'getPropsForCallHistory: Missing callId');
const callHistory = callHistorySelector(callId);
strictAssert(
callHistory != null,
'getPropsForCallHistory: Missing callHistory'
);
const conversation = conversationSelector(callHistory.peerId);
strictAssert(
conversation != null,
'getPropsForCallHistory: Missing conversation'
);
let callCreator: ConversationType | null = null;
if (callHistory.ringerId) {
callCreator = conversationSelector(callHistory.ringerId);
} else if (callHistory.direction === CallDirection.Outgoing) {
callCreator = conversationSelector(ourConversationId);
}
const activeCallConversationId = activeCall?.conversationId;
const call = callSelector(callHistory.callId);
switch (callHistoryDetails.callMode) {
// Old messages weren't saved with a call mode.
case undefined:
case CallMode.Direct:
return {
...callHistoryDetails,
activeCallConversationId,
callMode: CallMode.Direct,
};
case CallMode.Group: {
const { conversationId } = message;
if (!conversationId) {
throw new Error('getPropsForCallHistory: missing conversation ID');
}
let deviceCount = 0;
let maxDevices = Infinity;
if (
call?.callMode === CallMode.Group &&
call.peekInfo?.deviceCount != null &&
call.peekInfo?.maxDevices != null
) {
deviceCount = call.peekInfo.deviceCount;
maxDevices = call.peekInfo.maxDevices;
}
let call = callSelector(conversationId);
if (call && call.callMode !== CallMode.Group) {
log.error(
'getPropsForCallHistory: there is an unexpected non-group call; pretending it does not exist'
);
call = undefined;
}
const creator = conversationSelector(callHistoryDetails.creatorUuid);
const deviceCount = call?.peekInfo?.deviceCount ?? 0;
return {
activeCallConversationId,
callMode: CallMode.Group,
conversationId,
creator,
deviceCount,
ended:
callHistoryDetails.eraId !== call?.peekInfo?.eraId || !deviceCount,
maxDevices: call?.peekInfo?.maxDevices ?? Infinity,
startedTime: callHistoryDetails.startedTime,
};
let callExternalState: CallExternalState;
if (call == null || deviceCount === 0) {
callExternalState = CallExternalState.Ended;
} else if (activeCall != null) {
if (activeCall.conversationId === call.conversationId) {
callExternalState = CallExternalState.Joined;
} else {
callExternalState = CallExternalState.InOtherCall;
}
default:
throw new Error(
`getPropsForCallHistory: missing case ${missingCaseError(
callHistoryDetails
)}`
);
} else if (deviceCount >= maxDevices) {
callExternalState = CallExternalState.Full;
} else {
callExternalState = CallExternalState.Active;
}
return {
callHistory,
callCreator,
callExternalState,
deviceCount,
maxDevices,
};
}
// Profile Change

14
ts/state/selectors/nav.ts Normal file
View File

@@ -0,0 +1,14 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import type { StateType } from '../reducer';
import type { NavStateType } from '../ducks/nav';
function getNav(state: StateType): NavStateType {
return state.nav;
}
export const getSelectedNavTab = createSelector(getNav, nav => {
return nav.selectedNavTab;
});

View File

@@ -47,11 +47,6 @@ import { BodyRange, hydrateRanges } from '../../types/BodyRange';
export const getStoriesState = (state: StateType): StoriesStateType =>
state.stories;
export const shouldShowStoriesView = createSelector(
getStoriesState,
({ openedAtTimestamp }): boolean => Boolean(openedAtTimestamp)
);
export const hasSelectedStoryData = createSelector(
getStoriesState,
({ selectedStoryData }): boolean => Boolean(selectedStoryData)

View File

@@ -21,6 +21,7 @@ import {
} from './user';
import { getActiveCall, getCallSelector } from './calling';
import { getPropsForBubble } from './message';
import { getCallHistorySelector } from './callHistory';
export const getTimelineItem = (
state: StateType,
@@ -45,6 +46,7 @@ export const getTimelineItem = (
const ourPNI = getUserPNI(state);
const ourConversationId = getUserConversationId(state);
const callSelector = getCallSelector(state);
const callHistorySelector = getCallHistorySelector(state);
const activeCall = getActiveCall(state);
const accountSelector = getAccountSelector(state);
const contactNameColorSelector = getContactNameColorSelector(state);
@@ -61,6 +63,7 @@ export const getTimelineItem = (
targetedMessageCounter: targetedMessage?.counter,
contactNameColorSelector,
callSelector,
callHistorySelector,
activeCall,
accountSelector,
selectedMessageIds,

View File

@@ -11,7 +11,6 @@ import OS from '../../util/os/osMain';
import { SmartCallManager } from './CallManager';
import { SmartGlobalModalContainer } from './GlobalModalContainer';
import { SmartLightbox } from './Lightbox';
import { SmartStories } from './Stories';
import { SmartStoryViewer } from './StoryViewer';
import type { StateType } from '../reducer';
import {
@@ -22,10 +21,7 @@ import {
getIsMainWindowFullScreen,
getMenuOptions,
} from '../selectors/user';
import {
hasSelectedStoryData,
shouldShowStoriesView,
} from '../selectors/stories';
import { hasSelectedStoryData } from '../selectors/stories';
import { getHideMenuBar } from '../selectors/items';
import { mapDispatchToProps } from '../actions';
import { ErrorBoundary } from '../../components/ErrorBoundary';
@@ -57,12 +53,6 @@ const mapStateToProps = (state: StateType) => {
),
renderGlobalModalContainer: () => <SmartGlobalModalContainer />,
renderLightbox: () => <SmartLightbox />,
isShowingStoriesView: shouldShowStoriesView(state),
renderStories: (closeView: () => unknown) => (
<ErrorBoundary name="App/renderStories" closeView={closeView}>
<SmartStories />
</ErrorBoundary>
),
hasSelectedStoryData: hasSelectedStoryData(state),
renderStoryViewer: (closeView: () => unknown) => (
<ErrorBoundary name="App/renderStoryViewer" closeView={closeView}>

170
ts/state/smart/CallsTab.tsx Normal file
View File

@@ -0,0 +1,170 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { useItemsActions } from '../ducks/items';
import {
getNavTabsCollapsed,
getPreferredLeftPaneWidth,
} from '../selectors/items';
import { getIntl, getRegionCode } from '../selectors/user';
import { CallsTab } from '../../components/CallsTab';
import {
getAllConversations,
getConversationSelector,
} from '../selectors/conversations';
import { filterAndSortConversationsByRecent } from '../../util/filterAndSortConversations';
import type {
CallHistoryFilter,
CallHistoryFilterOptions,
CallHistoryGroup,
CallHistoryPagination,
} from '../../types/CallDisposition';
import type { ConversationType } from '../ducks/conversations';
import { SmartConversationDetails } from './ConversationDetails';
import { useCallingActions } from '../ducks/calling';
import { getActiveCallState } from '../selectors/calling';
import { useCallHistoryActions } from '../ducks/callHistory';
import { getCallHistoryEdition } from '../selectors/callHistory';
import * as log from '../../logging/log';
function getCallHistoryFilter(
allConversations: Array<ConversationType>,
regionCode: string | undefined,
options: CallHistoryFilterOptions
): CallHistoryFilter | null {
const query = options.query.normalize().trim();
if (query !== '') {
const currentConversations = allConversations.filter(conversation => {
return conversation.removalStage == null;
});
const filteredConversations = filterAndSortConversationsByRecent(
currentConversations,
query,
regionCode
);
// If there are no matching conversations, then no calls will match.
if (filteredConversations.length === 0) {
return null;
}
return {
status: options.status,
conversationIds: filteredConversations.map(conversation => {
return conversation.id;
}),
};
}
return {
status: options.status,
conversationIds: null,
};
}
function renderConversationDetails(
conversationId: string,
callHistoryGroup: CallHistoryGroup | null
): JSX.Element {
return (
<SmartConversationDetails
conversationId={conversationId}
callHistoryGroup={callHistoryGroup}
/>
);
}
export function SmartCallsTab(): JSX.Element {
const i18n = useSelector(getIntl);
const navTabsCollapsed = useSelector(getNavTabsCollapsed);
const preferredLeftPaneWidth = useSelector(getPreferredLeftPaneWidth);
const { savePreferredLeftPaneWidth, toggleNavTabsCollapse } =
useItemsActions();
const allConversations = useSelector(getAllConversations);
const regionCode = useSelector(getRegionCode);
const getConversation = useSelector(getConversationSelector);
const activeCall = useSelector(getActiveCallState);
const callHistoryEdition = useSelector(getCallHistoryEdition);
const {
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
} = useCallingActions();
const { clearAllCallHistory: clearCallHistory } = useCallHistoryActions();
const getCallHistoryGroupsCount = useCallback(
async (options: CallHistoryFilterOptions) => {
// Informs us if the call history has changed
log.info('getCallHistoryGroupsCount: edition', callHistoryEdition);
const callHistoryFilter = getCallHistoryFilter(
allConversations,
regionCode,
options
);
if (callHistoryFilter == null) {
return 0;
}
const count = await window.Signal.Data.getCallHistoryGroupsCount(
callHistoryFilter
);
log.info('getCallHistoryGroupsCount: count', count, callHistoryFilter);
return count;
},
[allConversations, regionCode, callHistoryEdition]
);
const getCallHistoryGroups = useCallback(
async (
options: CallHistoryFilterOptions,
pagination: CallHistoryPagination
) => {
// Informs us if the call history has changed
log.info('getCallHistoryGroups: edition', callHistoryEdition);
const callHistoryFilter = getCallHistoryFilter(
allConversations,
regionCode,
options
);
if (callHistoryFilter == null) {
return [];
}
const results = await window.Signal.Data.getCallHistoryGroups(
callHistoryFilter,
pagination
);
log.info(
'getCallHistoryGroupsCount: results',
results,
callHistoryFilter
);
return results;
},
[allConversations, regionCode, callHistoryEdition]
);
return (
<CallsTab
activeCall={activeCall}
allConversations={allConversations}
getConversation={getConversation}
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
getCallHistoryGroups={getCallHistoryGroups}
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onClearCallHistory={clearCallHistory}
onToggleNavTabsCollapse={toggleNavTabsCollapse}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
preferredLeftPaneWidth={preferredLeftPaneWidth}
renderConversationDetails={renderConversationDetails}
regionCode={regionCode}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
/>
);
}

151
ts/state/smart/ChatsTab.tsx Normal file
View File

@@ -0,0 +1,151 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { ChatsTab } from '../../components/ChatsTab';
import { SmartConversationView } from './ConversationView';
import { SmartMiniPlayer } from './MiniPlayer';
import { SmartLeftPane } from './LeftPane';
import type { NavTabPanelProps } from '../../components/NavTabs';
import { useGlobalModalActions } from '../ducks/globalModals';
import { getIntl } from '../selectors/user';
import { usePrevious } from '../../hooks/usePrevious';
import { TargetedMessageSource } from '../ducks/conversationsEnums';
import type { ConversationsStateType } from '../ducks/conversations';
import { useConversationsActions } from '../ducks/conversations';
import type { StateType } from '../reducer';
import { strictAssert } from '../../util/assert';
import { showToast } from '../../util/showToast';
import { ToastStickerPackInstallFailed } from '../../components/ToastStickerPackInstallFailed';
import { getNavTabsCollapsed } from '../selectors/items';
import { useItemsActions } from '../ducks/items';
function renderConversationView() {
return <SmartConversationView />;
}
function renderLeftPane(props: NavTabPanelProps) {
return <SmartLeftPane {...props} />;
}
function renderMiniPlayer(options: { shouldFlow: boolean }) {
return <SmartMiniPlayer {...options} />;
}
export function SmartChatsTab(): JSX.Element {
const i18n = useSelector(getIntl);
const navTabsCollapsed = useSelector(getNavTabsCollapsed);
const { selectedConversationId, targetedMessage, targetedMessageSource } =
useSelector<StateType, ConversationsStateType>(
state => state.conversations
);
const {
onConversationClosed,
onConversationOpened,
scrollToMessage,
showConversation,
} = useConversationsActions();
const { showWhatsNewModal } = useGlobalModalActions();
const { toggleNavTabsCollapse } = useItemsActions();
const prevConversationId = usePrevious(
selectedConversationId,
selectedConversationId
);
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 (!selectedConversationId) {
window.SignalCI?.handleEvent('empty-inbox:rendered', null);
}
}, [selectedConversationId]);
return (
<ChatsTab
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={toggleNavTabsCollapse}
prevConversationId={prevConversationId}
renderConversationView={renderConversationView}
renderLeftPane={renderLeftPane}
renderMiniPlayer={renderMiniPlayer}
selectedConversationId={selectedConversationId}
showWhatsNewModal={showWhatsNewModal}
/>
);
}

View File

@@ -7,7 +7,7 @@ import type { CompositionTextAreaProps } from '../../components/CompositionTextA
import { CompositionTextArea } from '../../components/CompositionTextArea';
import { getIntl, getPlatform } from '../selectors/user';
import { useActions as useEmojiActions } from '../ducks/emojis';
import { useActions as useItemsActions } from '../ducks/items';
import { useItemsActions } from '../ducks/items';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { useComposerActions } from '../ducks/composer';
import {

View File

@@ -33,9 +33,12 @@ import {
getGroupSizeRecommendedLimit,
getGroupSizeHardLimit,
} from '../../groups/limits';
import type { CallHistoryGroup } from '../../types/CallDisposition';
import { getSelectedNavTab } from '../selectors/nav';
export type SmartConversationDetailsProps = {
conversationId: string;
callHistoryGroup?: CallHistoryGroup | null;
};
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
@@ -96,6 +99,7 @@ const mapStateToProps = (
const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
return {
...props,
areWeASubscriber: getAreWeASubscriber(state),
badges,
canEditGroupInfo,
@@ -115,6 +119,7 @@ const mapStateToProps = (
hasGroupLink,
groupsInCommon: groupsInCommonSorted,
isGroup: conversation.type === 'group',
selectedNavTab: getSelectedNavTab(state),
theme: getTheme(state),
renderChooseGroupMembersModal,
renderConfirmAdditionsModal,

View File

@@ -7,7 +7,7 @@ import { useSelector } from 'react-redux';
import type { StateType } from '../reducer';
import type { LocalizerType } from '../../types/Util';
import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions';
import { useActions as useItemsActions } from '../ducks/items';
import { useItemsActions } from '../ducks/items';
import { getIntl } from '../selectors/user';
import { getEmojiSkinTone } from '../selectors/items';
import { useRecentEmojis } from '../selectors/emojis';

View File

@@ -4,32 +4,37 @@
import React from 'react';
import { useSelector } from 'react-redux';
import type { AppStateType } from '../ducks/app';
import type { ConversationsStateType } from '../ducks/conversations';
import type { StateType } from '../reducer';
import { Inbox } from '../../components/Inbox';
import { getIntl } from '../selectors/user';
import { SmartConversationView } from './ConversationView';
import { SmartCustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal';
import { SmartLeftPane } from './LeftPane';
import { useConversationsActions } from '../ducks/conversations';
import { useGlobalModalActions } from '../ducks/globalModals';
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
import { SmartMiniPlayer } from './MiniPlayer';
import type { SmartNavTabsProps } from './NavTabs';
import { SmartNavTabs } from './NavTabs';
import { SmartStoriesTab } from './StoriesTab';
import { SmartCallsTab } from './CallsTab';
import { useItemsActions } from '../ducks/items';
import { getNavTabsCollapsed } from '../selectors/items';
import { SmartChatsTab } from './ChatsTab';
function renderConversationView() {
return <SmartConversationView />;
function renderChatsTab() {
return <SmartChatsTab />;
}
function renderCallsTab() {
return <SmartCallsTab />;
}
function renderCustomizingPreferredReactionsModal() {
return <SmartCustomizingPreferredReactionsModal />;
}
function renderMiniPlayer(options: { shouldFlow: boolean }) {
return <SmartMiniPlayer {...options} />;
function renderNavTabs(props: SmartNavTabsProps) {
return <SmartNavTabs {...props} />;
}
function renderLeftPane() {
return <SmartLeftPane />;
function renderStoriesTab() {
return <SmartStoriesTab />;
}
export function SmartInbox(): JSX.Element {
@@ -46,17 +51,9 @@ export function SmartInbox(): JSX.Element {
const { hasInitialLoadCompleted } = useSelector<StateType, AppStateType>(
state => state.app
);
const { selectedConversationId, targetedMessage, targetedMessageSource } =
useSelector<StateType, ConversationsStateType>(
state => state.conversations
);
const {
onConversationClosed,
onConversationOpened,
scrollToMessage,
showConversation,
} = useConversationsActions();
const { showWhatsNewModal } = useGlobalModalActions();
const navTabsCollapsed = useSelector(getNavTabsCollapsed);
const { toggleNavTabsCollapse } = useItemsActions();
return (
<Inbox
@@ -65,20 +62,15 @@ export function SmartInbox(): JSX.Element {
hasInitialLoadCompleted={hasInitialLoadCompleted}
i18n={i18n}
isCustomizingPreferredReactions={isCustomizingPreferredReactions}
onConversationClosed={onConversationClosed}
onConversationOpened={onConversationOpened}
renderConversationView={renderConversationView}
navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={toggleNavTabsCollapse}
renderChatsTab={renderChatsTab}
renderCallsTab={renderCallsTab}
renderCustomizingPreferredReactionsModal={
renderCustomizingPreferredReactionsModal
}
renderLeftPane={renderLeftPane}
renderMiniPlayer={renderMiniPlayer}
scrollToMessage={scrollToMessage}
selectedConversationId={selectedConversationId}
targetedMessage={targetedMessage}
targetedMessageSource={targetedMessageSource}
showConversation={showConversation}
showWhatsNewModal={showWhatsNewModal}
renderNavTabs={renderNavTabs}
renderStoriesTab={renderStoriesTab}
/>
);
}

View File

@@ -41,6 +41,7 @@ import {
getPreferredLeftPaneWidth,
getUsernamesEnabled,
getContactManagementEnabled,
getNavTabsCollapsed,
} from '../selectors/items';
import {
getComposeAvatarData,
@@ -70,7 +71,6 @@ import {
getGroupSizeHardLimit,
} from '../../groups/limits';
import { SmartMainHeader } from './MainHeader';
import { SmartMessageSearchResult } from './MessageSearchResult';
import { SmartNetworkStatus } from './NetworkStatus';
import { SmartRelinkDialog } from './RelinkDialog';
@@ -80,9 +80,6 @@ import { SmartUpdateDialog } from './UpdateDialog';
import { SmartCaptchaDialog } from './CaptchaDialog';
import { SmartCrashReportDialog } from './CrashReportDialog';
function renderMainHeader(): JSX.Element {
return <SmartMainHeader />;
}
function renderMessageSearchResult(id: string): JSX.Element {
return <SmartMessageSearchResult id={id} />;
}
@@ -229,6 +226,7 @@ const mapStateToProps = (state: StateType) => {
unsupportedOSDialogType,
modeSpecificProps: getModeSpecificProps(state),
navTabsCollapsed: getNavTabsCollapsed(state),
preferredWidthFromStorage: getPreferredLeftPaneWidth(state),
selectedConversationId: getSelectedConversationId(state),
targetedMessageId: getTargetedMessage(state)?.id,
@@ -240,7 +238,6 @@ const mapStateToProps = (state: StateType) => {
regionCode: getRegionCode(state),
challengeStatus: state.network.challengeStatus,
crashReportCount: state.crashReports.count,
renderMainHeader,
renderMessageSearchResult,
renderNetworkStatus,
renderRelinkDialog,

View File

@@ -1,45 +0,0 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { MainHeader } from '../../components/MainHeader';
import type { StateType } from '../reducer';
import { getPreferredBadgeSelector } from '../selectors/badges';
import {
getIntl,
getRegionCode,
getTheme,
getUserConversationId,
getUserNumber,
} from '../selectors/user';
import { getMe } from '../selectors/conversations';
import { getStoriesEnabled } from '../selectors/items';
import {
getStoriesNotificationCount,
getHasAnyFailedStorySends,
} from '../selectors/stories';
const mapStateToProps = (state: StateType) => {
const me = getMe(state);
return {
areStoriesEnabled: getStoriesEnabled(state),
hasPendingUpdate: Boolean(state.updates.didSnooze),
regionCode: getRegionCode(state),
ourConversationId: getUserConversationId(state),
ourNumber: getUserNumber(state),
...me,
badge: getPreferredBadgeSelector(state)(me.badges),
theme: getTheme(state),
i18n: getIntl(state),
hasFailedStorySends: getHasAnyFailedStorySends(state),
unreadStoriesCount: getStoriesNotificationCount(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartMainHeader = smart(MainHeader);

View File

@@ -0,0 +1,94 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import type { NavTabPanelProps } from '../../components/NavTabs';
import { NavTabs } from '../../components/NavTabs';
import { getIntl, getTheme } from '../selectors/user';
import {
getAllConversationsUnreadStats,
getMe,
} from '../selectors/conversations';
import { getPreferredBadgeSelector } from '../selectors/badges';
import type { StateType } from '../reducer';
import {
getHasAnyFailedStorySends,
getStoriesNotificationCount,
} from '../selectors/stories';
import { showSettings } from '../../shims/Whisper';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useUpdatesActions } from '../ducks/updates';
import { getStoriesEnabled } from '../selectors/items';
import { getSelectedNavTab } from '../selectors/nav';
import type { NavTab } from '../ducks/nav';
import { useNavActions } from '../ducks/nav';
export type SmartNavTabsProps = Readonly<{
navTabsCollapsed: boolean;
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
renderCallsTab(props: NavTabPanelProps): JSX.Element;
renderChatsTab(props: NavTabPanelProps): JSX.Element;
renderStoriesTab(props: NavTabPanelProps): JSX.Element;
}>;
export function SmartNavTabs({
navTabsCollapsed,
onToggleNavTabsCollapse,
renderCallsTab,
renderChatsTab,
renderStoriesTab,
}: SmartNavTabsProps): JSX.Element {
const i18n = useSelector(getIntl);
const selectedNavTab = useSelector(getSelectedNavTab);
const { changeNavTab } = useNavActions();
const me = useSelector(getMe);
const badge = useSelector(getPreferredBadgeSelector)(me.badges);
const theme = useSelector(getTheme);
const storiesEnabled = useSelector(getStoriesEnabled);
const unreadConversationsStats = useSelector(getAllConversationsUnreadStats);
const unreadStoriesCount = useSelector(getStoriesNotificationCount);
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
const hasPendingUpdate = useSelector((state: StateType) => {
return state.updates.didSnooze;
});
const { toggleProfileEditor } = useGlobalModalActions();
const { startUpdate } = useUpdatesActions();
const onNavTabSelected = useCallback(
(tab: NavTab) => {
// For some reason react-aria will call this more often than the tab
// actually changing.
if (tab !== selectedNavTab) {
changeNavTab(tab);
}
},
[changeNavTab, selectedNavTab]
);
return (
<NavTabs
badge={badge}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
i18n={i18n}
me={me}
navTabsCollapsed={navTabsCollapsed}
onShowSettings={showSettings}
onStartUpdate={startUpdate}
onNavTabSelected={onNavTabSelected}
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
onToggleProfileEditor={toggleProfileEditor}
renderCallsTab={renderCallsTab}
renderChatsTab={renderChatsTab}
renderStoriesTab={renderStoriesTab}
selectedNavTab={selectedNavTab}
storiesEnabled={storiesEnabled}
theme={theme}
unreadConversationsStats={unreadConversationsStats}
unreadStoriesCount={unreadStoriesCount}
/>
);
}

View File

@@ -5,7 +5,7 @@ import * as React from 'react';
import { useSelector } from 'react-redux';
import type { StateType } from '../reducer';
import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions';
import { useActions as useItemsActions } from '../ducks/items';
import { useItemsActions } from '../ducks/items';
import { getIntl } from '../selectors/user';
import { getPreferredReactionEmoji } from '../selectors/items';

View File

@@ -24,7 +24,7 @@ import { useStoriesActions } from '../ducks/stories';
import { useConversationsActions } from '../ducks/conversations';
export function SmartStoriesSettingsModal(): JSX.Element | null {
const { toggleStoriesView, setStoriesDisabled } = useStoriesActions();
const { setStoriesDisabled } = useStoriesActions();
const { hideStoriesSettings, toggleSignalConnectionsModal } =
useGlobalModalActions();
const {
@@ -71,7 +71,6 @@ export function SmartStoriesSettingsModal(): JSX.Element | null {
setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections}
storyViewReceiptsEnabled={storyViewReceiptsEnabled}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
toggleStoriesView={toggleStoriesView}
setStoriesDisabled={setStoriesDisabled}
/>
);

View File

@@ -1,20 +1,21 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import type { LocalizerType } from '../../types/Util';
import type { StateType } from '../reducer';
import { SmartStoryCreator } from './StoryCreator';
import { Stories } from '../../components/Stories';
import { StoriesTab } from '../../components/StoriesTab';
import { getMaximumAttachmentSizeInKb } from '../../types/AttachmentSize';
import type { ConfigKeyType } from '../../RemoteConfig';
import { getMe } from '../selectors/conversations';
import { getIntl } from '../selectors/user';
import { getIntl, getTheme } from '../selectors/user';
import { getPreferredBadgeSelector } from '../selectors/badges';
import {
getHasStoryViewReceiptSetting,
getNavTabsCollapsed,
getPreferredLeftPaneWidth,
getRemoteConfig,
} from '../selectors/items';
@@ -22,19 +23,19 @@ import {
getAddStoryData,
getSelectedStoryData,
getStories,
shouldShowStoriesView,
} from '../selectors/stories';
import { useConversationsActions } from '../ducks/conversations';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useStoriesActions } from '../ducks/stories';
import { useToastActions } from '../ducks/toast';
import { useAudioPlayerActions } from '../ducks/audioPlayer';
import { useItemsActions } from '../ducks/items';
function renderStoryCreator(): JSX.Element {
return <SmartStoryCreator />;
}
export function SmartStories(): JSX.Element | null {
export function SmartStoriesTab(): JSX.Element | null {
const storiesActions = useStoriesActions();
const {
retryMessageSend,
@@ -48,10 +49,6 @@ export function SmartStories(): JSX.Element | null {
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const isShowingStoriesView = useSelector<StateType, boolean>(
shouldShowStoriesView
);
const preferredWidthFromStorage = useSelector<StateType, number>(
getPreferredLeftPaneWidth
);
@@ -79,12 +76,22 @@ export function SmartStories(): JSX.Element | null {
);
const { pauseVoiceNotePlayer } = useAudioPlayerActions();
if (!isShowingStoriesView) {
return null;
}
const preferredLeftPaneWidth = useSelector(getPreferredLeftPaneWidth);
const navTabsCollapsed = useSelector(getNavTabsCollapsed);
const { savePreferredLeftPaneWidth, toggleNavTabsCollapse } =
useItemsActions();
const theme = useSelector(getTheme);
useEffect(() => {
storiesActions.markStoriesTabViewed();
return () => {
storiesActions.clearStoriesTabState();
};
}, [storiesActions]);
return (
<Stories
<StoriesTab
addStoryData={addStoryData}
getPreferredBadge={getPreferredBadge}
hiddenStories={hiddenStories}
@@ -92,6 +99,7 @@ export function SmartStories(): JSX.Element | null {
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
me={me}
myStories={myStories}
navTabsCollapsed={navTabsCollapsed}
onForwardStory={messageId => {
toggleForwardMessagesModal([messageId]);
}}
@@ -100,14 +108,18 @@ export function SmartStories(): JSX.Element | null {
saveAttachment(story.attachment, story.timestamp);
}
}}
onToggleNavTabsCollapse={toggleNavTabsCollapse}
onMediaPlaybackStart={pauseVoiceNotePlayer}
preferredLeftPaneWidth={preferredLeftPaneWidth}
preferredWidthFromStorage={preferredWidthFromStorage}
renderStoryCreator={renderStoryCreator}
retryMessageSend={retryMessageSend}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
showConversation={showConversation}
showStoriesSettings={showStoriesSettings}
showToast={showToast}
stories={stories}
theme={theme}
toggleHideStories={toggleHideStories}
isViewingStory={selectedStoryData !== undefined}
isStoriesSettingsVisible={isStoriesSettingsVisible}

View File

@@ -35,7 +35,7 @@ import { processAttachment } from '../../util/processAttachment';
import { useConversationsActions } from '../ducks/conversations';
import { useActions as useEmojisActions } from '../ducks/emojis';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useActions as useItemsActions } from '../ducks/items';
import { useItemsActions } from '../ducks/items';
import { useLinkPreviewActions } from '../ducks/linkPreviews';
import { useRecentEmojis } from '../selectors/emojis';
import { useStoriesActions } from '../ducks/stories';

View File

@@ -35,7 +35,7 @@ import { asyncShouldNeverBeCalled } from '../../util/shouldNeverBeCalled';
import { useActions as useEmojisActions } from '../ducks/emojis';
import { useConversationsActions } from '../ducks/conversations';
import { useRecentEmojis } from '../selectors/emojis';
import { useActions as useItemsActions } from '../ducks/items';
import { useItemsActions } from '../ducks/items';
import { useAudioPlayerActions } from '../ducks/audioPlayer';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useStoriesActions } from '../ducks/stories';
@@ -136,7 +136,6 @@ export function SmartStoryViewer(): JSX.Element | null {
onHideStory={toggleHideStories}
onGoToConversation={senderId => {
showConversation({ conversationId: senderId });
storiesActions.toggleStoriesView();
}}
onReactToStory={async (emoji, story) => {
const { messageId } = story;

View File

@@ -6,6 +6,7 @@ import type { actions as app } from './ducks/app';
import type { actions as audioPlayer } from './ducks/audioPlayer';
import type { actions as audioRecorder } from './ducks/audioRecorder';
import type { actions as badges } from './ducks/badges';
import type { actions as callHistory } from './ducks/callHistory';
import type { actions as calling } from './ducks/calling';
import type { actions as composer } from './ducks/composer';
import type { actions as conversations } from './ducks/conversations';
@@ -35,6 +36,7 @@ export type ReduxActions = {
audioPlayer: typeof audioPlayer;
audioRecorder: typeof audioRecorder;
badges: typeof badges;
callHistory: typeof callHistory;
calling: typeof calling;
composer: typeof composer;
conversations: typeof conversations;

View File

@@ -2,11 +2,23 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { getCallingNotificationText } from '../../util/callingNotification';
import {
CallExternalState,
getCallingNotificationText,
} from '../../util/callingNotification';
import { CallMode } from '../../types/Calling';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { getDefaultConversation } from '../helpers/getDefaultConversation';
import {
getDefaultConversation,
getDefaultGroup,
} from '../helpers/getDefaultConversation';
import {
CallDirection,
CallType,
GroupCallStatus,
} from '../../types/CallDisposition';
import { getPeerIdFromConversation } from '../../util/callDisposition';
describe('calling notification helpers', () => {
const i18n = setupI18n('en', enMessages);
@@ -15,15 +27,24 @@ describe('calling notification helpers', () => {
// Direct call behavior is not tested here.
it('says that the call has ended', () => {
const callCreator = getDefaultConversation();
assert.strictEqual(
getCallingNotificationText(
{
callMode: CallMode.Group,
conversationId: 'abc123',
ended: true,
callHistory: {
callId: '123',
peerId: getPeerIdFromConversation(getDefaultGroup()),
ringerId: callCreator.uuid ?? null,
mode: CallMode.Group,
type: CallType.Group,
direction: CallDirection.Incoming,
timestamp: Date.now(),
status: GroupCallStatus.Missed,
},
callCreator,
callExternalState: CallExternalState.Ended,
deviceCount: 1,
maxDevices: 23,
startedTime: Date.now(),
},
i18n
),
@@ -32,19 +53,26 @@ describe('calling notification helpers', () => {
});
it("includes the creator's first name when describing a call", () => {
const conversation = getDefaultConversation({
const callCreator = getDefaultConversation({
systemGivenName: 'Luigi',
});
assert.strictEqual(
getCallingNotificationText(
{
callMode: CallMode.Group,
conversationId: 'abc123',
creator: conversation,
ended: false,
callHistory: {
callId: '123',
peerId: getPeerIdFromConversation(getDefaultGroup()),
ringerId: callCreator.uuid ?? null,
mode: CallMode.Group,
type: CallType.Group,
direction: CallDirection.Incoming,
timestamp: Date.now(),
status: GroupCallStatus.Ringing,
},
callCreator,
callExternalState: CallExternalState.Active,
deviceCount: 1,
maxDevices: 23,
startedTime: Date.now(),
},
i18n
),
@@ -53,20 +81,27 @@ describe('calling notification helpers', () => {
});
it("if the creator doesn't have a first name, falls back to their title", () => {
const conversation = getDefaultConversation({
const callCreator = getDefaultConversation({
systemGivenName: undefined,
title: 'Luigi Mario',
});
assert.strictEqual(
getCallingNotificationText(
{
callMode: CallMode.Group,
conversationId: 'abc123',
creator: conversation,
ended: false,
callHistory: {
callId: '123',
peerId: getPeerIdFromConversation(getDefaultGroup()),
ringerId: callCreator.uuid ?? null,
mode: CallMode.Group,
type: CallType.Group,
direction: CallDirection.Incoming,
timestamp: Date.now(),
status: GroupCallStatus.Ringing,
},
callCreator,
callExternalState: CallExternalState.Active,
deviceCount: 1,
maxDevices: 23,
startedTime: Date.now(),
},
i18n
),
@@ -75,19 +110,26 @@ describe('calling notification helpers', () => {
});
it('has a special message if you were the one to start the call', () => {
const conversation = getDefaultConversation({
const callCreator = getDefaultConversation({
isMe: true,
});
assert.strictEqual(
getCallingNotificationText(
{
callMode: CallMode.Group,
conversationId: 'abc123',
creator: conversation,
ended: false,
callHistory: {
callId: '123',
peerId: getPeerIdFromConversation(getDefaultGroup()),
ringerId: callCreator.uuid ?? null,
mode: CallMode.Group,
type: CallType.Group,
direction: CallDirection.Outgoing,
timestamp: Date.now(),
status: GroupCallStatus.Ringing,
},
callCreator,
callExternalState: CallExternalState.Active,
deviceCount: 1,
maxDevices: 23,
startedTime: Date.now(),
},
i18n
),
@@ -99,12 +141,20 @@ describe('calling notification helpers', () => {
assert.strictEqual(
getCallingNotificationText(
{
callMode: CallMode.Group,
conversationId: 'abc123',
ended: false,
callHistory: {
callId: '123',
peerId: getPeerIdFromConversation(getDefaultGroup()),
ringerId: null,
mode: CallMode.Group,
type: CallType.Group,
direction: CallDirection.Outgoing,
timestamp: Date.now(),
status: GroupCallStatus.Ringing,
},
callCreator: null,
callExternalState: CallExternalState.Active,
deviceCount: 1,
maxDevices: 23,
startedTime: Date.now(),
},
i18n
),

View File

@@ -27,9 +27,8 @@ import {
hmacSha256,
verifyHmacSha256,
randomInt,
uuidToBytes,
bytesToUuid,
} from '../Crypto';
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
const BUCKET_SIZES = [
541, 568, 596, 626, 657, 690, 725, 761, 799, 839, 881, 925, 972, 1020, 1071,

View File

@@ -0,0 +1,224 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import dataInterface from '../../sql/Client';
import { UUID } from '../../types/UUID';
import type { UUIDStringType } from '../../types/UUID';
import { CallMode } from '../../types/Calling';
import type {
CallHistoryDetails,
CallHistoryGroup,
} from '../../types/CallDisposition';
import {
CallDirection,
CallHistoryFilterStatus,
CallType,
DirectCallStatus,
} from '../../types/CallDisposition';
import { strictAssert } from '../../util/assert';
import type { ConversationAttributesType } from '../../model-types';
const { removeAll, getCallHistoryGroups, saveCallHistory, saveConversation } =
dataInterface;
function getUuid(): UUIDStringType {
return UUID.generate().toString();
}
function toGroup(calls: Array<CallHistoryDetails>): CallHistoryGroup {
const firstCall = calls.at(0);
strictAssert(firstCall != null, 'needs at least 1 item');
return {
peerId: firstCall.peerId,
mode: firstCall.mode,
type: firstCall.type,
direction: firstCall.direction,
timestamp: firstCall.timestamp,
status: firstCall.status,
children: calls.map(call => {
return { callId: call.callId, timestamp: call.timestamp };
}),
};
}
describe('sql/getCallHistoryGroups', () => {
beforeEach(async () => {
await removeAll();
});
it('should merge related items in order', async () => {
const now = Date.now();
const conversationId = getUuid();
function toCall(callId: string, timestamp: number) {
return {
callId,
peerId: conversationId,
ringerId: conversationId,
mode: CallMode.Direct,
type: CallType.Video,
direction: CallDirection.Incoming,
timestamp,
status: DirectCallStatus.Accepted,
};
}
const call1 = toCall('1', now - 10);
const call2 = toCall('2', now);
await saveCallHistory(call1);
await saveCallHistory(call2);
const groups = await getCallHistoryGroups(
{ status: CallHistoryFilterStatus.All, conversationIds: null },
{ offset: 0, limit: 0 }
);
assert.deepEqual(groups, [toGroup([call2, call1])]);
});
it('should separate unrelated items in order', async () => {
const now = Date.now();
const conversationId = getUuid();
function toCall(callId: string, timestamp: number, type: CallType) {
return {
callId,
peerId: conversationId,
ringerId: conversationId,
mode: CallMode.Direct,
type,
direction: CallDirection.Incoming,
timestamp,
status: DirectCallStatus.Accepted,
};
}
const call1 = toCall('1', now - 10, CallType.Video);
const call2 = toCall('2', now, CallType.Audio);
await saveCallHistory(call1);
await saveCallHistory(call2);
const groups = await getCallHistoryGroups(
{ status: CallHistoryFilterStatus.All, conversationIds: null },
{ offset: 0, limit: 0 }
);
assert.deepEqual(groups, [toGroup([call2]), toGroup([call1])]);
});
it('should split groups that are contiguous', async () => {
const now = Date.now();
const conversationId = getUuid();
function toCall(callId: string, timestamp: number, type: CallType) {
return {
callId,
peerId: conversationId,
ringerId: conversationId,
mode: CallMode.Direct,
type,
direction: CallDirection.Incoming,
timestamp,
status: DirectCallStatus.Accepted,
};
}
const call1 = toCall('1', now - 30, CallType.Video);
const call2 = toCall('2', now - 20, CallType.Video);
const call3 = toCall('3', now - 10, CallType.Audio);
const call4 = toCall('4', now, CallType.Video);
await saveCallHistory(call1);
await saveCallHistory(call2);
await saveCallHistory(call3);
await saveCallHistory(call4);
const groups = await getCallHistoryGroups(
{ status: CallHistoryFilterStatus.All, conversationIds: null },
{ offset: 0, limit: 0 }
);
assert.deepEqual(groups, [
toGroup([call4]),
toGroup([call3]),
toGroup([call2, call1]),
]);
});
it('should search in the correct conversations', async () => {
const now = Date.now();
const conversation1Uuid = getUuid();
const conversation2GroupId = 'groupId:2';
const conversation1: ConversationAttributesType = {
type: 'private',
version: 0,
id: 'id:1',
uuid: conversation1Uuid,
};
const conversation2: ConversationAttributesType = {
type: 'group',
version: 2,
id: 'id:2',
groupId: conversation2GroupId,
};
await saveConversation(conversation1);
await saveConversation(conversation2);
function toCall(
callId: string,
timestamp: number,
mode: CallMode,
conversationId: string | UUIDStringType
) {
return {
callId,
peerId: conversationId,
ringerId: null,
mode,
type: CallType.Video,
direction: CallDirection.Incoming,
timestamp,
status: DirectCallStatus.Accepted,
};
}
const call1 = toCall('1', now - 10, CallMode.Direct, conversation1Uuid);
const call2 = toCall('2', now, CallMode.Group, conversation2GroupId);
await saveCallHistory(call1);
await saveCallHistory(call2);
{
const groups = await getCallHistoryGroups(
{
status: CallHistoryFilterStatus.All,
conversationIds: [conversation1.id],
},
{ offset: 0, limit: 0 }
);
assert.deepEqual(groups, [toGroup([call1])]);
}
{
const groups = await getCallHistoryGroups(
{
status: CallHistoryFilterStatus.All,
conversationIds: [conversation2.id],
},
{ offset: 0, limit: 0 }
);
assert.deepEqual(groups, [toGroup([call2])]);
}
});
});

View File

@@ -8,7 +8,6 @@ import { UUID } from '../../types/UUID';
import type { UUIDStringType } from '../../types/UUID';
import type { MessageAttributesType } from '../../model-types.d';
import { CallMode } from '../../types/Calling';
const {
removeAll,
@@ -40,15 +39,7 @@ describe('sql/getCallHistoryMessageByCallId', () => {
sent_at: now - 10,
received_at: now - 10,
timestamp: now - 10,
callHistoryDetails: {
callId: '12345',
callMode: CallMode.Direct,
wasIncoming: true,
wasVideoCall: true,
wasDeclined: true,
acceptedTime: now - 10,
endedTime: undefined,
},
callId: '12345',
};
await saveMessages([callHistoryMessage], {
@@ -56,12 +47,13 @@ describe('sql/getCallHistoryMessageByCallId', () => {
ourUuid,
});
assert.lengthOf(await _getAllMessages(), 1);
const allMessages = await _getAllMessages();
assert.lengthOf(allMessages, 1);
const messageId = await getCallHistoryMessageByCallId(
const message = await getCallHistoryMessageByCallId({
conversationId,
'12345'
);
assert.strictEqual(messageId, callHistoryMessage.id);
callId: '12345',
});
assert.strictEqual(message?.id, callHistoryMessage.id);
});
});

View File

@@ -214,10 +214,9 @@ describe('story/messaging', function unknownContacts() {
debug('waiting for storage service sync to complete');
await app.waitForStorageService();
const leftPane = window.locator('#LeftPane');
await window.getByTestId('NavTabsItem--Stories').click();
debug('Create and send a story to the group');
await leftPane.getByRole('button', { name: 'Stories' }).click();
await window.getByRole('button', { name: 'Add a story' }).first().click();
await window.getByRole('button', { name: 'Text story' }).click();
await window.locator('.TextAttachment').click();

View File

@@ -102,9 +102,9 @@ describe('pnp/send gv2 invite', function needsName() {
debug('clicking compose and "New group" buttons');
await leftPane.locator('.module-main-header__compose-icon').click();
await window.getByRole('button', { name: 'New chat' }).click();
await leftPane.locator('[data-testid=CreateNewGroupButton]').click();
await leftPane.getByTestId('CreateNewGroupButton').click();
debug('inviting ACI member');

View File

@@ -156,9 +156,7 @@ describe('pnp/username', function needsName() {
const window = await app.getWindow();
debug('opening avatar context menu');
await window
.locator('.module-main-header .module-Avatar__contents')
.click();
await window.getByRole('button', { name: 'Profile' }).click();
debug('opening profile editor');
await window
@@ -288,7 +286,7 @@ describe('pnp/username', function needsName() {
const window = await app.getWindow();
debug('entering username into search field');
await window.locator('button[aria-label="New chat"]').click();
await window.getByRole('button', { name: 'New chat' }).click();
const searchInput = window.locator('.module-SearchInput__container input');
await searchInput.type(CARL_USERNAME);

View File

@@ -79,8 +79,9 @@ describe('story/no-sender-key', function needsName() {
debug('Posting a new story');
{
const storiesPane = window.locator('.Stories');
const storiesCreator = window.locator('.StoryCreator');
await window.locator('button.module-main-header__stories-icon').click();
await window.getByTestId('NavTabsItem--Stories').click();
await storiesPane
.locator('button.Stories__pane__add-story__button')
@@ -93,13 +94,15 @@ describe('story/no-sender-key', function needsName() {
.click();
debug('Focusing textarea');
await storiesPane.locator('.TextAttachment__story').click();
await storiesCreator.locator('.TextAttachment__story').click();
debug('Entering text');
await storiesPane.locator('.TextAttachment__text__textarea').type('123');
await storiesCreator
.locator('.TextAttachment__text__textarea')
.type('123');
debug('Clicking "Next"');
await storiesPane
await storiesCreator
.locator('.StoryCreator__toolbar button >> "Next"')
.click();

View File

@@ -18,6 +18,9 @@ import { SeenStatus } from '../MessageSeenStatus';
import { objectToJSON, sql, sqlJoin } from '../sql/util';
import type { MessageType } from '../sql/Interface';
import { BodyRange } from '../types/BodyRange';
import { CallMode } from '../types/Calling';
import { callHistoryDetailsSchema } from '../types/CallDisposition';
import type { MessageAttributesType } from '../model-types';
const OUR_UUID = generateGuid();
@@ -3451,19 +3454,19 @@ describe('SQL migrations test', () => {
updateToVersion(schemaVersion);
const [query, params] = sql`
EXPLAIN QUERY PLAN
SELECT
SELECT
messages.rowid,
mentionUuid
FROM mentions
INNER JOIN messages
ON
messages.id = mentions.messageId
INNER JOIN messages
ON
messages.id = mentions.messageId
AND mentions.mentionUuid IN (
${sqlJoin(['a', 'b', 'c'], ', ')}
)
AND messages.isViewOnce IS NOT 1
)
AND messages.isViewOnce IS NOT 1
AND messages.storyId IS NULL
LIMIT 100;
`;
const { detail } = db.prepare(query).get(params);
@@ -3574,4 +3577,86 @@ describe('SQL migrations test', () => {
);
});
});
describe('updateToSchemaVersion87', () => {
it('pulls out call history messages into the new table', () => {
updateToVersion(86);
const message1Id = generateGuid();
const message2Id = generateGuid();
const conversationId = generateGuid();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types
const message1: MessageAttributesType & { callHistoryDetails: any } = {
id: message1Id,
type: 'call-history',
conversationId,
sent_at: Date.now() - 10,
received_at: Date.now() - 10,
timestamp: Date.now() - 10,
callHistoryDetails: {
callId: '123',
callMode: CallMode.Direct,
wasDeclined: false,
wasDeleted: false,
wasIncoming: false,
wasVideoCall: false,
acceptedTime: Date.now(),
endedTime: undefined,
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types
const message2: MessageAttributesType & { callHistoryDetails: any } = {
id: message2Id,
type: 'call-history',
conversationId,
sent_at: Date.now(),
received_at: Date.now(),
timestamp: Date.now(),
callHistoryDetails: {
callMode: CallMode.Group,
creatorUuid: generateGuid(),
eraId: (0x123).toString(16),
startedTime: Date.now(),
},
};
const [insertQuery, insertParams] = sql`
INSERT INTO messages (
id,
conversationId,
type,
json
)
VALUES
(
${message1Id},
${conversationId},
${message1.type},
${JSON.stringify(message1)}
),
(
${message2Id},
${conversationId},
${message2.type},
${JSON.stringify(message2)}
);
`;
db.prepare(insertQuery).run(insertParams);
updateToVersion(87);
const [selectHistoryQuery] = sql`
SELECT * FROM callsHistory;
`;
const rows = db.prepare(selectHistoryQuery).all();
for (const row of rows) {
callHistoryDetailsSchema.parse(row);
}
});
});
});

View File

@@ -49,7 +49,7 @@ import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
import { Zone } from '../util/Zone';
import { DurationInSeconds, SECOND } from '../util/durations';
import { bytesToUuid } from '../Crypto';
import { bytesToUuid } from '../util/uuidToBytes';
import type { DownloadedAttachmentType } from '../types/Attachment';
import { Address } from '../types/Address';
import { QualifiedAddress } from '../types/QualifiedAddress';
@@ -88,6 +88,7 @@ import type {
UnprocessedType,
} from './Types.d';
import {
CallEventSyncEvent,
EmptyEvent,
EnvelopeQueuedEvent,
EnvelopeUnsealedEvent,
@@ -113,7 +114,7 @@ import {
ViewSyncEvent,
ContactSyncEvent,
StoryRecipientUpdateEvent,
CallEventSyncEvent,
CallLogEventSyncEvent,
} from './messageReceiverEvents';
import * as log from '../logging/log';
import * as durations from '../util/durations';
@@ -128,6 +129,8 @@ import { isOlderThan } from '../util/timestamp';
import { inspectUnknownFieldTags } from '../util/inspectProtobufs';
import { incrementMessageCounter } from '../util/incrementMessageCounter';
import { filterAndClean } from '../types/BodyRange';
import { getCallEventForProto } from '../util/callDisposition';
import { CallLogEvent } from '../types/CallDisposition';
const GROUPV2_ID_LENGTH = 32;
const RETRY_TIMEOUT = 2 * 60 * 1000;
@@ -640,6 +643,11 @@ export default class MessageReceiver
handler: (ev: CallEventSyncEvent) => void
): void;
public override addEventListener(
name: 'callLogEventSync',
handler: (ev: CallLogEventSyncEvent) => void
): void;
public override addEventListener(name: string, handler: EventHandler): void {
return super.addEventListener(name, handler);
}
@@ -3041,6 +3049,9 @@ export default class MessageReceiver
if (syncMessage.callEvent) {
return this.handleCallEvent(envelope, syncMessage.callEvent);
}
if (syncMessage.callLogEvent) {
return this.handleCallLogEvent(envelope, syncMessage.callLogEvent);
}
this.removeFromCache(envelope);
const envelopeId = getEnvelopeId(envelope);
@@ -3353,57 +3364,16 @@ export default class MessageReceiver
): Promise<void> {
const logId = getEnvelopeId(envelope);
log.info('MessageReceiver.handleCallEvent', logId);
const { peerUuid, callId, type } = callEvent;
if (!peerUuid) {
throw new Error('MessageReceiver.handleCallEvent: missing peerUuid');
}
if (!callId) {
throw new Error('MessageReceiver.handleCallEvent: missing callId');
}
logUnexpectedUrgentValue(envelope, 'callEventSync');
if (
type !== Proto.SyncMessage.CallEvent.Type.VIDEO_CALL &&
type !== Proto.SyncMessage.CallEvent.Type.AUDIO_CALL
) {
log.warn('MessageReceiver.handleCallEvent: unknown call type');
return;
}
const { receivedAtCounter } = envelope;
const peerUuidStr = bytesToUuid(peerUuid);
strictAssert(
peerUuidStr != null,
'MessageReceiver.handleCallEvent: invalid peerUuid'
);
const { receivedAtCounter, timestamp } = envelope;
const wasIncoming =
callEvent.direction === Proto.SyncMessage.CallEvent.Direction.INCOMING;
const wasVideoCall =
callEvent.type === Proto.SyncMessage.CallEvent.Type.VIDEO_CALL;
const wasAccepted =
callEvent.event === Proto.SyncMessage.CallEvent.Event.ACCEPTED;
const wasDeclined =
callEvent.event === Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED;
const acceptedTime = wasAccepted ? timestamp : undefined;
const endedTime = wasDeclined ? timestamp : undefined;
const callEventDetails = getCallEventForProto(callEvent);
const callEventSync = new CallEventSyncEvent(
{
timestamp: envelope.timestamp,
peerUuid: peerUuidStr,
callId: callId.toString(),
wasIncoming,
wasVideoCall,
wasDeclined,
acceptedTime,
endedTime,
callEventDetails,
receivedAtCounter,
},
this.removeFromCache.bind(this, envelope)
@@ -3413,6 +3383,49 @@ export default class MessageReceiver
log.info('handleCallEvent: finished');
}
private async handleCallLogEvent(
envelope: ProcessedEnvelope,
callLogEvent: Proto.SyncMessage.ICallLogEvent
): Promise<void> {
const logId = getEnvelopeId(envelope);
log.info('MessageReceiver.handleCallLogEvent', logId);
logUnexpectedUrgentValue(envelope, 'callLogEventSync');
const { receivedAtCounter } = envelope;
let event: CallLogEvent;
if (callLogEvent.type == null) {
throw new Error('MessageReceiver.handleCallLogEvent: type was null');
} else if (
callLogEvent.type === Proto.SyncMessage.CallLogEvent.Type.CLEAR
) {
event = CallLogEvent.Clear;
} else {
throw new Error(
`MessageReceiver.handleCallLogEvent: unknown type ${callLogEvent.type}`
);
}
if (callLogEvent.timestamp == null) {
throw new Error('MessageReceiver.handleCallLogEvent: timestamp was null');
}
const timestamp = callLogEvent.timestamp.toNumber();
const callLogEventSync = new CallLogEventSyncEvent(
{
event,
timestamp,
receivedAtCounter,
},
this.removeFromCache.bind(this, envelope)
);
await this.dispatchAndWait(logId, callLogEventSync);
log.info('handleCallLogEvent: finished');
}
private async handleContacts(
envelope: ProcessedEnvelope,
contacts: Proto.SyncMessage.IContacts

View File

@@ -1663,52 +1663,6 @@ export default class MessageSender {
};
}
static getCallEventSync(
peerUuid: string,
callId: string,
isVideoCall: boolean,
isIncoming: boolean,
isAccepted: boolean
): SingleProtoJobData {
const myUuid = window.textsecure.storage.user.getCheckedUuid();
const syncMessage = MessageSender.createSyncMessage();
const type = isVideoCall
? Proto.SyncMessage.CallEvent.Type.VIDEO_CALL
: Proto.SyncMessage.CallEvent.Type.AUDIO_CALL;
const direction = isIncoming
? Proto.SyncMessage.CallEvent.Direction.INCOMING
: Proto.SyncMessage.CallEvent.Direction.OUTGOING;
const event = isAccepted
? Proto.SyncMessage.CallEvent.Event.ACCEPTED
: Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED;
syncMessage.callEvent = new Proto.SyncMessage.CallEvent({
peerUuid: uuidToBytes(peerUuid),
callId: Long.fromString(callId),
type,
direction,
event,
timestamp: Long.fromNumber(Date.now()),
});
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return {
contentHint: ContentHint.RESENDABLE,
identifier: myUuid.toString(),
isSyncMessage: true,
protoBase64: Bytes.toBase64(
Proto.Content.encode(contentMessage).finish()
),
type: 'callEventSync',
urgent: false,
};
}
static getVerificationSync(
destinationE164: string | undefined,
destinationUuid: string | undefined,

View File

@@ -11,7 +11,7 @@ import type { LoggerType } from '../../types/Logging';
import { strictAssert } from '../../util/assert';
import { UUID_BYTE_SIZE } from '../../types/UUID';
import * as Bytes from '../../Bytes';
import { uuidToBytes, bytesToUuid } from '../../Crypto';
import { uuidToBytes, bytesToUuid } from '../../util/uuidToBytes';
import { SignalService as Proto } from '../../protobuf';
import type {
CDSRequestOptionsType,

View File

@@ -12,6 +12,7 @@ import type {
ProcessedSent,
} from './Types.d';
import type { ModifiedContactDetails } from './ContactsParser';
import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition';
export class EmptyEvent extends Event {
constructor() {
@@ -404,14 +405,7 @@ export class ViewSyncEvent extends ConfirmableEvent {
}
export type CallEventSyncEventData = Readonly<{
timestamp: number;
peerUuid: string;
callId: string;
wasVideoCall: boolean;
wasIncoming: boolean;
wasDeclined: boolean;
acceptedTime: number | undefined;
endedTime: number | undefined;
callEventDetails: CallEventDetails;
receivedAtCounter: number;
}>;
@@ -424,6 +418,21 @@ export class CallEventSyncEvent extends ConfirmableEvent {
}
}
export type CallLogEventSyncEventData = Readonly<{
event: CallLogEvent;
timestamp: number;
receivedAtCounter: number;
}>;
export class CallLogEventSyncEvent extends ConfirmableEvent {
constructor(
public readonly callLogEvent: CallLogEventSyncEventData,
confirm: ConfirmCallback
) {
super('callLogEventSync', confirm);
}
}
export type StoryRecipientUpdateData = Readonly<{
destinationUuid: string;
storyMessageRecipients: Array<Proto.SyncMessage.Sent.IStoryMessageRecipient>;

206
ts/types/CallDisposition.ts Normal file
View File

@@ -0,0 +1,206 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
import Long from 'long';
import { CallMode } from './Calling';
import { bytesToUuid } from '../util/uuidToBytes';
import { SignalService as Proto } from '../protobuf';
import * as Bytes from '../Bytes';
export enum CallType {
Audio = 'Audio',
Video = 'Video',
Group = 'Group',
}
export enum CallDirection {
Incoming = 'Incoming',
Outgoing = 'Outgoing',
}
export enum CallLogEvent {
Clear = 'Clear',
}
export enum LocalCallEvent {
Started = 'LocalStarted',
Ringing = 'LocalRinging',
Accepted = 'LocalAccepted',
Declined = 'LocalDeclined',
Hangup = 'LocalHangup', // Incoming = Declined, Outgoing = Missed
RemoteHangup = 'LocalRemoteHangup', // Incoming = Missed, Outgoing = Declined
Missed = 'LocalMissed',
Delete = 'LocalDelete',
}
export enum RemoteCallEvent {
Accepted = 'Accepted',
NotAccepted = 'NotAccepted',
Delete = 'Delete',
}
export type CallEvent = LocalCallEvent | RemoteCallEvent;
export enum DirectCallStatus {
Pending = 'Pending',
Accepted = 'Accepted',
Missed = 'Missed',
Declined = 'Declined',
Deleted = 'Deleted',
}
export enum GroupCallStatus {
GenericGroupCall = 'GenericGroupCall',
OutgoingRing = 'OutgoingRing',
Ringing = 'Ringing',
Joined = 'Joined',
// keep these in sync with direct
Accepted = DirectCallStatus.Accepted,
Missed = DirectCallStatus.Missed,
Declined = DirectCallStatus.Declined,
Deleted = DirectCallStatus.Deleted,
}
export type CallStatus = DirectCallStatus | GroupCallStatus;
export type CallDetails = Readonly<{
callId: string;
peerId: string;
ringerId: string | null;
mode: CallMode;
type: CallType;
direction: CallDirection;
timestamp: number;
}>;
export type CallEventDetails = CallDetails &
Readonly<{
event: CallEvent;
}>;
export type CallHistoryDetails = CallDetails &
Readonly<{
status: CallStatus;
}>;
export type CallHistoryGroup = Omit<CallHistoryDetails, 'callId' | 'ringerId'> &
Readonly<{
children: ReadonlyArray<{
callId: string;
timestamp: number;
}>;
}>;
export type GroupCallMeta = Readonly<{
callId: string;
ringerId: string;
}>;
export enum CallHistoryFilterStatus {
All = 'All',
Missed = 'Missed',
}
export type CallHistoryFilterOptions = Readonly<{
status: CallHistoryFilterStatus;
query: string;
}>;
export type CallHistoryFilter = Readonly<{
status: CallHistoryFilterStatus;
conversationIds: ReadonlyArray<string> | null;
}>;
export type CallHistoryPagination = Readonly<{
offset: number;
limit: number;
}>;
const ringerIdSchema = z.union([z.string(), z.null()]);
const callModeSchema = z.nativeEnum(CallMode);
const callTypeSchema = z.nativeEnum(CallType);
const callDirectionSchema = z.nativeEnum(CallDirection);
const callEventSchema = z.union([
z.nativeEnum(LocalCallEvent),
z.nativeEnum(RemoteCallEvent),
]);
const callStatusSchema = z.union([
z.nativeEnum(DirectCallStatus),
z.nativeEnum(GroupCallStatus),
]);
export const callDetailsSchema = z.object({
callId: z.string(),
peerId: z.string(),
ringerId: ringerIdSchema,
mode: callModeSchema,
type: callTypeSchema,
direction: callDirectionSchema,
timestamp: z.number(),
}) satisfies z.ZodType<CallDetails>;
export const callEventDetailsSchema = callDetailsSchema.extend({
event: callEventSchema,
}) satisfies z.ZodType<CallEventDetails>;
export const callHistoryDetailsSchema = callDetailsSchema.extend({
status: callStatusSchema,
}) satisfies z.ZodType<CallHistoryDetails>;
export const callHistoryGroupSchema = z.object({
peerId: z.string(),
mode: callModeSchema,
type: callTypeSchema,
direction: callDirectionSchema,
status: callStatusSchema,
timestamp: z.number(),
children: z.array(
z.object({
callId: z.string(),
timestamp: z.number(),
})
),
}) satisfies z.ZodType<CallHistoryGroup>;
const peerIdInBytesSchema = z.instanceof(Uint8Array).transform(value => {
const uuid = bytesToUuid(value);
if (uuid != null) {
return uuid;
}
// assuming groupId
return Bytes.toBase64(value);
});
const longToStringSchema = z
.instanceof(Long)
.transform(long => long.toString());
const longToNumberSchema = z
.instanceof(Long)
.transform(long => long.toNumber());
export const callEventNormalizeSchema = z.object({
peerId: peerIdInBytesSchema,
callId: longToStringSchema,
timestamp: longToNumberSchema,
type: z.nativeEnum(Proto.SyncMessage.CallEvent.Type),
direction: z.nativeEnum(Proto.SyncMessage.CallEvent.Direction),
event: z.nativeEnum(Proto.SyncMessage.CallEvent.Event),
});
export function isSameCallHistoryGroup(
a: CallHistoryGroup,
b: CallHistoryGroup
): boolean {
return (
a.peerId === b.peerId &&
a.timestamp === b.timestamp &&
// For a bit more safety.
a.mode === b.mode &&
a.type === b.type &&
a.direction === b.direction &&
a.status === b.status
);
}

View File

@@ -113,7 +113,6 @@ export enum CallEndedReason {
AcceptedOnAnotherDevice = 'AcceptedOnAnotherDevice',
DeclinedOnAnotherDevice = 'DeclinedOnAnotherDevice',
BusyOnAnotherDevice = 'BusyOnAnotherDevice',
CallerIsNotMultiring = 'CallerIsNotMultiring',
}
// Must be kept in sync with RingRTC's ConnectionState
@@ -166,33 +165,6 @@ export type MediaDeviceSettings = AvailableIODevicesType & {
selectedCamera: string | undefined;
};
type DirectCallHistoryDetailsType = {
callId: string;
callMode: CallMode.Direct;
wasIncoming: boolean;
wasVideoCall: boolean;
wasDeclined: boolean;
acceptedTime?: number;
endedTime?: number;
};
type GroupCallHistoryDetailsType = {
callMode: CallMode.Group;
creatorUuid: string;
eraId: string;
startedTime: number;
};
export type CallHistoryDetailsType =
| DirectCallHistoryDetailsType
| GroupCallHistoryDetailsType;
// Old messages weren't saved with a `callMode`.
export type CallHistoryDetailsFromDiskType =
| (Omit<DirectCallHistoryDetailsType, 'callMode'> &
Partial<Pick<DirectCallHistoryDetailsType, 'callMode'>>)
| GroupCallHistoryDetailsType;
export type ChangeIODevicePayloadType =
| { type: CallingDeviceType.CAMERA; selectedDevice: string }
| { type: CallingDeviceType.MICROPHONE; selectedDevice: AudioDevice }

View File

@@ -154,6 +154,7 @@ export type StorageAccessType = {
zoomFactor: ZoomFactorType;
preferredLeftPaneWidth: number;
nextScheduledUpdateKeyTime: number;
navTabsCollapsed: boolean;
areWeASubscriber: boolean;
subscriberId: Uint8Array;
subscriberCurrencyCode: string;

View File

@@ -7,6 +7,7 @@ export enum ToastType {
AlreadyRequestedToJoin = 'AlreadyRequestedToJoin',
Blocked = 'Blocked',
BlockedGroup = 'BlockedGroup',
CallHistoryCleared = 'CallHistoryCleared',
CannotEditMessage = 'CannotEditMessage',
CannotForwardEmptyMessage = 'CannotForwardEmptyMessage',
CannotMixMultiAndNonMultiAttachments = 'CannotMixMultiAndNonMultiAttachments',
@@ -55,6 +56,7 @@ export type AnyToast =
| { toastType: ToastType.AlreadyRequestedToJoin }
| { toastType: ToastType.Blocked }
| { toastType: ToastType.BlockedGroup }
| { toastType: ToastType.CallHistoryCleared }
| { toastType: ToastType.CannotEditMessage }
| { toastType: ToastType.CannotForwardEmptyMessage }
| { toastType: ToastType.CannotMixMultiAndNonMultiAttachments }

Some files were not shown because too many files have changed in this diff Show More