Merge branch 'main' into pr/5866
This commit is contained in:
commit
fb21285ce3
165 changed files with 4189 additions and 2541 deletions
25
ts/MessageSeenStatus.ts
Normal file
25
ts/MessageSeenStatus.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/**
|
||||
* `SeenStatus` represents either the idea that a message doesn't need to track its seen
|
||||
* status, or the standard unseen/seen status pair.
|
||||
*
|
||||
* Unseen is a lot like unread - except that unseen messages only affect the placement
|
||||
* of the last seen indicator and the count it shows. Unread messages will affect the
|
||||
* left pane badging for conversations, as well as the overall badge count on the app.
|
||||
*/
|
||||
export enum SeenStatus {
|
||||
NotApplicable = 0,
|
||||
Unseen = 1,
|
||||
Seen = 2,
|
||||
}
|
||||
|
||||
const STATUS_NUMBERS: Record<SeenStatus, number> = {
|
||||
[SeenStatus.NotApplicable]: 0,
|
||||
[SeenStatus.Unseen]: 1,
|
||||
[SeenStatus.Seen]: 2,
|
||||
};
|
||||
|
||||
export const maxSeenStatus = (a: SeenStatus, b: SeenStatus): SeenStatus =>
|
||||
STATUS_NUMBERS[a] > STATUS_NUMBERS[b] ? a : b;
|
|
@ -1859,9 +1859,9 @@ export class SignalProtocolStore extends EventsMixin {
|
|||
});
|
||||
}
|
||||
|
||||
getAllUnprocessed(): Promise<Array<UnprocessedType>> {
|
||||
getAllUnprocessedAndIncrementAttempts(): Promise<Array<UnprocessedType>> {
|
||||
return this.withZone(GLOBAL_ZONE, 'getAllUnprocessed', async () => {
|
||||
return window.Signal.Data.getAllUnprocessed();
|
||||
return window.Signal.Data.getAllUnprocessedAndIncrementAttempts();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -143,6 +143,7 @@ import { ReactionSource } from './reactions/ReactionSource';
|
|||
import { singleProtoJobQueue } from './jobs/singleProtoJobQueue';
|
||||
import { getInitialState } from './state/getInitialState';
|
||||
import { conversationJobQueue } from './jobs/conversationJobQueue';
|
||||
import { SeenStatus } from './MessageSeenStatus';
|
||||
|
||||
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
|
||||
|
||||
|
@ -2177,9 +2178,6 @@ export async function startApp(): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
log.info('firstRun: disabling post link experience');
|
||||
window.Signal.Util.postLinkExperience.stop();
|
||||
|
||||
// Switch to inbox view even if contact sync is still running
|
||||
if (
|
||||
window.reduxStore.getState().app.appView === AppViewType.Installer
|
||||
|
@ -2646,13 +2644,6 @@ export async function startApp(): Promise<void> {
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (window.Signal.Util.postLinkExperience.isActive()) {
|
||||
log.info(
|
||||
'onContactReceived: Adding the message history disclaimer on link'
|
||||
);
|
||||
await conversation.addMessageHistoryDisclaimer();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('onContactReceived error:', Errors.toLogFormat(error));
|
||||
}
|
||||
|
@ -2721,12 +2712,6 @@ export async function startApp(): Promise<void> {
|
|||
|
||||
window.Signal.Data.updateConversation(conversation.attributes);
|
||||
|
||||
if (window.Signal.Util.postLinkExperience.isActive()) {
|
||||
log.info(
|
||||
'onGroupReceived: Adding the message history disclaimer on link'
|
||||
);
|
||||
await conversation.addMessageHistoryDisclaimer();
|
||||
}
|
||||
const { expireTimer } = details;
|
||||
const isValidExpireTimer = typeof expireTimer === 'number';
|
||||
if (!isValidExpireTimer) {
|
||||
|
@ -2920,6 +2905,7 @@ export async function startApp(): Promise<void> {
|
|||
}
|
||||
|
||||
if (handleGroupCallUpdateMessage(data.message, messageDescriptor)) {
|
||||
confirm();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
@ -3052,22 +3038,24 @@ export async function startApp(): Promise<void> {
|
|||
}
|
||||
|
||||
return new window.Whisper.Message({
|
||||
source: window.textsecure.storage.user.getNumber(),
|
||||
sourceUuid: window.textsecure.storage.user.getUuid()?.toString(),
|
||||
sourceDevice: data.device,
|
||||
sent_at: timestamp,
|
||||
serverTimestamp: data.serverTimestamp,
|
||||
received_at: data.receivedAtCounter,
|
||||
received_at_ms: data.receivedAtDate,
|
||||
conversationId: descriptor.id,
|
||||
timestamp,
|
||||
type: 'outgoing',
|
||||
sendStateByConversationId,
|
||||
unidentifiedDeliveries,
|
||||
expirationStartTimestamp: Math.min(
|
||||
data.expirationStartTimestamp || timestamp,
|
||||
now
|
||||
),
|
||||
readStatus: ReadStatus.Read,
|
||||
received_at_ms: data.receivedAtDate,
|
||||
received_at: data.receivedAtCounter,
|
||||
seenStatus: SeenStatus.NotApplicable,
|
||||
sendStateByConversationId,
|
||||
sent_at: timestamp,
|
||||
serverTimestamp: data.serverTimestamp,
|
||||
source: window.textsecure.storage.user.getNumber(),
|
||||
sourceDevice: data.device,
|
||||
sourceUuid: window.textsecure.storage.user.getUuid()?.toString(),
|
||||
timestamp,
|
||||
type: 'outgoing',
|
||||
unidentifiedDeliveries,
|
||||
} as Partial<MessageAttributesType> as WhatIsThis);
|
||||
}
|
||||
|
||||
|
@ -3316,6 +3304,7 @@ export async function startApp(): Promise<void> {
|
|||
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
|
||||
type: data.message.isStory ? 'story' : 'incoming',
|
||||
readStatus: ReadStatus.Unread,
|
||||
seenStatus: SeenStatus.Unseen,
|
||||
timestamp: data.timestamp,
|
||||
} as Partial<MessageAttributesType> as WhatIsThis);
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
import { assert } from './util/assert';
|
||||
import { isOlderThan } from './util/timestamp';
|
||||
import { parseRetryAfter } from './util/parseRetryAfter';
|
||||
import { parseRetryAfterWithDefault } from './util/parseRetryAfter';
|
||||
import { clearTimeoutIfNecessary } from './util/clearTimeoutIfNecessary';
|
||||
import { getEnvironment, Environment } from './environment';
|
||||
import type { StorageInterface } from './types/Storage.d';
|
||||
|
@ -70,7 +70,7 @@ export const STORAGE_KEY = 'challenge:conversations';
|
|||
export type RegisteredChallengeType = Readonly<{
|
||||
conversationId: string;
|
||||
createdAt: number;
|
||||
retryAt: number;
|
||||
retryAt?: number;
|
||||
token?: string;
|
||||
}>;
|
||||
|
||||
|
@ -80,7 +80,12 @@ const CAPTCHA_STAGING_URL =
|
|||
'https://signalcaptchas.org/staging/challenge/generate.html';
|
||||
|
||||
function shouldStartQueue(registered: RegisteredChallengeType): boolean {
|
||||
if (!registered.retryAt || registered.retryAt <= Date.now()) {
|
||||
// No retryAt provided; waiting for user to complete captcha
|
||||
if (!registered.retryAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (registered.retryAt <= Date.now()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -214,21 +219,26 @@ export class ChallengeHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
const waitTime = Math.max(0, challenge.retryAt - Date.now());
|
||||
const oldTimer = this.startTimers.get(conversationId);
|
||||
if (oldTimer) {
|
||||
clearTimeoutIfNecessary(oldTimer);
|
||||
if (challenge.retryAt) {
|
||||
const waitTime = Math.max(0, challenge.retryAt - Date.now());
|
||||
const oldTimer = this.startTimers.get(conversationId);
|
||||
if (oldTimer) {
|
||||
clearTimeoutIfNecessary(oldTimer);
|
||||
}
|
||||
this.startTimers.set(
|
||||
conversationId,
|
||||
setTimeout(() => {
|
||||
this.startTimers.delete(conversationId);
|
||||
|
||||
this.startQueue(conversationId);
|
||||
}, waitTime)
|
||||
);
|
||||
log.info(
|
||||
`challenge: tracking ${conversationId} with waitTime=${waitTime}`
|
||||
);
|
||||
} else {
|
||||
log.info(`challenge: tracking ${conversationId} with no waitTime`);
|
||||
}
|
||||
this.startTimers.set(
|
||||
conversationId,
|
||||
setTimeout(() => {
|
||||
this.startTimers.delete(conversationId);
|
||||
|
||||
this.startQueue(conversationId);
|
||||
}, waitTime)
|
||||
);
|
||||
|
||||
log.info(`challenge: tracking ${conversationId} with waitTime=${waitTime}`);
|
||||
|
||||
if (data && !data.options?.includes('recaptcha')) {
|
||||
log.error(
|
||||
|
@ -379,7 +389,9 @@ export class ChallengeHandler {
|
|||
throw error;
|
||||
}
|
||||
|
||||
const retryAfter = parseRetryAfter(error.responseHeaders['retry-after']);
|
||||
const retryAfter = parseRetryAfterWithDefault(
|
||||
error.responseHeaders['retry-after']
|
||||
);
|
||||
|
||||
log.info(`challenge: retry after ${retryAfter}ms`);
|
||||
this.options.onChallengeFailed(retryAfter);
|
||||
|
|
|
@ -34,7 +34,7 @@ export const About = ({
|
|||
<div>
|
||||
<a
|
||||
className="acknowledgments"
|
||||
href="https://github.com/signalapp/Signal-Desktop/blob/development/ACKNOWLEDGMENTS.md"
|
||||
href="https://github.com/signalapp/Signal-Desktop/blob/main/ACKNOWLEDGMENTS.md"
|
||||
>
|
||||
{i18n('softwareAcknowledgments')}
|
||||
</a>
|
||||
|
|
20
ts/components/AnimatedEmojiGalore.stories.tsx
Normal file
20
ts/components/AnimatedEmojiGalore.stories.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import type { PropsType } from './AnimatedEmojiGalore';
|
||||
import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
|
||||
|
||||
const story = storiesOf('Components/AnimatedEmojiGalore', module);
|
||||
|
||||
function getDefaultProps(): PropsType {
|
||||
return {
|
||||
emoji: '❤️',
|
||||
onAnimationEnd: action('onAnimationEnd'),
|
||||
};
|
||||
}
|
||||
|
||||
story.add('Hearts', () => <AnimatedEmojiGalore {...getDefaultProps()} />);
|
72
ts/components/AnimatedEmojiGalore.tsx
Normal file
72
ts/components/AnimatedEmojiGalore.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { animated, to as interpolate, useSprings } from '@react-spring/web';
|
||||
import { random } from 'lodash';
|
||||
import { Emojify } from './conversation/Emojify';
|
||||
|
||||
export type PropsType = {
|
||||
emoji: string;
|
||||
onAnimationEnd: () => unknown;
|
||||
rotate?: number;
|
||||
scale?: number;
|
||||
x?: number;
|
||||
};
|
||||
|
||||
const NUM_EMOJIS = 16;
|
||||
const MAX_HEIGHT = 1280;
|
||||
|
||||
const to = (i: number, f: () => unknown) => ({
|
||||
delay: i * random(80, 120),
|
||||
rotate: random(-24, 24),
|
||||
scale: random(0.5, 1.0, true),
|
||||
y: -144,
|
||||
onRest: i === NUM_EMOJIS - 1 ? f : undefined,
|
||||
});
|
||||
const from = (_i: number) => ({
|
||||
rotate: 0,
|
||||
scale: 1,
|
||||
y: MAX_HEIGHT,
|
||||
});
|
||||
|
||||
function transform(y: number, scale: number, rotate: number): string {
|
||||
return `translateY(${y}px) scale(${scale}) rotate(${rotate}deg)`;
|
||||
}
|
||||
|
||||
export const AnimatedEmojiGalore = ({
|
||||
emoji,
|
||||
onAnimationEnd,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [springs] = useSprings(NUM_EMOJIS, i => ({
|
||||
...to(i, onAnimationEnd),
|
||||
from: from(i),
|
||||
config: {
|
||||
mass: 20,
|
||||
tension: 120,
|
||||
friction: 80,
|
||||
clamp: true,
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
{springs.map((styles, index) => (
|
||||
<animated.div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
style={{
|
||||
left: `${random(0, 100)}%`,
|
||||
position: 'absolute',
|
||||
transform: interpolate(
|
||||
[styles.y, styles.scale, styles.rotate],
|
||||
transform
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Emojify sizeClass="extra-large" text={emoji} />
|
||||
</animated.div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -14,7 +14,7 @@ import classNames from 'classnames';
|
|||
import * as grapheme from '../util/grapheme';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { getClassNamesFor } from '../util/getClassNamesFor';
|
||||
import { refMerger } from '../util/refMerger';
|
||||
import { useRefMerger } from '../hooks/useRefMerger';
|
||||
import { byteLength } from '../Bytes';
|
||||
|
||||
export type PropsType = {
|
||||
|
@ -84,6 +84,7 @@ export const Input = forwardRef<
|
|||
const valueOnKeydownRef = useRef<string>(value);
|
||||
const selectionStartOnKeydownRef = useRef<number>(value.length);
|
||||
const [isLarge, setIsLarge] = useState(false);
|
||||
const refMerger = useRefMerger();
|
||||
|
||||
const maybeSetLarge = useCallback(() => {
|
||||
if (!expandable) {
|
||||
|
|
|
@ -43,9 +43,23 @@ export const Stories = ({
|
|||
});
|
||||
|
||||
const onNextUserStories = useCallback(() => {
|
||||
// First find the next unread story if there are any
|
||||
const nextUnreadIndex = stories.findIndex(conversationStory =>
|
||||
conversationStory.stories.some(story => story.isUnread)
|
||||
);
|
||||
|
||||
if (nextUnreadIndex >= 0) {
|
||||
const nextStory = stories[nextUnreadIndex];
|
||||
setConversationIdToView(nextStory.conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not then play the next available story
|
||||
const storyIndex = stories.findIndex(
|
||||
x => x.conversationId === conversationIdToView
|
||||
);
|
||||
|
||||
// If we've reached the end, close the viewer
|
||||
if (storyIndex >= stories.length - 1 || storyIndex === -1) {
|
||||
setConversationIdToView(undefined);
|
||||
return;
|
||||
|
@ -59,7 +73,8 @@ export const Stories = ({
|
|||
x => x.conversationId === conversationIdToView
|
||||
);
|
||||
if (storyIndex <= 0) {
|
||||
setConversationIdToView(undefined);
|
||||
// Restart playback on the story if it's the oldest
|
||||
setConversationIdToView(conversationIdToView);
|
||||
return;
|
||||
}
|
||||
const prevStory = stories[storyIndex - 1];
|
||||
|
@ -80,12 +95,12 @@ export const Stories = ({
|
|||
<StoriesPane
|
||||
hiddenStories={hiddenStories}
|
||||
i18n={i18n}
|
||||
onBack={toggleStoriesView}
|
||||
onStoryClicked={setConversationIdToView}
|
||||
openConversationInternal={openConversationInternal}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
stories={stories}
|
||||
toggleHideStories={toggleHideStories}
|
||||
toggleStoriesView={toggleStoriesView}
|
||||
/>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
|
|
|
@ -52,23 +52,23 @@ function getNewestStory(story: ConversationStoryType): StoryViewType {
|
|||
export type PropsType = {
|
||||
hiddenStories: Array<ConversationStoryType>;
|
||||
i18n: LocalizerType;
|
||||
onBack: () => unknown;
|
||||
onStoryClicked: (conversationId: string) => unknown;
|
||||
openConversationInternal: (_: { conversationId: string }) => unknown;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
stories: Array<ConversationStoryType>;
|
||||
toggleHideStories: (conversationId: string) => unknown;
|
||||
toggleStoriesView: () => unknown;
|
||||
};
|
||||
|
||||
export const StoriesPane = ({
|
||||
hiddenStories,
|
||||
i18n,
|
||||
onBack,
|
||||
onStoryClicked,
|
||||
openConversationInternal,
|
||||
queueStoryDownload,
|
||||
stories,
|
||||
toggleHideStories,
|
||||
toggleStoriesView,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isShowingHiddenStories, setIsShowingHiddenStories] = useState(false);
|
||||
|
@ -89,7 +89,7 @@ export const StoriesPane = ({
|
|||
<button
|
||||
aria-label={i18n('back')}
|
||||
className="Stories__pane__header--back"
|
||||
onClick={onBack}
|
||||
onClick={toggleStoriesView}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
/>
|
||||
|
@ -119,11 +119,10 @@ export const StoriesPane = ({
|
|||
onClick={() => {
|
||||
onStoryClicked(story.conversationId);
|
||||
}}
|
||||
onHideStory={() => {
|
||||
toggleHideStories(getNewestStory(story).sender.id);
|
||||
}}
|
||||
onHideStory={toggleHideStories}
|
||||
onGoToConversation={conversationId => {
|
||||
openConversationInternal({ conversationId });
|
||||
toggleStoriesView();
|
||||
}}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
story={getNewestStory(story)}
|
||||
|
@ -149,11 +148,10 @@ export const StoriesPane = ({
|
|||
onClick={() => {
|
||||
onStoryClicked(story.conversationId);
|
||||
}}
|
||||
onHideStory={() => {
|
||||
toggleHideStories(getNewestStory(story).sender.id);
|
||||
}}
|
||||
onHideStory={toggleHideStories}
|
||||
onGoToConversation={conversationId => {
|
||||
openConversationInternal({ conversationId });
|
||||
toggleStoriesView();
|
||||
}}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
story={getNewestStory(story)}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
|
||||
|
@ -22,7 +23,9 @@ import { isVideoTypeSupported } from '../util/GoogleChrome';
|
|||
|
||||
export type PropsType = {
|
||||
readonly attachment?: AttachmentType;
|
||||
readonly children?: ReactNode;
|
||||
readonly i18n: LocalizerType;
|
||||
readonly isPaused?: boolean;
|
||||
readonly isThumbnail?: boolean;
|
||||
readonly label: string;
|
||||
readonly moduleClassName?: string;
|
||||
|
@ -32,7 +35,9 @@ export type PropsType = {
|
|||
|
||||
export const StoryImage = ({
|
||||
attachment,
|
||||
children,
|
||||
i18n,
|
||||
isPaused,
|
||||
isThumbnail,
|
||||
label,
|
||||
moduleClassName,
|
||||
|
@ -40,7 +45,11 @@ export const StoryImage = ({
|
|||
storyId,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
const shouldDownloadAttachment =
|
||||
!isDownloaded(attachment) && !isDownloading(attachment);
|
||||
!isDownloaded(attachment) &&
|
||||
!isDownloading(attachment) &&
|
||||
!hasNotResolved(attachment);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldDownloadAttachment) {
|
||||
|
@ -48,6 +57,18 @@ export const StoryImage = ({
|
|||
}
|
||||
}, [queueStoryDownload, shouldDownloadAttachment, storyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPaused) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
}, [isPaused]);
|
||||
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
@ -61,7 +82,11 @@ export const StoryImage = ({
|
|||
let storyElement: JSX.Element;
|
||||
if (attachment.textAttachment) {
|
||||
storyElement = (
|
||||
<TextAttachment i18n={i18n} textAttachment={attachment.textAttachment} />
|
||||
<TextAttachment
|
||||
i18n={i18n}
|
||||
isThumbnail={isThumbnail}
|
||||
textAttachment={attachment.textAttachment}
|
||||
/>
|
||||
);
|
||||
} else if (isNotReadyToShow) {
|
||||
storyElement = (
|
||||
|
@ -79,7 +104,9 @@ export const StoryImage = ({
|
|||
autoPlay
|
||||
className={getClassName('__image')}
|
||||
controls={false}
|
||||
key={attachment.url}
|
||||
loop={shouldLoop}
|
||||
ref={videoRef}
|
||||
>
|
||||
<source src={attachment.url} />
|
||||
</video>
|
||||
|
@ -98,10 +125,10 @@ export const StoryImage = ({
|
|||
);
|
||||
}
|
||||
|
||||
let spinner: JSX.Element | undefined;
|
||||
let overlay: JSX.Element | undefined;
|
||||
if (isPending) {
|
||||
spinner = (
|
||||
<div className="StoryImage__spinner-container">
|
||||
overlay = (
|
||||
<div className="StoryImage__overlay-container">
|
||||
<div className="StoryImage__spinner-bubble" title={i18n('loading')}>
|
||||
<Spinner moduleClassName="StoryImage__spinner" svgSize="small" />
|
||||
</div>
|
||||
|
@ -117,7 +144,8 @@ export const StoryImage = ({
|
|||
)}
|
||||
>
|
||||
{storyElement}
|
||||
{spinner}
|
||||
{overlay}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -23,6 +23,8 @@ function getDefaultProps(): PropsType {
|
|||
return {
|
||||
i18n,
|
||||
onClick: action('onClick'),
|
||||
onGoToConversation: action('onGoToConversation'),
|
||||
onHideStory: action('onHideStory'),
|
||||
queueStoryDownload: action('queueStoryDownload'),
|
||||
story: {
|
||||
messageId: '123',
|
||||
|
|
|
@ -40,7 +40,6 @@ export type StoryViewType = {
|
|||
isHidden?: boolean;
|
||||
isUnread?: boolean;
|
||||
messageId: string;
|
||||
selectedReaction?: string;
|
||||
sender: Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
|
@ -63,8 +62,8 @@ export type PropsType = Pick<
|
|||
> & {
|
||||
i18n: LocalizerType;
|
||||
onClick: () => unknown;
|
||||
onGoToConversation?: (conversationId: string) => unknown;
|
||||
onHideStory?: (conversationId: string) => unknown;
|
||||
onGoToConversation: (conversationId: string) => unknown;
|
||||
onHideStory: (conversationId: string) => unknown;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
story: StoryViewType;
|
||||
};
|
||||
|
@ -218,7 +217,7 @@ export const StoryListItem = ({
|
|||
: i18n('StoryListItem__hide'),
|
||||
onClick: () => {
|
||||
if (isHidden) {
|
||||
onHideStory?.(sender.id);
|
||||
onHideStory(sender.id);
|
||||
} else {
|
||||
setHasConfirmHideStory(true);
|
||||
}
|
||||
|
@ -228,7 +227,7 @@ export const StoryListItem = ({
|
|||
icon: 'StoryListItem__icon--chat',
|
||||
label: i18n('StoryListItem__go-to-chat'),
|
||||
onClick: () => {
|
||||
onGoToConversation?.(sender.id);
|
||||
onGoToConversation(sender.id);
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
@ -243,7 +242,7 @@ export const StoryListItem = ({
|
|||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: () => onHideStory?.(sender.id),
|
||||
action: () => onHideStory(sender.id),
|
||||
style: 'affirmative',
|
||||
text: i18n('StoryListItem__hide-modal--confirm'),
|
||||
},
|
||||
|
|
|
@ -27,6 +27,8 @@ function getDefaultProps(): PropsType {
|
|||
loadStoryReplies: action('loadStoryReplies'),
|
||||
markStoryRead: action('markStoryRead'),
|
||||
onClose: action('onClose'),
|
||||
onGoToConversation: action('onGoToConversation'),
|
||||
onHideStory: action('onHideStory'),
|
||||
onNextUserStories: action('onNextUserStories'),
|
||||
onPrevUserStories: action('onPrevUserStories'),
|
||||
onReactToStory: action('onReactToStory'),
|
||||
|
@ -40,8 +42,10 @@ function getDefaultProps(): PropsType {
|
|||
stories: [
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
path: 'snow.jpg',
|
||||
url: '/fixtures/snow.jpg',
|
||||
}),
|
||||
canReply: true,
|
||||
messageId: '123',
|
||||
sender,
|
||||
timestamp: Date.now(),
|
||||
|
@ -58,8 +62,10 @@ story.add('Wide story', () => (
|
|||
stories={[
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
path: 'file.jpg',
|
||||
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
|
||||
}),
|
||||
canReply: true,
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation(),
|
||||
timestamp: Date.now(),
|
||||
|
@ -87,6 +93,7 @@ story.add('Multi story', () => {
|
|||
stories={[
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
path: 'snow.jpg',
|
||||
url: '/fixtures/snow.jpg',
|
||||
}),
|
||||
messageId: '123',
|
||||
|
@ -95,8 +102,10 @@ story.add('Multi story', () => {
|
|||
},
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
path: 'file.jpg',
|
||||
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
|
||||
}),
|
||||
canReply: true,
|
||||
messageId: '456',
|
||||
sender,
|
||||
timestamp: Date.now() - 3600,
|
||||
|
@ -106,19 +115,41 @@ story.add('Multi story', () => {
|
|||
);
|
||||
});
|
||||
|
||||
story.add('So many stories', () => {
|
||||
const sender = getDefaultConversation();
|
||||
return (
|
||||
<StoryViewer
|
||||
{...getDefaultProps()}
|
||||
stories={Array(20).fill({
|
||||
story.add('Caption', () => (
|
||||
<StoryViewer
|
||||
{...getDefaultProps()}
|
||||
stories={[
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
caption: 'This place looks lovely',
|
||||
path: 'file.jpg',
|
||||
url: '/fixtures/nathan-anderson-316188-unsplash.jpg',
|
||||
}),
|
||||
canReply: true,
|
||||
messageId: '123',
|
||||
sender: getDefaultConversation(),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Long Caption', () => (
|
||||
<StoryViewer
|
||||
{...getDefaultProps()}
|
||||
stories={[
|
||||
{
|
||||
attachment: fakeAttachment({
|
||||
caption:
|
||||
'Snowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like',
|
||||
path: 'file.jpg',
|
||||
url: '/fixtures/snow.jpg',
|
||||
}),
|
||||
canReply: true,
|
||||
messageId: '123',
|
||||
sender,
|
||||
sender: getDefaultConversation(),
|
||||
timestamp: Date.now(),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
},
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
|
|
@ -2,7 +2,14 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useSpring, animated, to } from '@react-spring/web';
|
||||
import type { BodyRangeType, LocalizerType } from '../types/Util';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
|
@ -11,12 +18,16 @@ import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
|||
import type { RenderEmojiPickerProps } from './conversation/ReactionPicker';
|
||||
import type { ReplyStateType } from '../types/Stories';
|
||||
import type { StoryViewType } from './StoryListItem';
|
||||
import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { ContextMenuPopper } from './ContextMenu';
|
||||
import { Intl } from './Intl';
|
||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||
import { StoryImage } from './StoryImage';
|
||||
import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal';
|
||||
import { getAvatarColor } from '../types/Colors';
|
||||
import { getStoryBackground } from '../util/getStoryBackground';
|
||||
import { getStoryDuration } from '../util/getStoryDuration';
|
||||
import { graphemeAwareSlice } from '../util/graphemeAwareSlice';
|
||||
import { isDownloaded, isDownloading } from '../types/Attachment';
|
||||
|
@ -40,6 +51,8 @@ export type PropsType = {
|
|||
loadStoryReplies: (conversationId: string, messageId: string) => unknown;
|
||||
markStoryRead: (mId: string) => unknown;
|
||||
onClose: () => unknown;
|
||||
onGoToConversation: (conversationId: string) => unknown;
|
||||
onHideStory: (conversationId: string) => unknown;
|
||||
onNextUserStories: () => unknown;
|
||||
onPrevUserStories: () => unknown;
|
||||
onSetSkinTone: (tone: number) => unknown;
|
||||
|
@ -59,11 +72,19 @@ export type PropsType = {
|
|||
replyState?: ReplyStateType;
|
||||
skinTone?: number;
|
||||
stories: Array<StoryViewType>;
|
||||
views?: Array<string>;
|
||||
};
|
||||
|
||||
const CAPTION_BUFFER = 20;
|
||||
const CAPTION_INITIAL_LENGTH = 200;
|
||||
const CAPTION_MAX_LENGTH = 700;
|
||||
const MOUSE_IDLE_TIME = 3000;
|
||||
|
||||
enum Arrow {
|
||||
None,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
export const StoryViewer = ({
|
||||
conversationId,
|
||||
|
@ -73,6 +94,8 @@ export const StoryViewer = ({
|
|||
loadStoryReplies,
|
||||
markStoryRead,
|
||||
onClose,
|
||||
onGoToConversation,
|
||||
onHideStory,
|
||||
onNextUserStories,
|
||||
onPrevUserStories,
|
||||
onReactToStory,
|
||||
|
@ -87,18 +110,26 @@ export const StoryViewer = ({
|
|||
replyState,
|
||||
skinTone,
|
||||
stories,
|
||||
views,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [currentStoryIndex, setCurrentStoryIndex] = useState(0);
|
||||
const [storyDuration, setStoryDuration] = useState<number | undefined>();
|
||||
const [isShowingContextMenu, setIsShowingContextMenu] = useState(false);
|
||||
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
|
||||
const [referenceElement, setReferenceElement] =
|
||||
useState<HTMLButtonElement | null>(null);
|
||||
const [reactionEmoji, setReactionEmoji] = useState<string | undefined>();
|
||||
|
||||
const visibleStory = stories[currentStoryIndex];
|
||||
|
||||
const { attachment, canReply, messageId, timestamp } = visibleStory;
|
||||
const { attachment, canReply, isHidden, messageId, timestamp } = visibleStory;
|
||||
const {
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
color,
|
||||
isMe,
|
||||
id,
|
||||
firstName,
|
||||
name,
|
||||
profileName,
|
||||
sharedGroupNames,
|
||||
|
@ -137,6 +168,23 @@ export const StoryViewer = ({
|
|||
setHasExpandedCaption(false);
|
||||
}, [messageId]);
|
||||
|
||||
// These exist to change currentStoryIndex to the oldest unread story a user
|
||||
// has, or set to 0 whenever conversationId changes.
|
||||
// We use a ref so that we only depend on conversationId changing, since
|
||||
// the stories Array will change once we mark as story as viewed.
|
||||
const storiesRef = useRef(stories);
|
||||
|
||||
useEffect(() => {
|
||||
const unreadStoryIndex = storiesRef.current.findIndex(
|
||||
story => story.isUnread
|
||||
);
|
||||
setCurrentStoryIndex(unreadStoryIndex < 0 ? 0 : unreadStoryIndex);
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
storiesRef.current = stories;
|
||||
}, [stories]);
|
||||
|
||||
// Either we show the next story in the current user's stories or we ask
|
||||
// for the next user's stories.
|
||||
const showNextStory = useCallback(() => {
|
||||
|
@ -195,6 +243,11 @@ export const StoryViewer = ({
|
|||
// We need to be careful about this effect refreshing, it should only run
|
||||
// every time a story changes or its duration changes.
|
||||
useEffect(() => {
|
||||
if (!storyDuration) {
|
||||
spring.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
spring.start({
|
||||
config: {
|
||||
duration: storyDuration,
|
||||
|
@ -208,13 +261,20 @@ export const StoryViewer = ({
|
|||
};
|
||||
}, [currentStoryIndex, spring, storyDuration]);
|
||||
|
||||
const shouldPauseViewing =
|
||||
hasConfirmHideStory ||
|
||||
hasExpandedCaption ||
|
||||
hasReplyModal ||
|
||||
isShowingContextMenu ||
|
||||
Boolean(reactionEmoji);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasReplyModal) {
|
||||
if (shouldPauseViewing) {
|
||||
spring.pause();
|
||||
} else {
|
||||
spring.resume();
|
||||
}
|
||||
}, [hasReplyModal, spring]);
|
||||
}, [shouldPauseViewing, spring]);
|
||||
|
||||
useEffect(() => {
|
||||
markStoryRead(messageId);
|
||||
|
@ -230,7 +290,7 @@ export const StoryViewer = ({
|
|||
.map(story => story.messageId);
|
||||
}, [stories]);
|
||||
useEffect(() => {
|
||||
storiesToDownload.forEach(id => queueStoryDownload(id));
|
||||
storiesToDownload.forEach(storyId => queueStoryDownload(storyId));
|
||||
}, [queueStoryDownload, storiesToDownload]);
|
||||
|
||||
const navigateStories = useCallback(
|
||||
|
@ -264,20 +324,248 @@ export const StoryViewer = ({
|
|||
loadStoryReplies(conversationId, messageId);
|
||||
}, [conversationId, isGroupStory, loadStoryReplies, messageId]);
|
||||
|
||||
const [arrowToShow, setArrowToShow] = useState<Arrow>(Arrow.None);
|
||||
|
||||
useEffect(() => {
|
||||
if (arrowToShow === Arrow.None) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastMouseMove: number | undefined;
|
||||
|
||||
function updateLastMouseMove() {
|
||||
lastMouseMove = Date.now();
|
||||
}
|
||||
|
||||
function checkMouseIdle() {
|
||||
requestAnimationFrame(() => {
|
||||
if (lastMouseMove && Date.now() - lastMouseMove > MOUSE_IDLE_TIME) {
|
||||
setArrowToShow(Arrow.None);
|
||||
} else {
|
||||
checkMouseIdle();
|
||||
}
|
||||
});
|
||||
}
|
||||
checkMouseIdle();
|
||||
|
||||
document.addEventListener('mousemove', updateLastMouseMove);
|
||||
|
||||
return () => {
|
||||
lastMouseMove = undefined;
|
||||
document.removeEventListener('mousemove', updateLastMouseMove);
|
||||
};
|
||||
}, [arrowToShow]);
|
||||
|
||||
const replies =
|
||||
replyState && replyState.messageId === messageId ? replyState.replies : [];
|
||||
|
||||
const viewCount = 0;
|
||||
const viewCount = (views || []).length;
|
||||
const replyCount = replies.length;
|
||||
|
||||
return (
|
||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||
<div className="StoryViewer">
|
||||
<div className="StoryViewer__overlay" />
|
||||
<div
|
||||
className="StoryViewer__overlay"
|
||||
style={{ background: getStoryBackground(attachment) }}
|
||||
/>
|
||||
<div className="StoryViewer__content">
|
||||
<button
|
||||
aria-label={i18n('back')}
|
||||
className={classNames(
|
||||
'StoryViewer__arrow StoryViewer__arrow--left',
|
||||
{
|
||||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Left,
|
||||
}
|
||||
)}
|
||||
onClick={showPrevStory}
|
||||
onMouseMove={() => setArrowToShow(Arrow.Left)}
|
||||
type="button"
|
||||
/>
|
||||
<div className="StoryViewer__protection StoryViewer__protection--top" />
|
||||
<div className="StoryViewer__container">
|
||||
<StoryImage
|
||||
attachment={attachment}
|
||||
i18n={i18n}
|
||||
isPaused={shouldPauseViewing}
|
||||
label={i18n('lightboxImageAlt')}
|
||||
moduleClassName="StoryViewer__story"
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
storyId={messageId}
|
||||
>
|
||||
{reactionEmoji && (
|
||||
<div className="StoryViewer__animated-emojis">
|
||||
<AnimatedEmojiGalore
|
||||
emoji={reactionEmoji}
|
||||
onAnimationEnd={() => {
|
||||
setReactionEmoji(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</StoryImage>
|
||||
{hasExpandedCaption && (
|
||||
<button
|
||||
aria-label={i18n('close-popup')}
|
||||
className="StoryViewer__caption__overlay"
|
||||
onClick={() => setHasExpandedCaption(false)}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="StoryViewer__meta">
|
||||
{caption && (
|
||||
<div className="StoryViewer__caption">
|
||||
{caption.text}
|
||||
{caption.hasReadMore && !hasExpandedCaption && (
|
||||
<button
|
||||
className="MessageBody__read-more"
|
||||
onClick={() => {
|
||||
setHasExpandedCaption(true);
|
||||
}}
|
||||
onKeyDown={(ev: React.KeyboardEvent) => {
|
||||
if (ev.key === 'Space' || ev.key === 'Enter') {
|
||||
setHasExpandedCaption(true);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
...
|
||||
{i18n('MessageBody--read-more')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
badge={undefined}
|
||||
color={getAvatarColor(color)}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={Boolean(isMe)}
|
||||
name={name}
|
||||
profileName={profileName}
|
||||
sharedGroupNames={sharedGroupNames}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={title}
|
||||
/>
|
||||
{group && (
|
||||
<Avatar
|
||||
acceptedMessageRequest={group.acceptedMessageRequest}
|
||||
avatarPath={group.avatarPath}
|
||||
badge={undefined}
|
||||
className="StoryViewer__meta--group-avatar"
|
||||
color={getAvatarColor(group.color)}
|
||||
conversationType="group"
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
name={group.name}
|
||||
profileName={group.profileName}
|
||||
sharedGroupNames={group.sharedGroupNames}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={group.title}
|
||||
/>
|
||||
)}
|
||||
<div className="StoryViewer__meta--title">
|
||||
{group
|
||||
? i18n('Stories__from-to-group', {
|
||||
name: title,
|
||||
group: group.title,
|
||||
})
|
||||
: title}
|
||||
</div>
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
module="StoryViewer__meta--timestamp"
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
<div className="StoryViewer__progress">
|
||||
{stories.map((story, index) => (
|
||||
<div
|
||||
className="StoryViewer__progress--container"
|
||||
key={story.messageId}
|
||||
>
|
||||
{currentStoryIndex === index ? (
|
||||
<animated.div
|
||||
className="StoryViewer__progress--bar"
|
||||
style={{
|
||||
width: to([styles.width], width => `${width}%`),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="StoryViewer__progress--bar"
|
||||
style={{
|
||||
width: currentStoryIndex < index ? '0%' : '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="StoryViewer__actions">
|
||||
{canReply && (
|
||||
<button
|
||||
className="StoryViewer__reply"
|
||||
onClick={() => setHasReplyModal(true)}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
<>
|
||||
{viewCount > 0 &&
|
||||
(viewCount === 1 ? (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="MyStories__views--singular"
|
||||
components={[<strong>{viewCount}</strong>]}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="MyStories__views--plural"
|
||||
components={[<strong>{viewCount}</strong>]}
|
||||
/>
|
||||
))}
|
||||
{viewCount > 0 && replyCount > 0 && ' '}
|
||||
{replyCount > 0 &&
|
||||
(replyCount === 1 ? (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="MyStories__replies--singular"
|
||||
components={[<strong>{replyCount}</strong>]}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="MyStories__replies--plural"
|
||||
components={[<strong>{replyCount}</strong>]}
|
||||
/>
|
||||
))}
|
||||
{!viewCount && !replyCount && i18n('StoryViewer__reply')}
|
||||
</>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label={i18n('forward')}
|
||||
className={classNames(
|
||||
'StoryViewer__arrow StoryViewer__arrow--right',
|
||||
{
|
||||
'StoryViewer__arrow--visible': arrowToShow === Arrow.Right,
|
||||
}
|
||||
)}
|
||||
onClick={showNextStory}
|
||||
onMouseMove={() => setArrowToShow(Arrow.Right)}
|
||||
type="button"
|
||||
/>
|
||||
<div className="StoryViewer__protection StoryViewer__protection--bottom" />
|
||||
<button
|
||||
aria-label={i18n('MyStories__more')}
|
||||
className="StoryViewer__more"
|
||||
onClick={() => setIsShowingContextMenu(true)}
|
||||
ref={setReferenceElement}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
/>
|
||||
|
@ -288,170 +576,55 @@ export const StoryViewer = ({
|
|||
tabIndex={0}
|
||||
type="button"
|
||||
/>
|
||||
<div className="StoryViewer__container">
|
||||
<StoryImage
|
||||
attachment={attachment}
|
||||
i18n={i18n}
|
||||
label={i18n('lightboxImageAlt')}
|
||||
moduleClassName="StoryViewer__story"
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
storyId={messageId}
|
||||
/>
|
||||
{hasExpandedCaption && (
|
||||
<div className="StoryViewer__caption__overlay" />
|
||||
)}
|
||||
<div className="StoryViewer__meta">
|
||||
{caption && (
|
||||
<div className="StoryViewer__caption">
|
||||
{caption.text}
|
||||
{caption.hasReadMore && !hasExpandedCaption && (
|
||||
<button
|
||||
className="MessageBody__read-more"
|
||||
onClick={() => {
|
||||
setHasExpandedCaption(true);
|
||||
}}
|
||||
onKeyDown={(ev: React.KeyboardEvent) => {
|
||||
if (ev.key === 'Space' || ev.key === 'Enter') {
|
||||
setHasExpandedCaption(true);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
...
|
||||
{i18n('MessageBody--read-more')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
badge={undefined}
|
||||
color={getAvatarColor(color)}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={Boolean(isMe)}
|
||||
name={name}
|
||||
profileName={profileName}
|
||||
sharedGroupNames={sharedGroupNames}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={title}
|
||||
/>
|
||||
{group && (
|
||||
<Avatar
|
||||
acceptedMessageRequest={group.acceptedMessageRequest}
|
||||
avatarPath={group.avatarPath}
|
||||
badge={undefined}
|
||||
className="StoryViewer__meta--group-avatar"
|
||||
color={getAvatarColor(group.color)}
|
||||
conversationType="group"
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
name={group.name}
|
||||
profileName={group.profileName}
|
||||
sharedGroupNames={group.sharedGroupNames}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={group.title}
|
||||
/>
|
||||
)}
|
||||
<div className="StoryViewer__meta--title">
|
||||
{group
|
||||
? i18n('Stories__from-to-group', {
|
||||
name: title,
|
||||
group: group.title,
|
||||
})
|
||||
: title}
|
||||
</div>
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
module="StoryViewer__meta--timestamp"
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
<div className="StoryViewer__progress">
|
||||
{stories.map((story, index) => (
|
||||
<div
|
||||
className="StoryViewer__progress--container"
|
||||
key={story.messageId}
|
||||
>
|
||||
{currentStoryIndex === index ? (
|
||||
<animated.div
|
||||
className="StoryViewer__progress--bar"
|
||||
style={{
|
||||
width: to([styles.width], width => `${width}%`),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="StoryViewer__progress--bar"
|
||||
style={{
|
||||
width: currentStoryIndex < index ? '0%' : '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="StoryViewer__actions">
|
||||
{isMe ? (
|
||||
<>
|
||||
{viewCount &&
|
||||
(viewCount === 1 ? (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="MyStories__views--singular"
|
||||
components={[<strong>{viewCount}</strong>]}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="MyStories__views--plural"
|
||||
components={[<strong>{viewCount}</strong>]}
|
||||
/>
|
||||
))}
|
||||
{viewCount && replyCount && ' '}
|
||||
{replyCount &&
|
||||
(replyCount === 1 ? (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="MyStories__replies--singular"
|
||||
components={[<strong>{replyCount}</strong>]}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="MyStories__replies--plural"
|
||||
components={[<strong>{replyCount}</strong>]}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
canReply && (
|
||||
<button
|
||||
className="StoryViewer__reply"
|
||||
onClick={() => setHasReplyModal(true)}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
{i18n('StoryViewer__reply')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenuPopper
|
||||
isMenuShowing={isShowingContextMenu}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'StoryListItem__icon--hide',
|
||||
label: isHidden
|
||||
? i18n('StoryListItem__unhide')
|
||||
: i18n('StoryListItem__hide'),
|
||||
onClick: () => {
|
||||
if (isHidden) {
|
||||
onHideStory(id);
|
||||
} else {
|
||||
setHasConfirmHideStory(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'StoryListItem__icon--chat',
|
||||
label: i18n('StoryListItem__go-to-chat'),
|
||||
onClick: () => {
|
||||
onGoToConversation(id);
|
||||
},
|
||||
},
|
||||
]}
|
||||
onClose={() => setIsShowingContextMenu(false)}
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
referenceElement={referenceElement}
|
||||
/>
|
||||
{hasReplyModal && canReply && (
|
||||
<StoryViewsNRepliesModal
|
||||
authorTitle={title}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
isGroupStory={isGroupStory}
|
||||
isMyStory={isMe}
|
||||
onClose={() => setHasReplyModal(false)}
|
||||
onReact={emoji => {
|
||||
onReactToStory(emoji, visibleStory);
|
||||
setHasReplyModal(false);
|
||||
setReactionEmoji(emoji);
|
||||
}}
|
||||
onReply={(message, mentions, replyTimestamp) => {
|
||||
setHasReplyModal(false);
|
||||
if (!isGroupStory) {
|
||||
setHasReplyModal(false);
|
||||
}
|
||||
onReplyToStory(message, mentions, replyTimestamp, visibleStory);
|
||||
}}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
|
@ -466,6 +639,23 @@ export const StoryViewer = ({
|
|||
views={[]}
|
||||
/>
|
||||
)}
|
||||
{hasConfirmHideStory && (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: () => onHideStory(id),
|
||||
style: 'affirmative',
|
||||
text: i18n('StoryListItem__hide-modal--confirm'),
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={() => {
|
||||
setHasConfirmHideStory(false);
|
||||
}}
|
||||
>
|
||||
{i18n('StoryListItem__hide-modal--body', [String(firstName)])}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
</div>
|
||||
</FocusTrap>
|
||||
);
|
||||
|
|
|
@ -112,12 +112,17 @@ story.add('Views only', () => (
|
|||
/>
|
||||
));
|
||||
|
||||
story.add('In a group (no replies)', () => (
|
||||
<StoryViewsNRepliesModal {...getDefaultProps()} isGroupStory />
|
||||
));
|
||||
|
||||
story.add('In a group', () => {
|
||||
const { views, replies } = getViewsAndReplies();
|
||||
|
||||
return (
|
||||
<StoryViewsNRepliesModal
|
||||
{...getDefaultProps()}
|
||||
isGroupStory
|
||||
replies={replies}
|
||||
views={views}
|
||||
/>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { usePopper } from 'react-popper';
|
||||
import type { AttachmentType } from '../types/Attachment';
|
||||
|
@ -52,6 +52,7 @@ export type PropsType = {
|
|||
authorTitle: string;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
i18n: LocalizerType;
|
||||
isGroupStory?: boolean;
|
||||
isMyStory?: boolean;
|
||||
onClose: () => unknown;
|
||||
onReact: (emoji: string) => unknown;
|
||||
|
@ -76,6 +77,7 @@ export const StoryViewsNRepliesModal = ({
|
|||
authorTitle,
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
isGroupStory,
|
||||
isMyStory,
|
||||
onClose,
|
||||
onReact,
|
||||
|
@ -91,7 +93,8 @@ export const StoryViewsNRepliesModal = ({
|
|||
storyPreviewAttachment,
|
||||
views,
|
||||
}: PropsType): JSX.Element => {
|
||||
const inputApiRef = React.useRef<InputApi | undefined>();
|
||||
const inputApiRef = useRef<InputApi | undefined>();
|
||||
const [bottom, setBottom] = useState<HTMLDivElement | null>(null);
|
||||
const [messageBodyText, setMessageBodyText] = useState('');
|
||||
const [showReactionPicker, setShowReactionPicker] = useState(false);
|
||||
|
||||
|
@ -122,13 +125,19 @@ export const StoryViewsNRepliesModal = ({
|
|||
strategy: 'fixed',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (replies.length) {
|
||||
bottom?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [bottom, replies.length]);
|
||||
|
||||
let composerElement: JSX.Element | undefined;
|
||||
|
||||
if (!isMyStory) {
|
||||
composerElement = (
|
||||
<div className="StoryViewsNRepliesModal__compose-container">
|
||||
<div className="StoryViewsNRepliesModal__composer">
|
||||
{!replies.length && (
|
||||
{!isGroupStory && (
|
||||
<Quote
|
||||
authorTitle={authorTitle}
|
||||
conversationColor="ultramarine"
|
||||
|
@ -154,7 +163,10 @@ export const StoryViewsNRepliesModal = ({
|
|||
setMessageBodyText(messageText);
|
||||
}}
|
||||
onPickEmoji={insertEmoji}
|
||||
onSubmit={onReply}
|
||||
onSubmit={(...args) => {
|
||||
inputApiRef.current?.reset();
|
||||
onReply(...args);
|
||||
}}
|
||||
onTextTooLong={onTextTooLong}
|
||||
placeholder={i18n('StoryViewsNRepliesModal__placeholder')}
|
||||
theme={ThemeType.dark}
|
||||
|
@ -204,12 +216,48 @@ export const StoryViewsNRepliesModal = ({
|
|||
);
|
||||
}
|
||||
|
||||
const repliesElement = replies.length ? (
|
||||
<div className="StoryViewsNRepliesModal__replies">
|
||||
{replies.map(reply =>
|
||||
reply.reactionEmoji ? (
|
||||
<div className="StoryViewsNRepliesModal__reaction" key={reply.id}>
|
||||
<div className="StoryViewsNRepliesModal__reaction--container">
|
||||
let repliesElement: JSX.Element | undefined;
|
||||
|
||||
if (replies.length) {
|
||||
repliesElement = (
|
||||
<div className="StoryViewsNRepliesModal__replies">
|
||||
{replies.map(reply =>
|
||||
reply.reactionEmoji ? (
|
||||
<div className="StoryViewsNRepliesModal__reaction" key={reply.id}>
|
||||
<div className="StoryViewsNRepliesModal__reaction--container">
|
||||
<Avatar
|
||||
acceptedMessageRequest={reply.acceptedMessageRequest}
|
||||
avatarPath={reply.avatarPath}
|
||||
badge={undefined}
|
||||
color={getAvatarColor(reply.color)}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={Boolean(reply.isMe)}
|
||||
name={reply.name}
|
||||
profileName={reply.profileName}
|
||||
sharedGroupNames={reply.sharedGroupNames || []}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={reply.title}
|
||||
/>
|
||||
<div className="StoryViewsNRepliesModal__reaction--body">
|
||||
<div className="StoryViewsNRepliesModal__reply--title">
|
||||
<ContactName
|
||||
contactNameColor={reply.contactNameColor}
|
||||
title={reply.title}
|
||||
/>
|
||||
</div>
|
||||
{i18n('StoryViewsNRepliesModal__reacted')}
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
module="StoryViewsNRepliesModal__reply--timestamp"
|
||||
timestamp={reply.timestamp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Emojify text={reply.reactionEmoji} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="StoryViewsNRepliesModal__reply" key={reply.id}>
|
||||
<Avatar
|
||||
acceptedMessageRequest={reply.acceptedMessageRequest}
|
||||
avatarPath={reply.avatarPath}
|
||||
|
@ -224,14 +272,32 @@ export const StoryViewsNRepliesModal = ({
|
|||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={reply.title}
|
||||
/>
|
||||
<div className="StoryViewsNRepliesModal__reaction--body">
|
||||
<div
|
||||
className={classNames(
|
||||
'StoryViewsNRepliesModal__message-bubble',
|
||||
{
|
||||
'StoryViewsNRepliesModal__message-bubble--doe': Boolean(
|
||||
reply.deletedForEveryone
|
||||
),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="StoryViewsNRepliesModal__reply--title">
|
||||
<ContactName
|
||||
contactNameColor={reply.contactNameColor}
|
||||
title={reply.title}
|
||||
/>
|
||||
</div>
|
||||
{i18n('StoryViewsNRepliesModal__reacted')}
|
||||
|
||||
<MessageBody
|
||||
i18n={i18n}
|
||||
text={
|
||||
reply.deletedForEveryone
|
||||
? i18n('message--deletedForEveryone')
|
||||
: String(reply.body)
|
||||
}
|
||||
/>
|
||||
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
module="StoryViewsNRepliesModal__reply--timestamp"
|
||||
|
@ -239,58 +305,18 @@ export const StoryViewsNRepliesModal = ({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Emojify text={reply.reactionEmoji} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="StoryViewsNRepliesModal__reply" key={reply.id}>
|
||||
<Avatar
|
||||
acceptedMessageRequest={reply.acceptedMessageRequest}
|
||||
avatarPath={reply.avatarPath}
|
||||
badge={undefined}
|
||||
color={getAvatarColor(reply.color)}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={Boolean(reply.isMe)}
|
||||
name={reply.name}
|
||||
profileName={reply.profileName}
|
||||
sharedGroupNames={reply.sharedGroupNames || []}
|
||||
size={AvatarSize.TWENTY_EIGHT}
|
||||
title={reply.title}
|
||||
/>
|
||||
<div
|
||||
className={classNames('StoryViewsNRepliesModal__message-bubble', {
|
||||
'StoryViewsNRepliesModal__message-bubble--doe': Boolean(
|
||||
reply.deletedForEveryone
|
||||
),
|
||||
})}
|
||||
>
|
||||
<div className="StoryViewsNRepliesModal__reply--title">
|
||||
<ContactName
|
||||
contactNameColor={reply.contactNameColor}
|
||||
title={reply.title}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MessageBody
|
||||
i18n={i18n}
|
||||
text={
|
||||
reply.deletedForEveryone
|
||||
? i18n('message--deletedForEveryone')
|
||||
: String(reply.body)
|
||||
}
|
||||
/>
|
||||
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
module="StoryViewsNRepliesModal__reply--timestamp"
|
||||
timestamp={reply.timestamp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : undefined;
|
||||
)
|
||||
)}
|
||||
<div ref={setBottom} />
|
||||
</div>
|
||||
);
|
||||
} else if (isGroupStory) {
|
||||
repliesElement = (
|
||||
<div className="StoryViewsNRepliesModal__replies--none">
|
||||
{i18n('StoryViewsNRepliesModal__no-replies')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const viewsElement = views.length ? (
|
||||
<div className="StoryViewsNRepliesModal__views">
|
||||
|
@ -358,28 +384,26 @@ export const StoryViewsNRepliesModal = ({
|
|||
</Tabs>
|
||||
) : undefined;
|
||||
|
||||
const hasOnlyViewsElement =
|
||||
viewsElement && !repliesElement && !composerElement;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
i18n={i18n}
|
||||
moduleClassName={classNames('StoryViewsNRepliesModal', {
|
||||
'StoryViewsNRepliesModal--group': Boolean(
|
||||
views.length && replies.length
|
||||
),
|
||||
})}
|
||||
moduleClassName="StoryViewsNRepliesModal"
|
||||
onClose={onClose}
|
||||
useFocusTrap={!hasOnlyViewsElement}
|
||||
useFocusTrap={Boolean(composerElement)}
|
||||
theme={Theme.Dark}
|
||||
>
|
||||
{tabsElement || (
|
||||
<>
|
||||
{viewsElement}
|
||||
{repliesElement}
|
||||
{composerElement}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={classNames({
|
||||
'StoryViewsNRepliesModal--group': Boolean(isGroupStory),
|
||||
})}
|
||||
>
|
||||
{tabsElement || (
|
||||
<>
|
||||
{viewsElement || repliesElement}
|
||||
{composerElement}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -164,7 +164,20 @@ story.add('Link preview', () => (
|
|||
preview: {
|
||||
url: 'https://www.signal.org/workworkwork',
|
||||
title: 'Signal >> Careers',
|
||||
// TODO add image
|
||||
},
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Link preview (thumbnail)', () => (
|
||||
<TextAttachment
|
||||
{...getDefaultProps()}
|
||||
isThumbnail
|
||||
textAttachment={{
|
||||
color: 4294951251,
|
||||
preview: {
|
||||
url: 'https://www.signal.org/workworkwork',
|
||||
title: 'Signal >> Careers',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -13,6 +13,10 @@ import { TextAttachmentStyleType } from '../types/Attachment';
|
|||
import { count } from '../util/grapheme';
|
||||
import { getDomain } from '../types/LinkPreview';
|
||||
import { getFontNameByTextScript } from '../util/getFontNameByTextScript';
|
||||
import {
|
||||
getHexFromNumber,
|
||||
getBackgroundColor,
|
||||
} from '../util/getStoryBackground';
|
||||
|
||||
const renderNewLines: RenderTextCallbackType = ({
|
||||
text: textWithNewLines,
|
||||
|
@ -36,6 +40,7 @@ enum TextSize {
|
|||
|
||||
export type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
isThumbnail?: boolean;
|
||||
textAttachment: TextAttachmentType;
|
||||
};
|
||||
|
||||
|
@ -53,20 +58,6 @@ function getTextSize(text: string): TextSize {
|
|||
return TextSize.Small;
|
||||
}
|
||||
|
||||
function getHexFromNumber(color: number): string {
|
||||
return `#${color.toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function getBackground({ color, gradient }: TextAttachmentType): string {
|
||||
if (gradient) {
|
||||
return `linear-gradient(${gradient.angle}deg, ${getHexFromNumber(
|
||||
gradient.startColor || COLOR_WHITE_INT
|
||||
)}, ${getHexFromNumber(gradient.endColor || COLOR_WHITE_INT)})`;
|
||||
}
|
||||
|
||||
return getHexFromNumber(color || COLOR_WHITE_INT);
|
||||
}
|
||||
|
||||
function getFont(
|
||||
text: string,
|
||||
textSize: TextSize,
|
||||
|
@ -95,6 +86,7 @@ function getFont(
|
|||
|
||||
export const TextAttachment = ({
|
||||
i18n,
|
||||
isThumbnail,
|
||||
textAttachment,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
const linkPreview = useRef<HTMLDivElement | null>(null);
|
||||
|
@ -123,7 +115,7 @@ export const TextAttachment = ({
|
|||
<div
|
||||
className="TextAttachment__story"
|
||||
style={{
|
||||
background: getBackground(textAttachment),
|
||||
background: getBackgroundColor(textAttachment),
|
||||
transform: `scale(${(contentRect.bounds?.height || 1) / 1280})`,
|
||||
}}
|
||||
>
|
||||
|
@ -159,25 +151,27 @@ export const TextAttachment = ({
|
|||
)}
|
||||
{textAttachment.preview && (
|
||||
<>
|
||||
{linkPreviewOffsetTop && textAttachment.preview.url && (
|
||||
<a
|
||||
className="TextAttachment__preview__tooltip"
|
||||
href={textAttachment.preview.url}
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
top: linkPreviewOffsetTop - 150,
|
||||
}}
|
||||
target="_blank"
|
||||
>
|
||||
<div>
|
||||
<div>{i18n('TextAttachment__preview__link')}</div>
|
||||
<div className="TextAttachment__preview__tooltip__url">
|
||||
{textAttachment.preview.url}
|
||||
{linkPreviewOffsetTop &&
|
||||
!isThumbnail &&
|
||||
textAttachment.preview.url && (
|
||||
<a
|
||||
className="TextAttachment__preview__tooltip"
|
||||
href={textAttachment.preview.url}
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
top: linkPreviewOffsetTop - 150,
|
||||
}}
|
||||
target="_blank"
|
||||
>
|
||||
<div>
|
||||
<div>{i18n('TextAttachment__preview__link')}</div>
|
||||
<div className="TextAttachment__preview__tooltip__url">
|
||||
{textAttachment.preview.url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="TextAttachment__preview__tooltip__arrow" />
|
||||
</a>
|
||||
)}
|
||||
<div className="TextAttachment__preview__tooltip__arrow" />
|
||||
</a>
|
||||
)}
|
||||
<div
|
||||
className={classNames('TextAttachment__preview', {
|
||||
'TextAttachment__preview--large': Boolean(
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import { Image } from './Image';
|
||||
import { CurveType, Image } from './Image';
|
||||
import { StagedGenericAttachment } from './StagedGenericAttachment';
|
||||
import { StagedPlaceholderAttachment } from './StagedPlaceholderAttachment';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
|
@ -109,7 +109,10 @@ export const AttachmentList = <T extends AttachmentType | AttachmentDraftType>({
|
|||
i18n={i18n}
|
||||
attachment={attachment}
|
||||
isDownloaded={isDownloaded}
|
||||
softCorners
|
||||
curveBottomLeft={CurveType.Tiny}
|
||||
curveBottomRight={CurveType.Tiny}
|
||||
curveTopLeft={CurveType.Tiny}
|
||||
curveTopRight={CurveType.Tiny}
|
||||
playIconOverlay={isVideo}
|
||||
height={IMAGE_HEIGHT}
|
||||
width={IMAGE_WIDTH}
|
||||
|
|
|
@ -204,6 +204,9 @@ export const ConversationHero = ({
|
|||
phoneNumber,
|
||||
sharedGroupNames,
|
||||
})}
|
||||
<div className="module-conversation-hero__linkNotification">
|
||||
{i18n('messageHistoryUnsynced')}
|
||||
</div>
|
||||
</div>
|
||||
{isShowingMessageRequestWarning && (
|
||||
<ConfirmationDialog
|
||||
|
|
|
@ -9,7 +9,7 @@ import { storiesOf } from '@storybook/react';
|
|||
|
||||
import { pngUrl } from '../../storybook/Fixtures';
|
||||
import type { Props } from './Image';
|
||||
import { Image } from './Image';
|
||||
import { CurveType, Image } from './Image';
|
||||
import { IMAGE_PNG } from '../../types/MIME';
|
||||
import type { ThemeType } from '../../types/Util';
|
||||
import { setupI18n } from '../../util/setupI18n';
|
||||
|
@ -34,16 +34,22 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
blurHash: text('blurHash', overrideProps.blurHash || ''),
|
||||
bottomOverlay: boolean('bottomOverlay', overrideProps.bottomOverlay || false),
|
||||
closeButton: boolean('closeButton', overrideProps.closeButton || false),
|
||||
curveBottomLeft: boolean(
|
||||
curveBottomLeft: number(
|
||||
'curveBottomLeft',
|
||||
overrideProps.curveBottomLeft || false
|
||||
overrideProps.curveBottomLeft || CurveType.None
|
||||
),
|
||||
curveBottomRight: boolean(
|
||||
curveBottomRight: number(
|
||||
'curveBottomRight',
|
||||
overrideProps.curveBottomRight || false
|
||||
overrideProps.curveBottomRight || CurveType.None
|
||||
),
|
||||
curveTopLeft: number(
|
||||
'curveTopLeft',
|
||||
overrideProps.curveTopLeft || CurveType.None
|
||||
),
|
||||
curveTopRight: number(
|
||||
'curveTopRight',
|
||||
overrideProps.curveTopRight || CurveType.None
|
||||
),
|
||||
curveTopLeft: boolean('curveTopLeft', overrideProps.curveTopLeft || false),
|
||||
curveTopRight: boolean('curveTopRight', overrideProps.curveTopRight || false),
|
||||
darkOverlay: boolean('darkOverlay', overrideProps.darkOverlay || false),
|
||||
height: number('height', overrideProps.height || 100),
|
||||
i18n,
|
||||
|
@ -57,11 +63,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
'playIconOverlay',
|
||||
overrideProps.playIconOverlay || false
|
||||
),
|
||||
smallCurveTopLeft: boolean(
|
||||
'smallCurveTopLeft',
|
||||
overrideProps.smallCurveTopLeft || false
|
||||
),
|
||||
softCorners: boolean('softCorners', overrideProps.softCorners || false),
|
||||
tabIndex: number('tabIndex', overrideProps.tabIndex || 0),
|
||||
theme: text('theme', overrideProps.theme || 'light') as ThemeType,
|
||||
url: text('url', 'url' in overrideProps ? overrideProps.url || null : pngUrl),
|
||||
|
@ -145,10 +146,10 @@ story.add('Pending w/blurhash', () => {
|
|||
|
||||
story.add('Curved Corners', () => {
|
||||
const props = createProps({
|
||||
curveBottomLeft: true,
|
||||
curveBottomRight: true,
|
||||
curveTopLeft: true,
|
||||
curveTopRight: true,
|
||||
curveBottomLeft: CurveType.Normal,
|
||||
curveBottomRight: CurveType.Normal,
|
||||
curveTopLeft: CurveType.Normal,
|
||||
curveTopRight: CurveType.Normal,
|
||||
});
|
||||
|
||||
return <Image {...props} />;
|
||||
|
@ -156,7 +157,7 @@ story.add('Curved Corners', () => {
|
|||
|
||||
story.add('Small Curve Top Left', () => {
|
||||
const props = createProps({
|
||||
smallCurveTopLeft: true,
|
||||
curveTopLeft: CurveType.Small,
|
||||
});
|
||||
|
||||
return <Image {...props} />;
|
||||
|
@ -164,7 +165,10 @@ story.add('Small Curve Top Left', () => {
|
|||
|
||||
story.add('Soft Corners', () => {
|
||||
const props = createProps({
|
||||
softCorners: true,
|
||||
curveBottomLeft: CurveType.Tiny,
|
||||
curveBottomRight: CurveType.Tiny,
|
||||
curveTopLeft: CurveType.Tiny,
|
||||
curveTopRight: CurveType.Tiny,
|
||||
});
|
||||
|
||||
return <Image {...props} />;
|
||||
|
|
|
@ -13,6 +13,13 @@ import {
|
|||
defaultBlurHash,
|
||||
} from '../../types/Attachment';
|
||||
|
||||
export enum CurveType {
|
||||
None = 0,
|
||||
Tiny = 4,
|
||||
Small = 10,
|
||||
Normal = 18,
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
alt: string;
|
||||
attachment: AttachmentType;
|
||||
|
@ -32,16 +39,13 @@ export type Props = {
|
|||
noBackground?: boolean;
|
||||
bottomOverlay?: boolean;
|
||||
closeButton?: boolean;
|
||||
curveBottomLeft?: boolean;
|
||||
curveBottomRight?: boolean;
|
||||
curveTopLeft?: boolean;
|
||||
curveTopRight?: boolean;
|
||||
|
||||
smallCurveTopLeft?: boolean;
|
||||
curveBottomLeft?: CurveType;
|
||||
curveBottomRight?: CurveType;
|
||||
curveTopLeft?: CurveType;
|
||||
curveTopRight?: CurveType;
|
||||
|
||||
darkOverlay?: boolean;
|
||||
playIconOverlay?: boolean;
|
||||
softCorners?: boolean;
|
||||
blurHash?: string;
|
||||
|
||||
i18n: LocalizerType;
|
||||
|
@ -158,8 +162,6 @@ export class Image extends React.Component<Props> {
|
|||
onError,
|
||||
overlayText,
|
||||
playIconOverlay,
|
||||
smallCurveTopLeft,
|
||||
softCorners,
|
||||
tabIndex,
|
||||
theme,
|
||||
url,
|
||||
|
@ -176,25 +178,25 @@ export class Image extends React.Component<Props> {
|
|||
|
||||
const resolvedBlurHash = blurHash || defaultBlurHash(theme);
|
||||
|
||||
const overlayClassName = classNames('module-image__border-overlay', {
|
||||
'module-image__border-overlay--with-border': !noBorder,
|
||||
'module-image__border-overlay--with-click-handler': canClick,
|
||||
'module-image--curved-top-left': curveTopLeft,
|
||||
'module-image--curved-top-right': curveTopRight,
|
||||
'module-image--curved-bottom-left': curveBottomLeft,
|
||||
'module-image--curved-bottom-right': curveBottomRight,
|
||||
'module-image--small-curved-top-left': smallCurveTopLeft,
|
||||
'module-image--soft-corners': softCorners,
|
||||
'module-image__border-overlay--dark': darkOverlay,
|
||||
'module-image--not-downloaded': imgNotDownloaded,
|
||||
});
|
||||
const curveStyles = {
|
||||
borderTopLeftRadius: curveTopLeft || CurveType.None,
|
||||
borderTopRightRadius: curveTopRight || CurveType.None,
|
||||
borderBottomLeftRadius: curveBottomLeft || CurveType.None,
|
||||
borderBottomRightRadius: curveBottomRight || CurveType.None,
|
||||
};
|
||||
|
||||
const overlay = canClick ? (
|
||||
// Not sure what this button does.
|
||||
// eslint-disable-next-line jsx-a11y/control-has-associated-label
|
||||
<button
|
||||
type="button"
|
||||
className={overlayClassName}
|
||||
className={classNames('module-image__border-overlay', {
|
||||
'module-image__border-overlay--with-border': !noBorder,
|
||||
'module-image__border-overlay--with-click-handler': canClick,
|
||||
'module-image__border-overlay--dark': darkOverlay,
|
||||
'module-image--not-downloaded': imgNotDownloaded,
|
||||
})}
|
||||
style={curveStyles}
|
||||
onClick={this.handleClick}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
tabIndex={tabIndex}
|
||||
|
@ -210,15 +212,13 @@ export class Image extends React.Component<Props> {
|
|||
'module-image',
|
||||
className,
|
||||
!noBackground ? 'module-image--with-background' : null,
|
||||
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
|
||||
curveBottomRight ? 'module-image--curved-bottom-right' : null,
|
||||
curveTopLeft ? 'module-image--curved-top-left' : null,
|
||||
curveTopRight ? 'module-image--curved-top-right' : null,
|
||||
smallCurveTopLeft ? 'module-image--small-curved-top-left' : null,
|
||||
softCorners ? 'module-image--soft-corners' : null,
|
||||
cropWidth || cropHeight ? 'module-image--cropped' : null
|
||||
)}
|
||||
style={{ width: width - cropWidth, height: height - cropHeight }}
|
||||
style={{
|
||||
width: width - cropWidth,
|
||||
height: height - cropHeight,
|
||||
...curveStyles,
|
||||
}}
|
||||
>
|
||||
{pending ? (
|
||||
this.renderPending()
|
||||
|
@ -248,11 +248,11 @@ export class Image extends React.Component<Props> {
|
|||
) : null}
|
||||
{bottomOverlay ? (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-image__bottom-overlay',
|
||||
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
|
||||
curveBottomRight ? 'module-image--curved-bottom-right' : null
|
||||
)}
|
||||
className="module-image__bottom-overlay"
|
||||
style={{
|
||||
borderBottomLeftRadius: curveBottomLeft || CurveType.None,
|
||||
borderBottomRightRadius: curveBottomRight || CurveType.None,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!pending && !imgNotDownloaded && playIconOverlay ? (
|
||||
|
|
|
@ -37,6 +37,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
}),
|
||||
],
|
||||
bottomOverlay: boolean('bottomOverlay', overrideProps.bottomOverlay || false),
|
||||
direction: overrideProps.direction || 'incoming',
|
||||
i18n,
|
||||
isSticker: boolean('isSticker', overrideProps.isSticker || false),
|
||||
onClick: action('onClick'),
|
||||
|
|
|
@ -14,18 +14,23 @@ import {
|
|||
isVideoAttachment,
|
||||
} from '../../types/Attachment';
|
||||
|
||||
import { Image } from './Image';
|
||||
import { Image, CurveType } from './Image';
|
||||
|
||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||
|
||||
export type DirectionType = 'incoming' | 'outgoing';
|
||||
|
||||
export type Props = {
|
||||
attachments: Array<AttachmentType>;
|
||||
withContentAbove?: boolean;
|
||||
withContentBelow?: boolean;
|
||||
bottomOverlay?: boolean;
|
||||
direction: DirectionType;
|
||||
isSticker?: boolean;
|
||||
shouldCollapseAbove?: boolean;
|
||||
shouldCollapseBelow?: boolean;
|
||||
stickerSize?: number;
|
||||
tabIndex?: number;
|
||||
withContentAbove?: boolean;
|
||||
withContentBelow?: boolean;
|
||||
|
||||
i18n: LocalizerType;
|
||||
theme?: ThemeType;
|
||||
|
@ -36,27 +41,85 @@ export type Props = {
|
|||
|
||||
const GAP = 1;
|
||||
|
||||
function getCurves({
|
||||
direction,
|
||||
shouldCollapseAbove,
|
||||
shouldCollapseBelow,
|
||||
withContentAbove,
|
||||
withContentBelow,
|
||||
}: {
|
||||
direction: DirectionType;
|
||||
shouldCollapseAbove?: boolean;
|
||||
shouldCollapseBelow?: boolean;
|
||||
withContentAbove?: boolean;
|
||||
withContentBelow?: boolean;
|
||||
}): {
|
||||
curveTopLeft: CurveType;
|
||||
curveTopRight: CurveType;
|
||||
curveBottomLeft: CurveType;
|
||||
curveBottomRight: CurveType;
|
||||
} {
|
||||
let curveTopLeft = CurveType.None;
|
||||
let curveTopRight = CurveType.None;
|
||||
let curveBottomLeft = CurveType.None;
|
||||
let curveBottomRight = CurveType.None;
|
||||
|
||||
if (shouldCollapseAbove && direction === 'incoming') {
|
||||
curveTopLeft = CurveType.Tiny;
|
||||
curveTopRight = CurveType.Normal;
|
||||
} else if (shouldCollapseAbove && direction === 'outgoing') {
|
||||
curveTopLeft = CurveType.Normal;
|
||||
curveTopRight = CurveType.Tiny;
|
||||
} else if (!withContentAbove) {
|
||||
curveTopLeft = CurveType.Normal;
|
||||
curveTopRight = CurveType.Normal;
|
||||
}
|
||||
|
||||
if (shouldCollapseBelow && direction === 'incoming') {
|
||||
curveBottomLeft = CurveType.Tiny;
|
||||
curveBottomRight = CurveType.None;
|
||||
} else if (shouldCollapseBelow && direction === 'outgoing') {
|
||||
curveBottomLeft = CurveType.None;
|
||||
curveBottomRight = CurveType.Tiny;
|
||||
} else if (!withContentBelow) {
|
||||
curveBottomLeft = CurveType.Normal;
|
||||
curveBottomRight = CurveType.Normal;
|
||||
}
|
||||
|
||||
return {
|
||||
curveTopLeft,
|
||||
curveTopRight,
|
||||
curveBottomLeft,
|
||||
curveBottomRight,
|
||||
};
|
||||
}
|
||||
|
||||
export const ImageGrid = ({
|
||||
attachments,
|
||||
bottomOverlay,
|
||||
direction,
|
||||
i18n,
|
||||
isSticker,
|
||||
stickerSize,
|
||||
onError,
|
||||
onClick,
|
||||
shouldCollapseAbove,
|
||||
shouldCollapseBelow,
|
||||
tabIndex,
|
||||
theme,
|
||||
withContentAbove,
|
||||
withContentBelow,
|
||||
}: Props): JSX.Element | null => {
|
||||
const curveTopLeft = !withContentAbove;
|
||||
const curveTopRight = curveTopLeft;
|
||||
const { curveTopLeft, curveTopRight, curveBottomLeft, curveBottomRight } =
|
||||
getCurves({
|
||||
direction,
|
||||
shouldCollapseAbove,
|
||||
shouldCollapseBelow,
|
||||
withContentAbove,
|
||||
withContentBelow,
|
||||
});
|
||||
|
||||
const curveBottom = !withContentBelow;
|
||||
const curveBottomLeft = curveBottom;
|
||||
const curveBottomRight = curveBottom;
|
||||
|
||||
const withBottomOverlay = Boolean(bottomOverlay && curveBottom);
|
||||
const withBottomOverlay = Boolean(bottomOverlay && !withContentBelow);
|
||||
|
||||
if (!attachments || !attachments.length) {
|
||||
return null;
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { setupI18n } from '../../util/setupI18n';
|
||||
import enMessages from '../../../_locales/en/messages.json';
|
||||
|
||||
import { LinkNotification } from './LinkNotification';
|
||||
|
||||
const story = storiesOf('Components/Conversation/LinkNotification', module);
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
story.add('Default', () => <LinkNotification i18n={i18n} />);
|
|
@ -1,13 +0,0 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { SystemMessage } from './SystemMessage';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
|
||||
export const LinkNotification = ({
|
||||
i18n,
|
||||
}: Readonly<{ i18n: LocalizerType }>): JSX.Element => (
|
||||
<SystemMessage icon="unsynced" contents={i18n('messageHistoryUnsynced')} />
|
||||
);
|
|
@ -222,15 +222,30 @@ const renderMany = (propsArray: ReadonlyArray<Props>) =>
|
|||
/>
|
||||
));
|
||||
|
||||
const renderBothDirections = (props: Props) =>
|
||||
renderMany([
|
||||
props,
|
||||
{
|
||||
const renderThree = (props: Props) => renderMany([props, props, props]);
|
||||
|
||||
const renderBothDirections = (props: Props) => (
|
||||
<>
|
||||
{renderThree(props)}
|
||||
{renderThree({
|
||||
...props,
|
||||
author: { ...props.author, id: getDefaultConversation().id },
|
||||
direction: 'outgoing',
|
||||
},
|
||||
]);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
const renderSingleBothDirections = (props: Props) => (
|
||||
<>
|
||||
<Message {...props} />
|
||||
<Message
|
||||
{...{
|
||||
...props,
|
||||
author: { ...props.author, id: getDefaultConversation().id },
|
||||
direction: 'outgoing',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
story.add('Plain Message', () => {
|
||||
const props = createProps({
|
||||
|
@ -353,7 +368,7 @@ story.add('Delivered', () => {
|
|||
text: 'Hello there from a pal! I am sending a long message so that it will wrap a bit, since I like that look.',
|
||||
});
|
||||
|
||||
return <Message {...props} />;
|
||||
return renderThree(props);
|
||||
});
|
||||
|
||||
story.add('Read', () => {
|
||||
|
@ -363,7 +378,7 @@ story.add('Read', () => {
|
|||
text: 'Hello there from a pal! I am sending a long message so that it will wrap a bit, since I like that look.',
|
||||
});
|
||||
|
||||
return <Message {...props} />;
|
||||
return renderThree(props);
|
||||
});
|
||||
|
||||
story.add('Sending', () => {
|
||||
|
@ -373,7 +388,7 @@ story.add('Sending', () => {
|
|||
text: 'Hello there from a pal! I am sending a long message so that it will wrap a bit, since I like that look.',
|
||||
});
|
||||
|
||||
return <Message {...props} />;
|
||||
return renderThree(props);
|
||||
});
|
||||
|
||||
story.add('Expiring', () => {
|
||||
|
@ -502,7 +517,7 @@ story.add('Reactions (wider message)', () => {
|
|||
],
|
||||
});
|
||||
|
||||
return renderBothDirections(props);
|
||||
return renderSingleBothDirections(props);
|
||||
});
|
||||
|
||||
const joyReactions = Array.from({ length: 52 }, () => getJoyReaction());
|
||||
|
@ -577,7 +592,7 @@ story.add('Reactions (short message)', () => {
|
|||
],
|
||||
});
|
||||
|
||||
return renderBothDirections(props);
|
||||
return renderSingleBothDirections(props);
|
||||
});
|
||||
|
||||
story.add('Avatar in Group', () => {
|
||||
|
@ -588,7 +603,7 @@ story.add('Avatar in Group', () => {
|
|||
text: 'Hello it is me, the saxophone.',
|
||||
});
|
||||
|
||||
return <Message {...props} />;
|
||||
return renderThree(props);
|
||||
});
|
||||
|
||||
story.add('Badge in Group', () => {
|
||||
|
@ -599,7 +614,7 @@ story.add('Badge in Group', () => {
|
|||
text: 'Hello it is me, the saxophone.',
|
||||
});
|
||||
|
||||
return <Message {...props} />;
|
||||
return renderThree(props);
|
||||
});
|
||||
|
||||
story.add('Sticker', () => {
|
||||
|
@ -673,8 +688,8 @@ story.add('Deleted with error', () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Message {...propsPartialError} />
|
||||
<Message {...propsError} />
|
||||
{renderThree(propsPartialError)}
|
||||
{renderThree(propsError)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -684,9 +699,10 @@ story.add('Can delete for everyone', () => {
|
|||
status: 'read',
|
||||
text: 'I hope you get this.',
|
||||
canDeleteForEveryone: true,
|
||||
direction: 'outgoing',
|
||||
});
|
||||
|
||||
return <Message {...props} direction="outgoing" />;
|
||||
return renderThree(props);
|
||||
});
|
||||
|
||||
story.add('Error', () => {
|
||||
|
@ -916,7 +932,7 @@ story.add('Link Preview with too new a date', () => {
|
|||
});
|
||||
|
||||
story.add('Image', () => {
|
||||
const props = createProps({
|
||||
const darkImageProps = createProps({
|
||||
attachments: [
|
||||
fakeAttachment({
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
|
@ -928,8 +944,25 @@ story.add('Image', () => {
|
|||
],
|
||||
status: 'sent',
|
||||
});
|
||||
const lightImageProps = createProps({
|
||||
attachments: [
|
||||
fakeAttachment({
|
||||
url: pngUrl,
|
||||
fileName: 'the-sax.png',
|
||||
contentType: IMAGE_PNG,
|
||||
height: 240,
|
||||
width: 320,
|
||||
}),
|
||||
],
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
return renderBothDirections(props);
|
||||
return (
|
||||
<>
|
||||
{renderBothDirections(darkImageProps)}
|
||||
{renderBothDirections(lightImageProps)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
for (let i = 2; i <= 5; i += 1) {
|
||||
|
@ -937,39 +970,39 @@ for (let i = 2; i <= 5; i += 1) {
|
|||
const props = createProps({
|
||||
attachments: [
|
||||
fakeAttachment({
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
width: 128,
|
||||
height: 128,
|
||||
url: pngUrl,
|
||||
fileName: 'the-sax.png',
|
||||
contentType: IMAGE_PNG,
|
||||
height: 240,
|
||||
width: 320,
|
||||
}),
|
||||
fakeAttachment({
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
width: 128,
|
||||
height: 128,
|
||||
url: pngUrl,
|
||||
fileName: 'the-sax.png',
|
||||
contentType: IMAGE_PNG,
|
||||
height: 240,
|
||||
width: 320,
|
||||
}),
|
||||
fakeAttachment({
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
width: 128,
|
||||
height: 128,
|
||||
url: pngUrl,
|
||||
fileName: 'the-sax.png',
|
||||
contentType: IMAGE_PNG,
|
||||
height: 240,
|
||||
width: 320,
|
||||
}),
|
||||
fakeAttachment({
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
width: 128,
|
||||
height: 128,
|
||||
url: pngUrl,
|
||||
fileName: 'the-sax.png',
|
||||
contentType: IMAGE_PNG,
|
||||
height: 240,
|
||||
width: 320,
|
||||
}),
|
||||
fakeAttachment({
|
||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
width: 128,
|
||||
height: 128,
|
||||
url: pngUrl,
|
||||
fileName: 'the-sax.png',
|
||||
contentType: IMAGE_PNG,
|
||||
height: 240,
|
||||
width: 320,
|
||||
}),
|
||||
].slice(0, i),
|
||||
status: 'sent',
|
||||
|
@ -1316,7 +1349,7 @@ story.add('TapToView Error', () => {
|
|||
status: 'sent',
|
||||
});
|
||||
|
||||
return <Message {...props} />;
|
||||
return renderThree(props);
|
||||
});
|
||||
|
||||
story.add('Dangerous File Type', () => {
|
||||
|
@ -1419,23 +1452,23 @@ story.add('Not approved, with link preview', () => {
|
|||
|
||||
story.add('Custom Color', () => (
|
||||
<>
|
||||
<Message
|
||||
{...createProps({ text: 'Solid.' })}
|
||||
direction="outgoing"
|
||||
customColor={{
|
||||
{renderThree({
|
||||
...createProps({ text: 'Solid.' }),
|
||||
direction: 'outgoing',
|
||||
customColor: {
|
||||
start: { hue: 82, saturation: 35 },
|
||||
}}
|
||||
/>
|
||||
},
|
||||
})}
|
||||
<br style={{ clear: 'both' }} />
|
||||
<Message
|
||||
{...createProps({ text: 'Gradient.' })}
|
||||
direction="outgoing"
|
||||
customColor={{
|
||||
{renderThree({
|
||||
...createProps({ text: 'Gradient.' }),
|
||||
direction: 'outgoing',
|
||||
customColor: {
|
||||
deg: 192,
|
||||
start: { hue: 304, saturation: 85 },
|
||||
end: { hue: 231, saturation: 76 },
|
||||
}}
|
||||
/>
|
||||
},
|
||||
})}
|
||||
</>
|
||||
));
|
||||
|
||||
|
@ -1506,20 +1539,18 @@ story.add('Collapsing text-only group messages', () => {
|
|||
story.add('Story reply', () => {
|
||||
const conversation = getDefaultConversation();
|
||||
|
||||
return (
|
||||
<Message
|
||||
{...createProps({ text: 'Wow!' })}
|
||||
storyReplyContext={{
|
||||
authorTitle: conversation.title,
|
||||
conversationColor: ConversationColors[0],
|
||||
isFromMe: false,
|
||||
rawAttachment: fakeAttachment({
|
||||
url: '/fixtures/snow.jpg',
|
||||
thumbnail: fakeThumbnail('/fixtures/snow.jpg'),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return renderThree({
|
||||
...createProps({ text: 'Wow!' }),
|
||||
storyReplyContext: {
|
||||
authorTitle: conversation.title,
|
||||
conversationColor: ConversationColors[0],
|
||||
isFromMe: false,
|
||||
rawAttachment: fakeAttachment({
|
||||
url: '/fixtures/snow.jpg',
|
||||
thumbnail: fakeThumbnail('/fixtures/snow.jpg'),
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const fullContact = {
|
||||
|
@ -1559,7 +1590,7 @@ story.add('EmbeddedContact: Full Contact', () => {
|
|||
return renderBothDirections(props);
|
||||
});
|
||||
|
||||
story.add('EmbeddedContact: 2x Incoming, with Send Message', () => {
|
||||
story.add('EmbeddedContact: with Send Message', () => {
|
||||
const props = createProps({
|
||||
contact: {
|
||||
...fullContact,
|
||||
|
@ -1568,19 +1599,7 @@ story.add('EmbeddedContact: 2x Incoming, with Send Message', () => {
|
|||
},
|
||||
direction: 'incoming',
|
||||
});
|
||||
return renderMany([props, props]);
|
||||
});
|
||||
|
||||
story.add('EmbeddedContact: 2x Outgoing, with Send Message', () => {
|
||||
const props = createProps({
|
||||
contact: {
|
||||
...fullContact,
|
||||
firstNumber: fullContact.number[0].value,
|
||||
uuid: UUID.generate().toString(),
|
||||
},
|
||||
direction: 'outgoing',
|
||||
});
|
||||
return renderMany([props, props]);
|
||||
return renderBothDirections(props);
|
||||
});
|
||||
|
||||
story.add('EmbeddedContact: Only Email', () => {
|
||||
|
|
|
@ -28,7 +28,7 @@ import { MessageMetadata } from './MessageMetadata';
|
|||
import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer';
|
||||
import { ImageGrid } from './ImageGrid';
|
||||
import { GIF } from './GIF';
|
||||
import { Image } from './Image';
|
||||
import { CurveType, Image } from './Image';
|
||||
import { ContactName } from './ContactName';
|
||||
import type { QuotedAttachmentType } from './Quote';
|
||||
import { Quote } from './Quote';
|
||||
|
@ -908,18 +908,17 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
<div className={containerClassName}>
|
||||
<ImageGrid
|
||||
attachments={attachments}
|
||||
withContentAbove={
|
||||
isSticker || withContentAbove || shouldCollapseAbove
|
||||
}
|
||||
withContentBelow={
|
||||
isSticker || withContentBelow || shouldCollapseBelow
|
||||
}
|
||||
direction={direction}
|
||||
withContentAbove={isSticker || withContentAbove}
|
||||
withContentBelow={isSticker || withContentBelow}
|
||||
isSticker={isSticker}
|
||||
stickerSize={STICKER_SIZE}
|
||||
bottomOverlay={bottomOverlay}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
onError={this.handleImageError}
|
||||
theme={theme}
|
||||
shouldCollapseAbove={shouldCollapseAbove}
|
||||
shouldCollapseBelow={shouldCollapseBelow}
|
||||
tabIndex={tabIndex}
|
||||
onClick={attachment => {
|
||||
if (!isDownloaded(attachment)) {
|
||||
|
@ -1060,6 +1059,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
openLink,
|
||||
previews,
|
||||
quote,
|
||||
shouldCollapseAbove,
|
||||
theme,
|
||||
kickOffAttachmentDownload,
|
||||
} = this.props;
|
||||
|
@ -1113,6 +1113,8 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
<ImageGrid
|
||||
attachments={[first.image]}
|
||||
withContentAbove={withContentAbove}
|
||||
direction={direction}
|
||||
shouldCollapseAbove={shouldCollapseAbove}
|
||||
withContentBelow
|
||||
onError={this.handleImageError}
|
||||
i18n={i18n}
|
||||
|
@ -1124,10 +1126,14 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
{first.image && previewHasImage && !isFullSizeImage ? (
|
||||
<div className="module-message__link-preview__icon_container">
|
||||
<Image
|
||||
smallCurveTopLeft={!withContentAbove}
|
||||
noBorder
|
||||
noBackground
|
||||
softCorners
|
||||
curveBottomLeft={
|
||||
withContentAbove ? CurveType.Tiny : CurveType.Small
|
||||
}
|
||||
curveBottomRight={CurveType.Tiny}
|
||||
curveTopRight={CurveType.Tiny}
|
||||
curveTopLeft={CurveType.Tiny}
|
||||
alt={i18n('previewThumbnail', [first.domain])}
|
||||
height={72}
|
||||
width={72}
|
||||
|
@ -2668,6 +2674,10 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
className={containerClassnames}
|
||||
style={containerStyles}
|
||||
onContextMenu={this.showContextMenu}
|
||||
role="row"
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onClick={this.handleClick}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{this.renderAuthor()}
|
||||
{this.renderContents()}
|
||||
|
@ -2717,7 +2727,6 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
// cannot be within another button
|
||||
role="button"
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onClick={this.handleClick}
|
||||
onFocus={this.handleFocus}
|
||||
ref={this.focusRef}
|
||||
>
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useReducer,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
|
@ -9,6 +15,7 @@ import { assert } from '../../util/assert';
|
|||
import type { LocalizerType } from '../../types/Util';
|
||||
import type { AttachmentType } from '../../types/Attachment';
|
||||
import { isDownloaded } from '../../types/Attachment';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import type { DirectionType, MessageStatusType } from './Message';
|
||||
|
||||
import type { ComputePeaksResult } from '../GlobalAudioContext';
|
||||
|
@ -133,6 +140,37 @@ const Button: React.FC<ButtonProps> = props => {
|
|||
);
|
||||
};
|
||||
|
||||
type StateType = Readonly<{
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
lastAriaTime: number;
|
||||
}>;
|
||||
|
||||
type ActionType = Readonly<
|
||||
| {
|
||||
type: 'SET_IS_PLAYING';
|
||||
value: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'SET_CURRENT_TIME';
|
||||
value: number;
|
||||
}
|
||||
>;
|
||||
|
||||
function reducer(state: StateType, action: ActionType): StateType {
|
||||
if (action.type === 'SET_IS_PLAYING') {
|
||||
return {
|
||||
...state,
|
||||
isPlaying: action.value,
|
||||
lastAriaTime: state.currentTime,
|
||||
};
|
||||
}
|
||||
if (action.type === 'SET_CURRENT_TIME') {
|
||||
return { ...state, currentTime: action.value };
|
||||
}
|
||||
throw missingCaseError(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display message audio attachment along with its waveform, duration, and
|
||||
* toggle Play/Pause button.
|
||||
|
@ -184,11 +222,27 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
|||
activeAudioID === id && activeAudioContext === renderingContext;
|
||||
|
||||
const waveformRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(
|
||||
isActive && !(audio.paused || audio.ended)
|
||||
const [{ isPlaying, currentTime, lastAriaTime }, dispatch] = useReducer(
|
||||
reducer,
|
||||
{
|
||||
isPlaying: isActive && !(audio.paused || audio.ended),
|
||||
currentTime: isActive ? audio.currentTime : 0,
|
||||
lastAriaTime: isActive ? audio.currentTime : 0,
|
||||
}
|
||||
);
|
||||
const [currentTime, setCurrentTime] = useState(
|
||||
isActive ? audio.currentTime : 0
|
||||
|
||||
const setIsPlaying = useCallback(
|
||||
(value: boolean) => {
|
||||
dispatch({ type: 'SET_IS_PLAYING', value });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setCurrentTime = useCallback(
|
||||
(value: number) => {
|
||||
dispatch({ type: 'SET_CURRENT_TIME', value });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// NOTE: Avoid division by zero
|
||||
|
@ -326,7 +380,15 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
|||
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.removeEventListener('durationchange', onDurationChange);
|
||||
};
|
||||
}, [id, audio, isActive, currentTime, duration]);
|
||||
}, [
|
||||
id,
|
||||
audio,
|
||||
isActive,
|
||||
currentTime,
|
||||
duration,
|
||||
setCurrentTime,
|
||||
setIsPlaying,
|
||||
]);
|
||||
|
||||
// This effect detects `isPlaying` changes and starts/pauses playback when
|
||||
// needed (+keeps waveform position and audio position in sync).
|
||||
|
@ -457,10 +519,10 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
|||
role="slider"
|
||||
aria-label={i18n('MessageAudio--slider')}
|
||||
aria-orientation="horizontal"
|
||||
aria-valuenow={currentTime}
|
||||
aria-valuenow={lastAriaTime}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration}
|
||||
aria-valuetext={timeToText(currentTime)}
|
||||
aria-valuetext={timeToText(lastAriaTime)}
|
||||
>
|
||||
{peaks.map((peak, i) => {
|
||||
let height = Math.max(BAR_MIN_HEIGHT, BAR_MAX_HEIGHT * peak);
|
||||
|
@ -531,6 +593,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
|||
const metadata = (
|
||||
<div className={`${CSS_BASE}__metadata`}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
`${CSS_BASE}__countdown`,
|
||||
`${CSS_BASE}__countdown--${played ? 'played' : 'unplayed'}`
|
||||
|
|
|
@ -5,7 +5,7 @@ import React from 'react';
|
|||
import classNames from 'classnames';
|
||||
import { unescape } from 'lodash';
|
||||
|
||||
import { Image } from './Image';
|
||||
import { CurveType, Image } from './Image';
|
||||
import { LinkPreviewDate } from './LinkPreviewDate';
|
||||
|
||||
import type { AttachmentType } from '../../types/Attachment';
|
||||
|
@ -51,7 +51,10 @@ export const StagedLinkPreview: React.FC<Props> = ({
|
|||
<div className="module-staged-link-preview__icon-container">
|
||||
<Image
|
||||
alt={i18n('stagedPreviewThumbnail', [domain])}
|
||||
softCorners
|
||||
curveBottomLeft={CurveType.Tiny}
|
||||
curveBottomRight={CurveType.Tiny}
|
||||
curveTopRight={CurveType.Tiny}
|
||||
curveTopLeft={CurveType.Tiny}
|
||||
height={72}
|
||||
width={72}
|
||||
url={image.url}
|
||||
|
|
|
@ -337,11 +337,6 @@ const items: Record<string, TimelineItemType> = {
|
|||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-15': {
|
||||
type: 'linkNotification',
|
||||
data: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const actions = () => ({
|
||||
|
@ -540,9 +535,9 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
items: overrideProps.items || Object.keys(items),
|
||||
scrollToIndex: overrideProps.scrollToIndex,
|
||||
scrollToIndexCounter: 0,
|
||||
totalUnread: number('totalUnread', overrideProps.totalUnread || 0),
|
||||
oldestUnreadIndex:
|
||||
number('oldestUnreadIndex', overrideProps.oldestUnreadIndex || 0) ||
|
||||
totalUnseen: number('totalUnseen', overrideProps.totalUnseen || 0),
|
||||
oldestUnseenIndex:
|
||||
number('oldestUnseenIndex', overrideProps.oldestUnseenIndex || 0) ||
|
||||
undefined,
|
||||
invitedContactsForNewlyCreatedGroup:
|
||||
overrideProps.invitedContactsForNewlyCreatedGroup || [],
|
||||
|
@ -608,8 +603,8 @@ story.add('Empty (just hero)', () => {
|
|||
|
||||
story.add('Last Seen', () => {
|
||||
const props = useProps({
|
||||
oldestUnreadIndex: 13,
|
||||
totalUnread: 2,
|
||||
oldestUnseenIndex: 13,
|
||||
totalUnseen: 2,
|
||||
});
|
||||
|
||||
return <Timeline {...props} />;
|
||||
|
|
|
@ -88,10 +88,10 @@ export type PropsDataType = {
|
|||
messageLoadingState?: TimelineMessageLoadingState;
|
||||
isNearBottom?: boolean;
|
||||
items: ReadonlyArray<string>;
|
||||
oldestUnreadIndex?: number;
|
||||
oldestUnseenIndex?: number;
|
||||
scrollToIndex?: number;
|
||||
scrollToIndexCounter: number;
|
||||
totalUnread: number;
|
||||
totalUnseen: number;
|
||||
};
|
||||
|
||||
type PropsHousekeepingType = {
|
||||
|
@ -342,7 +342,7 @@ export class Timeline extends React.Component<
|
|||
items,
|
||||
loadNewestMessages,
|
||||
messageLoadingState,
|
||||
oldestUnreadIndex,
|
||||
oldestUnseenIndex,
|
||||
selectMessage,
|
||||
} = this.props;
|
||||
const { newestBottomVisibleMessageId } = this.state;
|
||||
|
@ -358,15 +358,15 @@ export class Timeline extends React.Component<
|
|||
|
||||
if (
|
||||
newestBottomVisibleMessageId &&
|
||||
isNumber(oldestUnreadIndex) &&
|
||||
isNumber(oldestUnseenIndex) &&
|
||||
items.findIndex(item => item === newestBottomVisibleMessageId) <
|
||||
oldestUnreadIndex
|
||||
oldestUnseenIndex
|
||||
) {
|
||||
if (setFocus) {
|
||||
const messageId = items[oldestUnreadIndex];
|
||||
const messageId = items[oldestUnseenIndex];
|
||||
selectMessage(messageId, id);
|
||||
} else {
|
||||
this.scrollToItemIndex(oldestUnreadIndex);
|
||||
this.scrollToItemIndex(oldestUnseenIndex);
|
||||
}
|
||||
} else if (haveNewest) {
|
||||
this.scrollToBottom(setFocus);
|
||||
|
@ -790,7 +790,7 @@ export class Timeline extends React.Component<
|
|||
isSomeoneTyping,
|
||||
items,
|
||||
messageLoadingState,
|
||||
oldestUnreadIndex,
|
||||
oldestUnseenIndex,
|
||||
onBlock,
|
||||
onBlockAndReportSpam,
|
||||
onDelete,
|
||||
|
@ -804,7 +804,7 @@ export class Timeline extends React.Component<
|
|||
reviewMessageRequestNameCollision,
|
||||
showContactModal,
|
||||
theme,
|
||||
totalUnread,
|
||||
totalUnseen,
|
||||
unblurAvatar,
|
||||
unreadCount,
|
||||
updateSharedGroups,
|
||||
|
@ -898,17 +898,17 @@ export class Timeline extends React.Component<
|
|||
}
|
||||
|
||||
let unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
|
||||
if (oldestUnreadIndex === itemIndex) {
|
||||
if (oldestUnseenIndex === itemIndex) {
|
||||
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove;
|
||||
messageNodes.push(
|
||||
<LastSeenIndicator
|
||||
key="last seen indicator"
|
||||
count={totalUnread}
|
||||
count={totalUnseen}
|
||||
i18n={i18n}
|
||||
ref={this.lastSeenIndicatorRef}
|
||||
/>
|
||||
);
|
||||
} else if (oldestUnreadIndex === nextItemIndex) {
|
||||
} else if (oldestUnseenIndex === nextItemIndex) {
|
||||
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow;
|
||||
}
|
||||
|
||||
|
|
|
@ -417,10 +417,6 @@ storiesOf('Components/Conversation/TimelineItem', module)
|
|||
startedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'linkNotification',
|
||||
data: null,
|
||||
},
|
||||
{
|
||||
type: 'profileChange',
|
||||
data: {
|
||||
|
|
|
@ -23,7 +23,6 @@ import type {
|
|||
PropsDataType as DeliveryIssueProps,
|
||||
} from './DeliveryIssueNotification';
|
||||
import { DeliveryIssueNotification } from './DeliveryIssueNotification';
|
||||
import { LinkNotification } from './LinkNotification';
|
||||
import type { PropsData as ChangeNumberNotificationProps } from './ChangeNumberNotification';
|
||||
import { ChangeNumberNotification } from './ChangeNumberNotification';
|
||||
import type { CallingNotificationType } from '../../util/callingNotification';
|
||||
|
@ -69,10 +68,6 @@ type DeliveryIssueType = {
|
|||
type: 'deliveryIssue';
|
||||
data: DeliveryIssueProps;
|
||||
};
|
||||
type LinkNotificationType = {
|
||||
type: 'linkNotification';
|
||||
data: null;
|
||||
};
|
||||
type MessageType = {
|
||||
type: 'message';
|
||||
data: Omit<MessageProps, 'renderingContext'>;
|
||||
|
@ -129,7 +124,6 @@ export type TimelineItemType = (
|
|||
| GroupNotificationType
|
||||
| GroupV1MigrationType
|
||||
| GroupV2ChangeType
|
||||
| LinkNotificationType
|
||||
| MessageType
|
||||
| ProfileChangeNotificationType
|
||||
| ResetSessionNotificationType
|
||||
|
@ -261,8 +255,6 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'linkNotification') {
|
||||
notification = <LinkNotification i18n={i18n} />;
|
||||
} else if (item.type === 'timerNotification') {
|
||||
notification = (
|
||||
<TimerNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
|
|
|
@ -89,19 +89,20 @@ export class MediaGallery extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const { i18n } = this.props;
|
||||
const { selectedTab } = this.state;
|
||||
|
||||
return (
|
||||
<div className="module-media-gallery" tabIndex={-1} ref={this.focusRef}>
|
||||
<div className="module-media-gallery__tab-container">
|
||||
<Tab
|
||||
label="Media"
|
||||
label={i18n('media')}
|
||||
type="media"
|
||||
isSelected={selectedTab === 'media'}
|
||||
onSelect={this.handleTabSelect}
|
||||
/>
|
||||
<Tab
|
||||
label="Documents"
|
||||
label={i18n('documents')}
|
||||
type="documents"
|
||||
isSelected={selectedTab === 'documents'}
|
||||
onSelect={this.handleTabSelect}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import { isRecord } from '../../util/isRecord';
|
||||
import { HTTPError } from '../../textsecure/Errors';
|
||||
import { parseRetryAfter } from '../../util/parseRetryAfter';
|
||||
import { parseRetryAfterWithDefault } from '../../util/parseRetryAfter';
|
||||
|
||||
export function findRetryAfterTimeFromError(err: unknown): number {
|
||||
let rawValue: unknown;
|
||||
|
@ -16,5 +16,5 @@ export function findRetryAfterTimeFromError(err: unknown): number {
|
|||
}
|
||||
}
|
||||
|
||||
return parseRetryAfter(rawValue);
|
||||
return parseRetryAfterWithDefault(rawValue);
|
||||
}
|
||||
|
|
|
@ -205,6 +205,7 @@ export async function sendNormalMessage(
|
|||
profileKey,
|
||||
quote,
|
||||
sticker,
|
||||
storyContext,
|
||||
timestamp: messageTimestamp,
|
||||
mentions,
|
||||
},
|
||||
|
|
|
@ -7,6 +7,7 @@ import { handleMessageSend } from '../../util/handleMessageSend';
|
|||
import { getSendOptions } from '../../util/getSendOptions';
|
||||
import {
|
||||
isDirectConversation,
|
||||
isGroup,
|
||||
isGroupV2,
|
||||
} from '../../util/whatTypeOfConversation';
|
||||
import { SignalService as Proto } from '../../protobuf';
|
||||
|
@ -22,10 +23,39 @@ import type {
|
|||
ProfileKeyJobData,
|
||||
} from '../conversationJobQueue';
|
||||
import type { CallbackResultType } from '../../textsecure/Types.d';
|
||||
import { getUntrustedConversationIds } from './getUntrustedConversationIds';
|
||||
import { areAllErrorsUnregistered } from './areAllErrorsUnregistered';
|
||||
import { isConversationAccepted } from '../../util/isConversationAccepted';
|
||||
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
|
||||
import type { ConversationAttributesType } from '../../model-types.d';
|
||||
import {
|
||||
OutgoingIdentityKeyError,
|
||||
SendMessageChallengeError,
|
||||
SendMessageProtoError,
|
||||
UnregisteredUserError,
|
||||
} from '../../textsecure/Errors';
|
||||
|
||||
export function canAllErrorsBeIgnored(
|
||||
conversation: ConversationAttributesType,
|
||||
error: unknown
|
||||
): boolean {
|
||||
if (
|
||||
error instanceof OutgoingIdentityKeyError ||
|
||||
error instanceof SendMessageChallengeError ||
|
||||
error instanceof UnregisteredUserError
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
isGroup(conversation) &&
|
||||
error instanceof SendMessageProtoError &&
|
||||
error.errors?.every(
|
||||
item =>
|
||||
item instanceof OutgoingIdentityKeyError ||
|
||||
item instanceof SendMessageChallengeError ||
|
||||
item instanceof UnregisteredUserError
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Note: because we don't have a recipient map, we will resend this message to folks that
|
||||
// got it on the first go-round, if some sends fail. This is okay, because a recipient
|
||||
|
@ -71,18 +101,7 @@ export async function sendProfileKey(
|
|||
|
||||
// Note: flags and the profileKey itself are all that matter in the proto.
|
||||
|
||||
const untrustedConversationIds = getUntrustedConversationIds(
|
||||
conversation.getRecipients()
|
||||
);
|
||||
if (untrustedConversationIds.length) {
|
||||
window.reduxActions.conversations.conversationStoppedByMissingVerification({
|
||||
conversationId: conversation.id,
|
||||
untrustedConversationIds,
|
||||
});
|
||||
throw new Error(
|
||||
`Profile key send blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.`
|
||||
);
|
||||
}
|
||||
// Note: we don't check for untrusted conversations here; we attempt to send anyway
|
||||
|
||||
if (isDirectConversation(conversation.attributes)) {
|
||||
if (!isConversationAccepted(conversation.attributes)) {
|
||||
|
@ -149,9 +168,9 @@ export async function sendProfileKey(
|
|||
sendType,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (areAllErrorsUnregistered(conversation.attributes, error)) {
|
||||
if (canAllErrorsBeIgnored(conversation.attributes, error)) {
|
||||
log.info(
|
||||
'Group send failures were all UnregisteredUserError, returning succcessfully.'
|
||||
'Group send failures were all OutgoingIdentityKeyError, SendMessageChallengeError, or UnregisteredUserError. Returning succcessfully.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ import { getSendOptions } from '../../util/getSendOptions';
|
|||
import { SignalService as Proto } from '../../protobuf';
|
||||
import { handleMessageSend } from '../../util/handleMessageSend';
|
||||
import { ourProfileKeyService } from '../../services/ourProfileKey';
|
||||
import { canReact } from '../../state/selectors/message';
|
||||
import { canReact, isStory } from '../../state/selectors/message';
|
||||
import { findAndFormatContact } from '../../util/findAndFormatContact';
|
||||
import { UUID } from '../../types/UUID';
|
||||
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
|
||||
|
@ -204,12 +204,6 @@ export async function sendReaction(
|
|||
return;
|
||||
}
|
||||
|
||||
let storyMessage: MessageModel | undefined;
|
||||
const storyId = message.get('storyId');
|
||||
if (storyId) {
|
||||
storyMessage = await getMessageById(storyId);
|
||||
}
|
||||
|
||||
log.info('sending direct reaction message');
|
||||
promise = window.textsecure.messaging.sendMessageToIdentifier({
|
||||
identifier: recipientIdentifiersWithoutMe[0],
|
||||
|
@ -226,10 +220,10 @@ export async function sendReaction(
|
|||
groupId: undefined,
|
||||
profileKey,
|
||||
options: sendOptions,
|
||||
storyContext: storyMessage
|
||||
storyContext: isStory(message.attributes)
|
||||
? {
|
||||
authorUuid: storyMessage.get('sourceUuid'),
|
||||
timestamp: storyMessage.get('sent_at'),
|
||||
authorUuid: message.get('sourceUuid'),
|
||||
timestamp: message.get('sent_at'),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
@ -261,6 +255,12 @@ export async function sendReaction(
|
|||
timestamp: pendingReaction.timestamp,
|
||||
expireTimer,
|
||||
profileKey,
|
||||
storyContext: isStory(message.attributes)
|
||||
? {
|
||||
authorUuid: message.get('sourceUuid'),
|
||||
timestamp: message.get('sent_at'),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
messageId,
|
||||
sendOptions,
|
||||
|
@ -313,7 +313,8 @@ export async function sendReaction(
|
|||
const newReactions = reactionUtil.markOutgoingReactionSent(
|
||||
getReactions(message),
|
||||
pendingReaction,
|
||||
successfulConversationIds
|
||||
successfulConversationIds,
|
||||
message.attributes
|
||||
);
|
||||
setReactions(message, newReactions);
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import type { RequestInit, Response } from 'node-fetch';
|
||||
import type { AbortSignal as AbortSignalForNodeFetch } from 'abort-controller';
|
||||
import { blobToArrayBuffer } from 'blob-util';
|
||||
|
||||
import type { MIMEType } from '../types/MIME';
|
||||
import {
|
||||
|
@ -14,6 +15,7 @@ import {
|
|||
stringToMIMEType,
|
||||
} from '../types/MIME';
|
||||
import type { LoggerType } from '../types/Logging';
|
||||
import { scaleImageToLevel } from '../util/scaleImageToLevel';
|
||||
import * as log from '../logging/log';
|
||||
|
||||
const USER_AGENT = 'WhatsApp/2';
|
||||
|
@ -603,5 +605,20 @@ export async function fetchLinkPreviewImage(
|
|||
return null;
|
||||
}
|
||||
|
||||
// Scale link preview image
|
||||
if (contentType !== IMAGE_GIF) {
|
||||
const dataBlob = new Blob([data], {
|
||||
type: contentType,
|
||||
});
|
||||
const { blob: xcodedDataBlob } = await scaleImageToLevel(
|
||||
dataBlob,
|
||||
contentType,
|
||||
false
|
||||
);
|
||||
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
|
||||
|
||||
data = new Uint8Array(xcodedDataArrayBuffer);
|
||||
}
|
||||
|
||||
return { data, contentType };
|
||||
}
|
||||
|
|
6
ts/model-types.d.ts
vendored
6
ts/model-types.d.ts
vendored
|
@ -35,6 +35,7 @@ import { ReactionSource } from './reactions/ReactionSource';
|
|||
|
||||
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||
import MemberRoleEnum = Proto.Member.Role;
|
||||
import { SeenStatus } from './MessageSeenStatus';
|
||||
|
||||
export type WhatIsThis = any;
|
||||
|
||||
|
@ -170,7 +171,6 @@ export type MessageAttributesType = {
|
|||
| 'group-v2-change'
|
||||
| 'incoming'
|
||||
| 'keychange'
|
||||
| 'message-history-unsynced'
|
||||
| 'outgoing'
|
||||
| 'profile-change'
|
||||
| 'story'
|
||||
|
@ -219,8 +219,10 @@ export type MessageAttributesType = {
|
|||
|
||||
sendHQImages?: boolean;
|
||||
|
||||
// Should only be present for incoming messages
|
||||
// Should only be present for incoming messages and errors
|
||||
readStatus?: ReadStatus;
|
||||
// Used for all kinds of notifications, as well as incoming messages
|
||||
seenStatus?: SeenStatus;
|
||||
|
||||
// Should only be present for outgoing messages
|
||||
sendStateByConversationId?: SendStateByConversationId;
|
||||
|
|
|
@ -117,6 +117,8 @@ import { isMessageUnread } from '../util/isMessageUnread';
|
|||
import type { SenderKeyTargetType } from '../util/sendToGroup';
|
||||
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
||||
import { TimelineMessageLoadingState } from '../util/timelineUtil';
|
||||
import { SeenStatus } from '../MessageSeenStatus';
|
||||
import { getConversationIdForLogging } from '../util/idForLogging';
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
@ -217,8 +219,6 @@ export class ConversationModel extends window.Backbone
|
|||
|
||||
private isFetchingUUID?: boolean;
|
||||
|
||||
private hasAddedHistoryDisclaimer?: boolean;
|
||||
|
||||
private lastIsTyping?: boolean;
|
||||
|
||||
private muteTimer?: NodeJS.Timer;
|
||||
|
@ -237,17 +237,7 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
|
||||
idForLogging(): string {
|
||||
if (isDirectConversation(this.attributes)) {
|
||||
const uuid = this.get('uuid');
|
||||
const e164 = this.get('e164');
|
||||
return `${uuid || e164} (${this.id})`;
|
||||
}
|
||||
if (isGroupV2(this.attributes)) {
|
||||
return `groupv2(${this.get('groupId')})`;
|
||||
}
|
||||
|
||||
const groupId = this.get('groupId');
|
||||
return `group(${groupId})`;
|
||||
return getConversationIdForLogging(this.attributes);
|
||||
}
|
||||
|
||||
// This is one of the few times that we want to collapse our uuid/e164 pair down into
|
||||
|
@ -1508,8 +1498,8 @@ export class ConversationModel extends window.Backbone
|
|||
return;
|
||||
}
|
||||
|
||||
if (scrollToLatestUnread && metrics.oldestUnread) {
|
||||
this.loadAndScroll(metrics.oldestUnread.id, {
|
||||
if (scrollToLatestUnread && metrics.oldestUnseen) {
|
||||
this.loadAndScroll(metrics.oldestUnseen.id, {
|
||||
disableScroll: !setFocus,
|
||||
});
|
||||
return;
|
||||
|
@ -2667,17 +2657,17 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
}
|
||||
|
||||
const didSomethingChange = keyChange || beginningVerified !== verified;
|
||||
const didVerifiedChange = beginningVerified !== verified;
|
||||
const isExplicitUserAction = !options.viaStorageServiceSync;
|
||||
const shouldShowFromStorageSync =
|
||||
options.viaStorageServiceSync && verified !== UNVERIFIED;
|
||||
if (
|
||||
// The message came from an explicit verification in a client (not a contact sync
|
||||
// or storage service sync)
|
||||
(didSomethingChange && isExplicitUserAction) ||
|
||||
// The verification value received by the contact sync is different from what we
|
||||
// The message came from an explicit verification in a client (not
|
||||
// storage service sync)
|
||||
(didVerifiedChange && isExplicitUserAction) ||
|
||||
// The verification value received by the storage sync is different from what we
|
||||
// have on record (and it's not a transition to UNVERIFIED)
|
||||
(didSomethingChange && shouldShowFromStorageSync) ||
|
||||
(didVerifiedChange && shouldShowFromStorageSync) ||
|
||||
// Our local verification status is VERIFIED and it hasn't changed, but the key did
|
||||
// change (Key1/VERIFIED -> Key2/VERIFIED), but we don't want to show DEFAULT ->
|
||||
// DEFAULT or UNVERIFIED -> UNVERIFIED
|
||||
|
@ -2926,6 +2916,7 @@ export class ConversationModel extends window.Backbone
|
|||
received_at: receivedAtCounter,
|
||||
received_at_ms: receivedAt,
|
||||
readStatus: ReadStatus.Unread,
|
||||
seenStatus: SeenStatus.Unseen,
|
||||
// TODO: DESKTOP-722
|
||||
// this type does not fully implement the interface it is expected to
|
||||
} as unknown as MessageAttributesType;
|
||||
|
@ -2968,6 +2959,7 @@ export class ConversationModel extends window.Backbone
|
|||
received_at: receivedAtCounter,
|
||||
received_at_ms: receivedAt,
|
||||
readStatus: ReadStatus.Unread,
|
||||
seenStatus: SeenStatus.Unseen,
|
||||
// TODO: DESKTOP-722
|
||||
// this type does not fully implement the interface it is expected to
|
||||
} as unknown as MessageAttributesType;
|
||||
|
@ -3004,7 +2996,8 @@ export class ConversationModel extends window.Backbone
|
|||
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||
received_at_ms: timestamp,
|
||||
key_changed: keyChangedId.toString(),
|
||||
readStatus: ReadStatus.Unread,
|
||||
readStatus: ReadStatus.Read,
|
||||
seenStatus: SeenStatus.Unseen,
|
||||
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
|
||||
// TODO: DESKTOP-722
|
||||
// this type does not fully implement the interface it is expected to
|
||||
|
@ -3057,14 +3050,15 @@ export class ConversationModel extends window.Backbone
|
|||
const timestamp = Date.now();
|
||||
const message = {
|
||||
conversationId: this.id,
|
||||
type: 'verified-change',
|
||||
sent_at: lastMessage,
|
||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||
received_at_ms: timestamp,
|
||||
verifiedChanged: verifiedChangeId,
|
||||
verified,
|
||||
local: options.local,
|
||||
readStatus: ReadStatus.Unread,
|
||||
received_at_ms: timestamp,
|
||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||
seenStatus: SeenStatus.Unseen,
|
||||
sent_at: lastMessage,
|
||||
type: 'verified-change',
|
||||
verified,
|
||||
verifiedChanged: verifiedChangeId,
|
||||
// TODO: DESKTOP-722
|
||||
} as unknown as MessageAttributesType;
|
||||
|
||||
|
@ -3128,6 +3122,7 @@ export class ConversationModel extends window.Backbone
|
|||
receivedAtCounter || window.Signal.Util.incrementMessageCounter(),
|
||||
received_at_ms: timestamp,
|
||||
readStatus: unread ? ReadStatus.Unread : ReadStatus.Read,
|
||||
seenStatus: unread ? SeenStatus.Unseen : SeenStatus.NotApplicable,
|
||||
callHistoryDetails: detailsToSave,
|
||||
// TODO: DESKTOP-722
|
||||
} as unknown as MessageAttributesType;
|
||||
|
@ -3192,6 +3187,7 @@ export class ConversationModel extends window.Backbone
|
|||
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||
received_at_ms: now,
|
||||
readStatus: ReadStatus.Read,
|
||||
seenStatus: SeenStatus.NotApplicable,
|
||||
changedId: conversationId || this.id,
|
||||
profileChange,
|
||||
// TODO: DESKTOP-722
|
||||
|
@ -3228,14 +3224,15 @@ export class ConversationModel extends window.Backbone
|
|||
): Promise<string> {
|
||||
const now = Date.now();
|
||||
const message: Partial<MessageAttributesType> = {
|
||||
...extra,
|
||||
|
||||
conversationId: this.id,
|
||||
type,
|
||||
sent_at: now,
|
||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||
received_at_ms: now,
|
||||
readStatus: ReadStatus.Read,
|
||||
seenStatus: SeenStatus.NotApplicable,
|
||||
|
||||
...extra,
|
||||
};
|
||||
|
||||
const id = await window.Signal.Data.saveMessage(
|
||||
|
@ -3363,6 +3360,8 @@ export class ConversationModel extends window.Backbone
|
|||
await Promise.all(
|
||||
convos.map(convo => {
|
||||
return convo.addNotification('change-number-notification', {
|
||||
readStatus: ReadStatus.Read,
|
||||
seenStatus: SeenStatus.Unseen,
|
||||
sourceUuid: sourceUuid.toString(),
|
||||
});
|
||||
})
|
||||
|
@ -3384,6 +3383,7 @@ export class ConversationModel extends window.Backbone
|
|||
return this.queueJob('onReadMessage', () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.markRead(message.get('received_at')!, {
|
||||
newestSentAt: message.get('sent_at'),
|
||||
sendReadReceipts: false,
|
||||
readAt,
|
||||
})
|
||||
|
@ -4037,6 +4037,8 @@ export class ConversationModel extends window.Backbone
|
|||
received_at_ms: now,
|
||||
expireTimer,
|
||||
recipients,
|
||||
readStatus: ReadStatus.Read,
|
||||
seenStatus: SeenStatus.NotApplicable,
|
||||
sticker,
|
||||
bodyRanges: mentions,
|
||||
sendHQImages,
|
||||
|
@ -4546,21 +4548,20 @@ export class ConversationModel extends window.Backbone
|
|||
window.Signal.Data.updateConversation(this.attributes);
|
||||
|
||||
const model = new window.Whisper.Message({
|
||||
// Even though this isn't reflected to the user, we want to place the last seen
|
||||
// indicator above it. We set it to 'unread' to trigger that placement.
|
||||
readStatus: ReadStatus.Unread,
|
||||
conversationId: this.id,
|
||||
// No type; 'incoming' messages are specially treated by conversation.markRead()
|
||||
sent_at: sentAt,
|
||||
received_at: receivedAt,
|
||||
received_at_ms: receivedAtMS,
|
||||
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
expirationTimerUpdate: {
|
||||
expireTimer,
|
||||
source,
|
||||
fromSync: options.fromSync,
|
||||
fromGroupUpdate: options.fromGroupUpdate,
|
||||
},
|
||||
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
readStatus: ReadStatus.Unread,
|
||||
received_at_ms: receivedAtMS,
|
||||
received_at: receivedAt,
|
||||
seenStatus: SeenStatus.Unseen,
|
||||
sent_at: sentAt,
|
||||
type: 'timer-notification',
|
||||
// TODO: DESKTOP-722
|
||||
} as unknown as MessageAttributesType);
|
||||
|
||||
|
@ -4576,39 +4577,6 @@ export class ConversationModel extends window.Backbone
|
|||
return message;
|
||||
}
|
||||
|
||||
async addMessageHistoryDisclaimer(): Promise<void> {
|
||||
const timestamp = Date.now();
|
||||
|
||||
if (this.hasAddedHistoryDisclaimer) {
|
||||
log.warn(
|
||||
`addMessageHistoryDisclaimer/${this.idForLogging()}: Refusing to add another this session`
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.hasAddedHistoryDisclaimer = true;
|
||||
|
||||
const model = new window.Whisper.Message({
|
||||
type: 'message-history-unsynced',
|
||||
// Even though this isn't reflected to the user, we want to place the last seen
|
||||
// indicator above it. We set it to 'unread' to trigger that placement.
|
||||
readStatus: ReadStatus.Unread,
|
||||
conversationId: this.id,
|
||||
sent_at: timestamp,
|
||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||
received_at_ms: timestamp,
|
||||
// TODO: DESKTOP-722
|
||||
} as unknown as MessageAttributesType);
|
||||
|
||||
const id = await window.Signal.Data.saveMessage(model.attributes, {
|
||||
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||
});
|
||||
|
||||
model.set({ id });
|
||||
|
||||
const message = window.MessageController.register(id, model);
|
||||
this.addSingleMessage(message);
|
||||
}
|
||||
|
||||
isSearchable(): boolean {
|
||||
return !this.get('left');
|
||||
}
|
||||
|
@ -4633,12 +4601,14 @@ export class ConversationModel extends window.Backbone
|
|||
window.Signal.Data.updateConversation(this.attributes);
|
||||
|
||||
const model = new window.Whisper.Message({
|
||||
group_update: { left: 'You' },
|
||||
conversationId: this.id,
|
||||
type: 'outgoing',
|
||||
sent_at: now,
|
||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||
group_update: { left: 'You' },
|
||||
readStatus: ReadStatus.Read,
|
||||
received_at_ms: now,
|
||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||
seenStatus: SeenStatus.NotApplicable,
|
||||
sent_at: now,
|
||||
type: 'group',
|
||||
// TODO: DESKTOP-722
|
||||
} as unknown as MessageAttributesType);
|
||||
|
||||
|
@ -4665,14 +4635,25 @@ export class ConversationModel extends window.Backbone
|
|||
|
||||
async markRead(
|
||||
newestUnreadAt: number,
|
||||
options: { readAt?: number; sendReadReceipts: boolean } = {
|
||||
options: {
|
||||
readAt?: number;
|
||||
sendReadReceipts: boolean;
|
||||
newestSentAt?: number;
|
||||
} = {
|
||||
sendReadReceipts: true,
|
||||
}
|
||||
): Promise<void> {
|
||||
await markConversationRead(this.attributes, newestUnreadAt, options);
|
||||
await this.updateUnread();
|
||||
}
|
||||
|
||||
async updateUnread(): Promise<void> {
|
||||
const unreadCount = await window.Signal.Data.getTotalUnreadForConversation(
|
||||
this.id
|
||||
this.id,
|
||||
{
|
||||
storyId: undefined,
|
||||
isGroup: isGroup(this.attributes),
|
||||
}
|
||||
);
|
||||
|
||||
const prevUnreadCount = this.get('unreadCount');
|
||||
|
|
|
@ -91,7 +91,6 @@ import {
|
|||
isGroupV2Change,
|
||||
isIncoming,
|
||||
isKeyChange,
|
||||
isMessageHistoryUnsynced,
|
||||
isOutgoing,
|
||||
isStory,
|
||||
isProfileChange,
|
||||
|
@ -141,7 +140,7 @@ import {
|
|||
} from '../messages/helpers';
|
||||
import type { ReplacementValuesType } from '../types/I18N';
|
||||
import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
|
||||
import { getMessageIdForLogging } from '../util/getMessageIdForLogging';
|
||||
import { getMessageIdForLogging } from '../util/idForLogging';
|
||||
import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads';
|
||||
import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads';
|
||||
import { findStoryMessage } from '../util/findStoryMessage';
|
||||
|
@ -152,6 +151,8 @@ import { getMessageById } from '../messages/getMessageById';
|
|||
import { shouldDownloadStory } from '../util/shouldDownloadStory';
|
||||
import { shouldShowStoriesView } from '../state/selectors/stories';
|
||||
import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage';
|
||||
import { SeenStatus } from '../MessageSeenStatus';
|
||||
import { isNewReactionReplacingPrevious } from '../reactions/util';
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
/* eslint-disable more/no-then */
|
||||
|
@ -209,7 +210,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
|
||||
const readStatus = migrateLegacyReadStatus(this.attributes);
|
||||
if (readStatus !== undefined) {
|
||||
this.set('readStatus', readStatus, { silent: true });
|
||||
this.set(
|
||||
{
|
||||
readStatus,
|
||||
seenStatus:
|
||||
readStatus === ReadStatus.Unread
|
||||
? SeenStatus.Unseen
|
||||
: SeenStatus.Seen,
|
||||
},
|
||||
{ silent: true }
|
||||
);
|
||||
}
|
||||
|
||||
const sendStateByConversationId = migrateLegacySendAttributes(
|
||||
|
@ -233,12 +243,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
const { storyChanged } = window.reduxActions.stories;
|
||||
|
||||
if (isStory(this.attributes)) {
|
||||
const ourConversationId =
|
||||
window.ConversationController.getOurConversationIdOrThrow();
|
||||
const storyData = getStoryDataFromMessageAttributes(
|
||||
this.attributes,
|
||||
ourConversationId
|
||||
);
|
||||
const storyData = getStoryDataFromMessageAttributes(this.attributes);
|
||||
|
||||
if (!storyData) {
|
||||
return;
|
||||
|
@ -294,7 +299,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
!isGroupV2Change(attributes) &&
|
||||
!isGroupV1Migration(attributes) &&
|
||||
!isKeyChange(attributes) &&
|
||||
!isMessageHistoryUnsynced(attributes) &&
|
||||
!isProfileChange(attributes) &&
|
||||
!isUniversalTimerNotification(attributes) &&
|
||||
!isUnsupportedMessage(attributes) &&
|
||||
|
@ -1055,7 +1059,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
|
||||
// Locally-generated notifications
|
||||
const isKeyChangeValue = isKeyChange(attributes);
|
||||
const isMessageHistoryUnsyncedValue = isMessageHistoryUnsynced(attributes);
|
||||
const isProfileChangeValue = isProfileChange(attributes);
|
||||
const isUniversalTimerNotificationValue =
|
||||
isUniversalTimerNotification(attributes);
|
||||
|
@ -1084,7 +1087,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
hasErrorsValue ||
|
||||
// Locally-generated notifications
|
||||
isKeyChangeValue ||
|
||||
isMessageHistoryUnsyncedValue ||
|
||||
isProfileChangeValue ||
|
||||
isUniversalTimerNotificationValue;
|
||||
|
||||
|
@ -2440,9 +2442,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
|
||||
if (isStory(message.attributes)) {
|
||||
attributes.hasPostedStory = true;
|
||||
} else {
|
||||
attributes.active_at = now;
|
||||
}
|
||||
|
||||
attributes.active_at = now;
|
||||
conversation.set(attributes);
|
||||
|
||||
if (
|
||||
|
@ -2545,6 +2548,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
const isGroupStoryReply =
|
||||
isGroup(conversation.attributes) && message.get('storyId');
|
||||
if (
|
||||
!isStory(message.attributes) &&
|
||||
!isGroupStoryReply &&
|
||||
(!conversationTimestamp ||
|
||||
message.get('sent_at') > conversationTimestamp)
|
||||
|
@ -2643,6 +2647,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
|
||||
window.Whisper.events.trigger('incrementProgress');
|
||||
confirm();
|
||||
|
||||
conversation.queueJob('updateUnread', () => conversation.updateUnread());
|
||||
}
|
||||
|
||||
// This function is called twice - once from handleDataMessage, and then again from
|
||||
|
@ -2760,7 +2766,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
newReadStatus = ReadStatus.Read;
|
||||
}
|
||||
|
||||
message.set('readStatus', newReadStatus);
|
||||
message.set({
|
||||
readStatus: newReadStatus,
|
||||
seenStatus: SeenStatus.Seen,
|
||||
});
|
||||
changed = true;
|
||||
|
||||
this.pendingMarkRead = Math.min(
|
||||
|
@ -2769,7 +2778,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
);
|
||||
} else if (isFirstRun && !isGroupStoryReply) {
|
||||
conversation.set({
|
||||
unreadCount: (conversation.get('unreadCount') || 0) + 1,
|
||||
isArchived: false,
|
||||
});
|
||||
}
|
||||
|
@ -2890,14 +2898,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
|
||||
const reactions = reactionUtil.addOutgoingReaction(
|
||||
this.get('reactions') || [],
|
||||
newReaction
|
||||
newReaction,
|
||||
isStory(this.attributes)
|
||||
);
|
||||
this.set({ reactions });
|
||||
} else {
|
||||
const oldReactions = this.get('reactions') || [];
|
||||
let reactions: Array<MessageReactionType>;
|
||||
const oldReaction = oldReactions.find(
|
||||
re => re.fromId === reaction.get('fromId')
|
||||
const oldReaction = oldReactions.find(re =>
|
||||
isNewReactionReplacingPrevious(re, reaction.attributes, this.attributes)
|
||||
);
|
||||
if (oldReaction) {
|
||||
this.clearNotifications(oldReaction);
|
||||
|
@ -2912,12 +2921,20 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
if (reaction.get('source') === ReactionSource.FromSync) {
|
||||
reactions = oldReactions.filter(
|
||||
re =>
|
||||
re.fromId !== reaction.get('fromId') ||
|
||||
re.timestamp > reaction.get('timestamp')
|
||||
!isNewReactionReplacingPrevious(
|
||||
re,
|
||||
reaction.attributes,
|
||||
this.attributes
|
||||
) || re.timestamp > reaction.get('timestamp')
|
||||
);
|
||||
} else {
|
||||
reactions = oldReactions.filter(
|
||||
re => re.fromId !== reaction.get('fromId')
|
||||
re =>
|
||||
!isNewReactionReplacingPrevious(
|
||||
re,
|
||||
reaction.attributes,
|
||||
this.attributes
|
||||
)
|
||||
);
|
||||
}
|
||||
this.set({ reactions });
|
||||
|
@ -2946,7 +2963,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
}
|
||||
|
||||
reactions = oldReactions.filter(
|
||||
re => re.fromId !== reaction.get('fromId')
|
||||
re =>
|
||||
!isNewReactionReplacingPrevious(
|
||||
re,
|
||||
reaction.attributes,
|
||||
this.attributes
|
||||
)
|
||||
);
|
||||
reactions.push(reactionToAdd);
|
||||
this.set({ reactions });
|
||||
|
|
|
@ -2,8 +2,12 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { findLastIndex, has, identity, omit, negate } from 'lodash';
|
||||
import type { MessageReactionType } from '../model-types.d';
|
||||
import type {
|
||||
MessageAttributesType,
|
||||
MessageReactionType,
|
||||
} from '../model-types.d';
|
||||
import { areObjectEntriesEqual } from '../util/areObjectEntriesEqual';
|
||||
import { isStory } from '../state/selectors/message';
|
||||
|
||||
const isReactionEqual = (
|
||||
a: undefined | Readonly<MessageReactionType>,
|
||||
|
@ -31,8 +35,13 @@ const isOutgoingReactionCompletelyUnsent = ({
|
|||
|
||||
export function addOutgoingReaction(
|
||||
oldReactions: ReadonlyArray<MessageReactionType>,
|
||||
newReaction: Readonly<MessageReactionType>
|
||||
newReaction: Readonly<MessageReactionType>,
|
||||
isStoryMessage = false
|
||||
): Array<MessageReactionType> {
|
||||
if (isStoryMessage) {
|
||||
return [...oldReactions, newReaction];
|
||||
}
|
||||
|
||||
const pendingOutgoingReactions = new Set(
|
||||
oldReactions.filter(isOutgoingReactionPending)
|
||||
);
|
||||
|
@ -101,6 +110,17 @@ export function* getUnsentConversationIds({
|
|||
}
|
||||
}
|
||||
|
||||
// This function is used when filtering reactions so that we can limit normal
|
||||
// messages to a single reactions but allow multiple reactions from the same
|
||||
// sender for stories.
|
||||
export function isNewReactionReplacingPrevious(
|
||||
reaction: MessageReactionType,
|
||||
newReaction: MessageReactionType,
|
||||
messageAttributes: MessageAttributesType
|
||||
): boolean {
|
||||
return !isStory(messageAttributes) && reaction.fromId === newReaction.fromId;
|
||||
}
|
||||
|
||||
export const markOutgoingReactionFailed = (
|
||||
reactions: Array<MessageReactionType>,
|
||||
reaction: Readonly<MessageReactionType>
|
||||
|
@ -116,7 +136,8 @@ export const markOutgoingReactionFailed = (
|
|||
export const markOutgoingReactionSent = (
|
||||
reactions: ReadonlyArray<MessageReactionType>,
|
||||
reaction: Readonly<MessageReactionType>,
|
||||
conversationIdsSentTo: Iterable<string>
|
||||
conversationIdsSentTo: Iterable<string>,
|
||||
messageAttributes: MessageAttributesType
|
||||
): Array<MessageReactionType> => {
|
||||
const result: Array<MessageReactionType> = [];
|
||||
|
||||
|
@ -135,7 +156,8 @@ export const markOutgoingReactionSent = (
|
|||
if (!isReactionEqual(re, reaction)) {
|
||||
const shouldKeep = !isFullySent
|
||||
? true
|
||||
: re.fromId !== reaction.fromId || re.timestamp > reaction.timestamp;
|
||||
: !isNewReactionReplacingPrevious(re, reaction, messageAttributes) ||
|
||||
re.timestamp > reaction.timestamp;
|
||||
if (shouldKeep) {
|
||||
result.push(re);
|
||||
}
|
||||
|
|
|
@ -47,15 +47,25 @@ export async function afterSign({
|
|||
return;
|
||||
}
|
||||
|
||||
const teamId = process.env.APPLE_TEAM_ID;
|
||||
if (!teamId) {
|
||||
console.warn(
|
||||
'teamId must be provided in environment variable APPLE_TEAM_ID'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Notarizing with...');
|
||||
console.log(` primaryBundleId: ${appBundleId}`);
|
||||
console.log(` username: ${appleId}`);
|
||||
console.log(` file: ${appPath}`);
|
||||
|
||||
await notarize({
|
||||
tool: 'notarytool',
|
||||
appBundleId,
|
||||
appPath,
|
||||
appleId,
|
||||
appleIdPassword,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import type { MessageAttributesType } from '../model-types.d';
|
||||
import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus';
|
||||
import { notificationService } from './notifications';
|
||||
import { SeenStatus } from '../MessageSeenStatus';
|
||||
|
||||
function markReadOrViewed(
|
||||
messageAttrs: Readonly<MessageAttributesType>,
|
||||
|
@ -17,6 +18,7 @@ function markReadOrViewed(
|
|||
const nextMessageAttributes: MessageAttributesType = {
|
||||
...messageAttrs,
|
||||
readStatus: newReadStatus,
|
||||
seenStatus: SeenStatus.Seen,
|
||||
};
|
||||
|
||||
const { id: messageId, expireTimer, expirationStartTimestamp } = messageAttrs;
|
||||
|
|
|
@ -1111,16 +1111,6 @@ export async function mergeAccountRecord(
|
|||
|
||||
remotelyPinnedConversations.forEach(conversation => {
|
||||
conversation.set({ isPinned: true, isArchived: false });
|
||||
|
||||
if (
|
||||
window.Signal.Util.postLinkExperience.isActive() &&
|
||||
isGroupV2(conversation.attributes)
|
||||
) {
|
||||
log.info(
|
||||
'mergeAccountRecord: Adding the message history disclaimer on link'
|
||||
);
|
||||
conversation.addMessageHistoryDisclaimer();
|
||||
}
|
||||
updatedConversations.push(conversation);
|
||||
});
|
||||
|
||||
|
|
|
@ -17,8 +17,7 @@ export async function loadStories(): Promise<void> {
|
|||
}
|
||||
|
||||
export function getStoryDataFromMessageAttributes(
|
||||
message: MessageAttributesType,
|
||||
ourConversationId?: string
|
||||
message: MessageAttributesType
|
||||
): StoryDataType | undefined {
|
||||
const { attachments } = message;
|
||||
const unresolvedAttachment = attachments ? attachments[0] : undefined;
|
||||
|
@ -33,17 +32,13 @@ export function getStoryDataFromMessageAttributes(
|
|||
? getAttachmentsForMessage(message)
|
||||
: [unresolvedAttachment];
|
||||
|
||||
const selectedReaction = (
|
||||
(message.reactions || []).find(re => re.fromId === ourConversationId) || {}
|
||||
).emoji;
|
||||
|
||||
return {
|
||||
attachment,
|
||||
messageId: message.id,
|
||||
selectedReaction,
|
||||
...pick(message, [
|
||||
'conversationId',
|
||||
'deletedForEveryone',
|
||||
'reactions',
|
||||
'readStatus',
|
||||
'sendStateByConversationId',
|
||||
'source',
|
||||
|
@ -57,11 +52,8 @@ export function getStoryDataFromMessageAttributes(
|
|||
export function getStoriesForRedux(): Array<StoryDataType> {
|
||||
strictAssert(storyData, 'storyData has not been loaded');
|
||||
|
||||
const ourConversationId =
|
||||
window.ConversationController.getOurConversationId();
|
||||
|
||||
const stories = storyData
|
||||
.map(story => getStoryDataFromMessageAttributes(story, ourConversationId))
|
||||
.map(getStoryDataFromMessageAttributes)
|
||||
.filter(isNotNil);
|
||||
|
||||
storyData = undefined;
|
||||
|
|
|
@ -247,7 +247,7 @@ const dataInterface: ClientInterface = {
|
|||
migrateConversationMessages,
|
||||
|
||||
getUnprocessedCount,
|
||||
getAllUnprocessed,
|
||||
getAllUnprocessedAndIncrementAttempts,
|
||||
getUnprocessedById,
|
||||
updateUnprocessedWithData,
|
||||
updateUnprocessedsWithData,
|
||||
|
@ -1170,9 +1170,12 @@ async function getMessageBySender({
|
|||
|
||||
async function getTotalUnreadForConversation(
|
||||
conversationId: string,
|
||||
storyId?: UUIDStringType
|
||||
options: {
|
||||
storyId: UUIDStringType | undefined;
|
||||
isGroup: boolean;
|
||||
}
|
||||
) {
|
||||
return channels.getTotalUnreadForConversation(conversationId, storyId);
|
||||
return channels.getTotalUnreadForConversation(conversationId, options);
|
||||
}
|
||||
|
||||
async function getUnreadByConversationAndMarkRead(options: {
|
||||
|
@ -1440,8 +1443,8 @@ async function getUnprocessedCount() {
|
|||
return channels.getUnprocessedCount();
|
||||
}
|
||||
|
||||
async function getAllUnprocessed() {
|
||||
return channels.getAllUnprocessed();
|
||||
async function getAllUnprocessedAndIncrementAttempts() {
|
||||
return channels.getAllUnprocessedAndIncrementAttempts();
|
||||
}
|
||||
|
||||
async function getUnprocessedById(id: string) {
|
||||
|
|
|
@ -21,6 +21,7 @@ import type { UUIDStringType } from '../types/UUID';
|
|||
import type { BadgeType } from '../badges/types';
|
||||
import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration';
|
||||
import type { LoggerType } from '../types/Logging';
|
||||
import type { ReadStatus } from '../messages/MessageReadStatus';
|
||||
|
||||
export type AttachmentDownloadJobTypeType =
|
||||
| 'long-message'
|
||||
|
@ -50,8 +51,8 @@ export type MessageMetricsType = {
|
|||
export type ConversationMetricsType = {
|
||||
oldest?: MessageMetricsType;
|
||||
newest?: MessageMetricsType;
|
||||
oldestUnread?: MessageMetricsType;
|
||||
totalUnread: number;
|
||||
oldestUnseen?: MessageMetricsType;
|
||||
totalUnseen: number;
|
||||
};
|
||||
export type ConversationType = ConversationAttributesType;
|
||||
export type EmojiType = {
|
||||
|
@ -384,7 +385,10 @@ export type DataInterface = {
|
|||
removeMessages: (ids: Array<string>) => Promise<void>;
|
||||
getTotalUnreadForConversation: (
|
||||
conversationId: string,
|
||||
storyId?: UUIDStringType
|
||||
options: {
|
||||
storyId: UUIDStringType | undefined;
|
||||
isGroup: boolean;
|
||||
}
|
||||
) => Promise<number>;
|
||||
getUnreadByConversationAndMarkRead: (options: {
|
||||
conversationId: string;
|
||||
|
@ -394,7 +398,16 @@ export type DataInterface = {
|
|||
storyId?: UUIDStringType;
|
||||
}) => Promise<
|
||||
Array<
|
||||
Pick<MessageType, 'id' | 'source' | 'sourceUuid' | 'sent_at' | 'type'>
|
||||
{ originalReadStatus: ReadStatus | undefined } & Pick<
|
||||
MessageType,
|
||||
| 'id'
|
||||
| 'readStatus'
|
||||
| 'seenStatus'
|
||||
| 'sent_at'
|
||||
| 'source'
|
||||
| 'sourceUuid'
|
||||
| 'type'
|
||||
>
|
||||
>
|
||||
>;
|
||||
getUnreadReactionsAndMarkRead: (options: {
|
||||
|
@ -471,7 +484,7 @@ export type DataInterface = {
|
|||
) => Promise<void>;
|
||||
|
||||
getUnprocessedCount: () => Promise<number>;
|
||||
getAllUnprocessed: () => Promise<Array<UnprocessedType>>;
|
||||
getAllUnprocessedAndIncrementAttempts: () => Promise<Array<UnprocessedType>>;
|
||||
updateUnprocessedWithData: (
|
||||
id: string,
|
||||
data: UnprocessedUpdateType
|
||||
|
|
179
ts/sql/Server.ts
179
ts/sql/Server.ts
|
@ -110,6 +110,7 @@ import type {
|
|||
UnprocessedType,
|
||||
UnprocessedUpdateType,
|
||||
} from './Interface';
|
||||
import { SeenStatus } from '../MessageSeenStatus';
|
||||
|
||||
type ConversationRow = Readonly<{
|
||||
json: string;
|
||||
|
@ -243,7 +244,7 @@ const dataInterface: ServerInterface = {
|
|||
migrateConversationMessages,
|
||||
|
||||
getUnprocessedCount,
|
||||
getAllUnprocessed,
|
||||
getAllUnprocessedAndIncrementAttempts,
|
||||
updateUnprocessedWithData,
|
||||
updateUnprocessedsWithData,
|
||||
getUnprocessedById,
|
||||
|
@ -594,6 +595,7 @@ async function removeDB(): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
logger.warn('removeDB: Removing all database files');
|
||||
rimraf.sync(databaseFilePath);
|
||||
rimraf.sync(`${databaseFilePath}-shm`);
|
||||
rimraf.sync(`${databaseFilePath}-wal`);
|
||||
|
@ -1737,6 +1739,20 @@ function saveMessageSync(
|
|||
expireTimer,
|
||||
expirationStartTimestamp,
|
||||
} = data;
|
||||
let { seenStatus } = data;
|
||||
|
||||
if (readStatus === ReadStatus.Unread && seenStatus !== SeenStatus.Unseen) {
|
||||
log.warn(
|
||||
`saveMessage: Message ${id}/${type} is unread but had seenStatus=${seenStatus}. Forcing to UnseenStatus.Unseen.`
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
data = {
|
||||
...data,
|
||||
seenStatus: SeenStatus.Unseen,
|
||||
};
|
||||
seenStatus = SeenStatus.Unseen;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
id,
|
||||
|
@ -1762,6 +1778,7 @@ function saveMessageSync(
|
|||
storyId: storyId || null,
|
||||
type: type || null,
|
||||
readStatus: readStatus ?? null,
|
||||
seenStatus: seenStatus ?? SeenStatus.NotApplicable,
|
||||
};
|
||||
|
||||
if (id && !forceSave) {
|
||||
|
@ -1791,7 +1808,8 @@ function saveMessageSync(
|
|||
sourceDevice = $sourceDevice,
|
||||
storyId = $storyId,
|
||||
type = $type,
|
||||
readStatus = $readStatus
|
||||
readStatus = $readStatus,
|
||||
seenStatus = $seenStatus
|
||||
WHERE id = $id;
|
||||
`
|
||||
).run(payload);
|
||||
|
@ -1834,7 +1852,8 @@ function saveMessageSync(
|
|||
sourceDevice,
|
||||
storyId,
|
||||
type,
|
||||
readStatus
|
||||
readStatus,
|
||||
seenStatus
|
||||
) values (
|
||||
$id,
|
||||
$json,
|
||||
|
@ -1858,7 +1877,8 @@ function saveMessageSync(
|
|||
$sourceDevice,
|
||||
$storyId,
|
||||
$type,
|
||||
$readStatus
|
||||
$readStatus,
|
||||
$seenStatus
|
||||
);
|
||||
`
|
||||
).run({
|
||||
|
@ -2056,7 +2076,18 @@ async function getUnreadByConversationAndMarkRead({
|
|||
storyId?: UUIDStringType;
|
||||
readAt?: number;
|
||||
}): Promise<
|
||||
Array<Pick<MessageType, 'id' | 'source' | 'sourceUuid' | 'sent_at' | 'type'>>
|
||||
Array<
|
||||
{ originalReadStatus: ReadStatus | undefined } & Pick<
|
||||
MessageType,
|
||||
| 'id'
|
||||
| 'source'
|
||||
| 'sourceUuid'
|
||||
| 'sent_at'
|
||||
| 'type'
|
||||
| 'readStatus'
|
||||
| 'seenStatus'
|
||||
>
|
||||
>
|
||||
> {
|
||||
const db = getInstance();
|
||||
return db.transaction(() => {
|
||||
|
@ -2090,10 +2121,10 @@ async function getUnreadByConversationAndMarkRead({
|
|||
.prepare<Query>(
|
||||
`
|
||||
SELECT id, json FROM messages
|
||||
INDEXED BY messages_unread
|
||||
WHERE
|
||||
readStatus = ${ReadStatus.Unread} AND
|
||||
conversationId = $conversationId AND
|
||||
seenStatus = ${SeenStatus.Unseen} AND
|
||||
isStory = 0 AND
|
||||
(${_storyIdPredicate(storyId, isGroup)}) AND
|
||||
received_at <= $newestUnreadAt
|
||||
ORDER BY received_at DESC, sent_at DESC;
|
||||
|
@ -2110,16 +2141,21 @@ async function getUnreadByConversationAndMarkRead({
|
|||
UPDATE messages
|
||||
SET
|
||||
readStatus = ${ReadStatus.Read},
|
||||
seenStatus = ${SeenStatus.Seen},
|
||||
json = json_patch(json, $jsonPatch)
|
||||
WHERE
|
||||
readStatus = ${ReadStatus.Unread} AND
|
||||
conversationId = $conversationId AND
|
||||
seenStatus = ${SeenStatus.Unseen} AND
|
||||
isStory = 0 AND
|
||||
(${_storyIdPredicate(storyId, isGroup)}) AND
|
||||
received_at <= $newestUnreadAt;
|
||||
`
|
||||
).run({
|
||||
conversationId,
|
||||
jsonPatch: JSON.stringify({ readStatus: ReadStatus.Read }),
|
||||
jsonPatch: JSON.stringify({
|
||||
readStatus: ReadStatus.Read,
|
||||
seenStatus: SeenStatus.Seen,
|
||||
}),
|
||||
newestUnreadAt,
|
||||
storyId: storyId || null,
|
||||
});
|
||||
|
@ -2127,7 +2163,9 @@ async function getUnreadByConversationAndMarkRead({
|
|||
return rows.map(row => {
|
||||
const json = jsonToObject<MessageType>(row.json);
|
||||
return {
|
||||
originalReadStatus: json.readStatus,
|
||||
readStatus: ReadStatus.Read,
|
||||
seenStatus: SeenStatus.Seen,
|
||||
...pick(json, [
|
||||
'expirationStartTimestamp',
|
||||
'id',
|
||||
|
@ -2644,7 +2682,7 @@ async function getLastConversationMessage({
|
|||
return jsonToObject(row.json);
|
||||
}
|
||||
|
||||
function getOldestUnreadMessageForConversation(
|
||||
function getOldestUnseenMessageForConversation(
|
||||
conversationId: string,
|
||||
storyId?: UUIDStringType,
|
||||
isGroup?: boolean
|
||||
|
@ -2655,7 +2693,7 @@ function getOldestUnreadMessageForConversation(
|
|||
`
|
||||
SELECT * FROM messages WHERE
|
||||
conversationId = $conversationId AND
|
||||
readStatus = ${ReadStatus.Unread} AND
|
||||
seenStatus = ${SeenStatus.Unseen} AND
|
||||
isStory IS 0 AND
|
||||
(${_storyIdPredicate(storyId, isGroup)})
|
||||
ORDER BY received_at ASC, sent_at ASC
|
||||
|
@ -2676,14 +2714,22 @@ function getOldestUnreadMessageForConversation(
|
|||
|
||||
async function getTotalUnreadForConversation(
|
||||
conversationId: string,
|
||||
storyId?: UUIDStringType
|
||||
options: {
|
||||
storyId: UUIDStringType | undefined;
|
||||
isGroup: boolean;
|
||||
}
|
||||
): Promise<number> {
|
||||
return getTotalUnreadForConversationSync(conversationId, storyId);
|
||||
return getTotalUnreadForConversationSync(conversationId, options);
|
||||
}
|
||||
function getTotalUnreadForConversationSync(
|
||||
conversationId: string,
|
||||
storyId?: UUIDStringType,
|
||||
isGroup?: boolean
|
||||
{
|
||||
storyId,
|
||||
isGroup,
|
||||
}: {
|
||||
storyId: UUIDStringType | undefined;
|
||||
isGroup: boolean;
|
||||
}
|
||||
): number {
|
||||
const db = getInstance();
|
||||
const row = db
|
||||
|
@ -2709,6 +2755,35 @@ function getTotalUnreadForConversationSync(
|
|||
|
||||
return row['count(id)'];
|
||||
}
|
||||
function getTotalUnseenForConversationSync(
|
||||
conversationId: string,
|
||||
storyId?: UUIDStringType,
|
||||
isGroup?: boolean
|
||||
): number {
|
||||
const db = getInstance();
|
||||
const row = db
|
||||
.prepare<Query>(
|
||||
`
|
||||
SELECT count(id)
|
||||
FROM messages
|
||||
WHERE
|
||||
conversationId = $conversationId AND
|
||||
seenStatus = ${SeenStatus.Unseen} AND
|
||||
isStory IS 0 AND
|
||||
(${_storyIdPredicate(storyId, isGroup)})
|
||||
`
|
||||
)
|
||||
.get({
|
||||
conversationId,
|
||||
storyId: storyId || null,
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new Error('getTotalUnseenForConversationSync: Unable to get count');
|
||||
}
|
||||
|
||||
return row['count(id)'];
|
||||
}
|
||||
|
||||
async function getMessageMetricsForConversation(
|
||||
conversationId: string,
|
||||
|
@ -2732,12 +2807,12 @@ function getMessageMetricsForConversationSync(
|
|||
storyId,
|
||||
isGroup
|
||||
);
|
||||
const oldestUnread = getOldestUnreadMessageForConversation(
|
||||
const oldestUnseen = getOldestUnseenMessageForConversation(
|
||||
conversationId,
|
||||
storyId,
|
||||
isGroup
|
||||
);
|
||||
const totalUnread = getTotalUnreadForConversationSync(
|
||||
const totalUnseen = getTotalUnseenForConversationSync(
|
||||
conversationId,
|
||||
storyId,
|
||||
isGroup
|
||||
|
@ -2746,10 +2821,10 @@ function getMessageMetricsForConversationSync(
|
|||
return {
|
||||
oldest: oldest ? pick(oldest, ['received_at', 'sent_at', 'id']) : undefined,
|
||||
newest: newest ? pick(newest, ['received_at', 'sent_at', 'id']) : undefined,
|
||||
oldestUnread: oldestUnread
|
||||
? pick(oldestUnread, ['received_at', 'sent_at', 'id'])
|
||||
oldestUnseen: oldestUnseen
|
||||
? pick(oldestUnseen, ['received_at', 'sent_at', 'id'])
|
||||
: undefined,
|
||||
totalUnread,
|
||||
totalUnseen,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -3120,32 +3195,58 @@ async function getUnprocessedCount(): Promise<number> {
|
|||
return getCountFromTable(getInstance(), 'unprocessed');
|
||||
}
|
||||
|
||||
async function getAllUnprocessed(): Promise<Array<UnprocessedType>> {
|
||||
async function getAllUnprocessedAndIncrementAttempts(): Promise<
|
||||
Array<UnprocessedType>
|
||||
> {
|
||||
const db = getInstance();
|
||||
|
||||
const { changes: deletedCount } = db
|
||||
.prepare<Query>('DELETE FROM unprocessed WHERE timestamp < $monthAgo')
|
||||
.run({
|
||||
monthAgo: Date.now() - durations.MONTH,
|
||||
});
|
||||
return db.transaction(() => {
|
||||
const { changes: deletedStaleCount } = db
|
||||
.prepare<Query>('DELETE FROM unprocessed WHERE timestamp < $monthAgo')
|
||||
.run({
|
||||
monthAgo: Date.now() - durations.MONTH,
|
||||
});
|
||||
|
||||
if (deletedCount !== 0) {
|
||||
logger.warn(
|
||||
`getAllUnprocessed: deleting ${deletedCount} old unprocessed envelopes`
|
||||
);
|
||||
}
|
||||
if (deletedStaleCount !== 0) {
|
||||
logger.warn(
|
||||
'getAllUnprocessedAndIncrementAttempts: ' +
|
||||
`deleting ${deletedStaleCount} old unprocessed envelopes`
|
||||
);
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare<EmptyQuery>(
|
||||
db.prepare<EmptyQuery>(
|
||||
`
|
||||
SELECT *
|
||||
FROM unprocessed
|
||||
ORDER BY timestamp ASC;
|
||||
UPDATE unprocessed
|
||||
SET attempts = attempts + 1
|
||||
`
|
||||
)
|
||||
.all();
|
||||
).run();
|
||||
|
||||
return rows;
|
||||
const { changes: deletedInvalidCount } = db
|
||||
.prepare<Query>(
|
||||
`
|
||||
DELETE FROM unprocessed
|
||||
WHERE attempts >= $MAX_UNPROCESSED_ATTEMPTS
|
||||
`
|
||||
)
|
||||
.run({ MAX_UNPROCESSED_ATTEMPTS });
|
||||
|
||||
if (deletedInvalidCount !== 0) {
|
||||
logger.warn(
|
||||
'getAllUnprocessedAndIncrementAttempts: ' +
|
||||
`deleting ${deletedInvalidCount} invalid unprocessed envelopes`
|
||||
);
|
||||
}
|
||||
|
||||
return db
|
||||
.prepare<EmptyQuery>(
|
||||
`
|
||||
SELECT *
|
||||
FROM unprocessed
|
||||
ORDER BY timestamp ASC;
|
||||
`
|
||||
)
|
||||
.all();
|
||||
})();
|
||||
}
|
||||
|
||||
function removeUnprocessedsSync(ids: Array<string>): void {
|
||||
|
|
88
ts/sql/migrations/56-add-unseen-to-message.ts
Normal file
88
ts/sql/migrations/56-add-unseen-to-message.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||
import { SeenStatus } from '../../MessageSeenStatus';
|
||||
|
||||
import type { LoggerType } from '../../types/Logging';
|
||||
|
||||
export default function updateToSchemaVersion56(
|
||||
currentVersion: number,
|
||||
db: Database,
|
||||
logger: LoggerType
|
||||
): void {
|
||||
if (currentVersion >= 56) {
|
||||
return;
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
db.exec(
|
||||
`
|
||||
--- Add column to messages table
|
||||
|
||||
ALTER TABLE messages ADD COLUMN seenStatus NUMBER default 0;
|
||||
|
||||
--- Add index to make searching on this field easy
|
||||
|
||||
CREATE INDEX messages_unseen_no_story ON messages
|
||||
(conversationId, seenStatus, isStory, received_at, sent_at)
|
||||
WHERE
|
||||
seenStatus IS NOT NULL;
|
||||
|
||||
CREATE INDEX messages_unseen_with_story ON messages
|
||||
(conversationId, seenStatus, isStory, storyId, received_at, sent_at)
|
||||
WHERE
|
||||
seenStatus IS NOT NULL;
|
||||
|
||||
--- Update seenStatus to UnseenStatus.Unseen for certain messages
|
||||
--- (NULL included because 'timer-notification' in 1:1 convos had type = NULL)
|
||||
|
||||
UPDATE messages
|
||||
SET
|
||||
seenStatus = ${SeenStatus.Unseen}
|
||||
WHERE
|
||||
readStatus = ${ReadStatus.Unread} AND
|
||||
(
|
||||
type IS NULL
|
||||
OR
|
||||
type IN (
|
||||
'call-history',
|
||||
'change-number-notification',
|
||||
'chat-session-refreshed',
|
||||
'delivery-issue',
|
||||
'group',
|
||||
'incoming',
|
||||
'keychange',
|
||||
'timer-notification',
|
||||
'verified-change'
|
||||
)
|
||||
);
|
||||
|
||||
--- Set readStatus to ReadStatus.Read for all other message types
|
||||
|
||||
UPDATE messages
|
||||
SET
|
||||
readStatus = ${ReadStatus.Read}
|
||||
WHERE
|
||||
readStatus = ${ReadStatus.Unread} AND
|
||||
type IS NOT NULL AND
|
||||
type NOT IN (
|
||||
'call-history',
|
||||
'change-number-notification',
|
||||
'chat-session-refreshed',
|
||||
'delivery-issue',
|
||||
'group',
|
||||
'incoming',
|
||||
'keychange',
|
||||
'timer-notification',
|
||||
'verified-change'
|
||||
);
|
||||
`
|
||||
);
|
||||
|
||||
db.pragma('user_version = 56');
|
||||
})();
|
||||
|
||||
logger.info('updateToSchemaVersion56: success!');
|
||||
}
|
29
ts/sql/migrations/57-rm-message-history-unsynced.ts
Normal file
29
ts/sql/migrations/57-rm-message-history-unsynced.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Database } from 'better-sqlite3';
|
||||
|
||||
import type { LoggerType } from '../../types/Logging';
|
||||
|
||||
export default function updateToSchemaVersion57(
|
||||
currentVersion: number,
|
||||
db: Database,
|
||||
logger: LoggerType
|
||||
): void {
|
||||
if (currentVersion >= 57) {
|
||||
return;
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
db.exec(
|
||||
`
|
||||
DELETE FROM messages
|
||||
WHERE type IS 'message-history-unsynced';
|
||||
`
|
||||
);
|
||||
|
||||
db.pragma('user_version = 57');
|
||||
})();
|
||||
|
||||
logger.info('updateToSchemaVersion57: success!');
|
||||
}
|
|
@ -31,6 +31,8 @@ import updateToSchemaVersion52 from './52-optimize-stories';
|
|||
import updateToSchemaVersion53 from './53-gv2-banned-members';
|
||||
import updateToSchemaVersion54 from './54-unprocessed-received-at-counter';
|
||||
import updateToSchemaVersion55 from './55-report-message-aci';
|
||||
import updateToSchemaVersion56 from './56-add-unseen-to-message';
|
||||
import updateToSchemaVersion57 from './57-rm-message-history-unsynced';
|
||||
|
||||
function updateToSchemaVersion1(
|
||||
currentVersion: number,
|
||||
|
@ -1925,6 +1927,8 @@ export const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion53,
|
||||
updateToSchemaVersion54,
|
||||
updateToSchemaVersion55,
|
||||
updateToSchemaVersion56,
|
||||
updateToSchemaVersion57,
|
||||
];
|
||||
|
||||
export function updateSchema(db: Database, logger: LoggerType): void {
|
||||
|
|
|
@ -234,8 +234,8 @@ type MessagePointerType = {
|
|||
type MessageMetricsType = {
|
||||
newest?: MessagePointerType;
|
||||
oldest?: MessagePointerType;
|
||||
oldestUnread?: MessagePointerType;
|
||||
totalUnread: number;
|
||||
oldestUnseen?: MessagePointerType;
|
||||
totalUnseen: number;
|
||||
};
|
||||
|
||||
export type MessageLookupType = {
|
||||
|
@ -2673,7 +2673,7 @@ export function reducer(
|
|||
let metrics;
|
||||
if (messageIds.length === 0) {
|
||||
metrics = {
|
||||
totalUnread: 0,
|
||||
totalUnseen: 0,
|
||||
};
|
||||
} else {
|
||||
metrics = {
|
||||
|
@ -2791,7 +2791,7 @@ export function reducer(
|
|||
return state;
|
||||
}
|
||||
|
||||
let { newest, oldest, oldestUnread, totalUnread } =
|
||||
let { newest, oldest, oldestUnseen, totalUnseen } =
|
||||
existingConversation.metrics;
|
||||
|
||||
if (messages.length < 1) {
|
||||
|
@ -2853,7 +2853,7 @@ export function reducer(
|
|||
const newMessageIds = difference(newIds, existingConversation.messageIds);
|
||||
const { isNearBottom } = existingConversation;
|
||||
|
||||
if ((!isNearBottom || !isActive) && !oldestUnread) {
|
||||
if ((!isNearBottom || !isActive) && !oldestUnseen) {
|
||||
const oldestId = newMessageIds.find(messageId => {
|
||||
const message = lookup[messageId];
|
||||
|
||||
|
@ -2861,7 +2861,7 @@ export function reducer(
|
|||
});
|
||||
|
||||
if (oldestId) {
|
||||
oldestUnread = pick(lookup[oldestId], [
|
||||
oldestUnseen = pick(lookup[oldestId], [
|
||||
'id',
|
||||
'received_at',
|
||||
'sent_at',
|
||||
|
@ -2869,14 +2869,14 @@ export function reducer(
|
|||
}
|
||||
}
|
||||
|
||||
// If this is a new incoming message, we'll increment our totalUnread count
|
||||
if (isNewMessage && !isJustSent && oldestUnread) {
|
||||
// If this is a new incoming message, we'll increment our totalUnseen count
|
||||
if (isNewMessage && !isJustSent && oldestUnseen) {
|
||||
const newUnread: number = newMessageIds.reduce((sum, messageId) => {
|
||||
const message = lookup[messageId];
|
||||
|
||||
return sum + (message && isMessageUnread(message) ? 1 : 0);
|
||||
}, 0);
|
||||
totalUnread = (totalUnread || 0) + newUnread;
|
||||
totalUnseen = (totalUnseen || 0) + newUnread;
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -2896,8 +2896,8 @@ export function reducer(
|
|||
...existingConversation.metrics,
|
||||
newest,
|
||||
oldest,
|
||||
totalUnread,
|
||||
oldestUnread,
|
||||
totalUnseen,
|
||||
oldestUnseen,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -2926,8 +2926,8 @@ export function reducer(
|
|||
...existingConversation,
|
||||
metrics: {
|
||||
...existingConversation.metrics,
|
||||
oldestUnread: undefined,
|
||||
totalUnread: 0,
|
||||
oldestUnseen: undefined,
|
||||
totalUnseen: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -25,7 +25,11 @@ import { markViewed } from '../../services/MessageUpdater';
|
|||
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
|
||||
import { replaceIndex } from '../../util/replaceIndex';
|
||||
import { showToast } from '../../util/showToast';
|
||||
import { isDownloaded, isDownloading } from '../../types/Attachment';
|
||||
import {
|
||||
hasNotResolved,
|
||||
isDownloaded,
|
||||
isDownloading,
|
||||
} from '../../types/Attachment';
|
||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
|
||||
import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
|
||||
|
@ -33,11 +37,11 @@ import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
|
|||
export type StoryDataType = {
|
||||
attachment?: AttachmentType;
|
||||
messageId: string;
|
||||
selectedReaction?: string;
|
||||
} & Pick<
|
||||
MessageAttributesType,
|
||||
| 'conversationId'
|
||||
| 'deletedForEveryone'
|
||||
| 'reactions'
|
||||
| 'readStatus'
|
||||
| 'sendStateByConversationId'
|
||||
| 'source'
|
||||
|
@ -61,8 +65,8 @@ export type StoriesStateType = {
|
|||
|
||||
const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES';
|
||||
const MARK_STORY_READ = 'stories/MARK_STORY_READ';
|
||||
const REACT_TO_STORY = 'stories/REACT_TO_STORY';
|
||||
const REPLY_TO_STORY = 'stories/REPLY_TO_STORY';
|
||||
export const RESOLVE_ATTACHMENT_URL = 'stories/RESOLVE_ATTACHMENT_URL';
|
||||
const STORY_CHANGED = 'stories/STORY_CHANGED';
|
||||
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
|
||||
|
||||
|
@ -79,19 +83,19 @@ type MarkStoryReadActionType = {
|
|||
payload: string;
|
||||
};
|
||||
|
||||
type ReactToStoryActionType = {
|
||||
type: typeof REACT_TO_STORY;
|
||||
payload: {
|
||||
messageId: string;
|
||||
selectedReaction: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ReplyToStoryActionType = {
|
||||
type: typeof REPLY_TO_STORY;
|
||||
payload: MessageAttributesType;
|
||||
};
|
||||
|
||||
type ResolveAttachmentUrlActionType = {
|
||||
type: typeof RESOLVE_ATTACHMENT_URL;
|
||||
payload: {
|
||||
messageId: string;
|
||||
attachmentUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
type StoryChangedActionType = {
|
||||
type: typeof STORY_CHANGED;
|
||||
payload: StoryDataType;
|
||||
|
@ -106,8 +110,8 @@ export type StoriesActionType =
|
|||
| MarkStoryReadActionType
|
||||
| MessageChangedActionType
|
||||
| MessageDeletedActionType
|
||||
| ReactToStoryActionType
|
||||
| ReplyToStoryActionType
|
||||
| ResolveAttachmentUrlActionType
|
||||
| StoryChangedActionType
|
||||
| ToggleViewActionType;
|
||||
|
||||
|
@ -206,7 +210,12 @@ function markStoryRead(
|
|||
|
||||
function queueStoryDownload(
|
||||
storyId: string
|
||||
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
||||
): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
NoopActionType | ResolveAttachmentUrlActionType
|
||||
> {
|
||||
return async dispatch => {
|
||||
const story = await getMessageById(storyId);
|
||||
|
||||
|
@ -226,6 +235,25 @@ function queueStoryDownload(
|
|||
}
|
||||
|
||||
if (isDownloaded(attachment)) {
|
||||
if (!attachment.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This function also resolves the attachment's URL in case we've already
|
||||
// downloaded the attachment but haven't pointed its path to an absolute
|
||||
// location on disk.
|
||||
if (hasNotResolved(attachment)) {
|
||||
dispatch({
|
||||
type: RESOLVE_ATTACHMENT_URL,
|
||||
payload: {
|
||||
messageId: storyId,
|
||||
attachmentUrl: window.Signal.Migrations.getAbsoluteAttachmentPath(
|
||||
attachment.path
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -248,27 +276,24 @@ function queueStoryDownload(
|
|||
|
||||
function reactToStory(
|
||||
nextReaction: string,
|
||||
messageId: string,
|
||||
previousReaction?: string
|
||||
): ThunkAction<void, RootStateType, unknown, ReactToStoryActionType> {
|
||||
messageId: string
|
||||
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
||||
return async dispatch => {
|
||||
try {
|
||||
await enqueueReactionForSend({
|
||||
messageId,
|
||||
emoji: nextReaction,
|
||||
remove: nextReaction === previousReaction,
|
||||
});
|
||||
dispatch({
|
||||
type: REACT_TO_STORY,
|
||||
payload: {
|
||||
messageId,
|
||||
selectedReaction: nextReaction,
|
||||
},
|
||||
remove: false,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Error enqueuing reaction', error, messageId, nextReaction);
|
||||
showToast(ToastReactionFailed);
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'NOOP',
|
||||
payload: null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -365,8 +390,8 @@ export function reducer(
|
|||
'conversationId',
|
||||
'deletedForEveryone',
|
||||
'messageId',
|
||||
'reactions',
|
||||
'readStatus',
|
||||
'selectedReaction',
|
||||
'sendStateByConversationId',
|
||||
'source',
|
||||
'sourceUuid',
|
||||
|
@ -386,9 +411,14 @@ export function reducer(
|
|||
!isDownloaded(prevStory.attachment) &&
|
||||
isDownloaded(newStory.attachment);
|
||||
const readStatusChanged = prevStory.readStatus !== newStory.readStatus;
|
||||
const reactionsChanged =
|
||||
prevStory.reactions?.length !== newStory.reactions?.length;
|
||||
|
||||
const shouldReplace =
|
||||
isDownloadingAttachment || hasAttachmentDownloaded || readStatusChanged;
|
||||
isDownloadingAttachment ||
|
||||
hasAttachmentDownloaded ||
|
||||
readStatusChanged ||
|
||||
reactionsChanged;
|
||||
if (!shouldReplace) {
|
||||
return state;
|
||||
}
|
||||
|
@ -410,22 +440,6 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === REACT_TO_STORY) {
|
||||
return {
|
||||
...state,
|
||||
stories: state.stories.map(story => {
|
||||
if (story.messageId === action.payload.messageId) {
|
||||
return {
|
||||
...story,
|
||||
selectedReaction: action.payload.selectedReaction,
|
||||
};
|
||||
}
|
||||
|
||||
return story;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === MARK_STORY_READ) {
|
||||
return {
|
||||
...state,
|
||||
|
@ -500,5 +514,40 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (action.type === RESOLVE_ATTACHMENT_URL) {
|
||||
const { messageId, attachmentUrl } = action.payload;
|
||||
|
||||
const storyIndex = state.stories.findIndex(
|
||||
existingStory => existingStory.messageId === messageId
|
||||
);
|
||||
|
||||
if (storyIndex < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const story = state.stories[storyIndex];
|
||||
|
||||
if (!story.attachment) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const storyWithResolvedAttachment = {
|
||||
...story,
|
||||
attachment: {
|
||||
...story.attachment,
|
||||
url: attachmentUrl,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
stories: replaceIndex(
|
||||
state.stories,
|
||||
storyIndex,
|
||||
storyWithResolvedAttachment
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -839,7 +839,7 @@ export function _conversationMessagesSelector(
|
|||
const lastId =
|
||||
messageIds.length === 0 ? undefined : messageIds[messageIds.length - 1];
|
||||
|
||||
const { oldestUnread } = metrics;
|
||||
const { oldestUnseen } = metrics;
|
||||
|
||||
const haveNewest = !metrics.newest || !lastId || lastId === metrics.newest.id;
|
||||
const haveOldest =
|
||||
|
@ -847,13 +847,13 @@ export function _conversationMessagesSelector(
|
|||
|
||||
const items = messageIds;
|
||||
|
||||
const oldestUnreadIndex = oldestUnread
|
||||
? messageIds.findIndex(id => id === oldestUnread.id)
|
||||
const oldestUnseenIndex = oldestUnseen
|
||||
? messageIds.findIndex(id => id === oldestUnseen.id)
|
||||
: undefined;
|
||||
const scrollToIndex = scrollToMessageId
|
||||
? messageIds.findIndex(id => id === scrollToMessageId)
|
||||
: undefined;
|
||||
const { totalUnread } = metrics;
|
||||
const { totalUnseen } = metrics;
|
||||
|
||||
return {
|
||||
haveNewest,
|
||||
|
@ -861,14 +861,14 @@ export function _conversationMessagesSelector(
|
|||
isNearBottom,
|
||||
items,
|
||||
messageLoadingState,
|
||||
oldestUnreadIndex:
|
||||
isNumber(oldestUnreadIndex) && oldestUnreadIndex >= 0
|
||||
? oldestUnreadIndex
|
||||
oldestUnseenIndex:
|
||||
isNumber(oldestUnseenIndex) && oldestUnseenIndex >= 0
|
||||
? oldestUnseenIndex
|
||||
: undefined,
|
||||
scrollToIndex:
|
||||
isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : undefined,
|
||||
scrollToIndexCounter: scrollToMessageCounter,
|
||||
totalUnread,
|
||||
totalUnseen,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -901,7 +901,7 @@ export const getConversationMessagesSelector = createSelector(
|
|||
haveOldest: false,
|
||||
messageLoadingState: TimelineMessageLoadingState.DoingInitialLoad,
|
||||
scrollToIndexCounter: 0,
|
||||
totalUnread: 0,
|
||||
totalUnseen: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
|
|
@ -785,13 +785,6 @@ export function getPropsForBubble(
|
|||
timestamp,
|
||||
};
|
||||
}
|
||||
if (isMessageHistoryUnsynced(message)) {
|
||||
return {
|
||||
type: 'linkNotification',
|
||||
data: null,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
if (isExpirationTimerUpdate(message)) {
|
||||
return {
|
||||
type: 'timerNotification',
|
||||
|
@ -984,14 +977,6 @@ function getPropsForGroupV1Migration(
|
|||
};
|
||||
}
|
||||
|
||||
// Message History Unsynced
|
||||
|
||||
export function isMessageHistoryUnsynced(
|
||||
message: MessageWithUIFieldsType
|
||||
): boolean {
|
||||
return message.type === 'message-history-unsynced';
|
||||
}
|
||||
|
||||
// Note: props are null!
|
||||
|
||||
// Expiration Timer Update
|
||||
|
|
|
@ -5,10 +5,12 @@ import { createSelector } from 'reselect';
|
|||
import { pick } from 'lodash';
|
||||
|
||||
import type { GetConversationByIdType } from './conversations';
|
||||
import type { ConversationType } from '../ducks/conversations';
|
||||
import type {
|
||||
ConversationStoryType,
|
||||
StoryViewType,
|
||||
} from '../../components/StoryListItem';
|
||||
import type { MessageReactionType } from '../../model-types.d';
|
||||
import type { ReplyStateType } from '../../types/Stories';
|
||||
import type { StateType } from '../reducer';
|
||||
import type { StoryDataType, StoriesStateType } from '../ducks/stories';
|
||||
|
@ -55,6 +57,35 @@ function sortByRecencyAndUnread(
|
|||
return storyA.timestamp > storyB.timestamp ? -1 : 1;
|
||||
}
|
||||
|
||||
function getReactionUniqueId(reaction: MessageReactionType): string {
|
||||
return `${reaction.fromId}:${reaction.targetAuthorUuid}:${reaction.timestamp}`;
|
||||
}
|
||||
|
||||
function getAvatarData(
|
||||
conversation: ConversationType
|
||||
): Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'color'
|
||||
| 'isMe'
|
||||
| 'name'
|
||||
| 'profileName'
|
||||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
> {
|
||||
return pick(conversation, [
|
||||
'acceptedMessageRequest',
|
||||
'avatarPath',
|
||||
'color',
|
||||
'isMe',
|
||||
'name',
|
||||
'profileName',
|
||||
'sharedGroupNames',
|
||||
'title',
|
||||
]);
|
||||
}
|
||||
|
||||
function getConversationStory(
|
||||
conversationSelector: GetConversationByIdType,
|
||||
story: StoryDataType,
|
||||
|
@ -92,7 +123,6 @@ function getConversationStory(
|
|||
canReply: canReply(story, ourConversationId, conversationSelector),
|
||||
isUnread: story.readStatus === ReadStatus.Unread,
|
||||
messageId: story.messageId,
|
||||
selectedReaction: story.selectedReaction,
|
||||
sender,
|
||||
timestamp,
|
||||
};
|
||||
|
@ -153,38 +183,56 @@ export const getStoryReplies = createSelector(
|
|||
conversationSelector,
|
||||
contactNameColorSelector,
|
||||
me,
|
||||
{ replyState }: Readonly<StoriesStateType>
|
||||
{ stories, replyState }: Readonly<StoriesStateType>
|
||||
): ReplyStateType | undefined => {
|
||||
if (!replyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const foundStory = stories.find(
|
||||
story => story.messageId === replyState.messageId
|
||||
);
|
||||
|
||||
const reactions = foundStory
|
||||
? (foundStory.reactions || []).map(reaction => {
|
||||
const conversation = conversationSelector(reaction.fromId);
|
||||
|
||||
return {
|
||||
...getAvatarData(conversation),
|
||||
contactNameColor: contactNameColorSelector(
|
||||
foundStory.conversationId,
|
||||
conversation.id
|
||||
),
|
||||
id: getReactionUniqueId(reaction),
|
||||
reactionEmoji: reaction.emoji,
|
||||
timestamp: reaction.timestamp,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const replies = replyState.replies.map(reply => {
|
||||
const conversation =
|
||||
reply.type === 'outgoing'
|
||||
? me
|
||||
: conversationSelector(reply.sourceUuid || reply.source);
|
||||
|
||||
return {
|
||||
...getAvatarData(conversation),
|
||||
...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']),
|
||||
contactNameColor: contactNameColorSelector(
|
||||
reply.conversationId,
|
||||
conversation.id
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const combined = [...replies, ...reactions].sort((a, b) =>
|
||||
a.timestamp > b.timestamp ? 1 : -1
|
||||
);
|
||||
|
||||
return {
|
||||
messageId: replyState.messageId,
|
||||
replies: replyState.replies.map(reply => {
|
||||
const conversation =
|
||||
reply.type === 'outgoing'
|
||||
? me
|
||||
: conversationSelector(reply.sourceUuid || reply.source);
|
||||
|
||||
return {
|
||||
...pick(conversation, [
|
||||
'acceptedMessageRequest',
|
||||
'avatarPath',
|
||||
'color',
|
||||
'isMe',
|
||||
'name',
|
||||
'profileName',
|
||||
'sharedGroupNames',
|
||||
'title',
|
||||
]),
|
||||
...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']),
|
||||
contactNameColor: contactNameColorSelector(
|
||||
reply.conversationId,
|
||||
conversation.id
|
||||
),
|
||||
};
|
||||
}),
|
||||
replies: combined,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ComponentProps, ReactElement } from 'react';
|
||||
|
@ -193,8 +193,6 @@ export function SmartInstallScreen(): ReactElement {
|
|||
throw new Error('Cannot confirm number; the component was unmounted');
|
||||
}
|
||||
|
||||
window.Signal.Util.postLinkExperience.start();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
|
|
@ -13,13 +13,14 @@ import {
|
|||
getEmojiSkinTone,
|
||||
getPreferredReactionEmoji,
|
||||
} from '../selectors/items';
|
||||
import { getStoriesSelector, getStoryReplies } from '../selectors/stories';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
import { getStoriesSelector, getStoryReplies } from '../selectors/stories';
|
||||
import { renderEmojiPicker } from './renderEmojiPicker';
|
||||
import { showToast } from '../../util/showToast';
|
||||
import { useActions as useEmojisActions } from '../ducks/emojis';
|
||||
import { useActions as useItemsActions } from '../ducks/items';
|
||||
import { useConversationsActions } from '../ducks/conversations';
|
||||
import { useRecentEmojis } from '../selectors/emojis';
|
||||
import { useStoriesActions } from '../ducks/stories';
|
||||
|
||||
|
@ -39,6 +40,8 @@ export function SmartStoryViewer({
|
|||
const storiesActions = useStoriesActions();
|
||||
const { onSetSkinTone } = useItemsActions();
|
||||
const { onUseEmoji } = useEmojisActions();
|
||||
const { openConversationInternal, toggleHideStories } =
|
||||
useConversationsActions();
|
||||
|
||||
const i18n = useSelector<StateType, LocalizerType>(getIntl);
|
||||
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
|
||||
|
@ -64,11 +67,16 @@ export function SmartStoryViewer({
|
|||
group={group}
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
onHideStory={toggleHideStories}
|
||||
onGoToConversation={senderId => {
|
||||
openConversationInternal({ conversationId: senderId });
|
||||
storiesActions.toggleStoriesView();
|
||||
}}
|
||||
onNextUserStories={onNextUserStories}
|
||||
onPrevUserStories={onPrevUserStories}
|
||||
onReactToStory={async (emoji, story) => {
|
||||
const { messageId, selectedReaction: previousReaction } = story;
|
||||
storiesActions.reactToStory(emoji, messageId, previousReaction);
|
||||
const { messageId } = story;
|
||||
storiesActions.reactToStory(emoji, messageId);
|
||||
}}
|
||||
onReplyToStory={(message, mentions, timestamp, story) => {
|
||||
storiesActions.replyToStory(
|
||||
|
|
|
@ -4,7 +4,10 @@
|
|||
import { assert } from 'chai';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { omit } from 'lodash';
|
||||
import type { MessageReactionType } from '../../model-types.d';
|
||||
import type {
|
||||
MessageAttributesType,
|
||||
MessageReactionType,
|
||||
} from '../../model-types.d';
|
||||
import { isEmpty } from '../../util/iterables';
|
||||
|
||||
import {
|
||||
|
@ -48,6 +51,18 @@ describe('reaction utilities', () => {
|
|||
const newReactions = addOutgoingReaction(oldReactions, reaction);
|
||||
assert.deepStrictEqual(newReactions, [oldReactions[1], reaction]);
|
||||
});
|
||||
|
||||
it('does not remove any pending reactions if its a story', () => {
|
||||
const oldReactions = [
|
||||
{ ...rxn('😭', { isPending: true }), timestamp: 3 },
|
||||
{ ...rxn('💬'), fromId: uuid() },
|
||||
{ ...rxn('🥀', { isPending: true }), timestamp: 1 },
|
||||
{ ...rxn('🌹', { isPending: true }), timestamp: 2 },
|
||||
];
|
||||
const reaction = rxn('😀');
|
||||
const newReactions = addOutgoingReaction(oldReactions, reaction, true);
|
||||
assert.deepStrictEqual(newReactions, [...oldReactions, reaction]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNewestPendingOutgoingReaction', () => {
|
||||
|
@ -199,21 +214,36 @@ describe('reaction utilities', () => {
|
|||
|
||||
const reactions = [star, none, { ...rxn('🔕'), timestamp: 1 }];
|
||||
|
||||
function getMessage(): MessageAttributesType {
|
||||
const now = Date.now();
|
||||
return {
|
||||
conversationId: uuid(),
|
||||
id: uuid(),
|
||||
received_at: now,
|
||||
sent_at: now,
|
||||
timestamp: now,
|
||||
type: 'incoming',
|
||||
};
|
||||
}
|
||||
|
||||
it("does nothing if the reaction isn't in the list", () => {
|
||||
const result = markOutgoingReactionSent(
|
||||
reactions,
|
||||
rxn('🥀', { isPending: true }),
|
||||
[uuid()]
|
||||
[uuid()],
|
||||
getMessage()
|
||||
);
|
||||
assert.deepStrictEqual(result, reactions);
|
||||
});
|
||||
|
||||
it('updates reactions to be partially sent', () => {
|
||||
[star, none].forEach(reaction => {
|
||||
const result = markOutgoingReactionSent(reactions, reaction, [
|
||||
uuid1,
|
||||
uuid2,
|
||||
]);
|
||||
const result = markOutgoingReactionSent(
|
||||
reactions,
|
||||
reaction,
|
||||
[uuid1, uuid2],
|
||||
getMessage()
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
result.find(re => re.emoji === reaction.emoji)
|
||||
?.isSentByConversationId,
|
||||
|
@ -227,11 +257,12 @@ describe('reaction utilities', () => {
|
|||
});
|
||||
|
||||
it('removes sent state if a reaction with emoji is fully sent', () => {
|
||||
const result = markOutgoingReactionSent(reactions, star, [
|
||||
uuid1,
|
||||
uuid2,
|
||||
uuid3,
|
||||
]);
|
||||
const result = markOutgoingReactionSent(
|
||||
reactions,
|
||||
star,
|
||||
[uuid1, uuid2, uuid3],
|
||||
getMessage()
|
||||
);
|
||||
|
||||
const newReaction = result.find(re => re.emoji === '⭐️');
|
||||
assert.isDefined(newReaction);
|
||||
|
@ -239,11 +270,12 @@ describe('reaction utilities', () => {
|
|||
});
|
||||
|
||||
it('removes a fully-sent reaction removal', () => {
|
||||
const result = markOutgoingReactionSent(reactions, none, [
|
||||
uuid1,
|
||||
uuid2,
|
||||
uuid3,
|
||||
]);
|
||||
const result = markOutgoingReactionSent(
|
||||
reactions,
|
||||
none,
|
||||
[uuid1, uuid2, uuid3],
|
||||
getMessage()
|
||||
);
|
||||
|
||||
assert(
|
||||
result.every(({ emoji }) => typeof emoji === 'string'),
|
||||
|
@ -252,13 +284,25 @@ describe('reaction utilities', () => {
|
|||
});
|
||||
|
||||
it('removes older reactions of mine', () => {
|
||||
const result = markOutgoingReactionSent(reactions, star, [
|
||||
uuid1,
|
||||
uuid2,
|
||||
uuid3,
|
||||
]);
|
||||
const result = markOutgoingReactionSent(
|
||||
reactions,
|
||||
star,
|
||||
[uuid1, uuid2, uuid3],
|
||||
getMessage()
|
||||
);
|
||||
|
||||
assert.isUndefined(result.find(re => re.emoji === '🔕'));
|
||||
});
|
||||
|
||||
it('does not remove my older reactions if they are on a story', () => {
|
||||
const result = markOutgoingReactionSent(
|
||||
reactions,
|
||||
star,
|
||||
[uuid1, uuid2, uuid3],
|
||||
{ ...getMessage(), type: 'story' }
|
||||
);
|
||||
|
||||
assert.isDefined(result.find(re => re.emoji === '🔕'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -29,7 +29,7 @@ describe('Errors', () => {
|
|||
assert.isUndefined(error.stack);
|
||||
|
||||
const formattedError = Errors.toLogFormat(error);
|
||||
assert.strictEqual(formattedError, 'Error: boom');
|
||||
assert.strictEqual(formattedError, 'boom');
|
||||
});
|
||||
|
||||
[0, false, null, undefined].forEach(value => {
|
||||
|
|
|
@ -4,25 +4,25 @@
|
|||
import { assert } from 'chai';
|
||||
import { MINUTE } from '../../util/durations';
|
||||
|
||||
import { parseRetryAfter } from '../../util/parseRetryAfter';
|
||||
import { parseRetryAfterWithDefault } from '../../util/parseRetryAfter';
|
||||
|
||||
describe('parseRetryAfter', () => {
|
||||
it('should return 1 minute when passed non-strings', () => {
|
||||
assert.equal(parseRetryAfter(undefined), MINUTE);
|
||||
assert.equal(parseRetryAfter(1234), MINUTE);
|
||||
assert.equal(parseRetryAfterWithDefault(undefined), MINUTE);
|
||||
assert.equal(parseRetryAfterWithDefault(1234), MINUTE);
|
||||
});
|
||||
|
||||
it('should return 1 minute with invalid strings', () => {
|
||||
assert.equal(parseRetryAfter('nope'), MINUTE);
|
||||
assert.equal(parseRetryAfter('1ff'), MINUTE);
|
||||
assert.equal(parseRetryAfterWithDefault('nope'), MINUTE);
|
||||
assert.equal(parseRetryAfterWithDefault('1ff'), MINUTE);
|
||||
});
|
||||
|
||||
it('should return milliseconds on valid input', () => {
|
||||
assert.equal(parseRetryAfter('100'), 100000);
|
||||
assert.equal(parseRetryAfterWithDefault('100'), 100000);
|
||||
});
|
||||
|
||||
it('should return 1 second at minimum', () => {
|
||||
assert.equal(parseRetryAfter('0'), 1000);
|
||||
assert.equal(parseRetryAfter('-1'), 1000);
|
||||
assert.equal(parseRetryAfterWithDefault('0'), 1000);
|
||||
assert.equal(parseRetryAfterWithDefault('-1'), 1000);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -40,24 +40,6 @@ describe('<Timeline> utilities', () => {
|
|||
assert.isFalse(areMessagesInSameGroup(undefined, false, defaultNewer));
|
||||
});
|
||||
|
||||
it('returns false if either item is not a message', () => {
|
||||
const linkNotification = {
|
||||
type: 'linkNotification' as const,
|
||||
data: null,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
assert.isFalse(
|
||||
areMessagesInSameGroup(defaultNewer, false, linkNotification)
|
||||
);
|
||||
assert.isFalse(
|
||||
areMessagesInSameGroup(linkNotification, false, defaultNewer)
|
||||
);
|
||||
assert.isFalse(
|
||||
areMessagesInSameGroup(linkNotification, false, linkNotification)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false if authors don't match", () => {
|
||||
const older = {
|
||||
...defaultOlder,
|
||||
|
@ -155,18 +137,6 @@ describe('<Timeline> utilities', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('returns false if newer item is not a message', () => {
|
||||
const linkNotification = {
|
||||
type: 'linkNotification' as const,
|
||||
data: null,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
assert.isFalse(
|
||||
shouldCurrentMessageHideMetadata(true, defaultCurrent, linkNotification)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false if newer is deletedForEveryone', () => {
|
||||
const newer = {
|
||||
...defaultNewer,
|
||||
|
@ -352,7 +322,7 @@ describe('<Timeline> utilities', () => {
|
|||
const props = {
|
||||
...defaultProps,
|
||||
items: fakeItems(10),
|
||||
oldestUnreadIndex: 3,
|
||||
oldestUnseenIndex: 3,
|
||||
};
|
||||
|
||||
assert.strictEqual(
|
||||
|
|
|
@ -1499,7 +1499,8 @@ describe('SignalProtocolStore', () => {
|
|||
assert.equal(await store.loadSession(id), testSession);
|
||||
assert.equal(await store.getSenderKey(id, distributionId), testSenderKey);
|
||||
|
||||
const allUnprocessed = await store.getAllUnprocessed();
|
||||
const allUnprocessed =
|
||||
await store.getAllUnprocessedAndIncrementAttempts();
|
||||
assert.deepEqual(
|
||||
allUnprocessed.map(({ envelope }) => envelope),
|
||||
['second']
|
||||
|
@ -1551,7 +1552,7 @@ describe('SignalProtocolStore', () => {
|
|||
|
||||
assert.equal(await store.loadSession(id), testSession);
|
||||
assert.equal(await store.getSenderKey(id, distributionId), testSenderKey);
|
||||
assert.deepEqual(await store.getAllUnprocessed(), []);
|
||||
assert.deepEqual(await store.getAllUnprocessedAndIncrementAttempts(), []);
|
||||
});
|
||||
|
||||
it('can be re-entered', async () => {
|
||||
|
@ -1647,7 +1648,7 @@ describe('SignalProtocolStore', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
await store.removeAllUnprocessed();
|
||||
const items = await store.getAllUnprocessed();
|
||||
const items = await store.getAllUnprocessedAndIncrementAttempts();
|
||||
assert.strictEqual(items.length, 0);
|
||||
});
|
||||
|
||||
|
@ -1687,7 +1688,7 @@ describe('SignalProtocolStore', () => {
|
|||
}),
|
||||
]);
|
||||
|
||||
const items = await store.getAllUnprocessed();
|
||||
const items = await store.getAllUnprocessedAndIncrementAttempts();
|
||||
assert.strictEqual(items.length, 3);
|
||||
|
||||
// they are in the proper order because the collection comparator is 'timestamp'
|
||||
|
@ -1708,10 +1709,11 @@ describe('SignalProtocolStore', () => {
|
|||
});
|
||||
await store.updateUnprocessedWithData(id, { decrypted: 'updated' });
|
||||
|
||||
const items = await store.getAllUnprocessed();
|
||||
const items = await store.getAllUnprocessedAndIncrementAttempts();
|
||||
assert.strictEqual(items.length, 1);
|
||||
assert.strictEqual(items[0].decrypted, 'updated');
|
||||
assert.strictEqual(items[0].timestamp, NOW + 1);
|
||||
assert.strictEqual(items[0].attempts, 1);
|
||||
});
|
||||
|
||||
it('removeUnprocessed successfully deletes item', async () => {
|
||||
|
@ -1726,7 +1728,21 @@ describe('SignalProtocolStore', () => {
|
|||
});
|
||||
await store.removeUnprocessed(id);
|
||||
|
||||
const items = await store.getAllUnprocessed();
|
||||
const items = await store.getAllUnprocessedAndIncrementAttempts();
|
||||
assert.strictEqual(items.length, 0);
|
||||
});
|
||||
|
||||
it('getAllUnprocessedAndIncrementAttempts deletes items', async () => {
|
||||
await store.addUnprocessed({
|
||||
id: '1-one',
|
||||
envelope: 'first',
|
||||
timestamp: NOW + 1,
|
||||
receivedAtCounter: 0,
|
||||
version: 2,
|
||||
attempts: 3,
|
||||
});
|
||||
|
||||
const items = await store.getAllUnprocessedAndIncrementAttempts();
|
||||
assert.strictEqual(items.length, 0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1151,15 +1151,14 @@ describe('link preview fetching', () => {
|
|||
);
|
||||
|
||||
assert.deepEqual(
|
||||
await fetchLinkPreviewImage(
|
||||
fakeFetch,
|
||||
'https://example.com/img',
|
||||
new AbortController().signal
|
||||
),
|
||||
{
|
||||
data: fixture,
|
||||
contentType: stringToMIMEType(contentType),
|
||||
}
|
||||
(
|
||||
await fetchLinkPreviewImage(
|
||||
fakeFetch,
|
||||
'https://example.com/img',
|
||||
new AbortController().signal
|
||||
)
|
||||
)?.contentType,
|
||||
stringToMIMEType(contentType)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -1238,15 +1237,14 @@ describe('link preview fetching', () => {
|
|||
);
|
||||
|
||||
assert.deepEqual(
|
||||
await fetchLinkPreviewImage(
|
||||
fakeFetch,
|
||||
'https://example.com/img',
|
||||
new AbortController().signal
|
||||
),
|
||||
{
|
||||
data: fixture,
|
||||
contentType: IMAGE_JPEG,
|
||||
}
|
||||
(
|
||||
await fetchLinkPreviewImage(
|
||||
fakeFetch,
|
||||
'https://example.com/img',
|
||||
new AbortController().signal
|
||||
)
|
||||
)?.contentType,
|
||||
IMAGE_JPEG
|
||||
);
|
||||
|
||||
sinon.assert.calledTwice(fakeFetch);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
@ -166,15 +166,6 @@ describe('sql/conversationSummary', () => {
|
|||
timestamp: now + 3,
|
||||
};
|
||||
const message4: MessageAttributesType = {
|
||||
id: getUuid(),
|
||||
body: 'message 4',
|
||||
type: 'message-history-unsynced',
|
||||
conversationId,
|
||||
sent_at: now + 4,
|
||||
received_at: now + 4,
|
||||
timestamp: now + 4,
|
||||
};
|
||||
const message5: MessageAttributesType = {
|
||||
id: getUuid(),
|
||||
body: 'message 5',
|
||||
type: 'profile-change',
|
||||
|
@ -183,7 +174,7 @@ describe('sql/conversationSummary', () => {
|
|||
received_at: now + 5,
|
||||
timestamp: now + 5,
|
||||
};
|
||||
const message6: MessageAttributesType = {
|
||||
const message5: MessageAttributesType = {
|
||||
id: getUuid(),
|
||||
body: 'message 6',
|
||||
type: 'story',
|
||||
|
@ -192,7 +183,7 @@ describe('sql/conversationSummary', () => {
|
|||
received_at: now + 6,
|
||||
timestamp: now + 6,
|
||||
};
|
||||
const message7: MessageAttributesType = {
|
||||
const message6: MessageAttributesType = {
|
||||
id: getUuid(),
|
||||
body: 'message 7',
|
||||
type: 'universal-timer-notification',
|
||||
|
@ -201,7 +192,7 @@ describe('sql/conversationSummary', () => {
|
|||
received_at: now + 7,
|
||||
timestamp: now + 7,
|
||||
};
|
||||
const message8: MessageAttributesType = {
|
||||
const message7: MessageAttributesType = {
|
||||
id: getUuid(),
|
||||
body: 'message 8',
|
||||
type: 'verified-change',
|
||||
|
@ -212,23 +203,14 @@ describe('sql/conversationSummary', () => {
|
|||
};
|
||||
|
||||
await saveMessages(
|
||||
[
|
||||
message1,
|
||||
message2,
|
||||
message3,
|
||||
message4,
|
||||
message5,
|
||||
message6,
|
||||
message7,
|
||||
message8,
|
||||
],
|
||||
[message1, message2, message3, message4, message5, message6, message7],
|
||||
{
|
||||
forceSave: true,
|
||||
ourUuid,
|
||||
}
|
||||
);
|
||||
|
||||
assert.lengthOf(await _getAllMessages(), 8);
|
||||
assert.lengthOf(await _getAllMessages(), 7);
|
||||
|
||||
const messages = await getConversationMessageStats({
|
||||
conversationId,
|
||||
|
@ -282,15 +264,6 @@ describe('sql/conversationSummary', () => {
|
|||
timestamp: now + 4,
|
||||
};
|
||||
const message5: MessageAttributesType = {
|
||||
id: getUuid(),
|
||||
body: 'message 5',
|
||||
type: 'message-history-unsynced',
|
||||
conversationId,
|
||||
sent_at: now + 5,
|
||||
received_at: now + 5,
|
||||
timestamp: now + 5,
|
||||
};
|
||||
const message6: MessageAttributesType = {
|
||||
id: getUuid(),
|
||||
body: 'message 6',
|
||||
type: 'profile-change',
|
||||
|
@ -299,7 +272,7 @@ describe('sql/conversationSummary', () => {
|
|||
received_at: now + 6,
|
||||
timestamp: now + 6,
|
||||
};
|
||||
const message7: MessageAttributesType = {
|
||||
const message6: MessageAttributesType = {
|
||||
id: getUuid(),
|
||||
body: 'message 7',
|
||||
type: 'story',
|
||||
|
@ -308,7 +281,7 @@ describe('sql/conversationSummary', () => {
|
|||
received_at: now + 7,
|
||||
timestamp: now + 7,
|
||||
};
|
||||
const message8: MessageAttributesType = {
|
||||
const message7: MessageAttributesType = {
|
||||
id: getUuid(),
|
||||
body: 'message 8',
|
||||
type: 'universal-timer-notification',
|
||||
|
@ -317,7 +290,7 @@ describe('sql/conversationSummary', () => {
|
|||
received_at: now + 8,
|
||||
timestamp: now + 8,
|
||||
};
|
||||
const message9: MessageAttributesType = {
|
||||
const message8: MessageAttributesType = {
|
||||
id: getUuid(),
|
||||
body: 'message 9',
|
||||
type: 'verified-change',
|
||||
|
@ -337,7 +310,6 @@ describe('sql/conversationSummary', () => {
|
|||
message6,
|
||||
message7,
|
||||
message8,
|
||||
message9,
|
||||
],
|
||||
{
|
||||
forceSave: true,
|
||||
|
@ -345,7 +317,7 @@ describe('sql/conversationSummary', () => {
|
|||
}
|
||||
);
|
||||
|
||||
assert.lengthOf(await _getAllMessages(), 9);
|
||||
assert.lengthOf(await _getAllMessages(), 8);
|
||||
|
||||
const messages = await getConversationMessageStats({
|
||||
conversationId,
|
||||
|
|
|
@ -124,7 +124,10 @@ describe('sql/markRead', () => {
|
|||
|
||||
assert.lengthOf(await _getAllMessages(), 7);
|
||||
assert.strictEqual(
|
||||
await getTotalUnreadForConversation(conversationId),
|
||||
await getTotalUnreadForConversation(conversationId, {
|
||||
storyId: undefined,
|
||||
isGroup: false,
|
||||
}),
|
||||
4,
|
||||
'unread count'
|
||||
);
|
||||
|
@ -137,7 +140,10 @@ describe('sql/markRead', () => {
|
|||
|
||||
assert.lengthOf(markedRead, 2, 'two messages marked read');
|
||||
assert.strictEqual(
|
||||
await getTotalUnreadForConversation(conversationId),
|
||||
await getTotalUnreadForConversation(conversationId, {
|
||||
storyId: undefined,
|
||||
isGroup: false,
|
||||
}),
|
||||
2,
|
||||
'unread count'
|
||||
);
|
||||
|
@ -160,11 +166,14 @@ describe('sql/markRead', () => {
|
|||
readAt,
|
||||
});
|
||||
|
||||
assert.lengthOf(markedRead2, 3, 'three messages marked read');
|
||||
assert.lengthOf(markedRead2, 2, 'two messages marked read');
|
||||
assert.strictEqual(markedRead2[0].id, message7.id, 'should be message7');
|
||||
|
||||
assert.strictEqual(
|
||||
await getTotalUnreadForConversation(conversationId),
|
||||
await getTotalUnreadForConversation(conversationId, {
|
||||
storyId: undefined,
|
||||
isGroup: false,
|
||||
}),
|
||||
0,
|
||||
'unread count'
|
||||
);
|
||||
|
@ -365,7 +374,10 @@ describe('sql/markRead', () => {
|
|||
});
|
||||
|
||||
assert.strictEqual(
|
||||
await getTotalUnreadForConversation(conversationId),
|
||||
await getTotalUnreadForConversation(conversationId, {
|
||||
storyId: undefined,
|
||||
isGroup: false,
|
||||
}),
|
||||
2,
|
||||
'unread count'
|
||||
);
|
||||
|
@ -384,7 +396,10 @@ describe('sql/markRead', () => {
|
|||
'first should be message4'
|
||||
);
|
||||
assert.strictEqual(
|
||||
await getTotalUnreadForConversation(conversationId),
|
||||
await getTotalUnreadForConversation(conversationId, {
|
||||
storyId: undefined,
|
||||
isGroup: false,
|
||||
}),
|
||||
1,
|
||||
'unread count'
|
||||
);
|
||||
|
|
|
@ -692,9 +692,9 @@ describe('sql/timelineFetches', () => {
|
|||
received_at: target - 8,
|
||||
timestamp: target - 8,
|
||||
};
|
||||
const oldestUnread: MessageAttributesType = {
|
||||
const oldestUnseen: MessageAttributesType = {
|
||||
id: getUuid(),
|
||||
body: 'oldestUnread',
|
||||
body: 'oldestUnseen',
|
||||
type: 'incoming',
|
||||
conversationId,
|
||||
sent_at: target - 7,
|
||||
|
@ -748,7 +748,7 @@ describe('sql/timelineFetches', () => {
|
|||
story,
|
||||
oldestInStory,
|
||||
oldest,
|
||||
oldestUnread,
|
||||
oldestUnseen,
|
||||
oldestStoryUnread,
|
||||
anotherUnread,
|
||||
newestInStory,
|
||||
|
@ -769,11 +769,11 @@ describe('sql/timelineFetches', () => {
|
|||
);
|
||||
assert.strictEqual(metricsInTimeline?.newest?.id, newest.id, 'newest');
|
||||
assert.strictEqual(
|
||||
metricsInTimeline?.oldestUnread?.id,
|
||||
oldestUnread.id,
|
||||
'oldestUnread'
|
||||
metricsInTimeline?.oldestUnseen?.id,
|
||||
oldestUnseen.id,
|
||||
'oldestUnseen'
|
||||
);
|
||||
assert.strictEqual(metricsInTimeline?.totalUnread, 3, 'totalUnread');
|
||||
assert.strictEqual(metricsInTimeline?.totalUnseen, 3, 'totalUnseen');
|
||||
|
||||
const metricsInStory = await getMessageMetricsForConversation(
|
||||
conversationId,
|
||||
|
@ -790,11 +790,11 @@ describe('sql/timelineFetches', () => {
|
|||
'newestInStory'
|
||||
);
|
||||
assert.strictEqual(
|
||||
metricsInStory?.oldestUnread?.id,
|
||||
metricsInStory?.oldestUnseen?.id,
|
||||
oldestStoryUnread.id,
|
||||
'oldestStoryUnread'
|
||||
);
|
||||
assert.strictEqual(metricsInStory?.totalUnread, 1, 'totalUnread');
|
||||
assert.strictEqual(metricsInStory?.totalUnseen, 1, 'totalUnseen');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -332,7 +332,7 @@ describe('both/state/ducks/conversations', () => {
|
|||
return {
|
||||
messageIds: [],
|
||||
metrics: {
|
||||
totalUnread: 0,
|
||||
totalUnseen: 0,
|
||||
},
|
||||
scrollToMessageCounter: 0,
|
||||
};
|
||||
|
@ -1008,7 +1008,7 @@ describe('both/state/ducks/conversations', () => {
|
|||
...getDefaultConversationMessage(),
|
||||
messageIds: [messageIdThree, messageIdTwo, messageId],
|
||||
metrics: {
|
||||
totalUnread: 0,
|
||||
totalUnseen: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -1028,7 +1028,7 @@ describe('both/state/ducks/conversations', () => {
|
|||
...getDefaultConversationMessage(),
|
||||
messageIds: [messageIdThree, messageIdTwo, messageId],
|
||||
metrics: {
|
||||
totalUnread: 0,
|
||||
totalUnseen: 0,
|
||||
newest: {
|
||||
id: messageId,
|
||||
received_at: time,
|
||||
|
@ -1058,7 +1058,7 @@ describe('both/state/ducks/conversations', () => {
|
|||
...getDefaultConversationMessage(),
|
||||
messageIds: [],
|
||||
metrics: {
|
||||
totalUnread: 0,
|
||||
totalUnseen: 0,
|
||||
newest: {
|
||||
id: messageId,
|
||||
received_at: time,
|
||||
|
@ -1082,7 +1082,7 @@ describe('both/state/ducks/conversations', () => {
|
|||
messageIds: [],
|
||||
metrics: {
|
||||
newest: undefined,
|
||||
totalUnread: 0,
|
||||
totalUnseen: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -1118,7 +1118,7 @@ describe('both/state/ducks/conversations', () => {
|
|||
...getDefaultConversationMessage(),
|
||||
messageIds: [messageId, messageIdTwo, messageIdThree],
|
||||
metrics: {
|
||||
totalUnread: 0,
|
||||
totalUnseen: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -1138,7 +1138,7 @@ describe('both/state/ducks/conversations', () => {
|
|||
...getDefaultConversationMessage(),
|
||||
messageIds: [messageId, messageIdTwo, messageIdThree],
|
||||
metrics: {
|
||||
totalUnread: 0,
|
||||
totalUnseen: 0,
|
||||
oldest: {
|
||||
id: messageId,
|
||||
received_at: time,
|
||||
|
@ -1168,7 +1168,7 @@ describe('both/state/ducks/conversations', () => {
|
|||
...getDefaultConversationMessage(),
|
||||
messageIds: [],
|
||||
metrics: {
|
||||
totalUnread: 0,
|
||||
totalUnseen: 0,
|
||||
oldest: {
|
||||
id: messageId,
|
||||
received_at: time,
|
||||
|
@ -1192,7 +1192,7 @@ describe('both/state/ducks/conversations', () => {
|
|||
messageIds: [],
|
||||
metrics: {
|
||||
oldest: undefined,
|
||||
totalUnread: 0,
|
||||
totalUnseen: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
178
ts/test-electron/state/ducks/stories_test.ts
Normal file
178
ts/test-electron/state/ducks/stories_test.ts
Normal file
|
@ -0,0 +1,178 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as sinon from 'sinon';
|
||||
import path from 'path';
|
||||
import { assert } from 'chai';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import type { StoriesStateType } from '../../../state/ducks/stories';
|
||||
import type { MessageAttributesType } from '../../../model-types.d';
|
||||
import { IMAGE_JPEG } from '../../../types/MIME';
|
||||
import {
|
||||
actions,
|
||||
getEmptyState,
|
||||
reducer,
|
||||
RESOLVE_ATTACHMENT_URL,
|
||||
} from '../../../state/ducks/stories';
|
||||
import { noopAction } from '../../../state/ducks/noop';
|
||||
import { reducer as rootReducer } from '../../../state/reducer';
|
||||
|
||||
describe('both/state/ducks/stories', () => {
|
||||
const getEmptyRootState = () => ({
|
||||
...rootReducer(undefined, noopAction()),
|
||||
stories: getEmptyState(),
|
||||
});
|
||||
|
||||
function getStoryMessage(id: string): MessageAttributesType {
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
conversationId: uuid(),
|
||||
id,
|
||||
received_at: now,
|
||||
sent_at: now,
|
||||
timestamp: now,
|
||||
type: 'story',
|
||||
};
|
||||
}
|
||||
|
||||
describe('queueStoryDownload', () => {
|
||||
const { queueStoryDownload } = actions;
|
||||
|
||||
it('no attachment, no dispatch', async function test() {
|
||||
const storyId = uuid();
|
||||
const messageAttributes = getStoryMessage(storyId);
|
||||
|
||||
window.MessageController.register(storyId, messageAttributes);
|
||||
|
||||
const dispatch = sinon.spy();
|
||||
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
|
||||
|
||||
sinon.assert.notCalled(dispatch);
|
||||
});
|
||||
|
||||
it('downloading, no dispatch', async function test() {
|
||||
const storyId = uuid();
|
||||
const messageAttributes = {
|
||||
...getStoryMessage(storyId),
|
||||
attachments: [
|
||||
{
|
||||
contentType: IMAGE_JPEG,
|
||||
downloadJobId: uuid(),
|
||||
pending: true,
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
window.MessageController.register(storyId, messageAttributes);
|
||||
|
||||
const dispatch = sinon.spy();
|
||||
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
|
||||
|
||||
sinon.assert.notCalled(dispatch);
|
||||
});
|
||||
|
||||
it('downloaded, no dispatch', async function test() {
|
||||
const storyId = uuid();
|
||||
const messageAttributes = {
|
||||
...getStoryMessage(storyId),
|
||||
attachments: [
|
||||
{
|
||||
contentType: IMAGE_JPEG,
|
||||
path: 'image.jpg',
|
||||
url: '/path/to/image.jpg',
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
window.MessageController.register(storyId, messageAttributes);
|
||||
|
||||
const dispatch = sinon.spy();
|
||||
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
|
||||
|
||||
sinon.assert.notCalled(dispatch);
|
||||
});
|
||||
|
||||
it('downloaded, but unresolved, we should resolve the path', async function test() {
|
||||
const storyId = uuid();
|
||||
const attachment = {
|
||||
contentType: IMAGE_JPEG,
|
||||
path: 'image.jpg',
|
||||
size: 0,
|
||||
};
|
||||
const messageAttributes = {
|
||||
...getStoryMessage(storyId),
|
||||
attachments: [attachment],
|
||||
};
|
||||
|
||||
window.MessageController.register(storyId, messageAttributes);
|
||||
|
||||
const dispatch = sinon.spy();
|
||||
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
|
||||
|
||||
const action = dispatch.getCall(0).args[0];
|
||||
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: RESOLVE_ATTACHMENT_URL,
|
||||
payload: {
|
||||
messageId: storyId,
|
||||
attachmentUrl: action.payload.attachmentUrl,
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
attachment.path,
|
||||
path.basename(action.payload.attachmentUrl)
|
||||
);
|
||||
|
||||
const stateWithStory: StoriesStateType = {
|
||||
...getEmptyRootState().stories,
|
||||
stories: [
|
||||
{
|
||||
...messageAttributes,
|
||||
messageId: storyId,
|
||||
attachment,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextState = reducer(stateWithStory, action);
|
||||
assert.isDefined(nextState.stories);
|
||||
assert.equal(
|
||||
nextState.stories[0].attachment?.url,
|
||||
action.payload.attachmentUrl
|
||||
);
|
||||
|
||||
const state = getEmptyRootState().stories;
|
||||
|
||||
const sameState = reducer(state, action);
|
||||
assert.isDefined(sameState.stories);
|
||||
assert.equal(sameState, state);
|
||||
});
|
||||
|
||||
it('not downloaded, queued for download', async function test() {
|
||||
const storyId = uuid();
|
||||
const messageAttributes = {
|
||||
...getStoryMessage(storyId),
|
||||
attachments: [
|
||||
{
|
||||
contentType: IMAGE_JPEG,
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
window.MessageController.register(storyId, messageAttributes);
|
||||
|
||||
const dispatch = sinon.spy();
|
||||
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
|
||||
|
||||
sinon.assert.calledWith(dispatch, {
|
||||
type: 'NOOP',
|
||||
payload: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -17,13 +17,14 @@ describe('gv2', function needsName() {
|
|||
|
||||
let bootstrap: Bootstrap;
|
||||
let app: App;
|
||||
let aciContact: PrimaryDevice;
|
||||
let pniContact: PrimaryDevice;
|
||||
|
||||
beforeEach(async () => {
|
||||
bootstrap = new Bootstrap();
|
||||
await bootstrap.init();
|
||||
|
||||
const { phone, contacts, server } = bootstrap;
|
||||
const { phone, server } = bootstrap;
|
||||
|
||||
let state = StorageState.getEmpty();
|
||||
|
||||
|
@ -32,17 +33,19 @@ describe('gv2', function needsName() {
|
|||
e164: phone.device.number,
|
||||
});
|
||||
|
||||
const [first] = contacts;
|
||||
state = state.addContact(first, {
|
||||
aciContact = await server.createPrimaryDevice({
|
||||
profileName: 'ACI Contact',
|
||||
});
|
||||
state = state.addContact(aciContact, {
|
||||
identityState: Proto.ContactRecord.IdentityState.VERIFIED,
|
||||
whitelisted: true,
|
||||
|
||||
identityKey: first.publicKey.serialize(),
|
||||
profileKey: first.profileKey.serialize(),
|
||||
identityKey: aciContact.publicKey.serialize(),
|
||||
profileKey: aciContact.profileKey.serialize(),
|
||||
});
|
||||
|
||||
pniContact = await server.createPrimaryDevice({
|
||||
profileName: 'My name is PNI',
|
||||
profileName: 'My profile is a secret',
|
||||
});
|
||||
state = state.addContact(pniContact, {
|
||||
identityState: Proto.ContactRecord.IdentityState.VERIFIED,
|
||||
|
@ -66,8 +69,7 @@ describe('gv2', function needsName() {
|
|||
});
|
||||
|
||||
it('should create group and modify it', async () => {
|
||||
const { phone, contacts } = bootstrap;
|
||||
const [first] = contacts;
|
||||
const { phone } = bootstrap;
|
||||
|
||||
let state = await phone.expectStorageState('initial state');
|
||||
|
||||
|
@ -87,17 +89,18 @@ describe('gv2', function needsName() {
|
|||
debug('inviting ACI member');
|
||||
|
||||
await leftPane
|
||||
.locator(
|
||||
'_react=BaseConversationListItem' +
|
||||
`[title = ${JSON.stringify(first.profileName)}]`
|
||||
)
|
||||
.locator('.module-left-pane__compose-search-form__input')
|
||||
.fill('ACI');
|
||||
|
||||
await leftPane
|
||||
.locator('_react=BaseConversationListItem[title = "ACI Contact"]')
|
||||
.click();
|
||||
|
||||
debug('inviting PNI member');
|
||||
|
||||
await leftPane
|
||||
.locator('.module-left-pane__compose-search-form__input')
|
||||
.type('PNI');
|
||||
.fill('PNI');
|
||||
|
||||
await leftPane
|
||||
.locator('_react=BaseConversationListItem[title = "PNI Contact"]')
|
||||
|
|
|
@ -13,6 +13,8 @@ import {
|
|||
insertJobSync,
|
||||
_storyIdPredicate,
|
||||
} from '../sql/Server';
|
||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||
import { SeenStatus } from '../MessageSeenStatus';
|
||||
|
||||
const OUR_UUID = generateGuid();
|
||||
|
||||
|
@ -1772,4 +1774,256 @@ describe('SQL migrations test', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateToSchemaVersion56', () => {
|
||||
it('updates unseenStatus for previously-unread messages', () => {
|
||||
const MESSAGE_ID_1 = generateGuid();
|
||||
const MESSAGE_ID_2 = generateGuid();
|
||||
const MESSAGE_ID_3 = generateGuid();
|
||||
const MESSAGE_ID_4 = generateGuid();
|
||||
const MESSAGE_ID_5 = generateGuid();
|
||||
const MESSAGE_ID_6 = generateGuid();
|
||||
const MESSAGE_ID_7 = generateGuid();
|
||||
const MESSAGE_ID_8 = generateGuid();
|
||||
const MESSAGE_ID_9 = generateGuid();
|
||||
const MESSAGE_ID_10 = generateGuid();
|
||||
const MESSAGE_ID_11 = generateGuid();
|
||||
const CONVERSATION_ID = generateGuid();
|
||||
|
||||
updateToVersion(55);
|
||||
|
||||
db.exec(
|
||||
`
|
||||
INSERT INTO messages
|
||||
(id, conversationId, type, readStatus)
|
||||
VALUES
|
||||
('${MESSAGE_ID_1}', '${CONVERSATION_ID}', 'call-history', ${ReadStatus.Unread}),
|
||||
('${MESSAGE_ID_2}', '${CONVERSATION_ID}', 'change-number-notification', ${ReadStatus.Unread}),
|
||||
('${MESSAGE_ID_3}', '${CONVERSATION_ID}', 'chat-session-refreshed', ${ReadStatus.Unread}),
|
||||
('${MESSAGE_ID_4}', '${CONVERSATION_ID}', 'delivery-issue', ${ReadStatus.Unread}),
|
||||
('${MESSAGE_ID_5}', '${CONVERSATION_ID}', 'group', ${ReadStatus.Unread}),
|
||||
('${MESSAGE_ID_6}', '${CONVERSATION_ID}', 'incoming', ${ReadStatus.Unread}),
|
||||
('${MESSAGE_ID_7}', '${CONVERSATION_ID}', 'keychange', ${ReadStatus.Unread}),
|
||||
('${MESSAGE_ID_8}', '${CONVERSATION_ID}', 'timer-notification', ${ReadStatus.Unread}),
|
||||
('${MESSAGE_ID_9}', '${CONVERSATION_ID}', 'verified-change', ${ReadStatus.Unread}),
|
||||
('${MESSAGE_ID_10}', '${CONVERSATION_ID}', NULL, ${ReadStatus.Unread}),
|
||||
('${MESSAGE_ID_11}', '${CONVERSATION_ID}', 'other', ${ReadStatus.Unread});
|
||||
`
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
db.prepare('SELECT COUNT(*) FROM messages;').pluck().get(),
|
||||
11,
|
||||
'starting total'
|
||||
);
|
||||
assert.strictEqual(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) FROM messages WHERE readStatus = ${ReadStatus.Unread};`
|
||||
)
|
||||
.pluck()
|
||||
.get(),
|
||||
11,
|
||||
'starting unread count'
|
||||
);
|
||||
|
||||
updateToVersion(56);
|
||||
|
||||
assert.strictEqual(
|
||||
db.prepare('SELECT COUNT(*) FROM messages;').pluck().get(),
|
||||
11,
|
||||
'ending total'
|
||||
);
|
||||
assert.strictEqual(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) FROM messages WHERE readStatus = ${ReadStatus.Unread};`
|
||||
)
|
||||
.pluck()
|
||||
.get(),
|
||||
10,
|
||||
'ending unread count'
|
||||
);
|
||||
assert.strictEqual(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) FROM messages WHERE seenStatus = ${SeenStatus.Unseen};`
|
||||
)
|
||||
.pluck()
|
||||
.get(),
|
||||
10,
|
||||
'ending unseen count'
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT readStatus FROM messages WHERE type = 'other' LIMIT 1;"
|
||||
)
|
||||
.pluck()
|
||||
.get(),
|
||||
ReadStatus.Read,
|
||||
"checking read status for lone 'other' message"
|
||||
);
|
||||
});
|
||||
|
||||
it('creates usable index for getOldestUnseenMessageForConversation', () => {
|
||||
updateToVersion(56);
|
||||
|
||||
const first = db
|
||||
.prepare(
|
||||
`
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT * FROM messages WHERE
|
||||
conversationId = 'id-conversation-4' AND
|
||||
seenStatus = ${SeenStatus.Unseen} AND
|
||||
isStory IS 0 AND
|
||||
NULL IS NULL
|
||||
ORDER BY received_at ASC, sent_at ASC
|
||||
LIMIT 1;
|
||||
`
|
||||
)
|
||||
.all()
|
||||
.map(({ detail }) => detail)
|
||||
.join('\n');
|
||||
|
||||
assert.include(first, 'USING INDEX messages_unseen_no_story', 'first');
|
||||
assert.notInclude(first, 'TEMP B-TREE', 'first');
|
||||
assert.notInclude(first, 'SCAN', 'first');
|
||||
|
||||
const second = db
|
||||
.prepare(
|
||||
`
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT * FROM messages WHERE
|
||||
conversationId = 'id-conversation-4' AND
|
||||
seenStatus = ${SeenStatus.Unseen} AND
|
||||
isStory IS 0 AND
|
||||
storyId IS 'id-story-4'
|
||||
ORDER BY received_at ASC, sent_at ASC
|
||||
LIMIT 1;
|
||||
`
|
||||
)
|
||||
.all()
|
||||
.map(({ detail }) => detail)
|
||||
.join('\n');
|
||||
|
||||
assert.include(
|
||||
second,
|
||||
'USING INDEX messages_unseen_with_story',
|
||||
'second'
|
||||
);
|
||||
assert.notInclude(second, 'TEMP B-TREE', 'second');
|
||||
assert.notInclude(second, 'SCAN', 'second');
|
||||
});
|
||||
|
||||
it('creates usable index for getUnreadByConversationAndMarkRead', () => {
|
||||
updateToVersion(56);
|
||||
|
||||
const first = db
|
||||
.prepare(
|
||||
`
|
||||
EXPLAIN QUERY PLAN
|
||||
UPDATE messages
|
||||
SET
|
||||
readStatus = ${ReadStatus.Read},
|
||||
seenStatus = ${SeenStatus.Seen},
|
||||
json = json_patch(json, '{ something: "one" }')
|
||||
WHERE
|
||||
conversationId = 'id-conversation-4' AND
|
||||
seenStatus = ${SeenStatus.Unseen} AND
|
||||
isStory = 0 AND
|
||||
NULL IS NULL AND
|
||||
received_at <= 2343233;
|
||||
`
|
||||
)
|
||||
.all()
|
||||
.map(({ detail }) => detail)
|
||||
.join('\n');
|
||||
|
||||
assert.include(first, 'USING INDEX messages_unseen_no_story', 'first');
|
||||
assert.notInclude(first, 'TEMP B-TREE', 'first');
|
||||
assert.notInclude(first, 'SCAN', 'first');
|
||||
|
||||
const second = db
|
||||
.prepare(
|
||||
`
|
||||
EXPLAIN QUERY PLAN
|
||||
UPDATE messages
|
||||
SET
|
||||
readStatus = ${ReadStatus.Read},
|
||||
seenStatus = ${SeenStatus.Seen},
|
||||
json = json_patch(json, '{ something: "one" }')
|
||||
WHERE
|
||||
conversationId = 'id-conversation-4' AND
|
||||
seenStatus = ${SeenStatus.Unseen} AND
|
||||
isStory = 0 AND
|
||||
storyId IS 'id-story-4' AND
|
||||
received_at <= 2343233;
|
||||
`
|
||||
)
|
||||
.all()
|
||||
.map(({ detail }) => detail)
|
||||
.join('\n');
|
||||
|
||||
assert.include(
|
||||
second,
|
||||
'USING INDEX messages_unseen_with_story',
|
||||
'second'
|
||||
);
|
||||
assert.notInclude(second, 'TEMP B-TREE', 'second');
|
||||
assert.notInclude(second, 'SCAN', 'second');
|
||||
});
|
||||
|
||||
it('creates usable index for getTotalUnseenForConversationSync', () => {
|
||||
updateToVersion(56);
|
||||
|
||||
const first = db
|
||||
.prepare(
|
||||
`
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT count(id)
|
||||
FROM messages
|
||||
WHERE
|
||||
conversationId = 'id-conversation-4' AND
|
||||
seenStatus = ${SeenStatus.Unseen} AND
|
||||
isStory IS 0 AND
|
||||
NULL IS NULL;
|
||||
`
|
||||
)
|
||||
.all()
|
||||
.map(({ detail }) => detail)
|
||||
.join('\n');
|
||||
|
||||
// Weird, but we don't included received_at so it doesn't really matter
|
||||
assert.include(first, 'USING INDEX messages_unseen_with_story', 'first');
|
||||
assert.notInclude(first, 'TEMP B-TREE', 'first');
|
||||
assert.notInclude(first, 'SCAN', 'first');
|
||||
|
||||
const second = db
|
||||
.prepare(
|
||||
`
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT count(id)
|
||||
FROM messages
|
||||
WHERE
|
||||
conversationId = 'id-conversation-4' AND
|
||||
seenStatus = ${SeenStatus.Unseen} AND
|
||||
isStory IS 0 AND
|
||||
storyId IS 'id-story-4';
|
||||
`
|
||||
)
|
||||
.all()
|
||||
.map(({ detail }) => detail)
|
||||
.join('\n');
|
||||
|
||||
assert.include(
|
||||
second,
|
||||
'USING INDEX messages_unseen_with_story',
|
||||
'second'
|
||||
);
|
||||
assert.notInclude(second, 'TEMP B-TREE', 'second');
|
||||
assert.notInclude(second, 'SCAN', 'second');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -112,7 +112,7 @@ describe('updater/differential', () => {
|
|||
});
|
||||
|
||||
if (ranges.length === 1) {
|
||||
res.writeHead(200, {
|
||||
res.writeHead(206, {
|
||||
'content-type': 'application/octet-stream',
|
||||
});
|
||||
if (shouldTimeout === 'response') {
|
||||
|
|
|
@ -310,8 +310,20 @@ export default class AccountManager extends EventTarget {
|
|||
kind
|
||||
);
|
||||
|
||||
await this.server.registerKeys(keys, kind);
|
||||
await this.confirmKeys(keys, kind);
|
||||
try {
|
||||
await this.server.registerKeys(keys, kind);
|
||||
await this.confirmKeys(keys, kind);
|
||||
} catch (error) {
|
||||
if (kind === UUIDKind.PNI) {
|
||||
log.error(
|
||||
'Failed to upload PNI prekeys. Moving on',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
|
|
|
@ -156,7 +156,7 @@ export class SendMessageChallengeError extends ReplayableError {
|
|||
|
||||
public readonly data: SendMessageChallengeData | undefined;
|
||||
|
||||
public readonly retryAt: number;
|
||||
public readonly retryAt?: number;
|
||||
|
||||
constructor(identifier: string, httpError: HTTPError) {
|
||||
super({
|
||||
|
@ -171,7 +171,10 @@ export class SendMessageChallengeError extends ReplayableError {
|
|||
|
||||
const headers = httpError.responseHeaders || {};
|
||||
|
||||
this.retryAt = Date.now() + parseRetryAfter(headers['retry-after']);
|
||||
const retryAfter = parseRetryAfter(headers['retry-after']);
|
||||
if (retryAfter) {
|
||||
this.retryAt = Date.now() + retryAfter;
|
||||
}
|
||||
|
||||
appendStack(this, httpError);
|
||||
}
|
||||
|
|
|
@ -802,17 +802,11 @@ export default class MessageReceiver
|
|||
return [];
|
||||
}
|
||||
|
||||
const items = await this.storage.protocol.getAllUnprocessed();
|
||||
const items =
|
||||
await this.storage.protocol.getAllUnprocessedAndIncrementAttempts();
|
||||
log.info('getAllFromCache loaded', items.length, 'saved envelopes');
|
||||
|
||||
return items.map(item => {
|
||||
const { attempts = 0 } = item;
|
||||
|
||||
return {
|
||||
...item,
|
||||
attempts: attempts + 1,
|
||||
};
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
private async decryptAndCacheBatch(
|
||||
|
|
|
@ -730,7 +730,7 @@ export function isDownloaded(attachment?: AttachmentType): boolean {
|
|||
}
|
||||
|
||||
export function hasNotResolved(attachment?: AttachmentType): boolean {
|
||||
return Boolean(attachment && !attachment.url);
|
||||
return Boolean(attachment && !attachment.url && !attachment.textAttachment);
|
||||
}
|
||||
|
||||
export function isDownloading(attachment?: AttachmentType): boolean {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2018-2021 Signal Messenger, LLC
|
||||
// Copyright 2018-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
|
@ -10,7 +10,6 @@ import type { IndexableBoolean, IndexablePresence } from './IndexedDB';
|
|||
export type Message = (
|
||||
| UserMessage
|
||||
| VerifiedChangeMessage
|
||||
| MessageHistoryUnsyncedMessage
|
||||
| ProfileChangeNotificationMessage
|
||||
) & { deletedForEveryone?: boolean };
|
||||
export type UserMessage = IncomingMessage | OutgoingMessage;
|
||||
|
@ -68,14 +67,6 @@ export type VerifiedChangeMessage = Readonly<
|
|||
ExpirationTimerUpdate
|
||||
>;
|
||||
|
||||
export type MessageHistoryUnsyncedMessage = Readonly<
|
||||
{
|
||||
type: 'message-history-unsynced';
|
||||
} & SharedMessageProperties &
|
||||
MessageSchemaVersion5 &
|
||||
ExpirationTimerUpdate
|
||||
>;
|
||||
|
||||
export type ProfileChangeNotificationMessage = Readonly<
|
||||
{
|
||||
type: 'profile-change';
|
||||
|
|
|
@ -3,11 +3,17 @@
|
|||
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import { get, has } from 'lodash';
|
||||
|
||||
export function toLogFormat(error: unknown): string {
|
||||
if (error instanceof Error && error.stack) {
|
||||
return error.stack;
|
||||
}
|
||||
|
||||
if (has(error, 'message')) {
|
||||
return get(error, 'message');
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2018-2020 Signal Messenger, LLC
|
||||
// Copyright 2018-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as Attachment from '../Attachment';
|
||||
|
@ -19,9 +19,6 @@ export const initializeAttachmentMetadata = async (
|
|||
if (message.type === 'verified-change') {
|
||||
return message;
|
||||
}
|
||||
if (message.type === 'message-history-unsynced') {
|
||||
return message;
|
||||
}
|
||||
if (message.type === 'profile-change') {
|
||||
return message;
|
||||
}
|
||||
|
|
|
@ -180,6 +180,13 @@ export abstract class Updater {
|
|||
|
||||
const mainWindow = this.getMainWindow();
|
||||
mainWindow?.webContents.send('show-update-dialog', dialogType);
|
||||
|
||||
this.setUpdateListener(async () => {
|
||||
this.logger.info('updater/markCannotUpdate: retrying after user action');
|
||||
|
||||
this.markedCannotUpdate = false;
|
||||
await this.checkForUpdatesMaybeInstall();
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
|
|
|
@ -386,8 +386,14 @@ export async function downloadRanges(
|
|||
'response'
|
||||
);
|
||||
|
||||
// When the result is single range we might get 200 status code
|
||||
if (ranges.length === 1 && statusCode === 200) {
|
||||
strictAssert(statusCode === 206, `Invalid status code: ${statusCode}`);
|
||||
|
||||
const match = headers['content-type']?.match(
|
||||
/^multipart\/byteranges;\s*boundary=([^\s;]+)/
|
||||
);
|
||||
|
||||
// When the result is single range we might non-multipart response
|
||||
if (ranges.length === 1 && !match) {
|
||||
await saveDiffStream({
|
||||
diff: ranges[0],
|
||||
stream,
|
||||
|
@ -398,13 +404,6 @@ export async function downloadRanges(
|
|||
return;
|
||||
}
|
||||
|
||||
strictAssert(statusCode === 206, `Invalid status code: ${statusCode}`);
|
||||
|
||||
const match = headers['content-type']?.match(
|
||||
/^multipart\/byteranges;\s*boundary=([^\s;]+)/
|
||||
);
|
||||
strictAssert(match, `Invalid Content-Type: ${headers['content-type']}`);
|
||||
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
boundary = match[1];
|
||||
} catch (error) {
|
||||
|
@ -511,6 +510,12 @@ async function saveDiffStream({
|
|||
await output.write(chunk, 0, chunk.length, offset + diff.writeOffset);
|
||||
offset += chunk.length;
|
||||
|
||||
// Check for signal again so that we don't invoke status callback when
|
||||
// aborted.
|
||||
if (abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
chunkStatusCallback(chunk.length);
|
||||
}
|
||||
|
||||
|
|
|
@ -9,9 +9,10 @@ import * as packageJson from '../../package.json';
|
|||
import { getUserAgent } from '../util/getUserAgent';
|
||||
import * as durations from '../util/durations';
|
||||
|
||||
export const GOT_CONNECT_TIMEOUT = 5 * durations.MINUTE;
|
||||
export const GOT_LOOKUP_TIMEOUT = 5 * durations.MINUTE;
|
||||
export const GOT_SOCKET_TIMEOUT = 5 * durations.MINUTE;
|
||||
export const GOT_CONNECT_TIMEOUT = durations.MINUTE;
|
||||
export const GOT_LOOKUP_TIMEOUT = durations.MINUTE;
|
||||
export const GOT_SOCKET_TIMEOUT = durations.MINUTE;
|
||||
const GOT_RETRY_LIMIT = 3;
|
||||
|
||||
export function getProxyUrl(): string | undefined {
|
||||
return process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||
|
@ -47,5 +48,19 @@ export function getGotOptions(): GotOptions {
|
|||
// This timeout is reset whenever we get new data on the socket
|
||||
socket: GOT_SOCKET_TIMEOUT,
|
||||
},
|
||||
retry: {
|
||||
limit: GOT_RETRY_LIMIT,
|
||||
errorCodes: [
|
||||
'ETIMEDOUT',
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'EPIPE',
|
||||
'ENOTFOUND',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
],
|
||||
methods: ['GET', 'HEAD'],
|
||||
statusCodes: [413, 429, 503],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -15,13 +15,30 @@ const TEMP_PATH = 'temp';
|
|||
const UPDATE_CACHE_PATH = 'update-cache';
|
||||
const DRAFT_PATH = 'drafts.noindex';
|
||||
|
||||
const CACHED_PATHS = new Map<string, string>();
|
||||
|
||||
const createPathGetter =
|
||||
(subpath: string) =>
|
||||
(userDataPath: string): string => {
|
||||
if (!isString(userDataPath)) {
|
||||
throw new TypeError("'userDataPath' must be a string");
|
||||
}
|
||||
return join(userDataPath, subpath);
|
||||
|
||||
const naivePath = join(userDataPath, subpath);
|
||||
|
||||
const cached = CACHED_PATHS.get(naivePath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let result = naivePath;
|
||||
if (fse.pathExistsSync(naivePath)) {
|
||||
result = fse.realpathSync(naivePath);
|
||||
}
|
||||
|
||||
CACHED_PATHS.set(naivePath, result);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getAvatarsPath = createPathGetter(AVATAR_PATH);
|
||||
|
|
|
@ -9,7 +9,7 @@ import { parseAndFormatPhoneNumber } from './libphonenumberInstance';
|
|||
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationType> = {
|
||||
// A small-but-nonzero threshold lets us match parts of E164s better, and makes the
|
||||
// search a little more forgiving.
|
||||
threshold: 0.1,
|
||||
threshold: 0.2,
|
||||
useExtendedSearch: true,
|
||||
keys: [
|
||||
{
|
||||
|
@ -42,6 +42,29 @@ const cachedIndices = new WeakMap<
|
|||
Fuse<ConversationType>
|
||||
>();
|
||||
|
||||
type CommandRunnerType = (
|
||||
conversations: ReadonlyArray<ConversationType>,
|
||||
query: string
|
||||
) => Array<ConversationType>;
|
||||
|
||||
const COMMANDS = new Map<string, CommandRunnerType>();
|
||||
|
||||
COMMANDS.set('uuidEndsWith', (conversations, query) => {
|
||||
return conversations.filter(convo => convo.uuid?.endsWith(query));
|
||||
});
|
||||
|
||||
COMMANDS.set('idEndsWith', (conversations, query) => {
|
||||
return conversations.filter(convo => convo.id?.endsWith(query));
|
||||
});
|
||||
|
||||
COMMANDS.set('e164EndsWith', (conversations, query) => {
|
||||
return conversations.filter(convo => convo.e164?.endsWith(query));
|
||||
});
|
||||
|
||||
COMMANDS.set('groupIdEndsWith', (conversations, query) => {
|
||||
return conversations.filter(convo => convo.groupId?.endsWith(query));
|
||||
});
|
||||
|
||||
// See https://fusejs.io/examples.html#extended-search for
|
||||
// extended search documentation.
|
||||
function searchConversations(
|
||||
|
@ -49,6 +72,16 @@ function searchConversations(
|
|||
searchTerm: string,
|
||||
regionCode: string | undefined
|
||||
): Array<ConversationType> {
|
||||
const maybeCommand = searchTerm.match(/^!([^\s]+):(.*)$/);
|
||||
if (maybeCommand) {
|
||||
const [, commandName, query] = maybeCommand;
|
||||
|
||||
const command = COMMANDS.get(commandName);
|
||||
if (command) {
|
||||
return command(conversations, query);
|
||||
}
|
||||
}
|
||||
|
||||
const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode);
|
||||
|
||||
// Escape the search term
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
// Copyright 2020-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { MessageAttributesType } from '../model-types.d';
|
||||
import { getSource, getSourceDevice, getSourceUuid } from '../messages/helpers';
|
||||
|
||||
export function getMessageIdForLogging(message: MessageAttributesType): string {
|
||||
const account = getSourceUuid(message) || getSource(message);
|
||||
const device = getSourceDevice(message);
|
||||
const timestamp = message.sent_at;
|
||||
|
||||
return `${account}.${device} ${timestamp}`;
|
||||
}
|
40
ts/util/getStoryBackground.ts
Normal file
40
ts/util/getStoryBackground.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { AttachmentType, TextAttachmentType } from '../types/Attachment';
|
||||
|
||||
const COLOR_BLACK_ALPHA_90 = 'rgba(0, 0, 0, 0.9)';
|
||||
const COLOR_WHITE_INT = 4294704123;
|
||||
|
||||
export function getHexFromNumber(color: number): string {
|
||||
return `#${color.toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function getBackgroundColor({
|
||||
color,
|
||||
gradient,
|
||||
}: TextAttachmentType): string {
|
||||
if (gradient) {
|
||||
return `linear-gradient(${gradient.angle}deg, ${getHexFromNumber(
|
||||
gradient.startColor || COLOR_WHITE_INT
|
||||
)}, ${getHexFromNumber(gradient.endColor || COLOR_WHITE_INT)})`;
|
||||
}
|
||||
|
||||
return getHexFromNumber(color || COLOR_WHITE_INT);
|
||||
}
|
||||
|
||||
export function getStoryBackground(attachment?: AttachmentType): string {
|
||||
if (!attachment) {
|
||||
return COLOR_BLACK_ALPHA_90;
|
||||
}
|
||||
|
||||
if (attachment.textAttachment) {
|
||||
return getBackgroundColor(attachment.textAttachment);
|
||||
}
|
||||
|
||||
if (attachment.url) {
|
||||
return `url("${attachment.url}")`;
|
||||
}
|
||||
|
||||
return COLOR_BLACK_ALPHA_90;
|
||||
}
|
|
@ -2,7 +2,12 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { AttachmentType } from '../types/Attachment';
|
||||
import { isGIF, isVideo } from '../types/Attachment';
|
||||
import {
|
||||
hasNotResolved,
|
||||
isDownloaded,
|
||||
isGIF,
|
||||
isVideo,
|
||||
} from '../types/Attachment';
|
||||
import { count } from './grapheme';
|
||||
import { SECOND } from './durations';
|
||||
|
||||
|
@ -12,7 +17,11 @@ const MIN_TEXT_DURATION = 3 * SECOND;
|
|||
|
||||
export async function getStoryDuration(
|
||||
attachment: AttachmentType
|
||||
): Promise<number> {
|
||||
): Promise<number | undefined> {
|
||||
if (!isDownloaded(attachment) || hasNotResolved(attachment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGIF([attachment]) || isVideo([attachment])) {
|
||||
const videoEl = document.createElement('video');
|
||||
if (!attachment.url) {
|
||||
|
|
31
ts/util/idForLogging.ts
Normal file
31
ts/util/idForLogging.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Copyright 2020-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type {
|
||||
ConversationAttributesType,
|
||||
MessageAttributesType,
|
||||
} from '../model-types.d';
|
||||
import { getSource, getSourceDevice, getSourceUuid } from '../messages/helpers';
|
||||
import { isDirectConversation, isGroupV2 } from './whatTypeOfConversation';
|
||||
|
||||
export function getMessageIdForLogging(message: MessageAttributesType): string {
|
||||
const account = getSourceUuid(message) || getSource(message);
|
||||
const device = getSourceDevice(message);
|
||||
const timestamp = message.sent_at;
|
||||
|
||||
return `${account}.${device} ${timestamp}`;
|
||||
}
|
||||
|
||||
export function getConversationIdForLogging(
|
||||
conversation: ConversationAttributesType
|
||||
): string {
|
||||
if (isDirectConversation(conversation)) {
|
||||
const { uuid, e164, id } = conversation;
|
||||
return `${uuid || e164} (${id})`;
|
||||
}
|
||||
if (isGroupV2(conversation)) {
|
||||
return `groupv2(${conversation.groupId})`;
|
||||
}
|
||||
|
||||
return `group(${conversation.groupId})`;
|
||||
}
|
|
@ -38,7 +38,6 @@ import {
|
|||
} from './sessionTranslation';
|
||||
import * as zkgroup from './zkgroup';
|
||||
import { StartupQueue } from './StartupQueue';
|
||||
import { postLinkExperience } from './postLinkExperience';
|
||||
import { sendToGroup, sendContentMessageToGroup } from './sendToGroup';
|
||||
import { RetryPlaceholders } from './retryPlaceholders';
|
||||
import * as expirationTimer from './expirationTimer';
|
||||
|
@ -70,7 +69,6 @@ export {
|
|||
MessageController,
|
||||
missingCaseError,
|
||||
parseRemoteClientExpiration,
|
||||
postLinkExperience,
|
||||
queueUpdateMessage,
|
||||
RetryPlaceholders,
|
||||
saveNewMessageBatcher,
|
||||
|
|
|
@ -7636,10 +7636,24 @@
|
|||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2021-11-30T10:15:33.662Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/StoryImage.tsx",
|
||||
"line": " const videoRef = useRef<HTMLVideoElement | null>(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2022-04-29T23:54:21.656Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/StoryViewer.tsx",
|
||||
"line": " const storiesRef = useRef(stories);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2022-04-30T00:44:47.213Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/StoryViewsNRepliesModal.tsx",
|
||||
"line": " const inputApiRef = React.useRef<InputApi | undefined>();",
|
||||
"line": " const inputApiRef = useRef<InputApi | undefined>();",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2022-02-15T17:57:06.507Z"
|
||||
},
|
||||
|
|
|
@ -108,7 +108,6 @@ const excludedFilesRegexp = RegExp(
|
|||
'^node_modules/react-color/.+/(?:core-js|fbjs|lodash)/.+',
|
||||
|
||||
// Modules used only in test/development scenarios
|
||||
'^node_modules/esbuild/.+',
|
||||
'^node_modules/@babel/.+',
|
||||
'^node_modules/@chanzuckerberg/axe-storybook-testing/.+',
|
||||
'^node_modules/@signalapp/mock-server/.+',
|
||||
|
@ -162,6 +161,7 @@ const excludedFilesRegexp = RegExp(
|
|||
'^node_modules/es-abstract/.+',
|
||||
'^node_modules/es5-shim/.+', // Currently only used in storybook
|
||||
'^node_modules/es6-shim/.+', // Currently only used in storybook
|
||||
'^node_modules/esbuild/.+',
|
||||
'^node_modules/escodegen/.+',
|
||||
'^node_modules/eslint.+',
|
||||
'^node_modules/@typescript-eslint.+',
|
||||
|
@ -187,6 +187,7 @@ const excludedFilesRegexp = RegExp(
|
|||
'^node_modules/istanbul.+',
|
||||
'^node_modules/jimp/.+',
|
||||
'^node_modules/jquery/.+',
|
||||
'^node_modules/jake/.+',
|
||||
'^node_modules/jss-global/.+',
|
||||
'^node_modules/jss/.+',
|
||||
'^node_modules/liftup/.+',
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import type { ConversationAttributesType } from '../model-types.d';
|
||||
import { hasErrors } from '../state/selectors/message';
|
||||
import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue';
|
||||
|
@ -8,11 +10,17 @@ import { readSyncJobQueue } from '../jobs/readSyncJobQueue';
|
|||
import { notificationService } from '../services/notifications';
|
||||
import { isGroup } from './whatTypeOfConversation';
|
||||
import * as log from '../logging/log';
|
||||
import { getConversationIdForLogging } from './idForLogging';
|
||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||
|
||||
export async function markConversationRead(
|
||||
conversationAttrs: ConversationAttributesType,
|
||||
newestUnreadAt: number,
|
||||
options: { readAt?: number; sendReadReceipts: boolean } = {
|
||||
options: {
|
||||
readAt?: number;
|
||||
sendReadReceipts: boolean;
|
||||
newestSentAt?: number;
|
||||
} = {
|
||||
sendReadReceipts: true,
|
||||
}
|
||||
): Promise<boolean> {
|
||||
|
@ -32,7 +40,8 @@ export async function markConversationRead(
|
|||
]);
|
||||
|
||||
log.info('markConversationRead', {
|
||||
conversationId,
|
||||
conversationId: getConversationIdForLogging(conversationAttrs),
|
||||
newestSentAt: options.newestSentAt,
|
||||
newestUnreadAt,
|
||||
unreadMessages: unreadMessages.length,
|
||||
unreadReactions: unreadReactions.length,
|
||||
|
@ -70,11 +79,12 @@ export async function markConversationRead(
|
|||
const message = window.MessageController.getById(messageSyncData.id);
|
||||
// we update the in-memory MessageModel with the fresh database call data
|
||||
if (message) {
|
||||
message.set(messageSyncData);
|
||||
message.set(omit(messageSyncData, 'originalReadStatus'));
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: messageSyncData.id,
|
||||
originalReadStatus: messageSyncData.originalReadStatus,
|
||||
senderE164: messageSyncData.source,
|
||||
senderUuid: messageSyncData.sourceUuid,
|
||||
senderId: window.ConversationController.ensureContactIds({
|
||||
|
@ -86,14 +96,18 @@ export async function markConversationRead(
|
|||
};
|
||||
});
|
||||
|
||||
// Some messages we're marking read are local notifications with no sender
|
||||
// If a message has errors, we don't want to send anything out about it.
|
||||
// Some messages we're marking read are local notifications with no sender or were just
|
||||
// unseen and not unread.
|
||||
// Also, if a message has errors, we don't want to send anything out about it:
|
||||
// read syncs - let's wait for a client that really understands the message
|
||||
// to mark it read. we'll mark our local error read locally, though.
|
||||
// read receipts - here we can run into infinite loops, where each time the
|
||||
// conversation is viewed, another error message shows up for the contact
|
||||
const unreadMessagesSyncData = allReadMessagesSync.filter(
|
||||
item => Boolean(item.senderId) && !item.hasErrors
|
||||
item =>
|
||||
Boolean(item.senderId) &&
|
||||
item.originalReadStatus === ReadStatus.Unread &&
|
||||
!item.hasErrors
|
||||
);
|
||||
|
||||
const readSyncs: Array<{
|
||||
|
|
|
@ -7,15 +7,24 @@ import { isNormalNumber } from './isNormalNumber';
|
|||
const DEFAULT_RETRY_AFTER = MINUTE;
|
||||
const MINIMAL_RETRY_AFTER = SECOND;
|
||||
|
||||
export function parseRetryAfter(value: unknown): number {
|
||||
if (typeof value !== 'string') {
|
||||
export function parseRetryAfterWithDefault(value: unknown): number {
|
||||
const retryAfter = parseRetryAfter(value);
|
||||
if (retryAfter === undefined) {
|
||||
return DEFAULT_RETRY_AFTER;
|
||||
}
|
||||
|
||||
return Math.max(retryAfter, MINIMAL_RETRY_AFTER);
|
||||
}
|
||||
|
||||
export function parseRetryAfter(value: unknown): number | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const retryAfter = parseInt(value, 10);
|
||||
if (!isNormalNumber(retryAfter) || retryAfter.toString() !== value) {
|
||||
return DEFAULT_RETRY_AFTER;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.max(retryAfter * SECOND, MINIMAL_RETRY_AFTER);
|
||||
return retryAfter * SECOND;
|
||||
}
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { MINUTE } from './durations';
|
||||
|
||||
class PostLinkExperience {
|
||||
private hasNotFinishedSync: boolean;
|
||||
|
||||
constructor() {
|
||||
this.hasNotFinishedSync = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.hasNotFinishedSync = true;
|
||||
|
||||
// timeout "post link" after 10 minutes in case the syncs don't complete
|
||||
// in time or are never called.
|
||||
setTimeout(() => {
|
||||
this.stop();
|
||||
}, 10 * MINUTE);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.hasNotFinishedSync = false;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.hasNotFinishedSync === true;
|
||||
}
|
||||
}
|
||||
|
||||
export const postLinkExperience = new PostLinkExperience();
|
|
@ -13,7 +13,7 @@ import type {
|
|||
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
||||
import * as log from '../logging/log';
|
||||
import { isLongMessage } from '../types/MIME';
|
||||
import { getMessageIdForLogging } from './getMessageIdForLogging';
|
||||
import { getMessageIdForLogging } from './idForLogging';
|
||||
import {
|
||||
copyStickerToAttachments,
|
||||
savePackMetadata,
|
||||
|
|
|
@ -22,7 +22,7 @@ import {
|
|||
import { Address } from '../types/Address';
|
||||
import { QualifiedAddress } from '../types/QualifiedAddress';
|
||||
import { UUID } from '../types/UUID';
|
||||
import { isEnabled } from '../RemoteConfig';
|
||||
import { getValue, isEnabled } from '../RemoteConfig';
|
||||
import { isRecord } from './isRecord';
|
||||
|
||||
import { isOlderThan } from './timestamp';
|
||||
|
@ -55,7 +55,6 @@ import {
|
|||
multiRecipient410ResponseSchema,
|
||||
} from '../textsecure/WebAPI';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import * as RemoteConfig from '../RemoteConfig';
|
||||
|
||||
import { strictAssert } from './assert';
|
||||
import * as log from '../logging/log';
|
||||
|
@ -169,8 +168,8 @@ export async function sendContentMessageToGroup({
|
|||
|
||||
if (
|
||||
isEnabled('desktop.sendSenderKey3') &&
|
||||
isEnabled('desktop.senderKey.send') &&
|
||||
ourConversation?.get('capabilities')?.senderKey &&
|
||||
RemoteConfig.isEnabled('desktop.senderKey.send') &&
|
||||
sendTarget.isValid()
|
||||
) {
|
||||
try {
|
||||
|
@ -681,7 +680,7 @@ const MAX_SENDER_KEY_EXPIRE_DURATION = 90 * DAY;
|
|||
function getSenderKeyExpireDuration(): number {
|
||||
try {
|
||||
const parsed = parseIntOrThrow(
|
||||
window.Signal.RemoteConfig.getValue('desktop.senderKeyMaxAge'),
|
||||
getValue('desktop.senderKeyMaxAge'),
|
||||
'getSenderKeyExpireDuration'
|
||||
);
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue