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 { getInitialState } from './state/getInitialState';
import { conversationJobQueue } from './jobs/conversationJobQueue';
import { SeenStatus } from './MessageSeenStatus';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
@ -3052,22 +3053,24 @@ export async function startApp(): Promise<void> {
}
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,
timestamp,
type: 'outgoing',
sendStateByConversationId,
unidentifiedDeliveries,
expirationStartTimestamp: Math.min(
data.expirationStartTimestamp || timestamp,
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);
}
@ -3316,6 +3319,7 @@ export async function startApp(): Promise<void> {
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
type: data.message.isStory ? 'story' : 'incoming',
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
timestamp: data.timestamp,
} as Partial<MessageAttributesType> as WhatIsThis);
}

View File

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

View File

@ -88,10 +88,10 @@ export type PropsDataType = {
messageLoadingState?: TimelineMessageLoadingState;
isNearBottom?: boolean;
items: ReadonlyArray<string>;
oldestUnreadIndex?: number;
oldestUnseenIndex?: number;
scrollToIndex?: number;
scrollToIndexCounter: number;
totalUnread: number;
totalUnseen: number;
};
type PropsHousekeepingType = {
@ -342,7 +342,7 @@ export class Timeline extends React.Component<
items,
loadNewestMessages,
messageLoadingState,
oldestUnreadIndex,
oldestUnseenIndex,
selectMessage,
} = this.props;
const { newestBottomVisibleMessageId } = this.state;
@ -358,15 +358,15 @@ export class Timeline extends React.Component<
if (
newestBottomVisibleMessageId &&
isNumber(oldestUnreadIndex) &&
isNumber(oldestUnseenIndex) &&
items.findIndex(item => item === newestBottomVisibleMessageId) <
oldestUnreadIndex
oldestUnseenIndex
) {
if (setFocus) {
const messageId = items[oldestUnreadIndex];
const messageId = items[oldestUnseenIndex];
selectMessage(messageId, id);
} else {
this.scrollToItemIndex(oldestUnreadIndex);
this.scrollToItemIndex(oldestUnseenIndex);
}
} else if (haveNewest) {
this.scrollToBottom(setFocus);
@ -790,7 +790,7 @@ export class Timeline extends React.Component<
isSomeoneTyping,
items,
messageLoadingState,
oldestUnreadIndex,
oldestUnseenIndex,
onBlock,
onBlockAndReportSpam,
onDelete,
@ -804,7 +804,7 @@ export class Timeline extends React.Component<
reviewMessageRequestNameCollision,
showContactModal,
theme,
totalUnread,
totalUnseen,
unblurAvatar,
unreadCount,
updateSharedGroups,
@ -898,17 +898,17 @@ export class Timeline extends React.Component<
}
let unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement;
if (oldestUnreadIndex === itemIndex) {
if (oldestUnseenIndex === itemIndex) {
unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove;
messageNodes.push(
<LastSeenIndicator
key="last seen indicator"
count={totalUnread}
count={totalUnseen}
i18n={i18n}
ref={this.lastSeenIndicatorRef}
/>
);
} else if (oldestUnreadIndex === nextItemIndex) {
} else if (oldestUnseenIndex === nextItemIndex) {
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 MemberRoleEnum = Proto.Member.Role;
import { SeenStatus } from './MessageSeenStatus';
export type WhatIsThis = any;
@ -219,8 +220,10 @@ export type MessageAttributesType = {
sendHQImages?: boolean;
// Should only be present for incoming messages
// Should only be present for incoming messages and errors
readStatus?: ReadStatus;
// Used for all kinds of notifications, as well as incoming messages
seenStatus?: SeenStatus;
// Should only be present for outgoing messages
sendStateByConversationId?: SendStateByConversationId;

View File

@ -117,6 +117,8 @@ import { isMessageUnread } from '../util/isMessageUnread';
import type { SenderKeyTargetType } from '../util/sendToGroup';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { TimelineMessageLoadingState } from '../util/timelineUtil';
import { SeenStatus } from '../MessageSeenStatus';
import { getConversationIdForLogging } from '../util/idForLogging';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@ -237,17 +239,7 @@ export class ConversationModel extends window.Backbone
}
idForLogging(): string {
if (isDirectConversation(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})`;
return getConversationIdForLogging(this.attributes);
}
// 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;
}
if (scrollToLatestUnread && metrics.oldestUnread) {
this.loadAndScroll(metrics.oldestUnread.id, {
if (scrollToLatestUnread && metrics.oldestUnseen) {
this.loadAndScroll(metrics.oldestUnseen.id, {
disableScroll: !setFocus,
});
return;
@ -2926,6 +2918,7 @@ export class ConversationModel extends window.Backbone
received_at: receivedAtCounter,
received_at_ms: receivedAt,
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
// TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to
} as unknown as MessageAttributesType;
@ -2968,6 +2961,7 @@ export class ConversationModel extends window.Backbone
received_at: receivedAtCounter,
received_at_ms: receivedAt,
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
// TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to
} as unknown as MessageAttributesType;
@ -3004,7 +2998,8 @@ export class ConversationModel extends window.Backbone
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp,
key_changed: keyChangedId.toString(),
readStatus: ReadStatus.Unread,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
// TODO: DESKTOP-722
// 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 message = {
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,
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
} as unknown as MessageAttributesType;
@ -3128,6 +3124,7 @@ export class ConversationModel extends window.Backbone
receivedAtCounter || window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp,
readStatus: unread ? ReadStatus.Unread : ReadStatus.Read,
seenStatus: unread ? SeenStatus.Unseen : SeenStatus.NotApplicable,
callHistoryDetails: detailsToSave,
// TODO: DESKTOP-722
} as unknown as MessageAttributesType;
@ -3192,6 +3189,7 @@ export class ConversationModel extends window.Backbone
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.NotApplicable,
changedId: conversationId || this.id,
profileChange,
// TODO: DESKTOP-722
@ -3228,14 +3226,15 @@ export class ConversationModel extends window.Backbone
): Promise<string> {
const now = Date.now();
const message: Partial<MessageAttributesType> = {
...extra,
conversationId: this.id,
type,
sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.NotApplicable,
...extra,
};
const id = await window.Signal.Data.saveMessage(
@ -3363,6 +3362,8 @@ export class ConversationModel extends window.Backbone
await Promise.all(
convos.map(convo => {
return convo.addNotification('change-number-notification', {
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
sourceUuid: sourceUuid.toString(),
});
})
@ -4037,6 +4038,8 @@ export class ConversationModel extends window.Backbone
received_at_ms: now,
expireTimer,
recipients,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.NotApplicable,
sticker,
bodyRanges: mentions,
sendHQImages,
@ -4546,21 +4549,20 @@ export class ConversationModel extends window.Backbone
window.Signal.Data.updateConversation(this.attributes);
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,
// 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: {
expireTimer,
source,
fromSync: options.fromSync,
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
} as unknown as MessageAttributesType);
@ -4589,9 +4591,8 @@ export class ConversationModel extends window.Backbone
const model = new window.Whisper.Message({
type: 'message-history-unsynced',
// 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,
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.NotApplicable,
conversationId: this.id,
sent_at: timestamp,
received_at: window.Signal.Util.incrementMessageCounter(),
@ -4633,12 +4634,14 @@ export class ConversationModel extends window.Backbone
window.Signal.Data.updateConversation(this.attributes);
const model = new window.Whisper.Message({
group_update: { left: 'You' },
conversationId: this.id,
type: 'outgoing',
sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(),
group_update: { left: 'You' },
readStatus: ReadStatus.Read,
received_at_ms: now,
received_at: window.Signal.Util.incrementMessageCounter(),
seenStatus: SeenStatus.NotApplicable,
sent_at: now,
type: 'group',
// TODO: DESKTOP-722
} as unknown as MessageAttributesType);
@ -4665,7 +4668,11 @@ export class ConversationModel extends window.Backbone
async markRead(
newestUnreadAt: number,
options: { readAt?: number; sendReadReceipts: boolean } = {
options: {
readAt?: number;
sendReadReceipts: boolean;
newestSentAt?: number;
} = {
sendReadReceipts: true,
}
): Promise<void> {

View File

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

View File

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

View File

@ -110,6 +110,7 @@ import type {
UnprocessedType,
UnprocessedUpdateType,
} from './Interface';
import { SeenStatus } from '../MessageSeenStatus';
type ConversationRow = Readonly<{
json: string;
@ -1737,6 +1738,20 @@ function saveMessageSync(
expireTimer,
expirationStartTimestamp,
} = 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 = {
id,
@ -1762,6 +1777,7 @@ function saveMessageSync(
storyId: storyId || null,
type: type || null,
readStatus: readStatus ?? null,
seenStatus: seenStatus ?? SeenStatus.NotApplicable,
};
if (id && !forceSave) {
@ -1791,7 +1807,8 @@ function saveMessageSync(
sourceDevice = $sourceDevice,
storyId = $storyId,
type = $type,
readStatus = $readStatus
readStatus = $readStatus,
seenStatus = $seenStatus
WHERE id = $id;
`
).run(payload);
@ -1834,7 +1851,8 @@ function saveMessageSync(
sourceDevice,
storyId,
type,
readStatus
readStatus,
seenStatus
) values (
$id,
$json,
@ -1858,7 +1876,8 @@ function saveMessageSync(
$sourceDevice,
$storyId,
$type,
$readStatus
$readStatus,
$seenStatus
);
`
).run({
@ -2110,16 +2129,21 @@ async function getUnreadByConversationAndMarkRead({
UPDATE messages
SET
readStatus = ${ReadStatus.Read},
seenStatus = ${SeenStatus.Seen},
json = json_patch(json, $jsonPatch)
WHERE
readStatus = ${ReadStatus.Unread} AND
conversationId = $conversationId AND
seenStatus = ${SeenStatus.Unseen} AND
isStory = 0 AND
(${_storyIdPredicate(storyId, isGroup)}) AND
received_at <= $newestUnreadAt;
`
).run({
conversationId,
jsonPatch: JSON.stringify({ readStatus: ReadStatus.Read }),
jsonPatch: JSON.stringify({
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
}),
newestUnreadAt,
storyId: storyId || null,
});
@ -2644,7 +2668,7 @@ async function getLastConversationMessage({
return jsonToObject(row.json);
}
function getOldestUnreadMessageForConversation(
function getOldestUnseenMessageForConversation(
conversationId: string,
storyId?: UUIDStringType,
isGroup?: boolean
@ -2655,7 +2679,7 @@ function getOldestUnreadMessageForConversation(
`
SELECT * FROM messages WHERE
conversationId = $conversationId AND
readStatus = ${ReadStatus.Unread} AND
seenStatus = ${SeenStatus.Unseen} AND
isStory IS 0 AND
(${_storyIdPredicate(storyId, isGroup)})
ORDER BY received_at ASC, sent_at ASC
@ -2709,6 +2733,35 @@ function getTotalUnreadForConversationSync(
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(
conversationId: string,
@ -2732,12 +2785,12 @@ function getMessageMetricsForConversationSync(
storyId,
isGroup
);
const oldestUnread = getOldestUnreadMessageForConversation(
const oldestUnseen = getOldestUnseenMessageForConversation(
conversationId,
storyId,
isGroup
);
const totalUnread = getTotalUnreadForConversationSync(
const totalUnseen = getTotalUnseenForConversationSync(
conversationId,
storyId,
isGroup
@ -2746,10 +2799,10 @@ function getMessageMetricsForConversationSync(
return {
oldest: oldest ? pick(oldest, ['received_at', 'sent_at', 'id']) : undefined,
newest: newest ? pick(newest, ['received_at', 'sent_at', 'id']) : undefined,
oldestUnread: oldestUnread
? pick(oldestUnread, ['received_at', 'sent_at', 'id'])
oldestUnseen: oldestUnseen
? pick(oldestUnseen, ['received_at', 'sent_at', 'id'])
: 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 updateToSchemaVersion54 from './54-unprocessed-received-at-counter';
import updateToSchemaVersion55 from './55-report-message-aci';
import updateToSchemaVersion56 from './56-add-unseen-to-message';
function updateToSchemaVersion1(
currentVersion: number,
@ -1925,6 +1926,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion53,
updateToSchemaVersion54,
updateToSchemaVersion55,
updateToSchemaVersion56,
];
export function updateSchema(db: Database, logger: LoggerType): void {

View File

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

View File

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

View File

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

View File

@ -692,9 +692,9 @@ describe('sql/timelineFetches', () => {
received_at: target - 8,
timestamp: target - 8,
};
const oldestUnread: MessageAttributesType = {
const oldestUnseen: MessageAttributesType = {
id: getUuid(),
body: 'oldestUnread',
body: 'oldestUnseen',
type: 'incoming',
conversationId,
sent_at: target - 7,
@ -748,7 +748,7 @@ describe('sql/timelineFetches', () => {
story,
oldestInStory,
oldest,
oldestUnread,
oldestUnseen,
oldestStoryUnread,
anotherUnread,
newestInStory,
@ -769,11 +769,11 @@ describe('sql/timelineFetches', () => {
);
assert.strictEqual(metricsInTimeline?.newest?.id, newest.id, 'newest');
assert.strictEqual(
metricsInTimeline?.oldestUnread?.id,
oldestUnread.id,
'oldestUnread'
metricsInTimeline?.oldestUnseen?.id,
oldestUnseen.id,
'oldestUnseen'
);
assert.strictEqual(metricsInTimeline?.totalUnread, 3, 'totalUnread');
assert.strictEqual(metricsInTimeline?.totalUnseen, 3, 'totalUnseen');
const metricsInStory = await getMessageMetricsForConversation(
conversationId,
@ -790,11 +790,11 @@ describe('sql/timelineFetches', () => {
'newestInStory'
);
assert.strictEqual(
metricsInStory?.oldestUnread?.id,
metricsInStory?.oldestUnseen?.id,
oldestStoryUnread.id,
'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 {
messageIds: [],
metrics: {
totalUnread: 0,
totalUnseen: 0,
},
scrollToMessageCounter: 0,
};
@ -1008,7 +1008,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(),
messageIds: [messageIdThree, messageIdTwo, messageId],
metrics: {
totalUnread: 0,
totalUnseen: 0,
},
},
},
@ -1028,7 +1028,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(),
messageIds: [messageIdThree, messageIdTwo, messageId],
metrics: {
totalUnread: 0,
totalUnseen: 0,
newest: {
id: messageId,
received_at: time,
@ -1058,7 +1058,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(),
messageIds: [],
metrics: {
totalUnread: 0,
totalUnseen: 0,
newest: {
id: messageId,
received_at: time,
@ -1082,7 +1082,7 @@ describe('both/state/ducks/conversations', () => {
messageIds: [],
metrics: {
newest: undefined,
totalUnread: 0,
totalUnseen: 0,
},
},
},
@ -1118,7 +1118,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(),
messageIds: [messageId, messageIdTwo, messageIdThree],
metrics: {
totalUnread: 0,
totalUnseen: 0,
},
},
},
@ -1138,7 +1138,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(),
messageIds: [messageId, messageIdTwo, messageIdThree],
metrics: {
totalUnread: 0,
totalUnseen: 0,
oldest: {
id: messageId,
received_at: time,
@ -1168,7 +1168,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultConversationMessage(),
messageIds: [],
metrics: {
totalUnread: 0,
totalUnseen: 0,
oldest: {
id: messageId,
received_at: time,
@ -1192,7 +1192,7 @@ describe('both/state/ducks/conversations', () => {
messageIds: [],
metrics: {
oldest: undefined,
totalUnread: 0,
totalUnseen: 0,
},
},
},

View File

@ -13,6 +13,8 @@ import {
insertJobSync,
_storyIdPredicate,
} from '../sql/Server';
import { ReadStatus } from '../messages/MessageReadStatus';
import { SeenStatus } from '../MessageSeenStatus';
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 { isGroup } from './whatTypeOfConversation';
import * as log from '../logging/log';
import { getConversationIdForLogging } from './idForLogging';
export async function markConversationRead(
conversationAttrs: ConversationAttributesType,
newestUnreadAt: number,
options: { readAt?: number; sendReadReceipts: boolean } = {
options: {
readAt?: number;
sendReadReceipts: boolean;
newestSentAt?: number;
} = {
sendReadReceipts: true,
}
): Promise<boolean> {
@ -32,7 +37,8 @@ export async function markConversationRead(
]);
log.info('markConversationRead', {
conversationId,
conversationId: getConversationIdForLogging(conversationAttrs),
newestSentAt: options.newestSentAt,
newestUnreadAt,
unreadMessages: unreadMessages.length,
unreadReactions: unreadReactions.length,

View File

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

View File

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