Page media in Lightbox

This commit is contained in:
Fedor Indutny 2023-03-03 19:03:15 -08:00 committed by GitHub
parent 03697f66e7
commit 5dff1768bd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 603 additions and 395 deletions

View file

@ -35,6 +35,10 @@ export function AvatarLightbox({
saveAttachment={noop} saveAttachment={noop}
toggleForwardMessageModal={noop} toggleForwardMessageModal={noop}
onMediaPlaybackStart={noop} onMediaPlaybackStart={noop}
onNextAttachment={noop}
onPrevAttachment={noop}
onSelectAttachment={noop}
selectedIndex={0}
> >
<AvatarPreview <AvatarPreview
avatarColor={avatarColor} avatarColor={avatarColor}

View file

@ -1,7 +1,7 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import React, { useState } from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { number } from '@storybook/addon-knobs'; import { number } from '@storybook/addon-knobs';
@ -55,16 +55,30 @@ function createMediaItem(
}; };
} }
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
closeLightbox: action('closeLightbox'), // eslint-disable-next-line react-hooks/rules-of-hooks
i18n, const [selectedIndex, setSelectedIndex] = useState(
isViewOnce: Boolean(overrideProps.isViewOnce), number('selectedIndex', overrideProps.selectedIndex || 0)
media: overrideProps.media || [], );
saveAttachment: action('saveAttachment'), const media = overrideProps.media || [];
selectedIndex: number('selectedIndex', overrideProps.selectedIndex || 0), return {
toggleForwardMessageModal: action('toggleForwardMessageModal'), closeLightbox: action('closeLightbox'),
onMediaPlaybackStart: noop, i18n,
}); isViewOnce: Boolean(overrideProps.isViewOnce),
media,
saveAttachment: action('saveAttachment'),
selectedIndex,
toggleForwardMessageModal: action('toggleForwardMessageModal'),
onMediaPlaybackStart: noop,
onPrevAttachment: () => {
setSelectedIndex(Math.max(0, selectedIndex - 1));
},
onNextAttachment: () => {
setSelectedIndex(Math.min(media.length - 1, selectedIndex + 1));
},
onSelectAttachment: setSelectedIndex,
};
};
export function Multimedia(): JSX.Element { export function Multimedia(): JSX.Element {
const props = createProps({ const props = createProps({

View file

@ -32,9 +32,14 @@ export type PropsType = {
isViewOnce?: boolean; isViewOnce?: boolean;
media: ReadonlyArray<ReadonlyDeep<MediaItemType>>; media: ReadonlyArray<ReadonlyDeep<MediaItemType>>;
saveAttachment: SaveAttachmentActionCreatorType; saveAttachment: SaveAttachmentActionCreatorType;
selectedIndex?: number; selectedIndex: number;
toggleForwardMessageModal: (messageId: string) => unknown; toggleForwardMessageModal: (messageId: string) => unknown;
onMediaPlaybackStart: () => void; onMediaPlaybackStart: () => void;
onNextAttachment: () => void;
onPrevAttachment: () => void;
onSelectAttachment: (index: number) => void;
hasPrevMessage?: boolean;
hasNextMessage?: boolean;
}; };
const ZOOM_SCALE = 3; const ZOOM_SCALE = 3;
@ -59,13 +64,16 @@ export function Lightbox({
i18n, i18n,
isViewOnce = false, isViewOnce = false,
saveAttachment, saveAttachment,
selectedIndex: initialSelectedIndex = 0, selectedIndex,
toggleForwardMessageModal, toggleForwardMessageModal,
onMediaPlaybackStart, onMediaPlaybackStart,
onNextAttachment,
onPrevAttachment,
onSelectAttachment,
hasNextMessage,
hasPrevMessage,
}: PropsType): JSX.Element | null { }: PropsType): JSX.Element | null {
const [root, setRoot] = React.useState<HTMLElement | undefined>(); const [root, setRoot] = React.useState<HTMLElement | undefined>();
const [selectedIndex, setSelectedIndex] =
useState<number>(initialSelectedIndex);
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>( const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
null null
@ -106,9 +114,9 @@ export function Lightbox({
return; return;
} }
setSelectedIndex(prevSelectedIndex => Math.max(prevSelectedIndex - 1, 0)); onPrevAttachment();
}, },
[isZoomed] [isZoomed, onPrevAttachment]
); );
const onNext = useCallback( const onNext = useCallback(
@ -122,11 +130,9 @@ export function Lightbox({
return; return;
} }
setSelectedIndex(prevSelectedIndex => onNextAttachment();
Math.min(prevSelectedIndex + 1, media.length - 1)
);
}, },
[isZoomed, media] [isZoomed, onNextAttachment]
); );
const onTimeUpdate = useCallback(() => { const onTimeUpdate = useCallback(() => {
@ -521,8 +527,9 @@ export function Lightbox({
} }
} }
const hasNext = !isZoomed && selectedIndex < media.length - 1; const hasNext =
const hasPrevious = !isZoomed && selectedIndex > 0; !isZoomed && (selectedIndex < media.length - 1 || hasNextMessage);
const hasPrevious = !isZoomed && (selectedIndex > 0 || hasPrevMessage);
return root return root
? createPortal( ? createPortal(
@ -663,7 +670,7 @@ export function Lightbox({
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
setSelectedIndex(index); onSelectAttachment(index);
}} }}
> >
{item.thumbnailObjectUrl ? ( {item.thumbnailObjectUrl ? (

View file

@ -1491,7 +1491,8 @@ export class ConversationModel extends window.Backbone
} }
} }
const metrics = await getMessageMetricsForConversation(conversationId, { const metrics = await getMessageMetricsForConversation({
conversationId,
includeStoryReplies: !isGroup(this.attributes), includeStoryReplies: !isGroup(this.attributes),
}); });
@ -1511,7 +1512,8 @@ export class ConversationModel extends window.Backbone
return; return;
} }
const messages = await getOlderMessagesByConversation(conversationId, { const messages = await getOlderMessagesByConversation({
conversationId,
includeStoryReplies: !isGroup(this.attributes), includeStoryReplies: !isGroup(this.attributes),
limit: MESSAGE_LOAD_CHUNK_SIZE, limit: MESSAGE_LOAD_CHUNK_SIZE,
storyId: undefined, storyId: undefined,
@ -1564,7 +1566,8 @@ export class ConversationModel extends window.Backbone
const receivedAt = message.received_at; const receivedAt = message.received_at;
const sentAt = message.sent_at; const sentAt = message.sent_at;
const models = await getOlderMessagesByConversation(conversationId, { const models = await getOlderMessagesByConversation({
conversationId,
includeStoryReplies: !isGroup(this.attributes), includeStoryReplies: !isGroup(this.attributes),
limit: MESSAGE_LOAD_CHUNK_SIZE, limit: MESSAGE_LOAD_CHUNK_SIZE,
messageId: oldestMessageId, messageId: oldestMessageId,
@ -1619,7 +1622,8 @@ export class ConversationModel extends window.Backbone
const receivedAt = message.received_at; const receivedAt = message.received_at;
const sentAt = message.sent_at; const sentAt = message.sent_at;
const models = await getNewerMessagesByConversation(conversationId, { const models = await getNewerMessagesByConversation({
conversationId,
includeStoryReplies: !isGroup(this.attributes), includeStoryReplies: !isGroup(this.attributes),
limit: MESSAGE_LOAD_CHUNK_SIZE, limit: MESSAGE_LOAD_CHUNK_SIZE,
receivedAt, receivedAt,
@ -2188,17 +2192,15 @@ export class ConversationModel extends window.Backbone
const first = messages ? messages[0] : undefined; const first = messages ? messages[0] : undefined;
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
messages = await window.Signal.Data.getOlderMessagesByConversation( messages = await window.Signal.Data.getOlderMessagesByConversation({
this.get('id'), conversationId: this.get('id'),
{ includeStoryReplies: !isGroup(this.attributes),
includeStoryReplies: !isGroup(this.attributes), limit: 100,
limit: 100, messageId: first ? first.id : undefined,
messageId: first ? first.id : undefined, receivedAt: first ? first.received_at : undefined,
receivedAt: first ? first.received_at : undefined, sentAt: first ? first.sent_at : undefined,
sentAt: first ? first.sent_at : undefined, storyId: undefined,
storyId: undefined, });
}
);
if (!messages.length) { if (!messages.length) {
return; return;

View file

@ -235,14 +235,12 @@ async function shouldReplyNotifyUser(
// If the story is from a different user, only notify if the user has // If the story is from a different user, only notify if the user has
// replied or reacted to the story // replied or reacted to the story
const replies = await dataInterface.getOlderMessagesByConversation( const replies = await dataInterface.getOlderMessagesByConversation({
conversation.id, conversationId: conversation.id,
{ limit: 9000,
limit: 9000, storyId,
storyId, includeStoryReplies: true,
includeStoryReplies: true, });
}
);
const prevCurrentUserReply = replies.find(replyMessage => { const prevCurrentUserReply = replies.find(replyMessage => {
return replyMessage.type === 'outgoing'; return replyMessage.type === 'outgoing';

View file

@ -40,6 +40,7 @@ import { cleanupMessage } from '../util/cleanup';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import type { import type {
AdjacentMessagesByConversationOptionsType,
AllItemsType, AllItemsType,
AttachmentDownloadJobType, AttachmentDownloadJobType,
ClientInterface, ClientInterface,
@ -674,77 +675,24 @@ function handleMessageJSON(
} }
async function getNewerMessagesByConversation( async function getNewerMessagesByConversation(
conversationId: string, options: AdjacentMessagesByConversationOptionsType
{
includeStoryReplies,
limit = 100,
receivedAt = 0,
sentAt = 0,
storyId,
}: {
includeStoryReplies: boolean;
limit?: number;
receivedAt?: number;
sentAt?: number;
storyId: UUIDStringType | undefined;
}
): Promise<Array<MessageType>> { ): Promise<Array<MessageType>> {
const messages = await channels.getNewerMessagesByConversation( const messages = await channels.getNewerMessagesByConversation(options);
conversationId,
{
includeStoryReplies,
limit,
receivedAt,
sentAt,
storyId,
}
);
return handleMessageJSON(messages); return handleMessageJSON(messages);
} }
async function getOlderMessagesByConversation( async function getOlderMessagesByConversation(
conversationId: string, options: AdjacentMessagesByConversationOptionsType
{
includeStoryReplies,
limit = 100,
messageId,
receivedAt = Number.MAX_VALUE,
sentAt = Number.MAX_VALUE,
storyId,
}: {
includeStoryReplies: boolean;
limit?: number;
messageId?: string;
receivedAt?: number;
sentAt?: number;
storyId: string | undefined;
}
): Promise<Array<MessageType>> { ): Promise<Array<MessageType>> {
const messages = await channels.getOlderMessagesByConversation( const messages = await channels.getOlderMessagesByConversation(options);
conversationId,
{
includeStoryReplies,
limit,
receivedAt,
sentAt,
messageId,
storyId,
}
);
return handleMessageJSON(messages); return handleMessageJSON(messages);
} }
async function getConversationRangeCenteredOnMessage(options: { async function getConversationRangeCenteredOnMessage(
conversationId: string; options: AdjacentMessagesByConversationOptionsType
includeStoryReplies: boolean; ): Promise<GetConversationRangeCenteredOnMessageResultType<MessageType>> {
limit?: number;
messageId: string;
receivedAt: number;
sentAt?: number;
storyId: UUIDStringType | undefined;
}): Promise<GetConversationRangeCenteredOnMessageResultType<MessageType>> {
const result = await channels.getConversationRangeCenteredOnMessage(options); const result = await channels.getConversationRangeCenteredOnMessage(options);
return { return {
@ -771,7 +719,8 @@ async function removeAllMessagesInConversation(
// Yes, we really want the await in the loop. We're deleting a chunk at a // Yes, we really want the await in the loop. We're deleting a chunk at a
// time so we don't use too much memory. // time so we don't use too much memory.
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
messages = await getOlderMessagesByConversation(conversationId, { messages = await getOlderMessagesByConversation({
conversationId,
limit: chunkSize, limit: chunkSize,
includeStoryReplies: true, includeStoryReplies: true,
storyId: undefined, storyId: undefined,

View file

@ -19,6 +19,17 @@ import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration';
import type { LoggerType } from '../types/Logging'; import type { LoggerType } from '../types/Logging';
import type { ReadStatus } from '../messages/MessageReadStatus'; import type { ReadStatus } from '../messages/MessageReadStatus';
export type AdjacentMessagesByConversationOptionsType = Readonly<{
conversationId: string;
messageId?: string;
includeStoryReplies: boolean;
limit?: number;
receivedAt?: number;
sentAt?: number;
storyId: string | undefined;
requireVisualMediaAttachments?: boolean;
}>;
export type AttachmentDownloadJobTypeType = export type AttachmentDownloadJobTypeType =
| 'long-message' | 'long-message'
| 'attachment' | 'attachment'
@ -481,7 +492,7 @@ export type DataInterface = {
getTotalUnreadForConversation: ( getTotalUnreadForConversation: (
conversationId: string, conversationId: string,
options: { options: {
storyId: UUIDStringType | undefined; storyId: string | undefined;
includeStoryReplies: boolean; includeStoryReplies: boolean;
} }
) => Promise<number>; ) => Promise<number>;
@ -491,12 +502,12 @@ export type DataInterface = {
newestUnreadAt: number; newestUnreadAt: number;
now?: number; now?: number;
readAt?: number; readAt?: number;
storyId?: UUIDStringType; storyId?: string;
}) => Promise<GetUnreadByConversationAndMarkReadResultType>; }) => Promise<GetUnreadByConversationAndMarkReadResultType>;
getUnreadReactionsAndMarkRead: (options: { getUnreadReactionsAndMarkRead: (options: {
conversationId: string; conversationId: string;
newestUnreadAt: number; newestUnreadAt: number;
storyId?: UUIDStringType; storyId?: string;
}) => Promise<Array<ReactionResultType>>; }) => Promise<Array<ReactionResultType>>;
markReactionAsRead: ( markReactionAsRead: (
targetAuthorUuid: string, targetAuthorUuid: string,
@ -536,13 +547,11 @@ export type DataInterface = {
sourceUuid?: UUIDStringType; sourceUuid?: UUIDStringType;
}) => Promise<GetAllStoriesResultType>; }) => Promise<GetAllStoriesResultType>;
// getNewerMessagesByConversation is JSON on server, full message on Client // getNewerMessagesByConversation is JSON on server, full message on Client
getMessageMetricsForConversation: ( getMessageMetricsForConversation: (options: {
conversationId: string, conversationId: string;
options: { storyId?: string;
storyId?: UUIDStringType; includeStoryReplies: boolean;
includeStoryReplies: boolean; }) => Promise<ConversationMetricsType>;
}
) => Promise<ConversationMetricsType>;
// getConversationRangeCenteredOnMessage is JSON on server, full message on client // getConversationRangeCenteredOnMessage is JSON on server, full message on client
getConversationMessageStats: (options: { getConversationMessageStats: (options: {
conversationId: string; conversationId: string;
@ -738,35 +747,14 @@ export type ServerInterface = DataInterface & {
) => Promise<Array<ServerSearchResultMessageType>>; ) => Promise<Array<ServerSearchResultMessageType>>;
getOlderMessagesByConversation: ( getOlderMessagesByConversation: (
conversationId: string, options: AdjacentMessagesByConversationOptionsType
options: {
includeStoryReplies: boolean;
limit?: number;
messageId?: string;
receivedAt?: number;
sentAt?: number;
storyId: string | undefined;
}
) => Promise<Array<MessageTypeUnhydrated>>; ) => Promise<Array<MessageTypeUnhydrated>>;
getNewerMessagesByConversation: ( getNewerMessagesByConversation: (
conversationId: string, options: AdjacentMessagesByConversationOptionsType
options: {
includeStoryReplies: boolean;
limit?: number;
receivedAt?: number;
sentAt?: number;
storyId: UUIDStringType | undefined;
}
) => Promise<Array<MessageTypeUnhydrated>>; ) => Promise<Array<MessageTypeUnhydrated>>;
getConversationRangeCenteredOnMessage: (options: { getConversationRangeCenteredOnMessage: (
conversationId: string; options: AdjacentMessagesByConversationOptionsType
includeStoryReplies: boolean; ) => Promise<
limit?: number;
messageId: string;
receivedAt: number;
sentAt?: number;
storyId: UUIDStringType | undefined;
}) => Promise<
GetConversationRangeCenteredOnMessageResultType<MessageTypeUnhydrated> GetConversationRangeCenteredOnMessageResultType<MessageTypeUnhydrated>
>; >;
@ -843,35 +831,14 @@ export type ClientExclusiveInterface = {
) => Promise<Array<ClientSearchResultMessageType>>; ) => Promise<Array<ClientSearchResultMessageType>>;
getOlderMessagesByConversation: ( getOlderMessagesByConversation: (
conversationId: string, options: AdjacentMessagesByConversationOptionsType
options: {
includeStoryReplies: boolean;
limit?: number;
messageId?: string;
receivedAt?: number;
sentAt?: number;
storyId: string | undefined;
}
) => Promise<Array<MessageAttributesType>>; ) => Promise<Array<MessageAttributesType>>;
getNewerMessagesByConversation: ( getNewerMessagesByConversation: (
conversationId: string, options: AdjacentMessagesByConversationOptionsType
options: {
includeStoryReplies: boolean;
limit?: number;
receivedAt?: number;
sentAt?: number;
storyId: UUIDStringType | undefined;
}
) => Promise<Array<MessageAttributesType>>; ) => Promise<Array<MessageAttributesType>>;
getConversationRangeCenteredOnMessage: (options: { getConversationRangeCenteredOnMessage: (
conversationId: string; options: AdjacentMessagesByConversationOptionsType
includeStoryReplies: boolean; ) => Promise<GetConversationRangeCenteredOnMessageResultType<MessageType>>;
limit?: number;
messageId: string;
receivedAt: number;
sentAt?: number;
storyId: UUIDStringType | undefined;
}) => Promise<GetConversationRangeCenteredOnMessageResultType<MessageType>>;
createOrUpdateIdentityKey: (data: IdentityKeyType) => Promise<void>; createOrUpdateIdentityKey: (data: IdentityKeyType) => Promise<void>;
getIdentityKeyById: ( getIdentityKeyById: (

View file

@ -71,6 +71,7 @@ import {
import { updateSchema } from './migrations'; import { updateSchema } from './migrations';
import type { import type {
AdjacentMessagesByConversationOptionsType,
StoredAllItemsType, StoredAllItemsType,
AttachmentDownloadJobType, AttachmentDownloadJobType,
ConversationMetricsType, ConversationMetricsType,
@ -2212,7 +2213,7 @@ async function getUnreadByConversationAndMarkRead({
conversationId: string; conversationId: string;
includeStoryReplies: boolean; includeStoryReplies: boolean;
newestUnreadAt: number; newestUnreadAt: number;
storyId?: UUIDStringType; storyId?: string;
readAt?: number; readAt?: number;
now?: number; now?: number;
}): Promise<GetUnreadByConversationAndMarkReadResultType> { }): Promise<GetUnreadByConversationAndMarkReadResultType> {
@ -2315,7 +2316,7 @@ async function getUnreadReactionsAndMarkRead({
}: { }: {
conversationId: string; conversationId: string;
newestUnreadAt: number; newestUnreadAt: number;
storyId?: UUIDStringType; storyId?: string;
}): Promise<Array<ReactionResultType>> { }): Promise<Array<ReactionResultType>> {
const db = getInstance(); const db = getInstance();
@ -2477,64 +2478,106 @@ async function _removeAllReactions(): Promise<void> {
db.prepare<EmptyQuery>('DELETE from reactions;').run(); db.prepare<EmptyQuery>('DELETE from reactions;').run();
} }
async function getOlderMessagesByConversation( enum AdjacentDirection {
conversationId: string, Older = 'Older',
options: { Newer = 'Newer',
includeStoryReplies: boolean;
limit?: number;
messageId?: string;
receivedAt?: number;
sentAt?: number;
storyId: string | undefined;
}
): Promise<Array<MessageTypeUnhydrated>> {
return getOlderMessagesByConversationSync(conversationId, options);
} }
function getOlderMessagesByConversationSync(
conversationId: string, function getAdjacentMessagesByConversationSync(
direction: AdjacentDirection,
{ {
conversationId,
includeStoryReplies, includeStoryReplies,
limit = 100, limit = 100,
messageId, messageId,
receivedAt = Number.MAX_VALUE, receivedAt = direction === AdjacentDirection.Older ? Number.MAX_VALUE : 0,
sentAt = Number.MAX_VALUE, sentAt = direction === AdjacentDirection.Older ? Number.MAX_VALUE : 0,
requireVisualMediaAttachments,
storyId, storyId,
}: { }: AdjacentMessagesByConversationOptionsType
includeStoryReplies: boolean;
limit?: number;
messageId?: string;
receivedAt?: number;
sentAt?: number;
storyId: string | undefined;
}
): Array<MessageTypeUnhydrated> { ): Array<MessageTypeUnhydrated> {
const db = getInstance(); const db = getInstance();
return db const timeFilter =
.prepare<Query>( direction === AdjacentDirection.Older
` ? `
SELECT json FROM messages WHERE (received_at = $received_at AND sent_at < $sent_at) OR
conversationId = $conversationId AND received_at < $received_at
($messageId IS NULL OR id IS NOT $messageId) AND `
isStory IS 0 AND : `
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND (received_at = $received_at AND sent_at > $sent_at) OR
received_at > $received_at
`;
const timeOrder = direction === AdjacentDirection.Older ? 'DESC' : 'ASC';
const requireDifferentMessage =
direction === AdjacentDirection.Older || requireVisualMediaAttachments;
let query = `
SELECT json FROM messages WHERE
conversationId = $conversationId AND
${
requireDifferentMessage
? '($messageId IS NULL OR id IS NOT $messageId) AND'
: ''
}
${
requireVisualMediaAttachments
? 'hasVisualMediaAttachments IS 1 AND'
: ''
}
isStory IS 0 AND
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
(
${timeFilter}
)
ORDER BY received_at ${timeOrder}, sent_at ${timeOrder}
`;
// See `filterValidAttachments` in ts/state/ducks/lightbox.ts
if (requireVisualMediaAttachments) {
query = `
SELECT json
FROM (${query}) as messages
WHERE
( (
(received_at = $received_at AND sent_at < $sent_at) OR SELECT COUNT(*)
received_at < $received_at FROM json_each(messages.json ->> 'attachments') AS attachment
) WHERE
ORDER BY received_at DESC, sent_at DESC attachment.value ->> 'thumbnail' IS NOT NULL AND
attachment.value ->> 'pending' IS NOT 1 AND
attachment.value ->> 'error' IS NULL
) > 0
LIMIT $limit; LIMIT $limit;
` `;
) } else {
.all({ query = `${query} LIMIT $limit`;
conversationId, }
limit,
messageId: messageId || null, const results = db.prepare<Query>(query).all({
received_at: receivedAt, conversationId,
sent_at: sentAt, limit,
storyId: storyId || null, messageId: messageId || null,
}) received_at: receivedAt,
.reverse(); sent_at: sentAt,
storyId: storyId || null,
});
if (direction === AdjacentDirection.Older) {
results.reverse();
}
return results;
}
async function getOlderMessagesByConversation(
options: AdjacentMessagesByConversationOptionsType
): Promise<Array<MessageTypeUnhydrated>> {
return getAdjacentMessagesByConversationSync(
AdjacentDirection.Older,
options
);
} }
async function getAllStories({ async function getAllStories({
@ -2587,58 +2630,12 @@ async function getAllStories({
} }
async function getNewerMessagesByConversation( async function getNewerMessagesByConversation(
conversationId: string, options: AdjacentMessagesByConversationOptionsType
options: {
includeStoryReplies: boolean;
limit?: number;
receivedAt?: number;
sentAt?: number;
storyId: UUIDStringType | undefined;
}
): Promise<Array<MessageTypeUnhydrated>> { ): Promise<Array<MessageTypeUnhydrated>> {
return getNewerMessagesByConversationSync(conversationId, options); return getAdjacentMessagesByConversationSync(
} AdjacentDirection.Newer,
function getNewerMessagesByConversationSync( options
conversationId: string, );
{
includeStoryReplies,
limit = 100,
receivedAt = 0,
sentAt = 0,
storyId,
}: {
includeStoryReplies: boolean;
limit?: number;
receivedAt?: number;
sentAt?: number;
storyId: UUIDStringType | undefined;
}
): Array<MessageTypeUnhydrated> {
const db = getInstance();
const rows: JSONRows = db
.prepare<Query>(
`
SELECT json FROM messages WHERE
conversationId = $conversationId AND
isStory IS 0 AND
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
(
(received_at = $received_at AND sent_at > $sent_at) OR
received_at > $received_at
)
ORDER BY received_at ASC, sent_at ASC
LIMIT $limit;
`
)
.all({
conversationId,
limit,
received_at: receivedAt,
sent_at: sentAt,
storyId: storyId || null,
});
return rows;
} }
function getOldestMessageForConversation( function getOldestMessageForConversation(
conversationId: string, conversationId: string,
@ -2646,7 +2643,7 @@ function getOldestMessageForConversation(
storyId, storyId,
includeStoryReplies, includeStoryReplies,
}: { }: {
storyId?: UUIDStringType; storyId?: string;
includeStoryReplies: boolean; includeStoryReplies: boolean;
} }
): MessageMetricsType | undefined { ): MessageMetricsType | undefined {
@ -2679,7 +2676,7 @@ function getNewestMessageForConversation(
storyId, storyId,
includeStoryReplies, includeStoryReplies,
}: { }: {
storyId?: UUIDStringType; storyId?: string;
includeStoryReplies: boolean; includeStoryReplies: boolean;
} }
): MessageMetricsType | undefined { ): MessageMetricsType | undefined {
@ -2842,7 +2839,7 @@ function getOldestUnseenMessageForConversation(
storyId, storyId,
includeStoryReplies, includeStoryReplies,
}: { }: {
storyId?: UUIDStringType; storyId?: string;
includeStoryReplies: boolean; includeStoryReplies: boolean;
} }
): MessageMetricsType | undefined { ): MessageMetricsType | undefined {
@ -2874,7 +2871,7 @@ function getOldestUnseenMessageForConversation(
async function getTotalUnreadForConversation( async function getTotalUnreadForConversation(
conversationId: string, conversationId: string,
options: { options: {
storyId: UUIDStringType | undefined; storyId: string | undefined;
includeStoryReplies: boolean; includeStoryReplies: boolean;
} }
): Promise<number> { ): Promise<number> {
@ -2886,7 +2883,7 @@ function getTotalUnreadForConversationSync(
storyId, storyId,
includeStoryReplies, includeStoryReplies,
}: { }: {
storyId: UUIDStringType | undefined; storyId: string | undefined;
includeStoryReplies: boolean; includeStoryReplies: boolean;
} }
): number { ): number {
@ -2917,7 +2914,7 @@ function getTotalUnseenForConversationSync(
storyId, storyId,
includeStoryReplies, includeStoryReplies,
}: { }: {
storyId?: UUIDStringType; storyId?: string;
includeStoryReplies: boolean; includeStoryReplies: boolean;
} }
): number { ): number {
@ -2943,22 +2940,19 @@ function getTotalUnseenForConversationSync(
return row; return row;
} }
async function getMessageMetricsForConversation( async function getMessageMetricsForConversation(options: {
conversationId: string, conversationId: string;
options: { storyId?: string;
storyId?: UUIDStringType; includeStoryReplies: boolean;
includeStoryReplies: boolean; }): Promise<ConversationMetricsType> {
} return getMessageMetricsForConversationSync(options);
): Promise<ConversationMetricsType> {
return getMessageMetricsForConversationSync(conversationId, options);
} }
function getMessageMetricsForConversationSync( function getMessageMetricsForConversationSync(options: {
conversationId: string, conversationId: string;
options: { storyId?: string;
storyId?: UUIDStringType; includeStoryReplies: boolean;
includeStoryReplies: boolean; }): ConversationMetricsType {
} const { conversationId } = options;
): ConversationMetricsType {
const oldest = getOldestMessageForConversation(conversationId, options); const oldest = getOldestMessageForConversation(conversationId, options);
const newest = getNewestMessageForConversation(conversationId, options); const newest = getNewestMessageForConversation(conversationId, options);
const oldestUnseen = getOldestUnseenMessageForConversation( const oldestUnseen = getOldestUnseenMessageForConversation(
@ -2980,48 +2974,24 @@ function getMessageMetricsForConversationSync(
}; };
} }
async function getConversationRangeCenteredOnMessage({ async function getConversationRangeCenteredOnMessage(
conversationId, options: AdjacentMessagesByConversationOptionsType
includeStoryReplies, ): Promise<
limit,
messageId,
receivedAt,
sentAt,
storyId,
}: {
conversationId: string;
includeStoryReplies: boolean;
limit?: number;
messageId: string;
receivedAt: number;
sentAt?: number;
storyId: UUIDStringType | undefined;
}): Promise<
GetConversationRangeCenteredOnMessageResultType<MessageTypeUnhydrated> GetConversationRangeCenteredOnMessageResultType<MessageTypeUnhydrated>
> { > {
const db = getInstance(); const db = getInstance();
return db.transaction(() => { return db.transaction(() => {
return { return {
older: getOlderMessagesByConversationSync(conversationId, { older: getAdjacentMessagesByConversationSync(
includeStoryReplies, AdjacentDirection.Older,
limit, options
messageId, ),
receivedAt, newer: getAdjacentMessagesByConversationSync(
sentAt, AdjacentDirection.Newer,
storyId, options
}), ),
newer: getNewerMessagesByConversationSync(conversationId, { metrics: getMessageMetricsForConversationSync(options),
includeStoryReplies,
limit,
receivedAt,
sentAt,
storyId,
}),
metrics: getMessageMetricsForConversationSync(conversationId, {
storyId,
includeStoryReplies,
}),
}; };
})(); })();
} }
@ -4998,11 +4968,15 @@ async function getMessagesWithVisualMediaAttachments(
const rows: JSONRows = db const rows: JSONRows = db
.prepare<Query>( .prepare<Query>(
` `
SELECT json FROM messages WHERE SELECT json FROM messages
INDEXED BY messages_hasVisualMediaAttachments
WHERE
isStory IS 0 AND isStory IS 0 AND
storyId IS NULL AND storyId IS NULL AND
conversationId = $conversationId AND conversationId = $conversationId AND
hasVisualMediaAttachments = 1 -- Note that this check has to use 'IS' to utilize
-- 'messages_hasVisualMediaAttachments' INDEX
hasVisualMediaAttachments IS 1
ORDER BY received_at DESC, sent_at DESC ORDER BY received_at DESC, sent_at DESC
LIMIT $limit; LIMIT $limit;
` `

View file

@ -0,0 +1,32 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import type { LoggerType } from '../../types/Logging';
export default function updateToSchemaVersion79(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 79) {
return;
}
db.transaction(() => {
db.exec(`
DROP INDEX messages_hasVisualMediaAttachments;
CREATE INDEX messages_hasVisualMediaAttachments
ON messages (
conversationId, isStory, storyId,
hasVisualMediaAttachments, received_at, sent_at
)
WHERE hasVisualMediaAttachments IS 1;
`);
db.pragma('user_version = 79');
})();
logger.info('updateToSchemaVersion79: success!');
}

View file

@ -54,6 +54,7 @@ import updateToSchemaVersion75 from './75-noop';
import updateToSchemaVersion76 from './76-optimize-convo-open-2'; import updateToSchemaVersion76 from './76-optimize-convo-open-2';
import updateToSchemaVersion77 from './77-signal-tokenizer'; import updateToSchemaVersion77 from './77-signal-tokenizer';
import updateToSchemaVersion78 from './78-merge-receipt-jobs'; import updateToSchemaVersion78 from './78-merge-receipt-jobs';
import updateToSchemaVersion79 from './79-paging-lightbox';
function updateToSchemaVersion1( function updateToSchemaVersion1(
currentVersion: number, currentVersion: number,
@ -1977,6 +1978,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion76, updateToSchemaVersion76,
updateToSchemaVersion77, updateToSchemaVersion77,
updateToSchemaVersion78, updateToSchemaVersion78,
updateToSchemaVersion79,
]; ];
export function updateSchema(db: Database, logger: LoggerType): void { export function updateSchema(db: Database, logger: LoggerType): void {

View file

@ -18,6 +18,7 @@ import type { StateType as RootStateType } from '../reducer';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { getMessageById } from '../../messages/getMessageById'; import { getMessageById } from '../../messages/getMessageById';
import type { MessageAttributesType } from '../../model-types.d';
import { isGIF } from '../../types/Attachment'; import { isGIF } from '../../types/Attachment';
import { import {
isImageTypeSupported, isImageTypeSupported,
@ -34,6 +35,7 @@ import {
} from './conversations'; } from './conversations';
import { showStickerPackPreview } from './globalModals'; import { showStickerPackPreview } from './globalModals';
import { useBoundActions } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions';
import dataInterface from '../../sql/Client';
// eslint-disable-next-line local-rules/type-alias-readonlydeep // eslint-disable-next-line local-rules/type-alias-readonlydeep
export type LightboxStateType = export type LightboxStateType =
@ -44,11 +46,14 @@ export type LightboxStateType =
isShowingLightbox: true; isShowingLightbox: true;
isViewOnce: boolean; isViewOnce: boolean;
media: ReadonlyArray<ReadonlyDeep<MediaItemType>>; media: ReadonlyArray<ReadonlyDeep<MediaItemType>>;
hasPrevMessage: boolean;
hasNextMessage: boolean;
selectedAttachmentPath: string | undefined; selectedAttachmentPath: string | undefined;
}; };
const CLOSE_LIGHTBOX = 'lightbox/CLOSE'; const CLOSE_LIGHTBOX = 'lightbox/CLOSE';
const SHOW_LIGHTBOX = 'lightbox/SHOW'; const SHOW_LIGHTBOX = 'lightbox/SHOW';
const SET_SELECTED_LIGHTBOX_PATH = 'lightbox/SET_SELECTED_LIGHTBOX_PATH';
type CloseLightboxActionType = ReadonlyDeep<{ type CloseLightboxActionType = ReadonlyDeep<{
type: typeof CLOSE_LIGHTBOX; type: typeof CLOSE_LIGHTBOX;
@ -60,17 +65,25 @@ type ShowLightboxActionType = {
payload: { payload: {
isViewOnce: boolean; isViewOnce: boolean;
media: ReadonlyArray<ReadonlyDeep<MediaItemType>>; media: ReadonlyArray<ReadonlyDeep<MediaItemType>>;
hasPrevMessage: boolean;
hasNextMessage: boolean;
selectedAttachmentPath: string | undefined; selectedAttachmentPath: string | undefined;
}; };
}; };
type SetSelectedLightboxPathActionType = ReadonlyDeep<{
type: typeof SET_SELECTED_LIGHTBOX_PATH;
payload: string | undefined;
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep // eslint-disable-next-line local-rules/type-alias-readonlydeep
type LightboxActionType = type LightboxActionType =
| CloseLightboxActionType | CloseLightboxActionType
| MessageChangedActionType | MessageChangedActionType
| MessageDeletedActionType | MessageDeletedActionType
| MessageExpiredActionType | MessageExpiredActionType
| ShowLightboxActionType; | ShowLightboxActionType
| SetSelectedLightboxPathActionType;
function closeLightbox(): ThunkAction< function closeLightbox(): ThunkAction<
void, void,
@ -112,6 +125,8 @@ function showLightboxWithMedia(
isViewOnce: false, isViewOnce: false,
media, media,
selectedAttachmentPath, selectedAttachmentPath,
hasPrevMessage: false,
hasNextMessage: false,
}, },
}; };
} }
@ -188,11 +203,21 @@ function showLightboxForViewOnceMedia(
isViewOnce: true, isViewOnce: true,
media, media,
selectedAttachmentPath: undefined, selectedAttachmentPath: undefined,
hasPrevMessage: false,
hasNextMessage: false,
}, },
}); });
}; };
} }
function filterValidAttachments(
attributes: MessageAttributesType
): Array<AttachmentType> {
return (attributes.attachments ?? []).filter(
item => item.thumbnail && !item.pending && !item.error
);
}
function showLightbox(opts: { function showLightbox(opts: {
attachment: AttachmentType; attachment: AttachmentType;
messageId: string; messageId: string;
@ -232,44 +257,48 @@ function showLightbox(opts: {
return; return;
} }
const attachments: Array<AttachmentType> = message.get('attachments') || []; const attachments = filterValidAttachments(message.attributes);
const loop = isGIF(attachments); const loop = isGIF(attachments);
const { getAbsoluteAttachmentPath } = window.Signal.Migrations; const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
const media = attachments const authorId =
.filter(item => item.thumbnail && !item.pending && !item.error) window.ConversationController.lookupOrCreate({
.map((item, index) => ({ uuid: message.get('sourceUuid'),
objectURL: getAbsoluteAttachmentPath(item.path ?? ''), e164: message.get('source'),
path: item.path, reason: 'conversation_view.showLightBox',
contentType: item.contentType, })?.id || message.get('conversationId');
loop, const receivedAt = message.get('received_at');
index, const sentAt = message.get('sent_at');
message: {
attachments: message.get('attachments') || [], const media = attachments.map((item, index) => ({
id: message.get('id'), objectURL: getAbsoluteAttachmentPath(item.path ?? ''),
conversationId: path: item.path,
window.ConversationController.lookupOrCreate({ contentType: item.contentType,
uuid: message.get('sourceUuid'), loop,
e164: message.get('source'), index,
reason: 'conversation_view.showLightBox', message: {
})?.id || message.get('conversationId'), attachments: message.get('attachments') || [],
received_at: message.get('received_at'), id: messageId,
received_at_ms: Number(message.get('received_at_ms')), conversationId: authorId,
sent_at: message.get('sent_at'), received_at: receivedAt,
}, received_at_ms: Number(message.get('received_at_ms')),
attachment: item, sent_at: sentAt,
thumbnailObjectUrl: },
item.thumbnail?.objectUrl || attachment: item,
getAbsoluteAttachmentPath(item.thumbnail?.path ?? ''), thumbnailObjectUrl:
})); item.thumbnail?.objectUrl ||
getAbsoluteAttachmentPath(item.thumbnail?.path ?? ''),
}));
if (!media.length) { if (!media.length) {
log.error( log.error(
'showLightbox: unable to load attachment', 'showLightbox: unable to load attachment',
attachments.map(x => ({ sentAt,
message.get('attachments')?.map(x => ({
thumbnail: !!x.thumbnail,
contentType: x.contentType, contentType: x.contentType,
pending: x.pending,
error: x.error, error: x.error,
flags: x.flags, flags: x.flags,
path: x.path, path: x.path,
@ -286,22 +315,172 @@ function showLightbox(opts: {
return; return;
} }
const { older, newer } =
await dataInterface.getConversationRangeCenteredOnMessage({
conversationId: message.get('conversationId'),
messageId,
receivedAt,
sentAt,
limit: 1,
storyId: undefined,
includeStoryReplies: false,
// This is the critical option since we only want messages with visual
// attachments.
requireVisualMediaAttachments: true,
});
dispatch({ dispatch({
type: SHOW_LIGHTBOX, type: SHOW_LIGHTBOX,
payload: { payload: {
isViewOnce: false, isViewOnce: false,
media, media,
selectedAttachmentPath: attachment.path, selectedAttachmentPath: attachment.path,
hasPrevMessage:
older.length > 0 && filterValidAttachments(older[0]).length > 0,
hasNextMessage:
newer.length > 0 && filterValidAttachments(newer[0]).length > 0,
}, },
}); });
}; };
} }
enum AdjacentMessageDirection {
Previous = 'Previous',
Next = 'Next',
}
function showLightboxForAdjacentMessage(
direction: AdjacentMessageDirection
): ThunkAction<
void,
RootStateType,
unknown,
ShowLightboxActionType | ShowToastActionType
> {
return async (dispatch, getState) => {
const { lightbox } = getState();
if (!lightbox.isShowingLightbox || lightbox.media.length === 0) {
log.warn('showLightboxForAdjacentMessage: empty lightbox');
return;
}
const [media] = lightbox.media;
const {
id: messageId,
received_at: receivedAt,
sent_at: sentAt,
} = media.message;
const message = await getMessageById(messageId);
if (!message) {
log.warn('showLightboxForAdjacentMessage: original message is gone');
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.UnableToLoadAttachment,
},
});
return;
}
const conversationId = message.get('conversationId');
const options = {
conversationId,
messageId,
receivedAt,
sentAt,
limit: 1,
storyId: undefined,
includeStoryReplies: false,
// This is the critical option since we only want messages with visual
// attachments.
requireVisualMediaAttachments: true,
};
const [adjacent] =
direction === AdjacentMessageDirection.Previous
? await dataInterface.getOlderMessagesByConversation(options)
: await dataInterface.getNewerMessagesByConversation(options);
if (!adjacent) {
log.warn(
`showLightboxForAdjacentMessage(${direction}, ${messageId}, ` +
`${sentAt}): no ${direction} message found`
);
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.UnableToLoadAttachment,
},
});
return;
}
const attachments = filterValidAttachments(adjacent);
if (!attachments.length) {
log.warn(
`showLightboxForAdjacentMessage(${direction}, ${messageId}, ` +
`${sentAt}): no valid attachments found`
);
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.UnableToLoadAttachment,
},
});
return;
}
dispatch(
showLightbox({
attachment:
direction === AdjacentMessageDirection.Previous
? attachments[attachments.length - 1]
: attachments[0],
messageId: adjacent.id,
})
);
};
}
function showLightboxForNextMessage(): ThunkAction<
void,
RootStateType,
unknown,
ShowLightboxActionType
> {
return showLightboxForAdjacentMessage(AdjacentMessageDirection.Next);
}
function showLightboxForPrevMessage(): ThunkAction<
void,
RootStateType,
unknown,
ShowLightboxActionType
> {
return showLightboxForAdjacentMessage(AdjacentMessageDirection.Previous);
}
function setSelectedLightboxPath(
path: string | undefined
): SetSelectedLightboxPathActionType {
return {
type: SET_SELECTED_LIGHTBOX_PATH,
payload: path,
};
}
export const actions = { export const actions = {
closeLightbox, closeLightbox,
showLightbox, showLightbox,
showLightboxForViewOnceMedia, showLightboxForViewOnceMedia,
showLightboxWithMedia, showLightboxWithMedia,
showLightboxForPrevMessage,
showLightboxForNextMessage,
setSelectedLightboxPath,
}; };
export const useLightboxActions = (): BoundActionCreatorsMapObject< export const useLightboxActions = (): BoundActionCreatorsMapObject<
@ -329,6 +508,17 @@ export function reducer(
}; };
} }
if (action.type === SET_SELECTED_LIGHTBOX_PATH) {
if (!state.isShowingLightbox) {
return state;
}
return {
...state,
selectedAttachmentPath: action.payload,
};
}
if ( if (
action.type === MESSAGE_CHANGED || action.type === MESSAGE_CHANGED ||
action.type === MESSAGE_DELETED || action.type === MESSAGE_DELETED ||

View file

@ -325,14 +325,12 @@ function loadStoryReplies(
): ThunkAction<void, RootStateType, unknown, LoadStoryRepliesActionType> { ): ThunkAction<void, RootStateType, unknown, LoadStoryRepliesActionType> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const conversation = getConversationSelector(getState())(conversationId); const conversation = getConversationSelector(getState())(conversationId);
const replies = await dataInterface.getOlderMessagesByConversation( const replies = await dataInterface.getOlderMessagesByConversation({
conversationId, conversationId,
{ limit: 9000,
limit: 9000, storyId: messageId,
storyId: messageId, includeStoryReplies: !isGroup(conversation),
includeStoryReplies: !isGroup(conversation), });
}
);
dispatch({ dispatch({
type: LOAD_STORY_REPLIES, type: LOAD_STORY_REPLIES,

View file

@ -40,3 +40,13 @@ export const getMedia = createSelector(
(state): ReadonlyArray<ReadonlyDeep<MediaItemType>> => (state): ReadonlyArray<ReadonlyDeep<MediaItemType>> =>
state.isShowingLightbox ? state.media : [] state.isShowingLightbox ? state.media : []
); );
export const getHasPrevMessage = createSelector(
getLightboxState,
(state): boolean => state.isShowingLightbox && state.hasPrevMessage
);
export const getHasNextMessage = createSelector(
getLightboxState,
(state): boolean => state.isShowingLightbox && state.hasNextMessage
);

View file

@ -1,7 +1,7 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useCallback } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
@ -19,6 +19,8 @@ import { useAudioPlayerActions } from '../ducks/audioPlayer';
import { import {
getIsViewOnce, getIsViewOnce,
getMedia, getMedia,
getHasPrevMessage,
getHasNextMessage,
getSelectedIndex, getSelectedIndex,
shouldShowLightbox, shouldShowLightbox,
} from '../selectors/lightbox'; } from '../selectors/lightbox';
@ -26,7 +28,12 @@ import {
export function SmartLightbox(): JSX.Element | null { export function SmartLightbox(): JSX.Element | null {
const i18n = useSelector<StateType, LocalizerType>(getIntl); const i18n = useSelector<StateType, LocalizerType>(getIntl);
const { saveAttachment } = useConversationsActions(); const { saveAttachment } = useConversationsActions();
const { closeLightbox } = useLightboxActions(); const {
closeLightbox,
showLightboxForNextMessage,
showLightboxForPrevMessage,
setSelectedLightboxPath,
} = useLightboxActions();
const { toggleForwardMessageModal } = useGlobalModalActions(); const { toggleForwardMessageModal } = useGlobalModalActions();
const { pauseVoiceNotePlayer } = useAudioPlayerActions(); const { pauseVoiceNotePlayer } = useAudioPlayerActions();
@ -40,8 +47,49 @@ export function SmartLightbox(): JSX.Element | null {
StateType, StateType,
ReadonlyArray<ReadonlyDeep<MediaItemType>> ReadonlyArray<ReadonlyDeep<MediaItemType>>
>(getMedia); >(getMedia);
const hasPrevMessage = useSelector<StateType, boolean>(getHasPrevMessage);
const hasNextMessage = useSelector<StateType, boolean>(getHasNextMessage);
const selectedIndex = useSelector<StateType, number>(getSelectedIndex); const selectedIndex = useSelector<StateType, number>(getSelectedIndex);
const onPrevAttachment = useCallback(() => {
if (selectedIndex <= 0) {
if (hasPrevMessage) {
showLightboxForPrevMessage();
}
return;
}
setSelectedLightboxPath(media[selectedIndex - 1]?.attachment.path);
}, [
showLightboxForPrevMessage,
media,
selectedIndex,
setSelectedLightboxPath,
hasPrevMessage,
]);
const onNextAttachment = useCallback(() => {
if (selectedIndex >= media.length - 1) {
if (hasNextMessage) {
showLightboxForNextMessage();
}
return;
}
setSelectedLightboxPath(media[selectedIndex + 1]?.attachment.path);
}, [
showLightboxForNextMessage,
media,
selectedIndex,
setSelectedLightboxPath,
hasNextMessage,
]);
const onSelectAttachment = useCallback(
(newIndex: number) => {
setSelectedLightboxPath(media[newIndex]?.attachment.path);
},
[setSelectedLightboxPath, media]
);
if (!isShowingLightbox) { if (!isShowingLightbox) {
return null; return null;
} }
@ -57,6 +105,11 @@ export function SmartLightbox(): JSX.Element | null {
selectedIndex={selectedIndex || 0} selectedIndex={selectedIndex || 0}
toggleForwardMessageModal={toggleForwardMessageModal} toggleForwardMessageModal={toggleForwardMessageModal}
onMediaPlaybackStart={pauseVoiceNotePlayer} onMediaPlaybackStart={pauseVoiceNotePlayer}
onPrevAttachment={onPrevAttachment}
onNextAttachment={onNextAttachment}
onSelectAttachment={onSelectAttachment}
hasNextMessage={hasNextMessage}
hasPrevMessage={hasPrevMessage}
/> />
); );
} }

View file

@ -92,7 +92,8 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 5); assert.lengthOf(await _getAllMessages(), 5);
const messages = await getOlderMessagesByConversation(conversationId, { const messages = await getOlderMessagesByConversation({
conversationId,
includeStoryReplies: true, includeStoryReplies: true,
limit: 5, limit: 5,
storyId: undefined, storyId: undefined,
@ -149,7 +150,8 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 3); assert.lengthOf(await _getAllMessages(), 3);
const messages = await getOlderMessagesByConversation(conversationId, { const messages = await getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false, includeStoryReplies: false,
limit: 5, limit: 5,
storyId, storyId,
@ -203,7 +205,8 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 3); assert.lengthOf(await _getAllMessages(), 3);
const messages = await getOlderMessagesByConversation(conversationId, { const messages = await getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false, includeStoryReplies: false,
limit: 5, limit: 5,
storyId: undefined, storyId: undefined,
@ -254,7 +257,8 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 3); assert.lengthOf(await _getAllMessages(), 3);
const messages = await getOlderMessagesByConversation(conversationId, { const messages = await getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false, includeStoryReplies: false,
limit: 5, limit: 5,
receivedAt: target, receivedAt: target,
@ -307,7 +311,8 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 3); assert.lengthOf(await _getAllMessages(), 3);
const messages = await getOlderMessagesByConversation(conversationId, { const messages = await getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false, includeStoryReplies: false,
limit: 5, limit: 5,
receivedAt: target, receivedAt: target,
@ -364,7 +369,8 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 3); assert.lengthOf(await _getAllMessages(), 3);
const messages = await getOlderMessagesByConversation(conversationId, { const messages = await getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false, includeStoryReplies: false,
limit: 5, limit: 5,
messageId: message2.id, messageId: message2.id,
@ -442,7 +448,8 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 5); assert.lengthOf(await _getAllMessages(), 5);
const messages = await getNewerMessagesByConversation(conversationId, { const messages = await getNewerMessagesByConversation({
conversationId,
includeStoryReplies: true, includeStoryReplies: true,
limit: 5, limit: 5,
storyId: undefined, storyId: undefined,
@ -498,7 +505,8 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 3); assert.lengthOf(await _getAllMessages(), 3);
const messages = await getNewerMessagesByConversation(conversationId, { const messages = await getNewerMessagesByConversation({
conversationId,
includeStoryReplies: false, includeStoryReplies: false,
limit: 5, limit: 5,
storyId, storyId,
@ -550,7 +558,8 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 3); assert.lengthOf(await _getAllMessages(), 3);
const messages = await getNewerMessagesByConversation(conversationId, { const messages = await getNewerMessagesByConversation({
conversationId,
includeStoryReplies: false, includeStoryReplies: false,
limit: 5, limit: 5,
receivedAt: target, receivedAt: target,
@ -605,7 +614,8 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 3); assert.lengthOf(await _getAllMessages(), 3);
const messages = await getNewerMessagesByConversation(conversationId, { const messages = await getNewerMessagesByConversation({
conversationId,
includeStoryReplies: false, includeStoryReplies: false,
limit: 5, limit: 5,
storyId: undefined, storyId: undefined,
@ -658,7 +668,8 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 3); assert.lengthOf(await _getAllMessages(), 3);
const messages = await getNewerMessagesByConversation(conversationId, { const messages = await getNewerMessagesByConversation({
conversationId,
includeStoryReplies: false, includeStoryReplies: false,
limit: 5, limit: 5,
receivedAt: target, receivedAt: target,
@ -777,12 +788,10 @@ describe('sql/timelineFetches', () => {
assert.lengthOf(await _getAllMessages(), 8); assert.lengthOf(await _getAllMessages(), 8);
const metricsInTimeline = await getMessageMetricsForConversation( const metricsInTimeline = await getMessageMetricsForConversation({
conversationId, conversationId,
{ includeStoryReplies: false,
includeStoryReplies: false, });
}
);
assert.strictEqual(metricsInTimeline?.oldest?.id, oldest.id, 'oldest'); assert.strictEqual(metricsInTimeline?.oldest?.id, oldest.id, 'oldest');
assert.strictEqual(metricsInTimeline?.newest?.id, newest.id, 'newest'); assert.strictEqual(metricsInTimeline?.newest?.id, newest.id, 'newest');
assert.strictEqual( assert.strictEqual(
@ -792,10 +801,11 @@ describe('sql/timelineFetches', () => {
); );
assert.strictEqual(metricsInTimeline?.totalUnseen, 2, 'totalUnseen'); assert.strictEqual(metricsInTimeline?.totalUnseen, 2, 'totalUnseen');
const metricsInStory = await getMessageMetricsForConversation( const metricsInStory = await getMessageMetricsForConversation({
conversationId, conversationId,
{ storyId, includeStoryReplies: true } storyId,
); includeStoryReplies: true,
});
assert.strictEqual( assert.strictEqual(
metricsInStory?.oldest?.id, metricsInStory?.oldest?.id,
oldestInStory.id, oldestInStory.id,

View file

@ -41,15 +41,13 @@ async function cleanupStoryReplies(
): Promise<void> { ): Promise<void> {
const { messageId, receivedAt } = pagination || {}; const { messageId, receivedAt } = pagination || {};
const replies = await window.Signal.Data.getOlderMessagesByConversation( const replies = await window.Signal.Data.getOlderMessagesByConversation({
conversationId, conversationId,
{ includeStoryReplies: false,
includeStoryReplies: false, messageId,
messageId, receivedAt,
receivedAt, storyId,
storyId, });
}
);
if (!replies.length) { if (!replies.length) {
return; return;