signal-desktop/ts/state/ducks/lightbox.ts

680 lines
18 KiB
TypeScript
Raw Normal View History

2022-12-09 21:02:22 -05:00
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction } from 'redux-thunk';
import type { ReadonlyDeep } from 'type-fest';
2022-12-09 21:02:22 -05:00
import type { AttachmentType } from '../../types/Attachment';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import type { MediaItemType } from '../../types/MediaItem';
import type {
MessageChangedActionType,
MessageDeletedActionType,
MessageExpiredActionType,
} from './conversations';
2022-12-09 21:02:22 -05:00
import type { ShowStickerPackPreviewActionType } from './globalModals';
import type { ShowToastActionType } from './toast';
2022-12-20 12:50:23 -05:00
import type { StateType as RootStateType } from '../reducer';
2022-12-09 21:02:22 -05:00
import * as log from '../../logging/log';
import { getMessageById } from '../../messages/getMessageById';
import type { ReadonlyMessageAttributesType } from '../../model-types.d';
import { isGIF, isIncremental } from '../../types/Attachment';
2022-12-09 21:02:22 -05:00
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../../util/GoogleChrome';
2024-07-11 12:44:09 -07:00
import {
getLocalAttachmentUrl,
AttachmentDisposition,
} from '../../util/getLocalAttachmentUrl';
2022-12-09 21:02:22 -05:00
import { isTapToView } from '../selectors/message';
import { SHOW_TOAST } from './toast';
import { ToastType } from '../../types/Toast';
import {
MESSAGE_CHANGED,
MESSAGE_DELETED,
MESSAGE_EXPIRED,
saveAttachmentFromMessage,
} from './conversations';
2022-12-09 21:02:22 -05:00
import { showStickerPackPreview } from './globalModals';
import { useBoundActions } from '../../hooks/useBoundActions';
2024-07-22 11:16:33 -07:00
import { DataReader } from '../../sql/Client';
import { deleteDownloadsJobQueue } from '../../jobs/deleteDownloadsJobQueue';
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager';
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
import { getMessageIdForLogging } from '../../util/idForLogging';
import { markViewOnceMessageViewed } from '../../services/MessageUpdater';
2022-12-09 21:02:22 -05:00
// eslint-disable-next-line local-rules/type-alias-readonlydeep
2022-12-09 21:02:22 -05:00
export type LightboxStateType =
| {
isShowingLightbox: false;
}
| {
isShowingLightbox: true;
isViewOnce: boolean;
media: ReadonlyArray<ReadonlyDeep<MediaItemType>>;
2023-03-03 19:03:15 -08:00
hasPrevMessage: boolean;
hasNextMessage: boolean;
selectedIndex: number | undefined;
playbackDisabled: boolean;
2022-12-09 21:02:22 -05:00
};
const CLOSE_LIGHTBOX = 'lightbox/CLOSE';
const SHOW_LIGHTBOX = 'lightbox/SHOW';
const SET_SELECTED_LIGHTBOX_INDEX = 'lightbox/SET_SELECTED_LIGHTBOX_INDEX';
const SET_LIGHTBOX_PLAYBACK_DISABLED =
'lightbox/SET_LIGHTBOX_PLAYBACK_DISABLED';
2022-12-09 21:02:22 -05:00
type CloseLightboxActionType = ReadonlyDeep<{
2022-12-09 21:02:22 -05:00
type: typeof CLOSE_LIGHTBOX;
}>;
2022-12-09 21:02:22 -05:00
// eslint-disable-next-line local-rules/type-alias-readonlydeep
2022-12-09 21:02:22 -05:00
type ShowLightboxActionType = {
type: typeof SHOW_LIGHTBOX;
payload: {
isViewOnce: boolean;
media: ReadonlyArray<ReadonlyDeep<MediaItemType>>;
2023-03-03 19:03:15 -08:00
hasPrevMessage: boolean;
hasNextMessage: boolean;
selectedIndex: number | undefined;
2022-12-09 21:02:22 -05:00
};
};
type SetLightboxPlaybackDisabledActionType = ReadonlyDeep<{
type: typeof SET_LIGHTBOX_PLAYBACK_DISABLED;
payload: boolean;
}>;
type SetSelectedLightboxIndexActionType = ReadonlyDeep<{
type: typeof SET_SELECTED_LIGHTBOX_INDEX;
payload: number;
2023-03-03 19:03:15 -08:00
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
2022-12-20 12:50:23 -05:00
type LightboxActionType =
| CloseLightboxActionType
| MessageChangedActionType
| MessageDeletedActionType
2022-12-20 12:50:23 -05:00
| MessageExpiredActionType
2023-03-03 19:03:15 -08:00
| ShowLightboxActionType
| SetSelectedLightboxIndexActionType
| SetLightboxPlaybackDisabledActionType;
2022-12-09 21:02:22 -05:00
function closeLightbox(): ThunkAction<
void,
RootStateType,
unknown,
CloseLightboxActionType
> {
return (dispatch, getState) => {
const { lightbox } = getState();
if (!lightbox.isShowingLightbox) {
return;
}
deleteDownloadsJobQueue.resume();
2022-12-09 21:02:22 -05:00
const { isViewOnce, media } = lightbox;
if (isViewOnce) {
media.forEach(item => {
if (!item.attachment.path) {
return;
}
void window.Signal.Migrations.deleteTempFile(item.attachment.path);
2022-12-09 21:02:22 -05:00
});
}
dispatch({
type: CLOSE_LIGHTBOX,
});
};
}
function setPlaybackDisabled(
playbackDisabled: boolean
): ThunkAction<
void,
RootStateType,
unknown,
SetLightboxPlaybackDisabledActionType
> {
return (dispatch, getState) => {
const { lightbox } = getState();
if (!lightbox.isShowingLightbox) {
return;
}
dispatch({
type: SET_LIGHTBOX_PLAYBACK_DISABLED,
payload: playbackDisabled,
});
};
}
2022-12-09 21:02:22 -05:00
function showLightboxForViewOnceMedia(
messageId: string
): ThunkAction<void, RootStateType, unknown, ShowLightboxActionType> {
return async dispatch => {
log.info('showLightboxForViewOnceMedia: attempting to display message');
const message = await getMessageById(messageId);
2022-12-09 21:02:22 -05:00
if (!message) {
throw new Error(
`showLightboxForViewOnceMedia: Message ${messageId} missing!`
);
}
if (!isTapToView(message.attributes)) {
throw new Error(
`showLightboxForViewOnceMedia: Message ${getMessageIdForLogging(message.attributes)} is not a tap to view message`
2022-12-09 21:02:22 -05:00
);
}
if (message.get('isErased')) {
2022-12-09 21:02:22 -05:00
throw new Error(
`showLightboxForViewOnceMedia: Message ${getMessageIdForLogging(message.attributes)} is already erased`
2022-12-09 21:02:22 -05:00
);
}
const firstAttachment = (message.get('attachments') || [])[0];
if (!firstAttachment || !firstAttachment.path) {
throw new Error(
`showLightboxForViewOnceMedia: Message ${getMessageIdForLogging(message.attributes)} had no first attachment with path`
2022-12-09 21:02:22 -05:00
);
}
2024-07-11 12:44:09 -07:00
const { copyIntoTempDirectory, getAbsoluteAttachmentPath } =
window.Signal.Migrations;
2022-12-09 21:02:22 -05:00
const absolutePath = getAbsoluteAttachmentPath(firstAttachment.path);
const { path: tempPath } = await copyIntoTempDirectory(absolutePath);
const tempAttachment = {
...firstAttachment,
path: tempPath,
};
await markViewOnceMessageViewed(message);
2022-12-09 21:02:22 -05:00
2024-07-11 12:44:09 -07:00
const { contentType } = tempAttachment;
2022-12-09 21:02:22 -05:00
const media = [
{
attachment: tempAttachment,
2024-07-11 12:44:09 -07:00
objectURL: getLocalAttachmentUrl(tempAttachment, {
disposition: AttachmentDisposition.Temporary,
}),
2022-12-09 21:02:22 -05:00
contentType,
index: 0,
message: {
attachments: message.get('attachments') || [],
id: message.get('id'),
conversationId: message.get('conversationId'),
receivedAt: message.get('received_at'),
receivedAtMs: Number(message.get('received_at_ms')),
sentAt: message.get('sent_at'),
2022-12-09 21:02:22 -05:00
},
},
];
dispatch({
type: SHOW_LIGHTBOX,
payload: {
isViewOnce: true,
media,
selectedIndex: undefined,
2023-03-03 19:03:15 -08:00
hasPrevMessage: false,
hasNextMessage: false,
2022-12-09 21:02:22 -05:00
},
});
};
}
2023-03-03 19:03:15 -08:00
function filterValidAttachments(
attributes: ReadonlyMessageAttributesType
2023-03-03 19:03:15 -08:00
): Array<AttachmentType> {
return (attributes.attachments ?? []).filter(
item => (!item.pending || isIncremental(item)) && !item.error
2023-03-03 19:03:15 -08:00
);
}
2022-12-09 21:02:22 -05:00
function showLightbox(opts: {
attachment: AttachmentType;
messageId: string;
}): ThunkAction<
void,
RootStateType,
unknown,
| ShowLightboxActionType
| ShowStickerPackPreviewActionType
| ShowToastActionType
> {
2022-12-14 13:12:04 -05:00
return async (dispatch, getState) => {
2022-12-09 21:02:22 -05:00
const { attachment, messageId } = opts;
const message = await getMessageById(messageId);
2022-12-09 21:02:22 -05:00
if (!message) {
throw new Error(`showLightbox: Message ${messageId} missing!`);
}
const sticker = message.get('sticker');
if (sticker) {
const { packId, packKey } = sticker;
dispatch(showStickerPackPreview(packId, packKey));
return;
}
const { contentType } = attachment;
if (
!isImageTypeSupported(contentType) &&
!isVideoTypeSupported(contentType)
) {
2022-12-14 13:12:04 -05:00
saveAttachmentFromMessage(messageId, attachment)(
dispatch,
getState,
null
);
2022-12-09 21:02:22 -05:00
return;
}
if (isIncremental(attachment)) {
// Queue all attachments, but this target attachment should be IMMEDIATE
const wasUpdated = await queueAttachmentDownloads(message, {
urgency: AttachmentDownloadUrgency.STANDARD,
attachmentDigestForImmediate: attachment.digest,
});
if (wasUpdated) {
await window.MessageCache.saveMessage(message);
}
}
2023-03-03 19:03:15 -08:00
const attachments = filterValidAttachments(message.attributes);
2022-12-09 21:02:22 -05:00
const loop = isGIF(attachments);
2023-03-03 19:03:15 -08:00
const authorId =
window.ConversationController.lookupOrCreate({
2023-08-16 22:54:39 +02:00
serviceId: message.get('sourceServiceId'),
2023-03-03 19:03:15 -08:00
e164: message.get('source'),
reason: 'conversation_view.showLightBox',
})?.id || message.get('conversationId');
const receivedAt = message.get('received_at');
const sentAt = message.get('sent_at');
const media = attachments
.map((item, index) => ({
objectURL: item.path ? getLocalAttachmentUrl(item) : undefined,
incrementalObjectUrl:
isIncremental(item) && item.downloadPath
? getLocalAttachmentUrl(item, {
disposition: AttachmentDisposition.Download,
})
: undefined,
path: item.path,
contentType: item.contentType,
loop,
index,
message: {
attachments: message.get('attachments') || [],
id: messageId,
conversationId: authorId,
receivedAt,
receivedAtMs: Number(message.get('received_at_ms')),
sentAt,
},
attachment: item,
thumbnailObjectUrl:
item.thumbnail?.objectUrl || item.thumbnail?.path
? getLocalAttachmentUrl(item.thumbnail)
: undefined,
size: item.size,
totalDownloaded: item.totalDownloaded,
}))
.filter(item => item.objectURL || item.incrementalObjectUrl);
2022-12-09 21:02:22 -05:00
if (!media.length) {
log.error(
'showLightbox: unable to load attachment',
2023-03-03 19:03:15 -08:00
sentAt,
message.get('attachments')?.map(x => ({
2022-12-09 21:02:22 -05:00
contentType: x.contentType,
downloadPath: x.downloadPath,
2022-12-09 21:02:22 -05:00
error: x.error,
flags: x.flags,
isIncremental: isIncremental(x),
2022-12-09 21:02:22 -05:00
path: x.path,
pending: x.pending,
2022-12-09 21:02:22 -05:00
size: x.size,
thumbnail: !!x.thumbnail,
2022-12-09 21:02:22 -05:00
}))
);
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.UnableToLoadAttachment,
},
});
return;
}
2023-03-03 19:03:15 -08:00
const { older, newer } =
2024-07-22 11:16:33 -07:00
await DataReader.getConversationRangeCenteredOnMessage({
2023-03-03 19:03:15 -08:00
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,
});
const index = media.findIndex(({ path }) => path === attachment.path);
2022-12-09 21:02:22 -05:00
dispatch({
type: SHOW_LIGHTBOX,
payload: {
isViewOnce: false,
media,
selectedIndex: index === -1 ? 0 : index,
2023-03-03 19:03:15 -08:00
hasPrevMessage:
older.length > 0 && filterValidAttachments(older[0]).length > 0,
hasNextMessage:
newer.length > 0 && filterValidAttachments(newer[0]).length > 0,
playbackDisabled: false,
2022-12-09 21:02:22 -05:00
},
});
};
}
2023-03-03 19:03:15 -08:00
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, receivedAt, sentAt } = media.message;
2023-03-03 19:03:15 -08:00
const message = await getMessageById(messageId);
2023-03-03 19:03:15 -08:00
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
2024-07-22 11:16:33 -07:00
? await DataReader.getOlderMessagesByConversation(options)
: await DataReader.getNewerMessagesByConversation(options);
2023-03-03 19:03:15 -08:00
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 setSelectedLightboxIndex(
index: number
): SetSelectedLightboxIndexActionType {
2023-03-03 19:03:15 -08:00
return {
type: SET_SELECTED_LIGHTBOX_INDEX,
payload: index,
2023-03-03 19:03:15 -08:00
};
}
2022-12-09 21:02:22 -05:00
export const actions = {
closeLightbox,
showLightbox,
showLightboxForViewOnceMedia,
2023-03-03 19:03:15 -08:00
showLightboxForPrevMessage,
showLightboxForNextMessage,
setSelectedLightboxIndex,
setPlaybackDisabled,
2022-12-09 21:02:22 -05:00
};
export const useLightboxActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
export function getEmptyState(): LightboxStateType {
return {
isShowingLightbox: false,
};
}
export function reducer(
state: Readonly<LightboxStateType> = getEmptyState(),
action: Readonly<LightboxActionType>
): LightboxStateType {
if (action.type === CLOSE_LIGHTBOX) {
return getEmptyState();
}
if (action.type === SHOW_LIGHTBOX) {
return {
...action.payload,
isShowingLightbox: true,
playbackDisabled: false,
2022-12-09 21:02:22 -05:00
};
}
if (action.type === SET_SELECTED_LIGHTBOX_INDEX) {
2023-03-03 19:03:15 -08:00
if (!state.isShowingLightbox) {
return state;
}
return {
...state,
selectedIndex: Math.max(
0,
Math.min(state.media.length - 1, action.payload)
),
2023-03-03 19:03:15 -08:00
};
}
if (action.type === SET_LIGHTBOX_PLAYBACK_DISABLED) {
if (!state.isShowingLightbox) {
return state;
}
return {
...state,
playbackDisabled: action.payload,
};
}
if (
action.type === MESSAGE_CHANGED ||
action.type === MESSAGE_DELETED ||
action.type === MESSAGE_EXPIRED
) {
2022-12-20 12:50:23 -05:00
if (!state.isShowingLightbox) {
return state;
}
if (action.type === MESSAGE_EXPIRED && !state.isViewOnce) {
return state;
}
if (
action.type === MESSAGE_CHANGED &&
!action.payload.data.deletedForEveryone
) {
const message = action.payload.data;
const attachmentsByDigest = new Map<string, AttachmentType>();
if (!message.attachments || !message.attachments.length) {
return state;
}
message.attachments.forEach(attachment => {
const { digest } = attachment;
if (!digest) {
return;
}
attachmentsByDigest.set(digest, attachment);
});
let changed = false;
const media = state.media.map(item => {
if (item.message.id !== message.id) {
return item;
}
const { digest } = item.attachment;
if (!digest) {
return item;
}
const attachment = attachmentsByDigest.get(digest);
if (
!attachment ||
!isIncremental(attachment) ||
(!item.attachment.pending && !attachment.pending)
) {
return item;
}
const { totalDownloaded, pending } = attachment;
if (totalDownloaded !== item.attachment.totalDownloaded) {
changed = true;
return {
...item,
attachment: {
...item.attachment,
totalDownloaded,
pending,
},
};
}
return item;
});
if (changed) {
return {
...state,
media,
};
}
2022-12-20 12:50:23 -05:00
return state;
}
const nextMedia = state.media.filter(
item => item.message.id !== action.payload.id
2022-12-20 12:50:23 -05:00
);
if (nextMedia.length === state.media.length) {
2022-12-20 12:50:23 -05:00
return state;
}
if (!nextMedia.length) {
return getEmptyState();
}
return {
...state,
media: nextMedia,
};
2022-12-20 12:50:23 -05:00
}
2022-12-09 21:02:22 -05:00
return state;
}