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