Merge branch 'main' into pr/5866

This commit is contained in:
Josh Perez 2022-05-04 21:28:12 -04:00
commit fb21285ce3
165 changed files with 4189 additions and 2541 deletions

25
ts/MessageSeenStatus.ts Normal file
View 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;

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,6 +23,8 @@ function getDefaultProps(): PropsType {
return {
i18n,
onClick: action('onClick'),
onGoToConversation: action('onGoToConversation'),
onHideStory: action('onHideStory'),
queueStoryDownload: action('queueStoryDownload'),
story: {
messageId: '123',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -204,6 +204,9 @@ export const ConversationHero = ({
phoneNumber,
sharedGroupNames,
})}
<div className="module-conversation-hero__linkNotification">
{i18n('messageHistoryUnsynced')}
</div>
</div>
{isShowingMessageRequestWarning && (
<ConfirmationDialog

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -417,10 +417,6 @@ storiesOf('Components/Conversation/TimelineItem', module)
startedTime: Date.now(),
},
},
{
type: 'linkNotification',
data: null,
},
{
type: 'profileChange',
data: {

View file

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

View file

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

View file

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

View file

@ -205,6 +205,7 @@ export async function sendNormalMessage(
profileKey,
quote,
sticker,
storyContext,
timestamp: messageTimestamp,
mentions,
},

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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"]')

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -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/.+',

View file

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

View file

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

View file

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

View file

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

View file

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