New 'unseenStatus' field for certain secondary message types

This commit is contained in:
Scott Nonnenberg 2022-04-22 11:35:14 -07:00 committed by GitHub
parent ed9f54d7d6
commit 3a1df01c9e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 610 additions and 143 deletions

25
ts/MessageSeenStatus.ts Normal file
View file

@ -0,0 +1,25 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/**
* `SeenStatus` represents either the idea that a message doesn't need to track its seen
* status, or the standard unseen/seen status pair.
*
* Unseen is a lot like unread - except that unseen messages only affect the placement
* of the last seen indicator and the count it shows. Unread messages will affect the
* left pane badging for conversations, as well as the overall badge count on the app.
*/
export enum SeenStatus {
NotApplicable = 0,
Unseen = 1,
Seen = 2,
}
const STATUS_NUMBERS: Record<SeenStatus, number> = {
[SeenStatus.NotApplicable]: 0,
[SeenStatus.Unseen]: 1,
[SeenStatus.Seen]: 2,
};
export const maxSeenStatus = (a: SeenStatus, b: SeenStatus): SeenStatus =>
STATUS_NUMBERS[a] > STATUS_NUMBERS[b] ? a : b;

View file

@ -143,6 +143,7 @@ import { ReactionSource } from './reactions/ReactionSource';
import { singleProtoJobQueue } from './jobs/singleProtoJobQueue'; import { singleProtoJobQueue } from './jobs/singleProtoJobQueue';
import { getInitialState } from './state/getInitialState'; import { getInitialState } from './state/getInitialState';
import { conversationJobQueue } from './jobs/conversationJobQueue'; import { conversationJobQueue } from './jobs/conversationJobQueue';
import { SeenStatus } from './MessageSeenStatus';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
@ -3052,22 +3053,24 @@ export async function startApp(): Promise<void> {
} }
return new window.Whisper.Message({ return new window.Whisper.Message({
source: window.textsecure.storage.user.getNumber(),
sourceUuid: window.textsecure.storage.user.getUuid()?.toString(),
sourceDevice: data.device,
sent_at: timestamp,
serverTimestamp: data.serverTimestamp,
received_at: data.receivedAtCounter,
received_at_ms: data.receivedAtDate,
conversationId: descriptor.id, conversationId: descriptor.id,
timestamp,
type: 'outgoing',
sendStateByConversationId,
unidentifiedDeliveries,
expirationStartTimestamp: Math.min( expirationStartTimestamp: Math.min(
data.expirationStartTimestamp || timestamp, data.expirationStartTimestamp || timestamp,
now now
), ),
readStatus: ReadStatus.Read,
received_at_ms: data.receivedAtDate,
received_at: data.receivedAtCounter,
seenStatus: SeenStatus.NotApplicable,
sendStateByConversationId,
sent_at: timestamp,
serverTimestamp: data.serverTimestamp,
source: window.textsecure.storage.user.getNumber(),
sourceDevice: data.device,
sourceUuid: window.textsecure.storage.user.getUuid()?.toString(),
timestamp,
type: 'outgoing',
unidentifiedDeliveries,
} as Partial<MessageAttributesType> as WhatIsThis); } as Partial<MessageAttributesType> as WhatIsThis);
} }
@ -3316,6 +3319,7 @@ export async function startApp(): Promise<void> {
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
type: data.message.isStory ? 'story' : 'incoming', type: data.message.isStory ? 'story' : 'incoming',
readStatus: ReadStatus.Unread, readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
timestamp: data.timestamp, timestamp: data.timestamp,
} as Partial<MessageAttributesType> as WhatIsThis); } as Partial<MessageAttributesType> as WhatIsThis);
} }

View file

@ -540,9 +540,9 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
items: overrideProps.items || Object.keys(items), items: overrideProps.items || Object.keys(items),
scrollToIndex: overrideProps.scrollToIndex, scrollToIndex: overrideProps.scrollToIndex,
scrollToIndexCounter: 0, scrollToIndexCounter: 0,
totalUnread: number('totalUnread', overrideProps.totalUnread || 0), totalUnseen: number('totalUnseen', overrideProps.totalUnseen || 0),
oldestUnreadIndex: oldestUnseenIndex:
number('oldestUnreadIndex', overrideProps.oldestUnreadIndex || 0) || number('oldestUnseenIndex', overrideProps.oldestUnseenIndex || 0) ||
undefined, undefined,
invitedContactsForNewlyCreatedGroup: invitedContactsForNewlyCreatedGroup:
overrideProps.invitedContactsForNewlyCreatedGroup || [], overrideProps.invitedContactsForNewlyCreatedGroup || [],
@ -608,8 +608,8 @@ story.add('Empty (just hero)', () => {
story.add('Last Seen', () => { story.add('Last Seen', () => {
const props = useProps({ const props = useProps({
oldestUnreadIndex: 13, oldestUnseenIndex: 13,
totalUnread: 2, totalUnseen: 2,
}); });
return <Timeline {...props} />; return <Timeline {...props} />;

View file

@ -88,10 +88,10 @@ export type PropsDataType = {
messageLoadingState?: TimelineMessageLoadingState; messageLoadingState?: TimelineMessageLoadingState;
isNearBottom?: boolean; isNearBottom?: boolean;
items: ReadonlyArray<string>; items: ReadonlyArray<string>;
oldestUnreadIndex?: number; oldestUnseenIndex?: number;
scrollToIndex?: number; scrollToIndex?: number;
scrollToIndexCounter: number; scrollToIndexCounter: number;
totalUnread: number; totalUnseen: number;
}; };
type PropsHousekeepingType = { type PropsHousekeepingType = {
@ -342,7 +342,7 @@ export class Timeline extends React.Component<
items, items,
loadNewestMessages, loadNewestMessages,
messageLoadingState, messageLoadingState,
oldestUnreadIndex, oldestUnseenIndex,
selectMessage, selectMessage,
} = this.props; } = this.props;
const { newestBottomVisibleMessageId } = this.state; const { newestBottomVisibleMessageId } = this.state;
@ -358,15 +358,15 @@ export class Timeline extends React.Component<
if ( if (
newestBottomVisibleMessageId && newestBottomVisibleMessageId &&
isNumber(oldestUnreadIndex) && isNumber(oldestUnseenIndex) &&
items.findIndex(item => item === newestBottomVisibleMessageId) < items.findIndex(item => item === newestBottomVisibleMessageId) <
oldestUnreadIndex oldestUnseenIndex
) { ) {
if (setFocus) { if (setFocus) {
const messageId = items[oldestUnreadIndex]; const messageId = items[oldestUnseenIndex];
selectMessage(messageId, id); selectMessage(messageId, id);
} else { } else {
this.scrollToItemIndex(oldestUnreadIndex); this.scrollToItemIndex(oldestUnseenIndex);
} }
} else if (haveNewest) { } else if (haveNewest) {
this.scrollToBottom(setFocus); this.scrollToBottom(setFocus);
@ -790,7 +790,7 @@ export class Timeline extends React.Component<
isSomeoneTyping, isSomeoneTyping,
items, items,
messageLoadingState, messageLoadingState,
oldestUnreadIndex, oldestUnseenIndex,
onBlock, onBlock,
onBlockAndReportSpam, onBlockAndReportSpam,
onDelete, onDelete,
@ -804,7 +804,7 @@ export class Timeline extends React.Component<
reviewMessageRequestNameCollision, reviewMessageRequestNameCollision,
showContactModal, showContactModal,
theme, theme,
totalUnread, totalUnseen,
unblurAvatar, unblurAvatar,
unreadCount, unreadCount,
updateSharedGroups, updateSharedGroups,
@ -898,17 +898,17 @@ export class Timeline extends React.Component<
} }
let unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement; let unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
if (oldestUnreadIndex === itemIndex) { if (oldestUnseenIndex === itemIndex) {
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove; unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove;
messageNodes.push( messageNodes.push(
<LastSeenIndicator <LastSeenIndicator
key="last seen indicator" key="last seen indicator"
count={totalUnread} count={totalUnseen}
i18n={i18n} i18n={i18n}
ref={this.lastSeenIndicatorRef} ref={this.lastSeenIndicatorRef}
/> />
); );
} else if (oldestUnreadIndex === nextItemIndex) { } else if (oldestUnseenIndex === nextItemIndex) {
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow; unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow;
} }

5
ts/model-types.d.ts vendored
View file

@ -35,6 +35,7 @@ import { ReactionSource } from './reactions/ReactionSource';
import AccessRequiredEnum = Proto.AccessControl.AccessRequired; import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
import MemberRoleEnum = Proto.Member.Role; import MemberRoleEnum = Proto.Member.Role;
import { SeenStatus } from './MessageSeenStatus';
export type WhatIsThis = any; export type WhatIsThis = any;
@ -219,8 +220,10 @@ export type MessageAttributesType = {
sendHQImages?: boolean; sendHQImages?: boolean;
// Should only be present for incoming messages // Should only be present for incoming messages and errors
readStatus?: ReadStatus; readStatus?: ReadStatus;
// Used for all kinds of notifications, as well as incoming messages
seenStatus?: SeenStatus;
// Should only be present for outgoing messages // Should only be present for outgoing messages
sendStateByConversationId?: SendStateByConversationId; sendStateByConversationId?: SendStateByConversationId;

View file

@ -117,6 +117,8 @@ import { isMessageUnread } from '../util/isMessageUnread';
import type { SenderKeyTargetType } from '../util/sendToGroup'; import type { SenderKeyTargetType } from '../util/sendToGroup';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { TimelineMessageLoadingState } from '../util/timelineUtil'; import { TimelineMessageLoadingState } from '../util/timelineUtil';
import { SeenStatus } from '../MessageSeenStatus';
import { getConversationIdForLogging } from '../util/idForLogging';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
@ -237,17 +239,7 @@ export class ConversationModel extends window.Backbone
} }
idForLogging(): string { idForLogging(): string {
if (isDirectConversation(this.attributes)) { return getConversationIdForLogging(this.attributes);
const uuid = this.get('uuid');
const e164 = this.get('e164');
return `${uuid || e164} (${this.id})`;
}
if (isGroupV2(this.attributes)) {
return `groupv2(${this.get('groupId')})`;
}
const groupId = this.get('groupId');
return `group(${groupId})`;
} }
// This is one of the few times that we want to collapse our uuid/e164 pair down into // This is one of the few times that we want to collapse our uuid/e164 pair down into
@ -1508,8 +1500,8 @@ export class ConversationModel extends window.Backbone
return; return;
} }
if (scrollToLatestUnread && metrics.oldestUnread) { if (scrollToLatestUnread && metrics.oldestUnseen) {
this.loadAndScroll(metrics.oldestUnread.id, { this.loadAndScroll(metrics.oldestUnseen.id, {
disableScroll: !setFocus, disableScroll: !setFocus,
}); });
return; return;
@ -2926,6 +2918,7 @@ export class ConversationModel extends window.Backbone
received_at: receivedAtCounter, received_at: receivedAtCounter,
received_at_ms: receivedAt, received_at_ms: receivedAt,
readStatus: ReadStatus.Unread, readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to // this type does not fully implement the interface it is expected to
} as unknown as MessageAttributesType; } as unknown as MessageAttributesType;
@ -2968,6 +2961,7 @@ export class ConversationModel extends window.Backbone
received_at: receivedAtCounter, received_at: receivedAtCounter,
received_at_ms: receivedAt, received_at_ms: receivedAt,
readStatus: ReadStatus.Unread, readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to // this type does not fully implement the interface it is expected to
} as unknown as MessageAttributesType; } as unknown as MessageAttributesType;
@ -3004,7 +2998,8 @@ export class ConversationModel extends window.Backbone
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp, received_at_ms: timestamp,
key_changed: keyChangedId.toString(), key_changed: keyChangedId.toString(),
readStatus: ReadStatus.Unread, readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to // this type does not fully implement the interface it is expected to
@ -3057,14 +3052,15 @@ export class ConversationModel extends window.Backbone
const timestamp = Date.now(); const timestamp = Date.now();
const message = { const message = {
conversationId: this.id, conversationId: this.id,
type: 'verified-change',
sent_at: lastMessage,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp,
verifiedChanged: verifiedChangeId,
verified,
local: options.local, local: options.local,
readStatus: ReadStatus.Unread, readStatus: ReadStatus.Unread,
received_at_ms: timestamp,
received_at: window.Signal.Util.incrementMessageCounter(),
seenStatus: SeenStatus.Unseen,
sent_at: lastMessage,
type: 'verified-change',
verified,
verifiedChanged: verifiedChangeId,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
} as unknown as MessageAttributesType; } as unknown as MessageAttributesType;
@ -3128,6 +3124,7 @@ export class ConversationModel extends window.Backbone
receivedAtCounter || window.Signal.Util.incrementMessageCounter(), receivedAtCounter || window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp, received_at_ms: timestamp,
readStatus: unread ? ReadStatus.Unread : ReadStatus.Read, readStatus: unread ? ReadStatus.Unread : ReadStatus.Read,
seenStatus: unread ? SeenStatus.Unseen : SeenStatus.NotApplicable,
callHistoryDetails: detailsToSave, callHistoryDetails: detailsToSave,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
} as unknown as MessageAttributesType; } as unknown as MessageAttributesType;
@ -3192,6 +3189,7 @@ export class ConversationModel extends window.Backbone
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now, received_at_ms: now,
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
seenStatus: SeenStatus.NotApplicable,
changedId: conversationId || this.id, changedId: conversationId || this.id,
profileChange, profileChange,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
@ -3228,14 +3226,15 @@ export class ConversationModel extends window.Backbone
): Promise<string> { ): Promise<string> {
const now = Date.now(); const now = Date.now();
const message: Partial<MessageAttributesType> = { const message: Partial<MessageAttributesType> = {
...extra,
conversationId: this.id, conversationId: this.id,
type, type,
sent_at: now, sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now, received_at_ms: now,
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
seenStatus: SeenStatus.NotApplicable,
...extra,
}; };
const id = await window.Signal.Data.saveMessage( const id = await window.Signal.Data.saveMessage(
@ -3363,6 +3362,8 @@ export class ConversationModel extends window.Backbone
await Promise.all( await Promise.all(
convos.map(convo => { convos.map(convo => {
return convo.addNotification('change-number-notification', { return convo.addNotification('change-number-notification', {
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
sourceUuid: sourceUuid.toString(), sourceUuid: sourceUuid.toString(),
}); });
}) })
@ -4037,6 +4038,8 @@ export class ConversationModel extends window.Backbone
received_at_ms: now, received_at_ms: now,
expireTimer, expireTimer,
recipients, recipients,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.NotApplicable,
sticker, sticker,
bodyRanges: mentions, bodyRanges: mentions,
sendHQImages, sendHQImages,
@ -4546,21 +4549,20 @@ export class ConversationModel extends window.Backbone
window.Signal.Data.updateConversation(this.attributes); window.Signal.Data.updateConversation(this.attributes);
const model = new window.Whisper.Message({ const model = new window.Whisper.Message({
// Even though this isn't reflected to the user, we want to place the last seen
// indicator above it. We set it to 'unread' to trigger that placement.
readStatus: ReadStatus.Unread,
conversationId: this.id, conversationId: this.id,
// No type; 'incoming' messages are specially treated by conversation.markRead()
sent_at: sentAt,
received_at: receivedAt,
received_at_ms: receivedAtMS,
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
expirationTimerUpdate: { expirationTimerUpdate: {
expireTimer, expireTimer,
source, source,
fromSync: options.fromSync, fromSync: options.fromSync,
fromGroupUpdate: options.fromGroupUpdate, fromGroupUpdate: options.fromGroupUpdate,
}, },
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
readStatus: ReadStatus.Unread,
received_at_ms: receivedAtMS,
received_at: receivedAt,
seenStatus: SeenStatus.Unseen,
sent_at: sentAt,
type: 'timer-notification',
// TODO: DESKTOP-722 // TODO: DESKTOP-722
} as unknown as MessageAttributesType); } as unknown as MessageAttributesType);
@ -4589,9 +4591,8 @@ export class ConversationModel extends window.Backbone
const model = new window.Whisper.Message({ const model = new window.Whisper.Message({
type: 'message-history-unsynced', type: 'message-history-unsynced',
// Even though this isn't reflected to the user, we want to place the last seen readStatus: ReadStatus.Read,
// indicator above it. We set it to 'unread' to trigger that placement. seenStatus: SeenStatus.NotApplicable,
readStatus: ReadStatus.Unread,
conversationId: this.id, conversationId: this.id,
sent_at: timestamp, sent_at: timestamp,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
@ -4633,12 +4634,14 @@ export class ConversationModel extends window.Backbone
window.Signal.Data.updateConversation(this.attributes); window.Signal.Data.updateConversation(this.attributes);
const model = new window.Whisper.Message({ const model = new window.Whisper.Message({
group_update: { left: 'You' },
conversationId: this.id, conversationId: this.id,
type: 'outgoing', group_update: { left: 'You' },
sent_at: now, readStatus: ReadStatus.Read,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now, received_at_ms: now,
received_at: window.Signal.Util.incrementMessageCounter(),
seenStatus: SeenStatus.NotApplicable,
sent_at: now,
type: 'group',
// TODO: DESKTOP-722 // TODO: DESKTOP-722
} as unknown as MessageAttributesType); } as unknown as MessageAttributesType);
@ -4665,7 +4668,11 @@ export class ConversationModel extends window.Backbone
async markRead( async markRead(
newestUnreadAt: number, newestUnreadAt: number,
options: { readAt?: number; sendReadReceipts: boolean } = { options: {
readAt?: number;
sendReadReceipts: boolean;
newestSentAt?: number;
} = {
sendReadReceipts: true, sendReadReceipts: true,
} }
): Promise<void> { ): Promise<void> {

View file

@ -141,7 +141,7 @@ import {
} from '../messages/helpers'; } from '../messages/helpers';
import type { ReplacementValuesType } from '../types/I18N'; import type { ReplacementValuesType } from '../types/I18N';
import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue'; import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
import { getMessageIdForLogging } from '../util/getMessageIdForLogging'; import { getMessageIdForLogging } from '../util/idForLogging';
import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads'; import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads';
import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads'; import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads';
import { findStoryMessage } from '../util/findStoryMessage'; import { findStoryMessage } from '../util/findStoryMessage';
@ -152,6 +152,7 @@ import { getMessageById } from '../messages/getMessageById';
import { shouldDownloadStory } from '../util/shouldDownloadStory'; import { shouldDownloadStory } from '../util/shouldDownloadStory';
import { shouldShowStoriesView } from '../state/selectors/stories'; import { shouldShowStoriesView } from '../state/selectors/stories';
import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage'; import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage';
import { SeenStatus } from '../MessageSeenStatus';
/* eslint-disable camelcase */ /* eslint-disable camelcase */
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
@ -2760,7 +2761,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
newReadStatus = ReadStatus.Read; newReadStatus = ReadStatus.Read;
} }
message.set('readStatus', newReadStatus); message.set({
readStatus: newReadStatus,
seenStatus: SeenStatus.Seen,
});
changed = true; changed = true;
this.pendingMarkRead = Math.min( this.pendingMarkRead = Math.min(

View file

@ -50,8 +50,8 @@ export type MessageMetricsType = {
export type ConversationMetricsType = { export type ConversationMetricsType = {
oldest?: MessageMetricsType; oldest?: MessageMetricsType;
newest?: MessageMetricsType; newest?: MessageMetricsType;
oldestUnread?: MessageMetricsType; oldestUnseen?: MessageMetricsType;
totalUnread: number; totalUnseen: number;
}; };
export type ConversationType = ConversationAttributesType; export type ConversationType = ConversationAttributesType;
export type EmojiType = { export type EmojiType = {

View file

@ -110,6 +110,7 @@ import type {
UnprocessedType, UnprocessedType,
UnprocessedUpdateType, UnprocessedUpdateType,
} from './Interface'; } from './Interface';
import { SeenStatus } from '../MessageSeenStatus';
type ConversationRow = Readonly<{ type ConversationRow = Readonly<{
json: string; json: string;
@ -1737,6 +1738,20 @@ function saveMessageSync(
expireTimer, expireTimer,
expirationStartTimestamp, expirationStartTimestamp,
} = data; } = data;
let { seenStatus } = data;
if (readStatus === ReadStatus.Unread && seenStatus !== SeenStatus.Unseen) {
log.warn(
`saveMessage: Message ${id}/${type} is unread but had seenStatus=${seenStatus}. Forcing to UnseenStatus.Unseen.`
);
// eslint-disable-next-line no-param-reassign
data = {
...data,
seenStatus: SeenStatus.Unseen,
};
seenStatus = SeenStatus.Unseen;
}
const payload = { const payload = {
id, id,
@ -1762,6 +1777,7 @@ function saveMessageSync(
storyId: storyId || null, storyId: storyId || null,
type: type || null, type: type || null,
readStatus: readStatus ?? null, readStatus: readStatus ?? null,
seenStatus: seenStatus ?? SeenStatus.NotApplicable,
}; };
if (id && !forceSave) { if (id && !forceSave) {
@ -1791,7 +1807,8 @@ function saveMessageSync(
sourceDevice = $sourceDevice, sourceDevice = $sourceDevice,
storyId = $storyId, storyId = $storyId,
type = $type, type = $type,
readStatus = $readStatus readStatus = $readStatus,
seenStatus = $seenStatus
WHERE id = $id; WHERE id = $id;
` `
).run(payload); ).run(payload);
@ -1834,7 +1851,8 @@ function saveMessageSync(
sourceDevice, sourceDevice,
storyId, storyId,
type, type,
readStatus readStatus,
seenStatus
) values ( ) values (
$id, $id,
$json, $json,
@ -1858,7 +1876,8 @@ function saveMessageSync(
$sourceDevice, $sourceDevice,
$storyId, $storyId,
$type, $type,
$readStatus $readStatus,
$seenStatus
); );
` `
).run({ ).run({
@ -2110,16 +2129,21 @@ async function getUnreadByConversationAndMarkRead({
UPDATE messages UPDATE messages
SET SET
readStatus = ${ReadStatus.Read}, readStatus = ${ReadStatus.Read},
seenStatus = ${SeenStatus.Seen},
json = json_patch(json, $jsonPatch) json = json_patch(json, $jsonPatch)
WHERE WHERE
readStatus = ${ReadStatus.Unread} AND
conversationId = $conversationId AND conversationId = $conversationId AND
seenStatus = ${SeenStatus.Unseen} AND
isStory = 0 AND
(${_storyIdPredicate(storyId, isGroup)}) AND (${_storyIdPredicate(storyId, isGroup)}) AND
received_at <= $newestUnreadAt; received_at <= $newestUnreadAt;
` `
).run({ ).run({
conversationId, conversationId,
jsonPatch: JSON.stringify({ readStatus: ReadStatus.Read }), jsonPatch: JSON.stringify({
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
}),
newestUnreadAt, newestUnreadAt,
storyId: storyId || null, storyId: storyId || null,
}); });
@ -2644,7 +2668,7 @@ async function getLastConversationMessage({
return jsonToObject(row.json); return jsonToObject(row.json);
} }
function getOldestUnreadMessageForConversation( function getOldestUnseenMessageForConversation(
conversationId: string, conversationId: string,
storyId?: UUIDStringType, storyId?: UUIDStringType,
isGroup?: boolean isGroup?: boolean
@ -2655,7 +2679,7 @@ function getOldestUnreadMessageForConversation(
` `
SELECT * FROM messages WHERE SELECT * FROM messages WHERE
conversationId = $conversationId AND conversationId = $conversationId AND
readStatus = ${ReadStatus.Unread} AND seenStatus = ${SeenStatus.Unseen} AND
isStory IS 0 AND isStory IS 0 AND
(${_storyIdPredicate(storyId, isGroup)}) (${_storyIdPredicate(storyId, isGroup)})
ORDER BY received_at ASC, sent_at ASC ORDER BY received_at ASC, sent_at ASC
@ -2709,6 +2733,35 @@ function getTotalUnreadForConversationSync(
return row['count(id)']; return row['count(id)'];
} }
function getTotalUnseenForConversationSync(
conversationId: string,
storyId?: UUIDStringType,
isGroup?: boolean
): number {
const db = getInstance();
const row = db
.prepare<Query>(
`
SELECT count(id)
FROM messages
WHERE
conversationId = $conversationId AND
seenStatus = ${SeenStatus.Unseen} AND
isStory IS 0 AND
(${_storyIdPredicate(storyId, isGroup)})
`
)
.get({
conversationId,
storyId: storyId || null,
});
if (!row) {
throw new Error('getTotalUnseenForConversationSync: Unable to get count');
}
return row['count(id)'];
}
async function getMessageMetricsForConversation( async function getMessageMetricsForConversation(
conversationId: string, conversationId: string,
@ -2732,12 +2785,12 @@ function getMessageMetricsForConversationSync(
storyId, storyId,
isGroup isGroup
); );
const oldestUnread = getOldestUnreadMessageForConversation( const oldestUnseen = getOldestUnseenMessageForConversation(
conversationId, conversationId,
storyId, storyId,
isGroup isGroup
); );
const totalUnread = getTotalUnreadForConversationSync( const totalUnseen = getTotalUnseenForConversationSync(
conversationId, conversationId,
storyId, storyId,
isGroup isGroup
@ -2746,10 +2799,10 @@ function getMessageMetricsForConversationSync(
return { return {
oldest: oldest ? pick(oldest, ['received_at', 'sent_at', 'id']) : undefined, oldest: oldest ? pick(oldest, ['received_at', 'sent_at', 'id']) : undefined,
newest: newest ? pick(newest, ['received_at', 'sent_at', 'id']) : undefined, newest: newest ? pick(newest, ['received_at', 'sent_at', 'id']) : undefined,
oldestUnread: oldestUnread oldestUnseen: oldestUnseen
? pick(oldestUnread, ['received_at', 'sent_at', 'id']) ? pick(oldestUnseen, ['received_at', 'sent_at', 'id'])
: undefined, : undefined,
totalUnread, totalUnseen,
}; };
} }

View file

@ -0,0 +1,88 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from 'better-sqlite3';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { SeenStatus } from '../../MessageSeenStatus';
import type { LoggerType } from '../../types/Logging';
export default function updateToSchemaVersion56(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 56) {
return;
}
db.transaction(() => {
db.exec(
`
--- Add column to messages table
ALTER TABLE messages ADD COLUMN seenStatus NUMBER default 0;
--- Add index to make searching on this field easy
CREATE INDEX messages_unseen_no_story ON messages
(conversationId, seenStatus, isStory, received_at, sent_at)
WHERE
seenStatus IS NOT NULL;
CREATE INDEX messages_unseen_with_story ON messages
(conversationId, seenStatus, isStory, storyId, received_at, sent_at)
WHERE
seenStatus IS NOT NULL;
--- Update seenStatus to UnseenStatus.Unseen for certain messages
--- (NULL included because 'timer-notification' in 1:1 convos had type = NULL)
UPDATE messages
SET
seenStatus = ${SeenStatus.Unseen}
WHERE
readStatus = ${ReadStatus.Unread} AND
(
type IS NULL
OR
type IN (
'call-history',
'change-number-notification',
'chat-session-refreshed',
'delivery-issue',
'group',
'incoming',
'keychange',
'timer-notification',
'verified-change'
)
);
--- Set readStatus to ReadStatus.Read for all other message types
UPDATE messages
SET
readStatus = ${ReadStatus.Read}
WHERE
readStatus = ${ReadStatus.Unread} AND
type IS NOT NULL AND
type NOT IN (
'call-history',
'change-number-notification',
'chat-session-refreshed',
'delivery-issue',
'group',
'incoming',
'keychange',
'timer-notification',
'verified-change'
);
`
);
db.pragma('user_version = 56');
})();
logger.info('updateToSchemaVersion56: success!');
}

View file

@ -31,6 +31,7 @@ import updateToSchemaVersion52 from './52-optimize-stories';
import updateToSchemaVersion53 from './53-gv2-banned-members'; import updateToSchemaVersion53 from './53-gv2-banned-members';
import updateToSchemaVersion54 from './54-unprocessed-received-at-counter'; import updateToSchemaVersion54 from './54-unprocessed-received-at-counter';
import updateToSchemaVersion55 from './55-report-message-aci'; import updateToSchemaVersion55 from './55-report-message-aci';
import updateToSchemaVersion56 from './56-add-unseen-to-message';
function updateToSchemaVersion1( function updateToSchemaVersion1(
currentVersion: number, currentVersion: number,
@ -1925,6 +1926,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion53, updateToSchemaVersion53,
updateToSchemaVersion54, updateToSchemaVersion54,
updateToSchemaVersion55, updateToSchemaVersion55,
updateToSchemaVersion56,
]; ];
export function updateSchema(db: Database, logger: LoggerType): void { export function updateSchema(db: Database, logger: LoggerType): void {

View file

@ -234,8 +234,8 @@ type MessagePointerType = {
type MessageMetricsType = { type MessageMetricsType = {
newest?: MessagePointerType; newest?: MessagePointerType;
oldest?: MessagePointerType; oldest?: MessagePointerType;
oldestUnread?: MessagePointerType; oldestUnseen?: MessagePointerType;
totalUnread: number; totalUnseen: number;
}; };
export type MessageLookupType = { export type MessageLookupType = {
@ -2673,7 +2673,7 @@ export function reducer(
let metrics; let metrics;
if (messageIds.length === 0) { if (messageIds.length === 0) {
metrics = { metrics = {
totalUnread: 0, totalUnseen: 0,
}; };
} else { } else {
metrics = { metrics = {
@ -2791,7 +2791,7 @@ export function reducer(
return state; return state;
} }
let { newest, oldest, oldestUnread, totalUnread } = let { newest, oldest, oldestUnseen, totalUnseen } =
existingConversation.metrics; existingConversation.metrics;
if (messages.length < 1) { if (messages.length < 1) {
@ -2853,7 +2853,7 @@ export function reducer(
const newMessageIds = difference(newIds, existingConversation.messageIds); const newMessageIds = difference(newIds, existingConversation.messageIds);
const { isNearBottom } = existingConversation; const { isNearBottom } = existingConversation;
if ((!isNearBottom || !isActive) && !oldestUnread) { if ((!isNearBottom || !isActive) && !oldestUnseen) {
const oldestId = newMessageIds.find(messageId => { const oldestId = newMessageIds.find(messageId => {
const message = lookup[messageId]; const message = lookup[messageId];
@ -2861,7 +2861,7 @@ export function reducer(
}); });
if (oldestId) { if (oldestId) {
oldestUnread = pick(lookup[oldestId], [ oldestUnseen = pick(lookup[oldestId], [
'id', 'id',
'received_at', 'received_at',
'sent_at', 'sent_at',
@ -2869,14 +2869,14 @@ export function reducer(
} }
} }
// If this is a new incoming message, we'll increment our totalUnread count // If this is a new incoming message, we'll increment our totalUnseen count
if (isNewMessage && !isJustSent && oldestUnread) { if (isNewMessage && !isJustSent && oldestUnseen) {
const newUnread: number = newMessageIds.reduce((sum, messageId) => { const newUnread: number = newMessageIds.reduce((sum, messageId) => {
const message = lookup[messageId]; const message = lookup[messageId];
return sum + (message && isMessageUnread(message) ? 1 : 0); return sum + (message && isMessageUnread(message) ? 1 : 0);
}, 0); }, 0);
totalUnread = (totalUnread || 0) + newUnread; totalUnseen = (totalUnseen || 0) + newUnread;
} }
return { return {
@ -2896,8 +2896,8 @@ export function reducer(
...existingConversation.metrics, ...existingConversation.metrics,
newest, newest,
oldest, oldest,
totalUnread, totalUnseen,
oldestUnread, oldestUnseen,
}, },
}, },
}, },
@ -2926,8 +2926,8 @@ export function reducer(
...existingConversation, ...existingConversation,
metrics: { metrics: {
...existingConversation.metrics, ...existingConversation.metrics,
oldestUnread: undefined, oldestUnseen: undefined,
totalUnread: 0, totalUnseen: 0,
}, },
}, },
}, },

View file

@ -839,7 +839,7 @@ export function _conversationMessagesSelector(
const lastId = const lastId =
messageIds.length === 0 ? undefined : messageIds[messageIds.length - 1]; messageIds.length === 0 ? undefined : messageIds[messageIds.length - 1];
const { oldestUnread } = metrics; const { oldestUnseen } = metrics;
const haveNewest = !metrics.newest || !lastId || lastId === metrics.newest.id; const haveNewest = !metrics.newest || !lastId || lastId === metrics.newest.id;
const haveOldest = const haveOldest =
@ -847,13 +847,13 @@ export function _conversationMessagesSelector(
const items = messageIds; const items = messageIds;
const oldestUnreadIndex = oldestUnread const oldestUnseenIndex = oldestUnseen
? messageIds.findIndex(id => id === oldestUnread.id) ? messageIds.findIndex(id => id === oldestUnseen.id)
: undefined; : undefined;
const scrollToIndex = scrollToMessageId const scrollToIndex = scrollToMessageId
? messageIds.findIndex(id => id === scrollToMessageId) ? messageIds.findIndex(id => id === scrollToMessageId)
: undefined; : undefined;
const { totalUnread } = metrics; const { totalUnseen } = metrics;
return { return {
haveNewest, haveNewest,
@ -861,14 +861,14 @@ export function _conversationMessagesSelector(
isNearBottom, isNearBottom,
items, items,
messageLoadingState, messageLoadingState,
oldestUnreadIndex: oldestUnseenIndex:
isNumber(oldestUnreadIndex) && oldestUnreadIndex >= 0 isNumber(oldestUnseenIndex) && oldestUnseenIndex >= 0
? oldestUnreadIndex ? oldestUnseenIndex
: undefined, : undefined,
scrollToIndex: scrollToIndex:
isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : undefined, isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : undefined,
scrollToIndexCounter: scrollToMessageCounter, scrollToIndexCounter: scrollToMessageCounter,
totalUnread, totalUnseen,
}; };
} }
@ -901,7 +901,7 @@ export const getConversationMessagesSelector = createSelector(
haveOldest: false, haveOldest: false,
messageLoadingState: TimelineMessageLoadingState.DoingInitialLoad, messageLoadingState: TimelineMessageLoadingState.DoingInitialLoad,
scrollToIndexCounter: 0, scrollToIndexCounter: 0,
totalUnread: 0, totalUnseen: 0,
items: [], items: [],
}; };
} }

View file

@ -352,7 +352,7 @@ describe('<Timeline> utilities', () => {
const props = { const props = {
...defaultProps, ...defaultProps,
items: fakeItems(10), items: fakeItems(10),
oldestUnreadIndex: 3, oldestUnseenIndex: 3,
}; };
assert.strictEqual( assert.strictEqual(

View file

@ -692,9 +692,9 @@ describe('sql/timelineFetches', () => {
received_at: target - 8, received_at: target - 8,
timestamp: target - 8, timestamp: target - 8,
}; };
const oldestUnread: MessageAttributesType = { const oldestUnseen: MessageAttributesType = {
id: getUuid(), id: getUuid(),
body: 'oldestUnread', body: 'oldestUnseen',
type: 'incoming', type: 'incoming',
conversationId, conversationId,
sent_at: target - 7, sent_at: target - 7,
@ -748,7 +748,7 @@ describe('sql/timelineFetches', () => {
story, story,
oldestInStory, oldestInStory,
oldest, oldest,
oldestUnread, oldestUnseen,
oldestStoryUnread, oldestStoryUnread,
anotherUnread, anotherUnread,
newestInStory, newestInStory,
@ -769,11 +769,11 @@ describe('sql/timelineFetches', () => {
); );
assert.strictEqual(metricsInTimeline?.newest?.id, newest.id, 'newest'); assert.strictEqual(metricsInTimeline?.newest?.id, newest.id, 'newest');
assert.strictEqual( assert.strictEqual(
metricsInTimeline?.oldestUnread?.id, metricsInTimeline?.oldestUnseen?.id,
oldestUnread.id, oldestUnseen.id,
'oldestUnread' 'oldestUnseen'
); );
assert.strictEqual(metricsInTimeline?.totalUnread, 3, 'totalUnread'); assert.strictEqual(metricsInTimeline?.totalUnseen, 3, 'totalUnseen');
const metricsInStory = await getMessageMetricsForConversation( const metricsInStory = await getMessageMetricsForConversation(
conversationId, conversationId,
@ -790,11 +790,11 @@ describe('sql/timelineFetches', () => {
'newestInStory' 'newestInStory'
); );
assert.strictEqual( assert.strictEqual(
metricsInStory?.oldestUnread?.id, metricsInStory?.oldestUnseen?.id,
oldestStoryUnread.id, oldestStoryUnread.id,
'oldestStoryUnread' 'oldestStoryUnread'
); );
assert.strictEqual(metricsInStory?.totalUnread, 1, 'totalUnread'); assert.strictEqual(metricsInStory?.totalUnseen, 1, 'totalUnseen');
}); });
}); });
}); });

View file

@ -332,7 +332,7 @@ describe('both/state/ducks/conversations', () => {
return { return {
messageIds: [], messageIds: [],
metrics: { metrics: {
totalUnread: 0, totalUnseen: 0,
}, },
scrollToMessageCounter: 0, scrollToMessageCounter: 0,
}; };
@ -1008,7 +1008,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(), ...getDefaultConversationMessage(),
messageIds: [messageIdThree, messageIdTwo, messageId], messageIds: [messageIdThree, messageIdTwo, messageId],
metrics: { metrics: {
totalUnread: 0, totalUnseen: 0,
}, },
}, },
}, },
@ -1028,7 +1028,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(), ...getDefaultConversationMessage(),
messageIds: [messageIdThree, messageIdTwo, messageId], messageIds: [messageIdThree, messageIdTwo, messageId],
metrics: { metrics: {
totalUnread: 0, totalUnseen: 0,
newest: { newest: {
id: messageId, id: messageId,
received_at: time, received_at: time,
@ -1058,7 +1058,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(), ...getDefaultConversationMessage(),
messageIds: [], messageIds: [],
metrics: { metrics: {
totalUnread: 0, totalUnseen: 0,
newest: { newest: {
id: messageId, id: messageId,
received_at: time, received_at: time,
@ -1082,7 +1082,7 @@ describe('both/state/ducks/conversations', () => {
messageIds: [], messageIds: [],
metrics: { metrics: {
newest: undefined, newest: undefined,
totalUnread: 0, totalUnseen: 0,
}, },
}, },
}, },
@ -1118,7 +1118,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(), ...getDefaultConversationMessage(),
messageIds: [messageId, messageIdTwo, messageIdThree], messageIds: [messageId, messageIdTwo, messageIdThree],
metrics: { metrics: {
totalUnread: 0, totalUnseen: 0,
}, },
}, },
}, },
@ -1138,7 +1138,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(), ...getDefaultConversationMessage(),
messageIds: [messageId, messageIdTwo, messageIdThree], messageIds: [messageId, messageIdTwo, messageIdThree],
metrics: { metrics: {
totalUnread: 0, totalUnseen: 0,
oldest: { oldest: {
id: messageId, id: messageId,
received_at: time, received_at: time,
@ -1168,7 +1168,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(), ...getDefaultConversationMessage(),
messageIds: [], messageIds: [],
metrics: { metrics: {
totalUnread: 0, totalUnseen: 0,
oldest: { oldest: {
id: messageId, id: messageId,
received_at: time, received_at: time,
@ -1192,7 +1192,7 @@ describe('both/state/ducks/conversations', () => {
messageIds: [], messageIds: [],
metrics: { metrics: {
oldest: undefined, oldest: undefined,
totalUnread: 0, totalUnseen: 0,
}, },
}, },
}, },

View file

@ -13,6 +13,8 @@ import {
insertJobSync, insertJobSync,
_storyIdPredicate, _storyIdPredicate,
} from '../sql/Server'; } from '../sql/Server';
import { ReadStatus } from '../messages/MessageReadStatus';
import { SeenStatus } from '../MessageSeenStatus';
const OUR_UUID = generateGuid(); const OUR_UUID = generateGuid();
@ -1772,4 +1774,256 @@ describe('SQL migrations test', () => {
]); ]);
}); });
}); });
describe('updateToSchemaVersion56', () => {
it('updates unseenStatus for previously-unread messages', () => {
const MESSAGE_ID_1 = generateGuid();
const MESSAGE_ID_2 = generateGuid();
const MESSAGE_ID_3 = generateGuid();
const MESSAGE_ID_4 = generateGuid();
const MESSAGE_ID_5 = generateGuid();
const MESSAGE_ID_6 = generateGuid();
const MESSAGE_ID_7 = generateGuid();
const MESSAGE_ID_8 = generateGuid();
const MESSAGE_ID_9 = generateGuid();
const MESSAGE_ID_10 = generateGuid();
const MESSAGE_ID_11 = generateGuid();
const CONVERSATION_ID = generateGuid();
updateToVersion(55);
db.exec(
`
INSERT INTO messages
(id, conversationId, type, readStatus)
VALUES
('${MESSAGE_ID_1}', '${CONVERSATION_ID}', 'call-history', ${ReadStatus.Unread}),
('${MESSAGE_ID_2}', '${CONVERSATION_ID}', 'change-number-notification', ${ReadStatus.Unread}),
('${MESSAGE_ID_3}', '${CONVERSATION_ID}', 'chat-session-refreshed', ${ReadStatus.Unread}),
('${MESSAGE_ID_4}', '${CONVERSATION_ID}', 'delivery-issue', ${ReadStatus.Unread}),
('${MESSAGE_ID_5}', '${CONVERSATION_ID}', 'group', ${ReadStatus.Unread}),
('${MESSAGE_ID_6}', '${CONVERSATION_ID}', 'incoming', ${ReadStatus.Unread}),
('${MESSAGE_ID_7}', '${CONVERSATION_ID}', 'keychange', ${ReadStatus.Unread}),
('${MESSAGE_ID_8}', '${CONVERSATION_ID}', 'timer-notification', ${ReadStatus.Unread}),
('${MESSAGE_ID_9}', '${CONVERSATION_ID}', 'verified-change', ${ReadStatus.Unread}),
('${MESSAGE_ID_10}', '${CONVERSATION_ID}', NULL, ${ReadStatus.Unread}),
('${MESSAGE_ID_11}', '${CONVERSATION_ID}', 'other', ${ReadStatus.Unread});
`
);
assert.strictEqual(
db.prepare('SELECT COUNT(*) FROM messages;').pluck().get(),
11,
'starting total'
);
assert.strictEqual(
db
.prepare(
`SELECT COUNT(*) FROM messages WHERE readStatus = ${ReadStatus.Unread};`
)
.pluck()
.get(),
11,
'starting unread count'
);
updateToVersion(56);
assert.strictEqual(
db.prepare('SELECT COUNT(*) FROM messages;').pluck().get(),
11,
'ending total'
);
assert.strictEqual(
db
.prepare(
`SELECT COUNT(*) FROM messages WHERE readStatus = ${ReadStatus.Unread};`
)
.pluck()
.get(),
10,
'ending unread count'
);
assert.strictEqual(
db
.prepare(
`SELECT COUNT(*) FROM messages WHERE seenStatus = ${SeenStatus.Unseen};`
)
.pluck()
.get(),
10,
'ending unseen count'
);
assert.strictEqual(
db
.prepare(
"SELECT readStatus FROM messages WHERE type = 'other' LIMIT 1;"
)
.pluck()
.get(),
ReadStatus.Read,
"checking read status for lone 'other' message"
);
});
it('creates usable index for getOldestUnseenMessageForConversation', () => {
updateToVersion(56);
const first = db
.prepare(
`
EXPLAIN QUERY PLAN
SELECT * FROM messages WHERE
conversationId = 'id-conversation-4' AND
seenStatus = ${SeenStatus.Unseen} AND
isStory IS 0 AND
NULL IS NULL
ORDER BY received_at ASC, sent_at ASC
LIMIT 1;
`
)
.all()
.map(({ detail }) => detail)
.join('\n');
assert.include(first, 'USING INDEX messages_unseen_no_story', 'first');
assert.notInclude(first, 'TEMP B-TREE', 'first');
assert.notInclude(first, 'SCAN', 'first');
const second = db
.prepare(
`
EXPLAIN QUERY PLAN
SELECT * FROM messages WHERE
conversationId = 'id-conversation-4' AND
seenStatus = ${SeenStatus.Unseen} AND
isStory IS 0 AND
storyId IS 'id-story-4'
ORDER BY received_at ASC, sent_at ASC
LIMIT 1;
`
)
.all()
.map(({ detail }) => detail)
.join('\n');
assert.include(
second,
'USING INDEX messages_unseen_with_story',
'second'
);
assert.notInclude(second, 'TEMP B-TREE', 'second');
assert.notInclude(second, 'SCAN', 'second');
});
it('creates usable index for getUnreadByConversationAndMarkRead', () => {
updateToVersion(56);
const first = db
.prepare(
`
EXPLAIN QUERY PLAN
UPDATE messages
SET
readStatus = ${ReadStatus.Read},
seenStatus = ${SeenStatus.Seen},
json = json_patch(json, '{ something: "one" }')
WHERE
conversationId = 'id-conversation-4' AND
seenStatus = ${SeenStatus.Unseen} AND
isStory = 0 AND
NULL IS NULL AND
received_at <= 2343233;
`
)
.all()
.map(({ detail }) => detail)
.join('\n');
assert.include(first, 'USING INDEX messages_unseen_no_story', 'first');
assert.notInclude(first, 'TEMP B-TREE', 'first');
assert.notInclude(first, 'SCAN', 'first');
const second = db
.prepare(
`
EXPLAIN QUERY PLAN
UPDATE messages
SET
readStatus = ${ReadStatus.Read},
seenStatus = ${SeenStatus.Seen},
json = json_patch(json, '{ something: "one" }')
WHERE
conversationId = 'id-conversation-4' AND
seenStatus = ${SeenStatus.Unseen} AND
isStory = 0 AND
storyId IS 'id-story-4' AND
received_at <= 2343233;
`
)
.all()
.map(({ detail }) => detail)
.join('\n');
assert.include(
second,
'USING INDEX messages_unseen_with_story',
'second'
);
assert.notInclude(second, 'TEMP B-TREE', 'second');
assert.notInclude(second, 'SCAN', 'second');
});
it('creates usable index for getTotalUnseenForConversationSync', () => {
updateToVersion(56);
const first = db
.prepare(
`
EXPLAIN QUERY PLAN
SELECT count(id)
FROM messages
WHERE
conversationId = 'id-conversation-4' AND
seenStatus = ${SeenStatus.Unseen} AND
isStory IS 0 AND
NULL IS NULL;
`
)
.all()
.map(({ detail }) => detail)
.join('\n');
// Weird, but we don't included received_at so it doesn't really matter
assert.include(first, 'USING INDEX messages_unseen_with_story', 'first');
assert.notInclude(first, 'TEMP B-TREE', 'first');
assert.notInclude(first, 'SCAN', 'first');
const second = db
.prepare(
`
EXPLAIN QUERY PLAN
SELECT count(id)
FROM messages
WHERE
conversationId = 'id-conversation-4' AND
seenStatus = ${SeenStatus.Unseen} AND
isStory IS 0 AND
storyId IS 'id-story-4';
`
)
.all()
.map(({ detail }) => detail)
.join('\n');
assert.include(
second,
'USING INDEX messages_unseen_with_story',
'second'
);
assert.notInclude(second, 'TEMP B-TREE', 'second');
assert.notInclude(second, 'SCAN', 'second');
});
});
}); });

View file

@ -1,13 +0,0 @@
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageAttributesType } from '../model-types.d';
import { getSource, getSourceDevice, getSourceUuid } from '../messages/helpers';
export function getMessageIdForLogging(message: MessageAttributesType): string {
const account = getSourceUuid(message) || getSource(message);
const device = getSourceDevice(message);
const timestamp = message.sent_at;
return `${account}.${device} ${timestamp}`;
}

31
ts/util/idForLogging.ts Normal file
View file

@ -0,0 +1,31 @@
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type {
ConversationAttributesType,
MessageAttributesType,
} from '../model-types.d';
import { getSource, getSourceDevice, getSourceUuid } from '../messages/helpers';
import { isDirectConversation, isGroupV2 } from './whatTypeOfConversation';
export function getMessageIdForLogging(message: MessageAttributesType): string {
const account = getSourceUuid(message) || getSource(message);
const device = getSourceDevice(message);
const timestamp = message.sent_at;
return `${account}.${device} ${timestamp}`;
}
export function getConversationIdForLogging(
conversation: ConversationAttributesType
): string {
if (isDirectConversation(conversation)) {
const { uuid, e164, id } = conversation;
return `${uuid || e164} (${id})`;
}
if (isGroupV2(conversation)) {
return `groupv2(${conversation.groupId})`;
}
return `group(${conversation.groupId})`;
}

View file

@ -8,11 +8,16 @@ import { readSyncJobQueue } from '../jobs/readSyncJobQueue';
import { notificationService } from '../services/notifications'; import { notificationService } from '../services/notifications';
import { isGroup } from './whatTypeOfConversation'; import { isGroup } from './whatTypeOfConversation';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { getConversationIdForLogging } from './idForLogging';
export async function markConversationRead( export async function markConversationRead(
conversationAttrs: ConversationAttributesType, conversationAttrs: ConversationAttributesType,
newestUnreadAt: number, newestUnreadAt: number,
options: { readAt?: number; sendReadReceipts: boolean } = { options: {
readAt?: number;
sendReadReceipts: boolean;
newestSentAt?: number;
} = {
sendReadReceipts: true, sendReadReceipts: true,
} }
): Promise<boolean> { ): Promise<boolean> {
@ -32,7 +37,8 @@ export async function markConversationRead(
]); ]);
log.info('markConversationRead', { log.info('markConversationRead', {
conversationId, conversationId: getConversationIdForLogging(conversationAttrs),
newestSentAt: options.newestSentAt,
newestUnreadAt, newestUnreadAt,
unreadMessages: unreadMessages.length, unreadMessages: unreadMessages.length,
unreadReactions: unreadReactions.length, unreadReactions: unreadReactions.length,

View file

@ -13,7 +13,7 @@ import type {
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads'; import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { isLongMessage } from '../types/MIME'; import { isLongMessage } from '../types/MIME';
import { getMessageIdForLogging } from './getMessageIdForLogging'; import { getMessageIdForLogging } from './idForLogging';
import { import {
copyStickerToAttachments, copyStickerToAttachments,
savePackMetadata, savePackMetadata,

View file

@ -134,7 +134,7 @@ type ScrollAnchorBeforeUpdateProps = Readonly<
| 'isSomeoneTyping' | 'isSomeoneTyping'
| 'items' | 'items'
| 'messageLoadingState' | 'messageLoadingState'
| 'oldestUnreadIndex' | 'oldestUnseenIndex'
| 'scrollToIndex' | 'scrollToIndex'
| 'scrollToIndexCounter' | 'scrollToIndexCounter'
> >
@ -169,7 +169,7 @@ export function getScrollAnchorBeforeUpdate(
if (props.isIncomingMessageRequest) { if (props.isIncomingMessageRequest) {
return ScrollAnchor.ChangeNothing; return ScrollAnchor.ChangeNothing;
} }
if (isNumber(props.oldestUnreadIndex)) { if (isNumber(props.oldestUnseenIndex)) {
return ScrollAnchor.ScrollToUnreadIndicator; return ScrollAnchor.ScrollToUnreadIndicator;
} }
return ScrollAnchor.ScrollToBottom; return ScrollAnchor.ScrollToBottom;

View file

@ -493,7 +493,10 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
throw new Error(`markMessageRead: failed to load message ${messageId}`); throw new Error(`markMessageRead: failed to load message ${messageId}`);
} }
await this.model.markRead(message.get('received_at')); await this.model.markRead(message.get('received_at'), {
newestSentAt: message.get('sent_at'),
sendReadReceipts: true,
});
}; };
const createMessageRequestResponseHandler = const createMessageRequestResponseHandler =