Sync mute state
This commit is contained in:
parent
15247e1c9a
commit
6c0acd09df
16 changed files with 236 additions and 61 deletions
|
@ -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",
|
||||
|
|
|
@ -74,6 +74,7 @@ message ContactRecord {
|
|||
optional bool whitelisted = 10;
|
||||
optional bool archived = 11;
|
||||
optional bool markedUnread = 12;
|
||||
optional uint64 mutedUntilTimestamp = 13;
|
||||
}
|
||||
|
||||
message GroupV1Record {
|
||||
|
@ -82,6 +83,7 @@ message GroupV1Record {
|
|||
optional bool whitelisted = 3;
|
||||
optional bool archived = 4;
|
||||
optional bool markedUnread = 5;
|
||||
optional uint64 mutedUntilTimestamp = 6;
|
||||
}
|
||||
|
||||
message GroupV2Record {
|
||||
|
@ -90,6 +92,7 @@ message GroupV2Record {
|
|||
optional bool whitelisted = 3;
|
||||
optional bool archived = 4;
|
||||
optional bool markedUnread = 5;
|
||||
optional uint64 mutedUntilTimestamp = 6;
|
||||
}
|
||||
|
||||
message AccountRecord {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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')
|
||||
|
||||
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,
|
||||
},
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
@ -626,7 +626,8 @@ async function mergeRecord(
|
|||
window.log.error(
|
||||
'storageService.mergeRecord: Error with',
|
||||
redactStorageID(storageID),
|
||||
itemType
|
||||
itemType,
|
||||
String(err)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
51
ts/test-both/util/timestampLongUtils_test.ts
Normal file
51
ts/test-both/util/timestampLongUtils_test.ts
Normal 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
3
ts/textsecure.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
26
ts/util/timestampLongUtils.ts
Normal file
26
ts/util/timestampLongUtils.ts
Normal 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;
|
||||
}
|
|
@ -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
1
ts/window.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue