Show group replies icon for stories with replies

This commit is contained in:
Josh Perez 2022-10-22 02:26:16 -04:00 committed by GitHub
parent ba55285c74
commit 471a9e2e98
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 170 additions and 136 deletions

View file

@ -1009,8 +1009,11 @@ export async function startApp(): Promise<void> {
}; };
try { try {
// This needs to load before we prime the data because we expect
// ConversationController to be loaded and ready to use by then.
await window.ConversationController.load();
await Promise.all([ await Promise.all([
window.ConversationController.load(),
Stickers.load(), Stickers.load(),
loadRecentEmojis(), loadRecentEmojis(),
loadInitialBadgesState(), loadInitialBadgesState(),

View file

@ -179,6 +179,8 @@ export const StoriesPane = ({
conversationId={story.conversationId} conversationId={story.conversationId}
group={story.group} group={story.group}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
hasReplies={story.hasReplies}
hasRepliesFromSelf={story.hasRepliesFromSelf}
i18n={i18n} i18n={i18n}
key={story.storyView.timestamp} key={story.storyView.timestamp}
onHideStory={toggleHideStories} onHideStory={toggleHideStories}

View file

@ -45,12 +45,12 @@ const Template: Story<PropsType> = args => <StoryListItem {...args} />;
export const SomeonesStory = Template.bind({}); export const SomeonesStory = Template.bind({});
SomeonesStory.args = { SomeonesStory.args = {
hasReplies: true,
group: getDefaultConversation({ title: 'Sports Group' }), group: getDefaultConversation({ title: 'Sports Group' }),
story: { story: {
attachment: fakeAttachment({ attachment: fakeAttachment({
thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'), thumbnail: fakeThumbnail('/fixtures/tina-rolf-269345-unsplash.jpg'),
}), }),
hasReplies: true,
isUnread: true, isUnread: true,
messageId: '123', messageId: '123',
messageIdForLogging: 'for logging 123', messageIdForLogging: 'for logging 123',

View file

@ -21,6 +21,8 @@ import { getAvatarColor } from '../types/Colors';
export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & { export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
conversationId: string; conversationId: string;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
hasReplies?: boolean;
hasRepliesFromSelf?: boolean;
i18n: LocalizerType; i18n: LocalizerType;
onGoToConversation: (conversationId: string) => unknown; onGoToConversation: (conversationId: string) => unknown;
onHideStory: (conversationId: string) => unknown; onHideStory: (conversationId: string) => unknown;
@ -79,6 +81,8 @@ export const StoryListItem = ({
conversationId, conversationId,
getPreferredBadge, getPreferredBadge,
group, group,
hasReplies,
hasRepliesFromSelf,
i18n, i18n,
isHidden, isHidden,
onGoToConversation, onGoToConversation,
@ -89,14 +93,7 @@ export const StoryListItem = ({
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false); const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
const { const { attachment, isUnread, sender, timestamp } = story;
attachment,
hasReplies,
hasRepliesFromSelf,
isUnread,
sender,
timestamp,
} = story;
const { firstName, title } = sender; const { firstName, title } = sender;

View file

@ -11,16 +11,51 @@ import { getAttachmentsForMessage } from '../state/selectors/message';
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';
import { isGroup } from '../util/whatTypeOfConversation';
let storyData: Array<MessageAttributesType> | undefined; let storyData:
| Array<
MessageAttributesType & {
hasReplies?: boolean;
hasRepliesFromSelf?: boolean;
}
>
| undefined;
export async function loadStories(): Promise<void> { export async function loadStories(): Promise<void> {
storyData = await dataInterface.getOlderStories({}); const stories = await dataInterface.getAllStories({});
storyData = await Promise.all(
stories.map(async story => {
const conversation = window.ConversationController.get(
story.conversationId
);
if (!isGroup(conversation?.attributes)) {
return story;
}
const [hasReplies, hasRepliesFromSelf] = await Promise.all([
dataInterface.hasStoryReplies(story.id),
dataInterface.hasStoryRepliesFromSelf(story.id),
]);
return {
...story,
hasReplies,
hasRepliesFromSelf,
};
})
);
await repairUnexpiredStories(); await repairUnexpiredStories();
} }
export function getStoryDataFromMessageAttributes( export function getStoryDataFromMessageAttributes(
message: MessageAttributesType message: MessageAttributesType & {
hasReplies?: boolean;
hasRepliesFromSelf?: boolean;
}
): StoryDataType | undefined { ): StoryDataType | undefined {
const { attachments, deletedForEveryone } = message; const { attachments, deletedForEveryone } = message;
const unresolvedAttachment = attachments ? attachments[0] : undefined; const unresolvedAttachment = attachments ? attachments[0] : undefined;
@ -43,6 +78,8 @@ export function getStoryDataFromMessageAttributes(
'canReplyToStory', 'canReplyToStory',
'conversationId', 'conversationId',
'deletedForEveryone', 'deletedForEveryone',
'hasReplies',
'hasRepliesFromSelf',
'reactions', 'reactions',
'readAt', 'readAt',
'readStatus', 'readStatus',

View file

@ -256,7 +256,9 @@ const dataInterface: ClientInterface = {
getNextTapToViewMessageTimestampToAgeOut, getNextTapToViewMessageTimestampToAgeOut,
getTapToViewMessagesNeedingErase, getTapToViewMessagesNeedingErase,
getOlderMessagesByConversation, getOlderMessagesByConversation,
getOlderStories, getAllStories,
hasStoryReplies,
hasStoryRepliesFromSelf,
getNewerMessagesByConversation, getNewerMessagesByConversation,
getMessageMetricsForConversation, getMessageMetricsForConversation,
getConversationRangeCenteredOnMessage, getConversationRangeCenteredOnMessage,
@ -1349,14 +1351,20 @@ async function getOlderMessagesByConversation(
return handleMessageJSON(messages); return handleMessageJSON(messages);
} }
async function getOlderStories(options: {
async function getAllStories(options: {
conversationId?: string; conversationId?: string;
limit?: number;
receivedAt?: number;
sentAt?: number;
sourceUuid?: UUIDStringType; sourceUuid?: UUIDStringType;
}): Promise<Array<MessageType>> { }): Promise<Array<MessageType>> {
return channels.getOlderStories(options); return channels.getAllStories(options);
}
async function hasStoryReplies(storyId: string): Promise<boolean> {
return channels.hasStoryReplies(storyId);
}
async function hasStoryRepliesFromSelf(storyId: string): Promise<boolean> {
return channels.hasStoryRepliesFromSelf(storyId);
} }
async function getNewerMessagesByConversation( async function getNewerMessagesByConversation(

View file

@ -508,13 +508,12 @@ export type DataInterface = {
getNextTapToViewMessageTimestampToAgeOut: () => Promise<undefined | number>; getNextTapToViewMessageTimestampToAgeOut: () => Promise<undefined | number>;
getTapToViewMessagesNeedingErase: () => Promise<Array<MessageType>>; getTapToViewMessagesNeedingErase: () => Promise<Array<MessageType>>;
// getOlderMessagesByConversation is JSON on server, full message on Client // getOlderMessagesByConversation is JSON on server, full message on Client
getOlderStories: (options: { getAllStories: (options: {
conversationId?: string; conversationId?: string;
limit?: number;
receivedAt?: number;
sentAt?: number;
sourceUuid?: UUIDStringType; sourceUuid?: UUIDStringType;
}) => Promise<Array<MessageType>>; }) => Promise<Array<MessageType>>;
hasStoryReplies: (storyId: string) => Promise<boolean>;
hasStoryRepliesFromSelf: (storyId: string) => Promise<boolean>;
// getNewerMessagesByConversation is JSON on server, full message on Client // getNewerMessagesByConversation is JSON on server, full message on Client
getMessageMetricsForConversation: ( getMessageMetricsForConversation: (
conversationId: string, conversationId: string,

View file

@ -246,7 +246,9 @@ const dataInterface: ServerInterface = {
getNextTapToViewMessageTimestampToAgeOut, getNextTapToViewMessageTimestampToAgeOut,
getTapToViewMessagesNeedingErase, getTapToViewMessagesNeedingErase,
getOlderMessagesByConversation, getOlderMessagesByConversation,
getOlderStories, getAllStories,
hasStoryReplies,
hasStoryRepliesFromSelf,
getNewerMessagesByConversation, getNewerMessagesByConversation,
getTotalUnreadForConversation, getTotalUnreadForConversation,
getMessageMetricsForConversation, getMessageMetricsForConversation,
@ -2501,17 +2503,11 @@ function getOlderMessagesByConversationSync(
.reverse(); .reverse();
} }
async function getOlderStories({ async function getAllStories({
conversationId, conversationId,
limit = 9999,
receivedAt = Number.MAX_VALUE,
sentAt,
sourceUuid, sourceUuid,
}: { }: {
conversationId?: string; conversationId?: string;
limit?: number;
receivedAt?: number;
sentAt?: number;
sourceUuid?: UUIDStringType; sourceUuid?: UUIDStringType;
}): Promise<Array<MessageType>> { }): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
@ -2523,25 +2519,50 @@ async function getOlderStories({
WHERE WHERE
type IS 'story' AND type IS 'story' AND
($conversationId IS NULL OR conversationId IS $conversationId) AND ($conversationId IS NULL OR conversationId IS $conversationId) AND
($sourceUuid IS NULL OR sourceUuid IS $sourceUuid) AND ($sourceUuid IS NULL OR sourceUuid IS $sourceUuid)
(received_at < $receivedAt ORDER BY received_at ASC, sent_at ASC;
OR (received_at IS $receivedAt AND sent_at < $sentAt)
)
ORDER BY received_at ASC, sent_at ASC
LIMIT $limit;
` `
) )
.all({ .all({
conversationId: conversationId || null, conversationId: conversationId || null,
receivedAt,
sentAt: sentAt || null,
sourceUuid: sourceUuid || null, sourceUuid: sourceUuid || null,
limit,
}); });
return rows.map(row => jsonToObject(row.json)); return rows.map(row => jsonToObject(row.json));
} }
async function hasStoryReplies(storyId: string): Promise<boolean> {
const db = getInstance();
const row: { count: number } = db
.prepare<Query>(
`
SELECT COUNT(*) as count
FROM messages
WHERE storyId IS $storyId;
`
)
.get({ storyId });
return row.count !== 0;
}
async function hasStoryRepliesFromSelf(storyId: string): Promise<boolean> {
const db = getInstance();
const sql = `
SELECT COUNT(*) as count
FROM messages
WHERE
storyId IS $storyId AND
type IS 'outgoing'
`;
const row: { count: number } = db.prepare<Query>(sql).get({ storyId });
return row.count !== 0;
}
async function getNewerMessagesByConversation( async function getNewerMessagesByConversation(
conversationId: string, conversationId: string,
options: { options: {

View file

@ -55,6 +55,8 @@ import { viewedReceiptsJobQueue } from '../../jobs/viewedReceiptsJobQueue';
export type StoryDataType = { export type StoryDataType = {
attachment?: AttachmentType; attachment?: AttachmentType;
hasReplies?: boolean;
hasRepliesFromSelf?: boolean;
messageId: string; messageId: string;
startedDownload?: boolean; startedDownload?: boolean;
} & Pick< } & Pick<
@ -156,7 +158,10 @@ type QueueStoryDownloadActionType = {
type ReplyToStoryActionType = { type ReplyToStoryActionType = {
type: typeof REPLY_TO_STORY; type: typeof REPLY_TO_STORY;
payload: MessageAttributesType; payload: {
message: MessageAttributesType;
storyId: string;
};
}; };
type ResolveAttachmentUrlActionType = { type ResolveAttachmentUrlActionType = {
@ -470,7 +475,10 @@ function replyToStory(
if (messageAttributes) { if (messageAttributes) {
dispatch({ dispatch({
type: REPLY_TO_STORY, type: REPLY_TO_STORY,
payload: messageAttributes, payload: {
message: messageAttributes,
storyId: story.messageId,
},
}); });
} }
}; };
@ -1211,6 +1219,8 @@ export function reducer(
'deletedForEveryone', 'deletedForEveryone',
'expirationStartTimestamp', 'expirationStartTimestamp',
'expireTimer', 'expireTimer',
'hasReplies',
'hasRepliesFromSelf',
'messageId', 'messageId',
'reactions', 'reactions',
'readAt', 'readAt',
@ -1311,6 +1321,19 @@ export function reducer(
if (action.type === LOAD_STORY_REPLIES) { if (action.type === LOAD_STORY_REPLIES) {
return { return {
...state, ...state,
stories: state.stories.map(story => {
if (story.messageId === action.payload.messageId) {
return {
...story,
hasReplies: action.payload.replies.length > 0,
hasRepliesFromSelf: action.payload.replies.some(
reply => reply.type === 'outgoing'
),
};
}
return story;
}),
replyState: action.payload, replyState: action.payload,
}; };
} }
@ -1373,15 +1396,31 @@ export function reducer(
if (action.type === REPLY_TO_STORY) { if (action.type === REPLY_TO_STORY) {
const { replyState } = state; const { replyState } = state;
const stories = state.stories.map(story => {
if (story.messageId === action.payload.storyId) {
return {
...story,
hasRepliesFromSelf: true,
};
}
return story;
});
if (!replyState) { if (!replyState) {
return state; return {
...state,
stories,
};
} }
return { return {
...state, ...state,
stories,
replyState: { replyState: {
messageId: replyState.messageId, messageId: replyState.messageId,
replies: [...replyState.replies, action.payload], replies: [...replyState.replies, action.payload.message],
}, },
}; };
} }

View file

@ -249,6 +249,8 @@ export function getConversationStory(
return { return {
conversationId: conversation.id, conversationId: conversation.id,
group: conversation.id !== sender.id ? conversation : undefined, group: conversation.id !== sender.id ? conversation : undefined,
hasReplies: story.hasReplies,
hasRepliesFromSelf: story.hasRepliesFromSelf,
isHidden: Boolean(sender.hideStory), isHidden: Boolean(sender.hideStory),
storyView, storyView,
}; };
@ -430,6 +432,11 @@ export const getStories = createSelector(
storiesMap.set(conversationStory.conversationId, { storiesMap.set(conversationStory.conversationId, {
...existingConversationStory, ...existingConversationStory,
...conversationStory, ...conversationStory,
hasReplies:
existingConversationStory?.hasReplies || conversationStory.hasReplies,
hasRepliesFromSelf:
existingConversationStory?.hasRepliesFromSelf ||
conversationStory.hasRepliesFromSelf,
storyView: conversationStory.storyView, storyView: conversationStory.storyView,
}); });
}); });

View file

@ -49,7 +49,6 @@ export function getFakeStoryView(
attachment: getAttachmentWithThumbnail( attachment: getAttachmentWithThumbnail(
attachmentUrl || '/fixtures/tina-rolf-269345-unsplash.jpg' attachmentUrl || '/fixtures/tina-rolf-269345-unsplash.jpg'
), ),
hasReplies: Boolean(casual.coin_flip),
isUnread: Boolean(casual.coin_flip), isUnread: Boolean(casual.coin_flip),
messageId, messageId,
messageIdForLogging: `${messageId} (for logging)`, messageIdForLogging: `${messageId} (for logging)`,
@ -70,8 +69,14 @@ export function getFakeStory({
}): ConversationStoryType { }): ConversationStoryType {
const storyView = getFakeStoryView(attachmentUrl, timestamp); const storyView = getFakeStoryView(attachmentUrl, timestamp);
const hasReplies = group ? Boolean(casual.coin_flip) : false;
const hasRepliesFromSelf =
group && hasReplies ? Boolean(casual.coin_flip) : false;
return { return {
conversationId: storyView.sender.id, conversationId: storyView.sender.id,
hasReplies,
hasRepliesFromSelf,
group, group,
storyView, storyView,
}; };

View file

@ -9,7 +9,7 @@ import type { UUIDStringType } from '../../types/UUID';
import type { MessageAttributesType } from '../../model-types.d'; import type { MessageAttributesType } from '../../model-types.d';
const { removeAll, _getAllMessages, saveMessages, getOlderStories } = const { removeAll, _getAllMessages, saveMessages, getAllStories } =
dataInterface; dataInterface;
function getUuid(): UUIDStringType { function getUuid(): UUIDStringType {
@ -21,7 +21,7 @@ describe('sql/stories', () => {
await removeAll(); await removeAll();
}); });
describe('getOlderStories', () => { describe('getAllStories', () => {
it('returns N most recent stories overall, or in converation, or by author', async () => { it('returns N most recent stories overall, or in converation, or by author', async () => {
assert.lengthOf(await _getAllMessages(), 0); assert.lengthOf(await _getAllMessages(), 0);
@ -88,9 +88,7 @@ describe('sql/stories', () => {
assert.lengthOf(await _getAllMessages(), 5); assert.lengthOf(await _getAllMessages(), 5);
const stories = await getOlderStories({ const stories = await getAllStories({});
limit: 5,
});
assert.lengthOf(stories, 4, 'expect four total stories'); assert.lengthOf(stories, 4, 'expect four total stories');
// They are in ASC order // They are in ASC order
@ -105,9 +103,8 @@ describe('sql/stories', () => {
'stories last should be story1' 'stories last should be story1'
); );
const storiesInConversation = await getOlderStories({ const storiesInConversation = await getAllStories({
conversationId, conversationId,
limit: 5,
}); });
assert.lengthOf( assert.lengthOf(
storiesInConversation, storiesInConversation,
@ -127,9 +124,8 @@ describe('sql/stories', () => {
'storiesInConversation last should be story1' 'storiesInConversation last should be story1'
); );
const storiesByAuthor = await getOlderStories({ const storiesByAuthor = await getAllStories({
sourceUuid, sourceUuid,
limit: 5,
}); });
assert.lengthOf(storiesByAuthor, 2, 'expect two stories by author'); assert.lengthOf(storiesByAuthor, 2, 'expect two stories by author');
@ -145,85 +141,5 @@ describe('sql/stories', () => {
'storiesByAuthor last should be story2' 'storiesByAuthor last should be story2'
); );
}); });
it('returns N stories older than provided receivedAt/sentAt', async () => {
assert.lengthOf(await _getAllMessages(), 0);
const start = Date.now();
const conversationId = getUuid();
const ourUuid = getUuid();
const story1: MessageAttributesType = {
id: getUuid(),
body: 'message 1',
type: 'incoming',
conversationId,
sent_at: start - 2,
received_at: start - 2,
timestamp: start - 2,
};
const story2: MessageAttributesType = {
id: getUuid(),
body: 'story 2',
type: 'story',
conversationId,
sent_at: start - 1,
received_at: start - 1,
timestamp: start - 1,
};
const story3: MessageAttributesType = {
id: getUuid(),
body: 'story 3',
type: 'story',
conversationId,
sent_at: start - 1,
received_at: start,
timestamp: start,
};
const story4: MessageAttributesType = {
id: getUuid(),
body: 'story 4',
type: 'story',
conversationId,
sent_at: start,
received_at: start,
timestamp: start,
};
const story5: MessageAttributesType = {
id: getUuid(),
body: 'story 5',
type: 'story',
conversationId,
sent_at: start + 1,
received_at: start + 1,
timestamp: start + 1,
};
await saveMessages([story1, story2, story3, story4, story5], {
forceSave: true,
ourUuid,
});
assert.lengthOf(await _getAllMessages(), 5);
const stories = await getOlderStories({
receivedAt: story4.received_at,
sentAt: story4.sent_at,
limit: 5,
});
assert.lengthOf(stories, 2, 'expect two stories');
// They are in ASC order
assert.strictEqual(
stories[0].id,
story2.id,
'stories first should be story3'
);
assert.strictEqual(
stories[1].id,
story3.id,
'stories last should be story2'
);
});
}); });
}); });

View file

@ -42,6 +42,8 @@ export type ReplyStateType = {
export type ConversationStoryType = { export type ConversationStoryType = {
conversationId: string; conversationId: string;
hasReplies?: boolean;
hasRepliesFromSelf?: boolean;
group?: Pick< group?: Pick<
ConversationType, ConversationType,
| 'acceptedMessageRequest' | 'acceptedMessageRequest'
@ -70,8 +72,6 @@ export type StorySendStateType = {
export type StoryViewType = { export type StoryViewType = {
attachment?: AttachmentType; attachment?: AttachmentType;
canReply?: boolean; canReply?: boolean;
hasReplies?: boolean;
hasRepliesFromSelf?: boolean;
isHidden?: boolean; isHidden?: boolean;
isUnread?: boolean; isUnread?: boolean;
messageId: string; messageId: string;