Allow link-only stories, download previews

This commit is contained in:
Fedor Indutny 2022-10-31 14:28:28 -07:00 committed by GitHub
parent 5f109d76da
commit 8f62442822
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 155 additions and 194 deletions

View file

@ -2530,12 +2530,19 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const urls = LinkPreview.findLinks(dataMessage.body || ''); const urls = LinkPreview.findLinks(dataMessage.body || '');
const incomingPreview = dataMessage.preview || []; const incomingPreview = dataMessage.preview || [];
const preview = incomingPreview.filter( const preview = incomingPreview.filter((item: LinkPreviewType) => {
(item: LinkPreviewType) => if (!item.image && !item.title) {
(item.image || item.title) && return false;
urls.includes(item.url) && }
LinkPreview.shouldPreviewHref(item.url) // Story link previews don't have to correspond to links in the
); // message body.
if (isStory(message.attributes)) {
return true;
}
return (
urls.includes(item.url) && LinkPreview.shouldPreviewHref(item.url)
);
});
if (preview.length < incomingPreview.length) { if (preview.length < incomingPreview.length) {
log.info( log.info(
`${message.idForLogging()}: Eliminated ${ `${message.idForLogging()}: Eliminated ${

View file

@ -7,7 +7,11 @@ import type { StoryDataType } from '../state/ducks/stories';
import * as durations from '../util/durations'; import * as durations from '../util/durations';
import * as log from '../logging/log'; import * as log from '../logging/log';
import dataInterface from '../sql/Client'; import dataInterface from '../sql/Client';
import { getAttachmentsForMessage } from '../state/selectors/message'; import {
getAttachmentsForMessage,
getPropsForAttachment,
} from '../state/selectors/message';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { isNotNil } from '../util/isNotNil'; import { isNotNil } from '../util/isNotNil';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { dropNull } from '../util/dropNull'; import { dropNull } from '../util/dropNull';
@ -66,11 +70,38 @@ export function getStoryDataFromMessageAttributes(
return; return;
} }
const [attachment] = let [attachment] =
unresolvedAttachment && unresolvedAttachment.path unresolvedAttachment && unresolvedAttachment.path
? getAttachmentsForMessage(message) ? getAttachmentsForMessage(message)
: [unresolvedAttachment]; : [unresolvedAttachment];
let preview: LinkPreviewType | undefined;
if (message.preview?.length) {
strictAssert(
message.preview.length === 1,
'getStoryDataFromMessageAttributes: story can have only one preview'
);
[preview] = message.preview;
strictAssert(
attachment?.textAttachment,
'getStoryDataFromMessageAttributes: story must have a ' +
'textAttachment with preview'
);
attachment = {
...attachment,
textAttachment: {
...attachment.textAttachment,
preview: {
...preview,
image: preview.image && getPropsForAttachment(preview.image),
},
},
};
} else if (attachment) {
attachment = getPropsForAttachment(attachment);
}
return { return {
attachment, attachment,
messageId: message.id, messageId: message.id,

View file

@ -32,17 +32,15 @@ import { markViewed } from '../../services/MessageUpdater';
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
import { replaceIndex } from '../../util/replaceIndex'; import { replaceIndex } from '../../util/replaceIndex';
import { showToast } from '../../util/showToast'; import { showToast } from '../../util/showToast';
import { import { hasFailed, isDownloaded, isDownloading } from '../../types/Attachment';
hasFailed,
hasNotResolved,
isDownloaded,
isDownloading,
} from '../../types/Attachment';
import { import {
getConversationSelector, getConversationSelector,
getHideStoryConversationIds, getHideStoryConversationIds,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { getStories } from '../selectors/stories'; import {
getStories,
getStoryDownloadableAttachment,
} from '../selectors/stories';
import { getStoryDataFromMessageAttributes } from '../../services/storyLoader'; import { getStoryDataFromMessageAttributes } from '../../services/storyLoader';
import { isGroup } from '../../util/whatTypeOfConversation'; import { isGroup } from '../../util/whatTypeOfConversation';
import { isNotNil } from '../../util/isNotNil'; import { isNotNil } from '../../util/isNotNil';
@ -113,7 +111,6 @@ const LIST_MEMBERS_VERIFIED = 'stories/LIST_MEMBERS_VERIFIED';
const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES'; const LOAD_STORY_REPLIES = 'stories/LOAD_STORY_REPLIES';
const MARK_STORY_READ = 'stories/MARK_STORY_READ'; const MARK_STORY_READ = 'stories/MARK_STORY_READ';
const QUEUE_STORY_DOWNLOAD = 'stories/QUEUE_STORY_DOWNLOAD'; const QUEUE_STORY_DOWNLOAD = 'stories/QUEUE_STORY_DOWNLOAD';
export const RESOLVE_ATTACHMENT_URL = 'stories/RESOLVE_ATTACHMENT_URL';
const SEND_STORY_MODAL_OPEN_STATE_CHANGED = const SEND_STORY_MODAL_OPEN_STATE_CHANGED =
'stories/SEND_STORY_MODAL_OPEN_STATE_CHANGED'; 'stories/SEND_STORY_MODAL_OPEN_STATE_CHANGED';
const STORY_CHANGED = 'stories/STORY_CHANGED'; const STORY_CHANGED = 'stories/STORY_CHANGED';
@ -155,14 +152,6 @@ type QueueStoryDownloadActionType = {
payload: string; payload: string;
}; };
type ResolveAttachmentUrlActionType = {
type: typeof RESOLVE_ATTACHMENT_URL;
payload: {
messageId: string;
attachmentUrl: string;
};
};
type SendStoryModalOpenStateChanged = { type SendStoryModalOpenStateChanged = {
type: typeof SEND_STORY_MODAL_OPEN_STATE_CHANGED; type: typeof SEND_STORY_MODAL_OPEN_STATE_CHANGED;
payload: number | undefined; payload: number | undefined;
@ -195,7 +184,6 @@ export type StoriesActionType =
| MessageDeletedActionType | MessageDeletedActionType
| MessagesAddedActionType | MessagesAddedActionType
| QueueStoryDownloadActionType | QueueStoryDownloadActionType
| ResolveAttachmentUrlActionType
| SendStoryModalOpenStateChanged | SendStoryModalOpenStateChanged
| StoryChangedActionType | StoryChangedActionType
| ToggleViewActionType | ToggleViewActionType
@ -337,7 +325,7 @@ function queueStoryDownload(
void, void,
RootStateType, RootStateType,
unknown, unknown,
NoopActionType | QueueStoryDownloadActionType | ResolveAttachmentUrlActionType NoopActionType | QueueStoryDownloadActionType
> { > {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { stories } = getState().stories; const { stories } = getState().stories;
@ -347,7 +335,7 @@ function queueStoryDownload(
return; return;
} }
const { attachment } = story; const attachment = getStoryDownloadableAttachment(story);
if (!attachment) { if (!attachment) {
log.warn('queueStoryDownload: No attachment found for story', { log.warn('queueStoryDownload: No attachment found for story', {
@ -365,21 +353,6 @@ function queueStoryDownload(
return; 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; return;
} }
@ -403,7 +376,10 @@ function queueStoryDownload(
payload: storyId, payload: storyId,
}); });
await queueAttachmentDownloads(message.attributes); const updatedFields = await queueAttachmentDownloads(message.attributes);
if (updatedFields) {
message.set(updatedFields);
}
return; return;
} }
@ -627,11 +603,7 @@ const getSelectedStoryDataForDistributionListId = (
}; };
const getSelectedStoryDataForConversationId = ( const getSelectedStoryDataForConversationId = (
dispatch: ThunkDispatch< dispatch: ThunkDispatch<RootStateType, unknown, NoopActionType>,
RootStateType,
unknown,
NoopActionType | ResolveAttachmentUrlActionType
>,
getState: () => RootStateType, getState: () => RootStateType,
conversationId: string, conversationId: string,
selectedStoryId?: string selectedStoryId?: string
@ -671,12 +643,12 @@ const getSelectedStoryDataForConversationId = (
const numStories = storiesByConversationId.length; const numStories = storiesByConversationId.length;
// Queue all undownloaded stories once we're viewing someone's stories // Queue all undownloaded stories once we're viewing someone's stories
storiesByConversationId.forEach(item => { storiesByConversationId.forEach(({ attachment, messageId }) => {
if (isDownloaded(item.attachment) || isDownloading(item.attachment)) { if (isDownloaded(attachment) || isDownloading(attachment)) {
return; return;
} }
queueStoryDownload(item.messageId)(dispatch, getState, null); queueStoryDownload(messageId)(dispatch, getState, null);
}); });
return { return {
@ -1416,41 +1388,6 @@ 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
),
};
}
if (action.type === DOE_STORY) { if (action.type === DOE_STORY) {
return { return {
...state, ...state,

View file

@ -7,6 +7,7 @@ import { pick } from 'lodash';
import type { GetConversationByIdType } from './conversations'; import type { GetConversationByIdType } from './conversations';
import type { ConversationType } from '../ducks/conversations'; import type { ConversationType } from '../ducks/conversations';
import type { MessageReactionType } from '../../model-types.d'; import type { MessageReactionType } from '../../model-types.d';
import type { AttachmentType } from '../../types/Attachment';
import type { import type {
ConversationStoryType, ConversationStoryType,
MyStoryType, MyStoryType,
@ -133,6 +134,13 @@ function getAvatarData(
]); ]);
} }
export function getStoryDownloadableAttachment({
attachment,
}: StoryDataType): AttachmentType | undefined {
// See: getStoryDataFromMessageAttributes for how preview gets populated.
return attachment?.textAttachment?.preview?.image ?? attachment;
}
export function getStoryView( export function getStoryView(
conversationSelector: GetConversationByIdType, conversationSelector: GetConversationByIdType,
ourConversationId: string | undefined, ourConversationId: string | undefined,
@ -159,13 +167,7 @@ export function getStoryView(
expireTimer, expireTimer,
readAt, readAt,
timestamp, timestamp,
} = pick(story, [ } = story;
'attachment',
'expirationStartTimestamp',
'expireTimer',
'readAt',
'timestamp',
]);
const { sendStateByConversationId } = story; const { sendStateByConversationId } = story;
let sendState: Array<StorySendStateType> | undefined; let sendState: Array<StorySendStateType> | undefined;

View file

@ -3,12 +3,9 @@
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import casual from 'casual'; import casual from 'casual';
import path from 'path';
import { assert } from 'chai';
import type { import type {
DispatchableViewStoryType, DispatchableViewStoryType,
StoriesStateType,
StoryDataType, StoryDataType,
} from '../../../state/ducks/stories'; } from '../../../state/ducks/stories';
import type { ConversationType } from '../../../state/ducks/conversations'; import type { ConversationType } from '../../../state/ducks/conversations';
@ -16,19 +13,14 @@ import type { MessageAttributesType } from '../../../model-types.d';
import type { StateType as RootStateType } from '../../../state/reducer'; import type { StateType as RootStateType } from '../../../state/reducer';
import type { UUIDStringType } from '../../../types/UUID'; import type { UUIDStringType } from '../../../types/UUID';
import { DAY } from '../../../util/durations'; import { DAY } from '../../../util/durations';
import { IMAGE_JPEG } from '../../../types/MIME'; import { TEXT_ATTACHMENT, IMAGE_JPEG } from '../../../types/MIME';
import { ReadStatus } from '../../../messages/MessageReadStatus'; import { ReadStatus } from '../../../messages/MessageReadStatus';
import { import {
StoryViewDirectionType, StoryViewDirectionType,
StoryViewModeType, StoryViewModeType,
} from '../../../types/Stories'; } from '../../../types/Stories';
import { UUID } from '../../../types/UUID'; import { UUID } from '../../../types/UUID';
import { import { actions, getEmptyState } from '../../../state/ducks/stories';
actions,
getEmptyState,
reducer,
RESOLVE_ATTACHMENT_URL,
} from '../../../state/ducks/stories';
import { noopAction } from '../../../state/ducks/noop'; import { noopAction } from '../../../state/ducks/noop';
import { reducer as rootReducer } from '../../../state/reducer'; import { reducer as rootReducer } from '../../../state/reducer';
import { dropNull } from '../../../util/dropNull'; import { dropNull } from '../../../util/dropNull';
@ -917,86 +909,6 @@ describe('both/state/ducks/stories', () => {
sinon.assert.notCalled(dispatch); sinon.assert.notCalled(dispatch);
}); });
it('downloaded, but unresolved, we should resolve the path', async function test() {
const storyId = UUID.generate().toString();
const attachment = {
contentType: IMAGE_JPEG,
path: 'image.jpg',
size: 0,
};
const messageAttributes = {
...getStoryMessage(storyId),
attachments: [attachment],
};
const rootState = getEmptyRootState();
const getState = () => ({
...rootState,
stories: {
...rootState.stories,
stories: [
{
...messageAttributes,
attachment: messageAttributes.attachments[0],
messageId: messageAttributes.id,
expireTimer: messageAttributes.expireTimer,
expirationStartTimestamp: dropNull(
messageAttributes.expirationStartTimestamp
),
},
],
},
});
window.MessageController.register(storyId, messageAttributes);
const dispatch = sinon.spy();
await queueStoryDownload(storyId)(dispatch, getState, 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,
expireTimer: messageAttributes.expireTimer,
expirationStartTimestamp: dropNull(
messageAttributes.expirationStartTimestamp
),
},
],
};
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() { it('not downloaded, queued for download', async function test() {
const storyId = UUID.generate().toString(); const storyId = UUID.generate().toString();
const messageAttributes = { const messageAttributes = {
@ -1039,5 +951,59 @@ describe('both/state/ducks/stories', () => {
payload: storyId, payload: storyId,
}); });
}); });
it('preview not downloaded, queued for download', async function test() {
const storyId = UUID.generate().toString();
const preview = {
url: 'https://signal.org',
image: {
contentType: IMAGE_JPEG,
size: 0,
},
};
const messageAttributes = {
...getStoryMessage(storyId),
attachments: [
{
contentType: TEXT_ATTACHMENT,
size: 0,
textAttachment: {
preview,
},
},
],
preview: [preview],
};
const rootState = getEmptyRootState();
const getState = () => ({
...rootState,
stories: {
...rootState.stories,
stories: [
{
...messageAttributes,
attachment: messageAttributes.attachments[0],
messageId: messageAttributes.id,
expireTimer: messageAttributes.expireTimer,
expirationStartTimestamp: dropNull(
messageAttributes.expirationStartTimestamp
),
},
],
},
});
window.MessageController.register(storyId, messageAttributes);
const dispatch = sinon.spy();
await queueStoryDownload(storyId)(dispatch, getState, null);
sinon.assert.calledWith(dispatch, {
type: 'stories/QUEUE_STORY_DOWNLOAD',
payload: storyId,
});
});
}); });
}); });

View file

@ -3,7 +3,7 @@
/* eslint-disable no-bitwise */ /* eslint-disable no-bitwise */
import { isBoolean, isNumber } from 'lodash'; import { isBoolean, isNumber, omit } from 'lodash';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { v4 as getGuid } from 'uuid'; import { v4 as getGuid } from 'uuid';
@ -60,6 +60,7 @@ import createTaskWithTimeout from './TaskWithTimeout';
import { import {
processAttachment, processAttachment,
processDataMessage, processDataMessage,
processPreview,
processGroupV2Context, processGroupV2Context,
} from './processDataMessage'; } from './processDataMessage';
import { processSyncMessage } from './processSyncMessage'; import { processSyncMessage } from './processSyncMessage';
@ -75,6 +76,7 @@ import * as Bytes from '../Bytes';
import type { import type {
ProcessedAttachment, ProcessedAttachment,
ProcessedDataMessage, ProcessedDataMessage,
ProcessedPreview,
ProcessedSyncMessage, ProcessedSyncMessage,
ProcessedSent, ProcessedSent,
ProcessedEnvelope, ProcessedEnvelope,
@ -1993,6 +1995,7 @@ export default class MessageReceiver
log.info('MessageReceiver.handleStoryMessage', logId); log.info('MessageReceiver.handleStoryMessage', logId);
const attachments: Array<ProcessedAttachment> = []; const attachments: Array<ProcessedAttachment> = [];
let preview: ReadonlyArray<ProcessedPreview> | undefined;
if (msg.fileAttachment) { if (msg.fileAttachment) {
const attachment = processAttachment(msg.fileAttachment); const attachment = processAttachment(msg.fileAttachment);
@ -2000,16 +2003,17 @@ export default class MessageReceiver
} }
if (msg.textAttachment) { if (msg.textAttachment) {
const { text } = msg.textAttachment; const { text, preview: unprocessedPreview } = msg.textAttachment;
if (!text) { if (unprocessedPreview) {
throw new Error('Text attachments must have text!'); preview = processPreview([unprocessedPreview]);
} else if (!text) {
throw new Error('Text attachments must have text or link preview!');
} }
// TODO DESKTOP-3714 we should download the story link preview image
attachments.push({ attachments.push({
size: text.length, size: text?.length ?? 0,
contentType: TEXT_ATTACHMENT, contentType: TEXT_ATTACHMENT,
textAttachment: msg.textAttachment, textAttachment: omit(msg.textAttachment, 'preview'),
blurHash: generateBlurHash( blurHash: generateBlurHash(
(msg.textAttachment.color || (msg.textAttachment.color ||
msg.textAttachment.gradient?.startColor) ?? msg.textAttachment.gradient?.startColor) ??
@ -2045,6 +2049,7 @@ export default class MessageReceiver
const message: ProcessedDataMessage = { const message: ProcessedDataMessage = {
attachments, attachments,
preview,
canReplyToStory: Boolean(msg.allowsReplies), canReplyToStory: Boolean(msg.allowsReplies),
expireTimer: durations.DAY / 1000, expireTimer: durations.DAY / 1000,
flags: 0, flags: 0,

View file

@ -697,20 +697,33 @@ export function isGIF(attachments?: ReadonlyArray<AttachmentType>): boolean {
return hasFlag && isVideoAttachment(attachment); return hasFlag && isVideoAttachment(attachment);
} }
function resolveNestedAttachment(
attachment?: AttachmentType
): AttachmentType | undefined {
if (attachment?.textAttachment?.preview?.image) {
return attachment.textAttachment.preview.image;
}
return attachment;
}
export function isDownloaded(attachment?: AttachmentType): boolean { export function isDownloaded(attachment?: AttachmentType): boolean {
return Boolean(attachment && (attachment.path || attachment.textAttachment)); const resolved = resolveNestedAttachment(attachment);
return Boolean(resolved && (resolved.path || resolved.textAttachment));
} }
export function hasNotResolved(attachment?: AttachmentType): boolean { export function hasNotResolved(attachment?: AttachmentType): boolean {
return Boolean(attachment && !attachment.url && !attachment.textAttachment); const resolved = resolveNestedAttachment(attachment);
return Boolean(resolved && !resolved.url && !resolved.textAttachment);
} }
export function isDownloading(attachment?: AttachmentType): boolean { export function isDownloading(attachment?: AttachmentType): boolean {
return Boolean(attachment && attachment.downloadJobId && attachment.pending); const resolved = resolveNestedAttachment(attachment);
return Boolean(resolved && resolved.downloadJobId && resolved.pending);
} }
export function hasFailed(attachment?: AttachmentType): boolean { export function hasFailed(attachment?: AttachmentType): boolean {
return Boolean(attachment && attachment.error); const resolved = resolveNestedAttachment(attachment);
return Boolean(resolved && resolved.error);
} }
export function hasVideoBlurHash(attachments?: Array<AttachmentType>): boolean { export function hasVideoBlurHash(attachments?: Array<AttachmentType>): boolean {