Sync mute state

This commit is contained in:
Josh Perez 2021-04-09 09:19:38 -07:00 committed by GitHub
parent 15247e1c9a
commit 6c0acd09df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 236 additions and 61 deletions

View file

@ -3328,6 +3328,10 @@
"message": "Mute for one hour",
"description": "Label for muting the conversation"
},
"muteEightHours": {
"message": "Mute for eight hours",
"description": "Label for muting the conversation"
},
"muteDay": {
"message": "Mute for one day",
"description": "Label for muting the conversation"
@ -3336,14 +3340,18 @@
"message": "Mute for one week",
"description": "Label for muting the conversation"
},
"muteYear": {
"message": "Mute for one year",
"muteAlways": {
"message": "Mute always",
"description": "Label for muting the conversation"
},
"unmute": {
"message": "Unmute",
"description": "Label for unmuting the conversation"
},
"muteExpirationLabelAlways": {
"message": "Muted always",
"description": "Shown in the mute notifications submenu whenever a conversation has been muted"
},
"muteExpirationLabel": {
"message": "Muted until $duration$",
"description": "Shown in the mute notifications submenu whenever a conversation has been muted",

View file

@ -62,34 +62,37 @@ message ContactRecord {
UNVERIFIED = 2;
}
optional string serviceUuid = 1;
optional string serviceE164 = 2;
optional bytes profileKey = 3;
optional bytes identityKey = 4;
optional IdentityState identityState = 5;
optional string givenName = 6;
optional string familyName = 7;
optional string username = 8;
optional bool blocked = 9;
optional bool whitelisted = 10;
optional bool archived = 11;
optional bool markedUnread = 12;
optional string serviceUuid = 1;
optional string serviceE164 = 2;
optional bytes profileKey = 3;
optional bytes identityKey = 4;
optional IdentityState identityState = 5;
optional string givenName = 6;
optional string familyName = 7;
optional string username = 8;
optional bool blocked = 9;
optional bool whitelisted = 10;
optional bool archived = 11;
optional bool markedUnread = 12;
optional uint64 mutedUntilTimestamp = 13;
}
message GroupV1Record {
optional bytes id = 1;
optional bool blocked = 2;
optional bool whitelisted = 3;
optional bool archived = 4;
optional bool markedUnread = 5;
optional bytes id = 1;
optional bool blocked = 2;
optional bool whitelisted = 3;
optional bool archived = 4;
optional bool markedUnread = 5;
optional uint64 mutedUntilTimestamp = 6;
}
message GroupV2Record {
optional bytes masterKey = 1;
optional bool blocked = 2;
optional bool whitelisted = 3;
optional bool archived = 4;
optional bool markedUnread = 5;
optional bytes masterKey = 1;
optional bool blocked = 2;
optional bool whitelisted = 3;
optional bool archived = 4;
optional bool markedUnread = 5;
optional uint64 mutedUntilTimestamp = 6;
}
message AccountRecord {

View file

@ -4,6 +4,7 @@
/* eslint-disable no-console */
const ByteBuffer = require('../components/bytebuffer/dist/ByteBufferAB.js');
const Long = require('../components/long/dist/Long.js');
const { setEnvironment, Environment } = require('../ts/environment');
setEnvironment(Environment.Test);
@ -18,6 +19,7 @@ global.window = {
i18n: key => `i18n(${key})`,
dcodeIO: {
ByteBuffer,
Long,
},
};

View file

@ -239,6 +239,22 @@ const stories: Array<ConversationHeaderStory> = [
outgoingCallButtonStyle: OutgoingCallButtonStyle.Join,
},
},
{
title: 'In a forever muted group',
props: {
...commonProps,
color: 'signal-blue',
title: 'Way too many messages',
name: 'Way too many messages',
phoneNumber: '',
id: '1',
type: 'group',
expireTimer: 10,
acceptedMessageRequest: true,
outgoingCallButtonStyle: OutgoingCallButtonStyle.JustVideo,
muteExpiresAt: Infinity,
},
},
],
},
{

View file

@ -378,14 +378,24 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
const muteOptions: Array<MuteOption> = [];
if (isMuted(muteExpiresAt)) {
const expires = moment(muteExpiresAt);
const muteExpirationLabel = moment().isSame(expires, 'day')
? expires.format('hh:mm A')
: expires.format('M/D/YY, hh:mm A');
let muteExpirationLabel: string;
if (Number(muteExpiresAt) >= Number.MAX_SAFE_INTEGER) {
muteExpirationLabel = i18n('muteExpirationLabelAlways');
} else {
const muteExpirationUntil = moment().isSame(expires, 'day')
? expires.format('hh:mm A')
: expires.format('M/D/YY, hh:mm A');
muteExpirationLabel = i18n('muteExpirationLabel', [
muteExpirationUntil,
]);
}
muteOptions.push(
...[
{
name: i18n('muteExpirationLabel', [muteExpirationLabel]),
name: muteExpirationLabel,
disabled: true,
value: 0,
},

View file

@ -121,9 +121,9 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
/* eslint-disable no-nested-ternary */
messageText = (
<>
{muteExpiresAt && Date.now() < muteExpiresAt && (
{muteExpiresAt && Date.now() < muteExpiresAt ? (
<span className={`${MESSAGE_TEXT_CLASS_NAME}__muted`} />
)}
) : null}
{!acceptedMessageRequest ? (
<span className={`${MESSAGE_TEXT_CLASS_NAME}__message-request`}>
{i18n('ConversationListItem--message-request')}

View file

@ -5021,6 +5021,42 @@ export class ConversationModel extends window.Backbone.Model<
});
}
setMuteExpiration(
muteExpiresAt = 0,
{ viaStorageServiceSync = false } = {}
): void {
const prevExpiration = this.get('muteExpiresAt');
if (prevExpiration === muteExpiresAt) {
return;
}
// we use a timeoutId here so that we can reference the mute that was
// potentially set in the ConversationController. Specifically for a
// scenario where a conversation is already muted and we boot up the app,
// a timeout will be already set. But if we change the mute to a later
// date a new timeout would need to be set and the old one cleared. With
// this ID we can reference the existing timeout.
const timeoutId = this.getMuteTimeoutId();
window.Signal.Services.removeTimeout(timeoutId);
if (muteExpiresAt && muteExpiresAt < Number.MAX_SAFE_INTEGER) {
window.Signal.Services.onTimeout(
muteExpiresAt,
() => {
this.setMuteExpiration(0);
},
timeoutId
);
}
this.set({ muteExpiresAt });
if (!viaStorageServiceSync) {
this.captureChange('mutedUntilTimestamp');
}
window.Signal.Data.updateConversation(this.attributes);
}
isMuted(): boolean {
return isMuted(this.get('muteExpiresAt'));
}

View file

@ -626,7 +626,8 @@ async function mergeRecord(
window.log.error(
'storageService.mergeRecord: Error with',
redactStorageID(storageID),
itemType
itemType,
String(err)
);
}

View file

@ -34,6 +34,10 @@ import {
} from '../util/phoneNumberDiscoverability';
import { arePinnedConversationsEqual } from '../util/arePinnedConversationsEqual';
import { ConversationModel } from '../models/conversations';
import {
getSafeLongFromTimestamp,
getTimestampFromLong,
} from '../util/timestampLongUtils';
const { updateConversation } = dataInterface;
@ -131,6 +135,9 @@ export async function toContactRecord(
contactRecord.whitelisted = Boolean(conversation.get('profileSharing'));
contactRecord.archived = Boolean(conversation.get('isArchived'));
contactRecord.markedUnread = Boolean(conversation.get('markedUnread'));
contactRecord.mutedUntilTimestamp = getSafeLongFromTimestamp(
conversation.get('muteExpiresAt')
);
applyUnknownFields(contactRecord, conversation);
@ -278,6 +285,9 @@ export async function toGroupV1Record(
groupV1Record.whitelisted = Boolean(conversation.get('profileSharing'));
groupV1Record.archived = Boolean(conversation.get('isArchived'));
groupV1Record.markedUnread = Boolean(conversation.get('markedUnread'));
groupV1Record.mutedUntilTimestamp = getSafeLongFromTimestamp(
conversation.get('muteExpiresAt')
);
applyUnknownFields(groupV1Record, conversation);
@ -297,6 +307,9 @@ export async function toGroupV2Record(
groupV2Record.whitelisted = Boolean(conversation.get('profileSharing'));
groupV2Record.archived = Boolean(conversation.get('isArchived'));
groupV2Record.markedUnread = Boolean(conversation.get('markedUnread'));
groupV2Record.mutedUntilTimestamp = getSafeLongFromTimestamp(
conversation.get('muteExpiresAt')
);
applyUnknownFields(groupV2Record, conversation);
@ -522,6 +535,13 @@ export async function mergeGroupV1Record(
storageID,
});
conversation.setMuteExpiration(
getTimestampFromLong(groupV1Record.mutedUntilTimestamp),
{
viaStorageServiceSync: true,
}
);
applyMessageRequestState(groupV1Record, conversation);
let hasPendingChanges: boolean;
@ -622,6 +642,13 @@ export async function mergeGroupV2Record(
storageID,
});
conversation.setMuteExpiration(
getTimestampFromLong(groupV2Record.mutedUntilTimestamp),
{
viaStorageServiceSync: true,
}
);
applyMessageRequestState(groupV2Record, conversation);
addUnknownFields(groupV2Record, conversation);
@ -731,6 +758,13 @@ export async function mergeContactRecord(
storageID,
});
conversation.setMuteExpiration(
getTimestampFromLong(contactRecord.mutedUntilTimestamp),
{
viaStorageServiceSync: true,
}
);
const hasPendingChanges = doesRecordHavePendingChanges(
await toContactRecord(conversation),
contactRecord,

View file

@ -59,10 +59,12 @@ export function onTimeout(
}
export function removeTimeout(uuid: string): void {
if (timeoutStore.has(uuid)) {
timeoutStore.delete(uuid);
if (!timeoutStore.has(uuid)) {
return;
}
timeoutStore.delete(uuid);
allTimeouts.forEach((timeout: TimeoutType) => {
if (uuid === timeout.uuid) {
allTimeouts.delete(timeout);

View file

@ -0,0 +1,51 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
getSafeLongFromTimestamp,
getTimestampFromLong,
} from '../../util/timestampLongUtils';
describe('getSafeLongFromTimestamp', () => {
const { Long } = window.dcodeIO;
it('returns zero when passed undefined', () => {
assert(getSafeLongFromTimestamp(undefined).isZero());
});
it('returns the number as a Long when passed a "normal" number', () => {
assert(getSafeLongFromTimestamp(0).isZero());
assert.strictEqual(getSafeLongFromTimestamp(123).toString(), '123');
assert.strictEqual(getSafeLongFromTimestamp(-456).toString(), '-456');
});
it('returns Long.MAX_VALUE when passed Infinity', () => {
assert(getSafeLongFromTimestamp(Infinity).equals(Long.MAX_VALUE));
});
it("returns Long.MAX_VALUE when passed very large numbers, outside of JavaScript's safely representable range", () => {
assert.equal(getSafeLongFromTimestamp(Number.MAX_VALUE), Long.MAX_VALUE);
});
});
describe('getTimestampFromLong', () => {
const { Long } = window.dcodeIO;
it('returns zero when passed 0 Long', () => {
assert.equal(getTimestampFromLong(Long.fromNumber(0)), 0);
});
it('returns Number.MAX_SAFE_INTEGER when passed Long.MAX_VALUE', () => {
assert.equal(getTimestampFromLong(Long.MAX_VALUE), Number.MAX_SAFE_INTEGER);
});
it('returns a normal number', () => {
assert.equal(getTimestampFromLong(Long.fromNumber(16)), 16);
});
it('returns 0 for null value', () => {
assert.equal(getTimestampFromLong(null), 0);
});
});

3
ts/textsecure.d.ts vendored
View file

@ -1057,6 +1057,7 @@ export declare class ContactRecordClass {
whitelisted?: boolean | null;
archived?: boolean | null;
markedUnread?: boolean;
mutedUntilTimestamp?: ProtoBigNumberType;
__unknownFields?: ArrayBuffer;
}
@ -1073,6 +1074,7 @@ export declare class GroupV1RecordClass {
whitelisted?: boolean | null;
archived?: boolean | null;
markedUnread?: boolean;
mutedUntilTimestamp?: ProtoBigNumberType;
__unknownFields?: ArrayBuffer;
}
@ -1089,6 +1091,7 @@ export declare class GroupV2RecordClass {
whitelisted?: boolean | null;
archived?: boolean | null;
markedUnread?: boolean;
mutedUntilTimestamp?: ProtoBigNumberType;
__unknownFields?: ArrayBuffer;
}

View file

@ -16,6 +16,10 @@ export function getMuteOptions(i18n: LocalizerType): Array<MuteOption> {
name: i18n('muteHour'),
value: moment.duration(1, 'hour').as('milliseconds'),
},
{
name: i18n('muteEightHours'),
value: moment.duration(8, 'hour').as('milliseconds'),
},
{
name: i18n('muteDay'),
value: moment.duration(1, 'day').as('milliseconds'),
@ -25,8 +29,8 @@ export function getMuteOptions(i18n: LocalizerType): Array<MuteOption> {
value: moment.duration(1, 'week').as('milliseconds'),
},
{
name: i18n('muteYear'),
value: moment.duration(1, 'year').as('milliseconds'),
name: i18n('muteAlways'),
value: Number.MAX_SAFE_INTEGER,
},
];
}

View file

@ -0,0 +1,26 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { Long } from '../window.d';
export function getSafeLongFromTimestamp(timestamp = 0): Long {
if (timestamp >= Number.MAX_SAFE_INTEGER) {
return window.dcodeIO.Long.MAX_VALUE;
}
return window.dcodeIO.Long.fromNumber(timestamp);
}
export function getTimestampFromLong(value: Long | null): number {
if (!value) {
return 0;
}
const num = value.toNumber();
if (num >= Number.MAX_SAFE_INTEGER) {
return Number.MAX_SAFE_INTEGER;
}
return num;
}

View file

@ -478,7 +478,10 @@ Whisper.ConversationView = Whisper.View.extend({
: this.model.getTitle();
searchInConversation(this.model.id, name);
},
onSetMuteNotifications: (ms: number) => this.setMuteNotifications(ms),
onSetMuteNotifications: (ms: number) =>
this.model.setMuteExpiration(
ms >= Number.MAX_SAFE_INTEGER ? ms : Date.now() + ms
),
onSetPin: this.setPin.bind(this),
// These are view only and don't update the Conversation model, so they
// need a manual update call.
@ -3162,31 +3165,6 @@ Whisper.ConversationView = Whisper.View.extend({
});
},
setMuteNotifications(ms: number) {
const muteExpiresAt = ms > 0 ? Date.now() + ms : undefined;
if (muteExpiresAt) {
// we use a timeoutId here so that we can reference the mute that was
// potentially set in the ConversationController. Specifically for a
// scenario where a conversation is already muted and we boot up the app,
// a timeout will be already set. But if we change the mute to a later
// date a new timeout would need to be set and the old one cleared. With
// this ID we can reference the existing timeout.
const timeoutId = this.model.getMuteTimeoutId();
window.Signal.Services.removeTimeout(timeoutId);
window.Signal.Services.onTimeout(
muteExpiresAt,
() => {
this.setMuteNotifications(0);
},
timeoutId
);
}
this.model.set({ muteExpiresAt });
this.saveModel();
},
async destroyMessages() {
window.showConfirmationDialog({
message: window.i18n('deleteConversationConfirmation'),

1
ts/window.d.ts vendored
View file

@ -573,6 +573,7 @@ export type DCodeIOType = {
Long: DCodeIOType['Long'];
};
Long: Long & {
MAX_VALUE: Long;
equals: (other: Long | number | string) => boolean;
fromBits: (low: number, high: number, unsigned: boolean) => number;
fromNumber: (value: number, unsigned?: boolean) => Long;