Sync group stories through storage service
This commit is contained in:
parent
a711ae1c49
commit
95bee1c881
15 changed files with 355 additions and 157 deletions
|
@ -101,6 +101,12 @@ message GroupV1Record {
|
||||||
}
|
}
|
||||||
|
|
||||||
message GroupV2Record {
|
message GroupV2Record {
|
||||||
|
enum StorySendMode {
|
||||||
|
DEFAULT = 0;
|
||||||
|
DISABLED = 1;
|
||||||
|
ENABLED = 2;
|
||||||
|
}
|
||||||
|
|
||||||
optional bytes masterKey = 1;
|
optional bytes masterKey = 1;
|
||||||
optional bool blocked = 2;
|
optional bool blocked = 2;
|
||||||
optional bool whitelisted = 3;
|
optional bool whitelisted = 3;
|
||||||
|
@ -109,6 +115,8 @@ message GroupV2Record {
|
||||||
optional uint64 mutedUntilTimestamp = 6;
|
optional uint64 mutedUntilTimestamp = 6;
|
||||||
optional bool dontNotifyForMentionsIfMuted = 7;
|
optional bool dontNotifyForMentionsIfMuted = 7;
|
||||||
optional bool hideStory = 8;
|
optional bool hideStory = 8;
|
||||||
|
reserved /* storySendEnabled */ 9; // removed
|
||||||
|
optional StorySendMode storySendMode = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AccountRecord {
|
message AccountRecord {
|
||||||
|
|
|
@ -51,6 +51,7 @@ import {
|
||||||
import { senderCertificateService } from './services/senderCertificate';
|
import { senderCertificateService } from './services/senderCertificate';
|
||||||
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
|
import { GROUP_CREDENTIALS_KEY } from './services/groupCredentialFetcher';
|
||||||
import * as KeyboardLayout from './services/keyboardLayout';
|
import * as KeyboardLayout from './services/keyboardLayout';
|
||||||
|
import * as StorageService from './services/storage';
|
||||||
import { RoutineProfileRefresher } from './routineProfileRefresh';
|
import { RoutineProfileRefresher } from './routineProfileRefresh';
|
||||||
import { isMoreRecentThan, isOlderThan, toDayMillis } from './util/timestamp';
|
import { isMoreRecentThan, isOlderThan, toDayMillis } from './util/timestamp';
|
||||||
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji';
|
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji';
|
||||||
|
@ -786,7 +787,7 @@ export async function startApp(): Promise<void> {
|
||||||
window.isBeforeVersion(lastVersion, 'v1.36.0-beta.1') &&
|
window.isBeforeVersion(lastVersion, 'v1.36.0-beta.1') &&
|
||||||
window.isAfterVersion(lastVersion, 'v1.35.0-beta.1')
|
window.isAfterVersion(lastVersion, 'v1.35.0-beta.1')
|
||||||
) {
|
) {
|
||||||
await window.Signal.Services.eraseAllStorageServiceState();
|
await StorageService.eraseAllStorageServiceState();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.isBeforeVersion(lastVersion, 'v5.2.0')) {
|
if (window.isBeforeVersion(lastVersion, 'v5.2.0')) {
|
||||||
|
@ -846,6 +847,8 @@ export async function startApp(): Promise<void> {
|
||||||
|
|
||||||
// Don't block on the following operation
|
// Don't block on the following operation
|
||||||
window.Signal.Data.ensureFilePermissions();
|
window.Signal.Data.ensureFilePermissions();
|
||||||
|
|
||||||
|
StorageService.reprocessUnknownFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -1737,7 +1740,7 @@ export async function startApp(): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
async function runStorageService() {
|
async function runStorageService() {
|
||||||
window.Signal.Services.enableStorageService();
|
StorageService.enableStorageService();
|
||||||
|
|
||||||
if (window.ConversationController.areWePrimaryDevice()) {
|
if (window.ConversationController.areWePrimaryDevice()) {
|
||||||
log.warn(
|
log.warn(
|
||||||
|
@ -3548,7 +3551,7 @@ export async function startApp(): Promise<void> {
|
||||||
}
|
}
|
||||||
case FETCH_LATEST_ENUM.STORAGE_MANIFEST:
|
case FETCH_LATEST_ENUM.STORAGE_MANIFEST:
|
||||||
log.info('onFetchLatestSync: fetching latest manifest');
|
log.info('onFetchLatestSync: fetching latest manifest');
|
||||||
await window.Signal.Services.runStorageServiceSyncJob();
|
await StorageService.runStorageServiceSyncJob();
|
||||||
break;
|
break;
|
||||||
case FETCH_LATEST_ENUM.SUBSCRIPTION_STATUS:
|
case FETCH_LATEST_ENUM.SUBSCRIPTION_STATUS:
|
||||||
log.info('onFetchLatestSync: fetching latest subscription status');
|
log.info('onFetchLatestSync: fetching latest subscription status');
|
||||||
|
@ -3582,12 +3585,12 @@ export async function startApp(): Promise<void> {
|
||||||
'onKeysSync: updated storage service key, erasing state and fetching'
|
'onKeysSync: updated storage service key, erasing state and fetching'
|
||||||
);
|
);
|
||||||
await window.storage.put('storageKey', storageServiceKeyBase64);
|
await window.storage.put('storageKey', storageServiceKeyBase64);
|
||||||
await window.Signal.Services.eraseAllStorageServiceState({
|
await StorageService.eraseAllStorageServiceState({
|
||||||
keepUnknownFields: true,
|
keepUnknownFields: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await window.Signal.Services.runStorageServiceSyncJob();
|
await StorageService.runStorageServiceSyncJob();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -444,7 +444,7 @@ export const SendStoryModal = ({
|
||||||
<>
|
<>
|
||||||
<div className="SendStoryModal__selected-lists">{selectedNames}</div>
|
<div className="SendStoryModal__selected-lists">{selectedNames}</div>
|
||||||
<button
|
<button
|
||||||
aria-label={i18n('SendStoryModal__ok')}
|
aria-label={i18n('ok')}
|
||||||
className="SendStoryModal__ok"
|
className="SendStoryModal__ok"
|
||||||
disabled={!chosenGroupIds.size}
|
disabled={!chosenGroupIds.size}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
getCheckedCredentialsForToday,
|
getCheckedCredentialsForToday,
|
||||||
maybeFetchNewCredentials,
|
maybeFetchNewCredentials,
|
||||||
} from './services/groupCredentialFetcher';
|
} from './services/groupCredentialFetcher';
|
||||||
|
import { storageServiceUploadJob } from './services/storage';
|
||||||
import dataInterface from './sql/Client';
|
import dataInterface from './sql/Client';
|
||||||
import { toWebSafeBase64, fromWebSafeBase64 } from './util/webSafeBase64';
|
import { toWebSafeBase64, fromWebSafeBase64 } from './util/webSafeBase64';
|
||||||
import { assertDev, strictAssert } from './util/assert';
|
import { assertDev, strictAssert } from './util/assert';
|
||||||
|
@ -1933,7 +1934,7 @@ export async function createGroupV2(
|
||||||
);
|
);
|
||||||
|
|
||||||
await conversation.queueJob('storageServiceUploadJob', async () => {
|
await conversation.queueJob('storageServiceUploadJob', async () => {
|
||||||
await window.Signal.Services.storageServiceUploadJob();
|
await storageServiceUploadJob();
|
||||||
});
|
});
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
3
ts/model-types.d.ts
vendored
3
ts/model-types.d.ts
vendored
|
@ -30,6 +30,7 @@ import type { GiftBadgeStates } from './components/conversation/Message';
|
||||||
import type { LinkPreviewType } from './types/message/LinkPreviews';
|
import type { LinkPreviewType } from './types/message/LinkPreviews';
|
||||||
|
|
||||||
import type { StickerType } from './types/Stickers';
|
import type { StickerType } from './types/Stickers';
|
||||||
|
import type { StorySendMode } from './types/Stories';
|
||||||
import type { MIMEType } from './types/MIME';
|
import type { MIMEType } from './types/MIME';
|
||||||
|
|
||||||
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||||
|
@ -349,7 +350,7 @@ export type ConversationAttributesType = {
|
||||||
// to leave a group.
|
// to leave a group.
|
||||||
left?: boolean;
|
left?: boolean;
|
||||||
groupVersion?: number;
|
groupVersion?: number;
|
||||||
isGroupStorySendReady?: boolean;
|
storySendMode?: StorySendMode;
|
||||||
|
|
||||||
// GroupV1 only
|
// GroupV1 only
|
||||||
members?: Array<string>;
|
members?: Array<string>;
|
||||||
|
|
|
@ -28,6 +28,7 @@ import * as EmbeddedContact from '../types/EmbeddedContact';
|
||||||
import * as Conversation from '../types/Conversation';
|
import * as Conversation from '../types/Conversation';
|
||||||
import type { StickerType, StickerWithHydratedData } from '../types/Stickers';
|
import type { StickerType, StickerWithHydratedData } from '../types/Stickers';
|
||||||
import * as Stickers from '../types/Stickers';
|
import * as Stickers from '../types/Stickers';
|
||||||
|
import { StorySendMode } from '../types/Stories';
|
||||||
import type {
|
import type {
|
||||||
ContactWithHydratedAvatar,
|
ContactWithHydratedAvatar,
|
||||||
GroupV1InfoType,
|
GroupV1InfoType,
|
||||||
|
@ -69,6 +70,7 @@ import { migrateColor } from '../util/migrateColor';
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
import { dropNull } from '../util/dropNull';
|
import { dropNull } from '../util/dropNull';
|
||||||
import { notificationService } from '../services/notifications';
|
import { notificationService } from '../services/notifications';
|
||||||
|
import { storageServiceUploadJob } from '../services/storage';
|
||||||
import { getSendOptions } from '../util/getSendOptions';
|
import { getSendOptions } from '../util/getSendOptions';
|
||||||
import { isConversationAccepted } from '../util/isConversationAccepted';
|
import { isConversationAccepted } from '../util/isConversationAccepted';
|
||||||
import { markConversationRead } from '../util/markConversationRead';
|
import { markConversationRead } from '../util/markConversationRead';
|
||||||
|
@ -132,7 +134,7 @@ import { validateConversation } from '../util/validateConversation';
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
window.Whisper = window.Whisper || {};
|
window.Whisper = window.Whisper || {};
|
||||||
|
|
||||||
const { Services, Util } = window.Signal;
|
const { Util } = window.Signal;
|
||||||
const { Message } = window.Signal.Types;
|
const { Message } = window.Signal.Types;
|
||||||
const {
|
const {
|
||||||
deleteAttachmentData,
|
deleteAttachmentData,
|
||||||
|
@ -1868,7 +1870,7 @@ export class ConversationModel extends window.Backbone
|
||||||
groupVersion,
|
groupVersion,
|
||||||
groupId: this.get('groupId'),
|
groupId: this.get('groupId'),
|
||||||
groupLink: this.getGroupLink(),
|
groupLink: this.getGroupLink(),
|
||||||
isGroupStorySendReady: Boolean(this.get('isGroupStorySendReady')),
|
storySendMode: this.getStorySendMode(),
|
||||||
hideStory: Boolean(this.get('hideStory')),
|
hideStory: Boolean(this.get('hideStory')),
|
||||||
inboxPosition,
|
inboxPosition,
|
||||||
isArchived: this.get('isArchived'),
|
isArchived: this.get('isArchived'),
|
||||||
|
@ -5182,7 +5184,7 @@ export class ConversationModel extends window.Backbone
|
||||||
this.set({ needsStorageServiceSync: true });
|
this.set({ needsStorageServiceSync: true });
|
||||||
|
|
||||||
this.queueJob('captureChange', async () => {
|
this.queueJob('captureChange', async () => {
|
||||||
Services.storageServiceUploadJob();
|
storageServiceUploadJob();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5498,6 +5500,14 @@ export class ConversationModel extends window.Backbone
|
||||||
}
|
}
|
||||||
return window.textsecure.storage.protocol.signAlternateIdentity();
|
return window.textsecure.storage.protocol.signAlternateIdentity();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStorySendMode(): StorySendMode | undefined {
|
||||||
|
if (!isGroup(this.attributes)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.get('storySendMode') ?? StorySendMode.IfActive;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.Whisper.Conversation = ConversationModel;
|
window.Whisper.Conversation = ConversationModel;
|
||||||
|
|
|
@ -39,6 +39,7 @@ import { BackOff } from '../util/BackOff';
|
||||||
import { storageJobQueue } from '../util/JobQueue';
|
import { storageJobQueue } from '../util/JobQueue';
|
||||||
import { sleep } from '../util/sleep';
|
import { sleep } from '../util/sleep';
|
||||||
import { isMoreRecentThan } from '../util/timestamp';
|
import { isMoreRecentThan } from '../util/timestamp';
|
||||||
|
import { map, filter } from '../util/iterables';
|
||||||
import { ourProfileKeyService } from './ourProfileKey';
|
import { ourProfileKeyService } from './ourProfileKey';
|
||||||
import {
|
import {
|
||||||
ConversationTypes,
|
ConversationTypes,
|
||||||
|
@ -61,6 +62,7 @@ import type {
|
||||||
UninstalledStickerPackType,
|
UninstalledStickerPackType,
|
||||||
} from '../sql/Interface';
|
} from '../sql/Interface';
|
||||||
import { MY_STORIES_ID } from '../types/Stories';
|
import { MY_STORIES_ID } from '../types/Stories';
|
||||||
|
import { isNotNil } from '../util/isNotNil';
|
||||||
|
|
||||||
type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier;
|
type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier;
|
||||||
|
|
||||||
|
@ -119,7 +121,7 @@ function encryptRecord(
|
||||||
const storageItem = new Proto.StorageItem();
|
const storageItem = new Proto.StorageItem();
|
||||||
|
|
||||||
const storageKeyBuffer = storageID
|
const storageKeyBuffer = storageID
|
||||||
? Bytes.fromBase64(String(storageID))
|
? Bytes.fromBase64(storageID)
|
||||||
: generateStorageID();
|
: generateStorageID();
|
||||||
|
|
||||||
const storageKeyBase64 = window.storage.get('storageKey');
|
const storageKeyBase64 = window.storage.get('storageKey');
|
||||||
|
@ -149,9 +151,9 @@ function generateStorageID(): Uint8Array {
|
||||||
|
|
||||||
type GeneratedManifestType = {
|
type GeneratedManifestType = {
|
||||||
postUploadUpdateFunctions: Array<() => unknown>;
|
postUploadUpdateFunctions: Array<() => unknown>;
|
||||||
deleteKeys: Array<Uint8Array>;
|
recordsByID: Map<string, MergeableItemType | RemoteRecord>;
|
||||||
newItems: Set<Proto.IStorageItem>;
|
insertKeys: Set<string>;
|
||||||
storageManifest: Proto.IStorageManifest;
|
deleteKeys: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function generateManifest(
|
async function generateManifest(
|
||||||
|
@ -169,10 +171,9 @@ async function generateManifest(
|
||||||
const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type;
|
const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type;
|
||||||
|
|
||||||
const postUploadUpdateFunctions: Array<() => unknown> = [];
|
const postUploadUpdateFunctions: Array<() => unknown> = [];
|
||||||
const insertKeys: Array<string> = [];
|
const insertKeys = new Set<string>();
|
||||||
const deleteKeys: Array<Uint8Array> = [];
|
const deleteKeys = new Set<string>();
|
||||||
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
|
const recordsByID = new Map<string, MergeableItemType | RemoteRecord>();
|
||||||
const newItems: Set<Proto.IStorageItem> = new Set();
|
|
||||||
|
|
||||||
function processStorageRecord({
|
function processStorageRecord({
|
||||||
conversation,
|
conversation,
|
||||||
|
@ -189,9 +190,6 @@ async function generateManifest(
|
||||||
storageNeedsSync: boolean;
|
storageNeedsSync: boolean;
|
||||||
storageRecord: Proto.IStorageRecord;
|
storageRecord: Proto.IStorageRecord;
|
||||||
}) {
|
}) {
|
||||||
const identifier = new Proto.ManifestRecord.Identifier();
|
|
||||||
identifier.type = identifierType;
|
|
||||||
|
|
||||||
const currentRedactedID = currentStorageID
|
const currentRedactedID = currentStorageID
|
||||||
? redactStorageID(currentStorageID, currentStorageVersion)
|
? redactStorageID(currentStorageID, currentStorageVersion)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
@ -202,24 +200,16 @@ async function generateManifest(
|
||||||
? Bytes.toBase64(generateStorageID())
|
? Bytes.toBase64(generateStorageID())
|
||||||
: currentStorageID;
|
: currentStorageID;
|
||||||
|
|
||||||
let storageItem;
|
recordsByID.set(storageID, {
|
||||||
try {
|
itemType: identifierType,
|
||||||
storageItem = encryptRecord(storageID, storageRecord);
|
storageID,
|
||||||
} catch (err) {
|
storageRecord,
|
||||||
log.error(
|
});
|
||||||
`storageService.upload(${version}): encrypt record failed:`,
|
|
||||||
Errors.toLogFormat(err)
|
|
||||||
);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
identifier.raw = storageItem.key;
|
|
||||||
|
|
||||||
// When a client needs to update a given record it should create it
|
// When a client needs to update a given record it should create it
|
||||||
// under a new key and delete the existing key.
|
// under a new key and delete the existing key.
|
||||||
if (isNewItem) {
|
if (isNewItem) {
|
||||||
newItems.add(storageItem);
|
insertKeys.add(storageID);
|
||||||
|
|
||||||
insertKeys.push(storageID);
|
|
||||||
const newRedactedID = redactStorageID(storageID, version, conversation);
|
const newRedactedID = redactStorageID(storageID, version, conversation);
|
||||||
if (currentStorageID) {
|
if (currentStorageID) {
|
||||||
log.info(
|
log.info(
|
||||||
|
@ -227,7 +217,7 @@ async function generateManifest(
|
||||||
`updating from=${currentRedactedID} ` +
|
`updating from=${currentRedactedID} ` +
|
||||||
`to=${newRedactedID}`
|
`to=${newRedactedID}`
|
||||||
);
|
);
|
||||||
deleteKeys.push(Bytes.fromBase64(currentStorageID));
|
deleteKeys.add(currentStorageID);
|
||||||
} else {
|
} else {
|
||||||
log.info(
|
log.info(
|
||||||
`storageService.upload(${version}): adding key=${newRedactedID}`
|
`storageService.upload(${version}): adding key=${newRedactedID}`
|
||||||
|
@ -235,8 +225,6 @@ async function generateManifest(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
manifestRecordKeys.add(identifier);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isNewItem,
|
isNewItem,
|
||||||
storageID,
|
storageID,
|
||||||
|
@ -293,7 +281,7 @@ async function generateManifest(
|
||||||
`due to ${dropReason}`
|
`due to ${dropReason}`
|
||||||
);
|
);
|
||||||
conversation.unset('storageID');
|
conversation.unset('storageID');
|
||||||
deleteKeys.push(Bytes.fromBase64(droppedID));
|
deleteKeys.add(droppedID);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -468,11 +456,7 @@ async function generateManifest(
|
||||||
// When updating the manifest, ensure all "unknown" keys are added to the
|
// When updating the manifest, ensure all "unknown" keys are added to the
|
||||||
// new manifest, so we don't inadvertently delete something we don't understand
|
// new manifest, so we don't inadvertently delete something we don't understand
|
||||||
unknownRecordsArray.forEach((record: UnknownRecord) => {
|
unknownRecordsArray.forEach((record: UnknownRecord) => {
|
||||||
const identifier = new Proto.ManifestRecord.Identifier();
|
recordsByID.set(record.storageID, record);
|
||||||
identifier.type = record.itemType;
|
|
||||||
identifier.raw = Bytes.fromBase64(record.storageID);
|
|
||||||
|
|
||||||
manifestRecordKeys.add(identifier);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const recordsWithErrors: ReadonlyArray<UnknownRecord> = window.storage.get(
|
const recordsWithErrors: ReadonlyArray<UnknownRecord> = window.storage.get(
|
||||||
|
@ -489,11 +473,7 @@ async function generateManifest(
|
||||||
// These records failed to merge in the previous fetchManifest, but we still
|
// These records failed to merge in the previous fetchManifest, but we still
|
||||||
// need to include them so that the manifest is complete
|
// need to include them so that the manifest is complete
|
||||||
recordsWithErrors.forEach((record: UnknownRecord) => {
|
recordsWithErrors.forEach((record: UnknownRecord) => {
|
||||||
const identifier = new Proto.ManifestRecord.Identifier();
|
recordsByID.set(record.storageID, record);
|
||||||
identifier.type = record.itemType;
|
|
||||||
identifier.raw = Bytes.fromBase64(record.storageID);
|
|
||||||
|
|
||||||
manifestRecordKeys.add(identifier);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete keys that we wanted to drop during the processing of the manifest.
|
// Delete keys that we wanted to drop during the processing of the manifest.
|
||||||
|
@ -511,83 +491,73 @@ async function generateManifest(
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const { storageID } of storedPendingDeletes) {
|
for (const { storageID } of storedPendingDeletes) {
|
||||||
deleteKeys.push(Bytes.fromBase64(storageID));
|
deleteKeys.add(storageID);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate before writing
|
// Validate before writing
|
||||||
|
|
||||||
const rawDuplicates = new Set();
|
const duplicates = new Set<string>();
|
||||||
const typeRawDuplicates = new Set();
|
const typeDuplicates = new Set();
|
||||||
let hasAccountType = false;
|
let hasAccountType = false;
|
||||||
manifestRecordKeys.forEach(identifier => {
|
for (const [storageID, { itemType }] of recordsByID) {
|
||||||
// Ensure there are no duplicate StorageIdentifiers in your manifest
|
// Ensure there are no duplicate StorageIdentifiers in your manifest
|
||||||
// This can be broken down into two parts:
|
// This can be broken down into two parts:
|
||||||
// There are no duplicate type+raw pairs
|
// There are no duplicate type+raw pairs
|
||||||
// There are no duplicate raw bytes
|
// There are no duplicate raw bytes
|
||||||
strictAssert(identifier.raw, 'manifest record key without raw identifier');
|
const typeAndID = `${itemType}+${storageID}`;
|
||||||
const storageID = Bytes.toBase64(identifier.raw);
|
if (duplicates.has(storageID) || typeDuplicates.has(typeAndID)) {
|
||||||
const typeAndRaw = `${identifier.type}+${storageID}`;
|
|
||||||
if (
|
|
||||||
rawDuplicates.has(identifier.raw) ||
|
|
||||||
typeRawDuplicates.has(typeAndRaw)
|
|
||||||
) {
|
|
||||||
log.warn(
|
log.warn(
|
||||||
`storageService.upload(${version}): removing from duplicate item ` +
|
`storageService.upload(${version}): removing from duplicate item ` +
|
||||||
'from the manifest',
|
'from the manifest',
|
||||||
redactStorageID(storageID),
|
redactStorageID(storageID),
|
||||||
identifier.type
|
itemType
|
||||||
);
|
);
|
||||||
manifestRecordKeys.delete(identifier);
|
recordsByID.delete(storageID);
|
||||||
}
|
}
|
||||||
rawDuplicates.add(identifier.raw);
|
duplicates.add(storageID);
|
||||||
typeRawDuplicates.add(typeAndRaw);
|
typeDuplicates.add(typeAndID);
|
||||||
|
|
||||||
// Ensure all deletes are not present in the manifest
|
// Ensure all deletes are not present in the manifest
|
||||||
const hasDeleteKey = deleteKeys.find(
|
const hasDeleteKey = deleteKeys.has(storageID);
|
||||||
key => Bytes.toBase64(key) === storageID
|
|
||||||
);
|
|
||||||
if (hasDeleteKey) {
|
if (hasDeleteKey) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`storageService.upload(${version}): removing key which has been deleted`,
|
`storageService.upload(${version}): removing key which has been deleted`,
|
||||||
redactStorageID(storageID),
|
redactStorageID(storageID),
|
||||||
identifier.type
|
itemType
|
||||||
);
|
);
|
||||||
manifestRecordKeys.delete(identifier);
|
recordsByID.delete(storageID);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure that there is *exactly* one Account type in the manifest
|
// Ensure that there is *exactly* one Account type in the manifest
|
||||||
if (identifier.type === ITEM_TYPE.ACCOUNT) {
|
if (itemType === ITEM_TYPE.ACCOUNT) {
|
||||||
if (hasAccountType) {
|
if (hasAccountType) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`storageService.upload(${version}): removing duplicate account`,
|
`storageService.upload(${version}): removing duplicate account`,
|
||||||
redactStorageID(storageID)
|
redactStorageID(storageID)
|
||||||
);
|
);
|
||||||
manifestRecordKeys.delete(identifier);
|
recordsByID.delete(storageID);
|
||||||
}
|
}
|
||||||
hasAccountType = true;
|
hasAccountType = true;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
rawDuplicates.clear();
|
duplicates.clear();
|
||||||
typeRawDuplicates.clear();
|
typeDuplicates.clear();
|
||||||
|
|
||||||
const storageKeyDuplicates = new Set<string>();
|
const storageKeyDuplicates = new Set<string>();
|
||||||
|
|
||||||
newItems.forEach(storageItem => {
|
for (const storageID of insertKeys) {
|
||||||
// Ensure there are no duplicate StorageIdentifiers in your list of inserts
|
// Ensure there are no duplicate StorageIdentifiers in your list of inserts
|
||||||
strictAssert(storageItem.key, 'New storage item without key');
|
|
||||||
|
|
||||||
const storageID = Bytes.toBase64(storageItem.key);
|
|
||||||
if (storageKeyDuplicates.has(storageID)) {
|
if (storageKeyDuplicates.has(storageID)) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`storageService.upload(${version}): ` +
|
`storageService.upload(${version}): ` +
|
||||||
'removing duplicate identifier from inserts',
|
'removing duplicate identifier from inserts',
|
||||||
redactStorageID(storageID)
|
redactStorageID(storageID)
|
||||||
);
|
);
|
||||||
newItems.delete(storageItem);
|
insertKeys.delete(storageID);
|
||||||
}
|
}
|
||||||
storageKeyDuplicates.add(storageID);
|
storageKeyDuplicates.add(storageID);
|
||||||
});
|
}
|
||||||
|
|
||||||
storageKeyDuplicates.clear();
|
storageKeyDuplicates.clear();
|
||||||
|
|
||||||
|
@ -608,15 +578,13 @@ async function generateManifest(
|
||||||
);
|
);
|
||||||
|
|
||||||
const localKeys: Set<string> = new Set();
|
const localKeys: Set<string> = new Set();
|
||||||
manifestRecordKeys.forEach((identifier: IManifestRecordIdentifier) => {
|
for (const storageID of recordsByID.keys()) {
|
||||||
strictAssert(identifier.raw, 'Identifier without raw field');
|
|
||||||
const storageID = Bytes.toBase64(identifier.raw);
|
|
||||||
localKeys.add(storageID);
|
localKeys.add(storageID);
|
||||||
|
|
||||||
if (!remoteKeys.has(storageID)) {
|
if (!remoteKeys.has(storageID)) {
|
||||||
pendingInserts.add(storageID);
|
pendingInserts.add(storageID);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
remoteKeys.forEach(storageID => {
|
remoteKeys.forEach(storageID => {
|
||||||
if (!localKeys.has(storageID)) {
|
if (!localKeys.has(storageID)) {
|
||||||
|
@ -624,9 +592,9 @@ async function generateManifest(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deleteKeys.length !== pendingDeletes.size) {
|
if (deleteKeys.size !== pendingDeletes.size) {
|
||||||
const localDeletes = deleteKeys.map(key =>
|
const localDeletes = Array.from(deleteKeys).map(key =>
|
||||||
redactStorageID(Bytes.toBase64(key))
|
redactStorageID(key)
|
||||||
);
|
);
|
||||||
const remoteDeletes: Array<string> = [];
|
const remoteDeletes: Array<string> = [];
|
||||||
pendingDeletes.forEach(id => remoteDeletes.push(redactStorageID(id)));
|
pendingDeletes.forEach(id => remoteDeletes.push(redactStorageID(id)));
|
||||||
|
@ -639,24 +607,77 @@ async function generateManifest(
|
||||||
);
|
);
|
||||||
throw new Error('invalid write delete keys length do not match');
|
throw new Error('invalid write delete keys length do not match');
|
||||||
}
|
}
|
||||||
if (newItems.size !== pendingInserts.size) {
|
if (insertKeys.size !== pendingInserts.size) {
|
||||||
throw new Error('invalid write insert items length do not match');
|
throw new Error('invalid write insert items length do not match');
|
||||||
}
|
}
|
||||||
deleteKeys.forEach(key => {
|
for (const storageID of deleteKeys) {
|
||||||
const storageID = Bytes.toBase64(key);
|
|
||||||
if (!pendingDeletes.has(storageID)) {
|
if (!pendingDeletes.has(storageID)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'invalid write delete key missing from pending deletes'
|
'invalid write delete key missing from pending deletes'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
insertKeys.forEach(storageID => {
|
for (const storageID of insertKeys) {
|
||||||
if (!pendingInserts.has(storageID)) {
|
if (!pendingInserts.has(storageID)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'invalid write insert key missing from pending inserts'
|
'invalid write insert key missing from pending inserts'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
postUploadUpdateFunctions,
|
||||||
|
recordsByID,
|
||||||
|
insertKeys,
|
||||||
|
deleteKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type EncryptManifestOptionsType = {
|
||||||
|
recordsByID: Map<string, MergeableItemType | RemoteRecord>;
|
||||||
|
insertKeys: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EncryptedManifestType = {
|
||||||
|
newItems: Set<Proto.IStorageItem>;
|
||||||
|
storageManifest: Proto.IStorageManifest;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function encryptManifest(
|
||||||
|
version: number,
|
||||||
|
{ recordsByID, insertKeys }: EncryptManifestOptionsType
|
||||||
|
): Promise<EncryptedManifestType> {
|
||||||
|
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
|
||||||
|
const newItems: Set<Proto.IStorageItem> = new Set();
|
||||||
|
|
||||||
|
for (const [storageID, { itemType, storageRecord }] of recordsByID) {
|
||||||
|
const identifier = new Proto.ManifestRecord.Identifier({
|
||||||
|
type: itemType,
|
||||||
|
raw: Bytes.fromBase64(storageID),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
manifestRecordKeys.add(identifier);
|
||||||
|
|
||||||
|
if (insertKeys.has(storageID)) {
|
||||||
|
strictAssert(
|
||||||
|
storageRecord !== undefined,
|
||||||
|
'Inserted items must have an associated record'
|
||||||
|
);
|
||||||
|
|
||||||
|
let storageItem;
|
||||||
|
try {
|
||||||
|
storageItem = encryptRecord(storageID, storageRecord);
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
`storageService.upload(${version}): encrypt record failed:`,
|
||||||
|
Errors.toLogFormat(err)
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
newItems.add(storageItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifestRecord = new Proto.ManifestRecord();
|
const manifestRecord = new Proto.ManifestRecord();
|
||||||
|
@ -683,8 +704,6 @@ async function generateManifest(
|
||||||
storageManifest.value = encryptedManifest;
|
storageManifest.value = encryptedManifest;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
postUploadUpdateFunctions,
|
|
||||||
deleteKeys,
|
|
||||||
newItems,
|
newItems,
|
||||||
storageManifest,
|
storageManifest,
|
||||||
};
|
};
|
||||||
|
@ -692,18 +711,14 @@ async function generateManifest(
|
||||||
|
|
||||||
async function uploadManifest(
|
async function uploadManifest(
|
||||||
version: number,
|
version: number,
|
||||||
{
|
{ postUploadUpdateFunctions, deleteKeys }: GeneratedManifestType,
|
||||||
postUploadUpdateFunctions,
|
{ newItems, storageManifest }: EncryptedManifestType
|
||||||
deleteKeys,
|
|
||||||
newItems,
|
|
||||||
storageManifest,
|
|
||||||
}: GeneratedManifestType
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!window.textsecure.messaging) {
|
if (!window.textsecure.messaging) {
|
||||||
throw new Error('storageService.uploadManifest: We are offline!');
|
throw new Error('storageService.uploadManifest: We are offline!');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newItems.size === 0 && deleteKeys.length === 0) {
|
if (newItems.size === 0 && deleteKeys.size === 0) {
|
||||||
log.info(`storageService.upload(${version}): nothing to upload`);
|
log.info(`storageService.upload(${version}): nothing to upload`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -712,13 +727,15 @@ async function uploadManifest(
|
||||||
try {
|
try {
|
||||||
log.info(
|
log.info(
|
||||||
`storageService.upload(${version}): inserting=${newItems.size} ` +
|
`storageService.upload(${version}): inserting=${newItems.size} ` +
|
||||||
`deleting=${deleteKeys.length}`
|
`deleting=${deleteKeys.size}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const writeOperation = new Proto.WriteOperation();
|
const writeOperation = new Proto.WriteOperation();
|
||||||
writeOperation.manifest = storageManifest;
|
writeOperation.manifest = storageManifest;
|
||||||
writeOperation.insertItem = Array.from(newItems);
|
writeOperation.insertItem = Array.from(newItems);
|
||||||
writeOperation.deleteKey = deleteKeys;
|
writeOperation.deleteKey = Array.from(deleteKeys).map(storageID =>
|
||||||
|
Bytes.fromBase64(storageID)
|
||||||
|
);
|
||||||
|
|
||||||
await window.textsecure.messaging.modifyStorageRecords(
|
await window.textsecure.messaging.modifyStorageRecords(
|
||||||
Proto.WriteOperation.encode(writeOperation).finish(),
|
Proto.WriteOperation.encode(writeOperation).finish(),
|
||||||
|
@ -813,16 +830,19 @@ async function createNewManifest() {
|
||||||
|
|
||||||
const version = window.storage.get('manifestVersion', 0);
|
const version = window.storage.get('manifestVersion', 0);
|
||||||
|
|
||||||
const { postUploadUpdateFunctions, newItems, storageManifest } =
|
const generatedManifest = await generateManifest(version, undefined, true);
|
||||||
await generateManifest(version, undefined, true);
|
|
||||||
|
|
||||||
await uploadManifest(version, {
|
const encryptedManifest = await encryptManifest(version, generatedManifest);
|
||||||
postUploadUpdateFunctions,
|
|
||||||
|
await uploadManifest(
|
||||||
|
version,
|
||||||
|
{
|
||||||
|
...generatedManifest,
|
||||||
// we have created a new manifest, there should be no keys to delete
|
// we have created a new manifest, there should be no keys to delete
|
||||||
deleteKeys: [],
|
deleteKeys: new Set(),
|
||||||
newItems,
|
},
|
||||||
storageManifest,
|
encryptedManifest
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptManifest(
|
async function decryptManifest(
|
||||||
|
@ -1158,7 +1178,8 @@ async function processManifest(
|
||||||
|
|
||||||
let conflictCount = 0;
|
let conflictCount = 0;
|
||||||
if (remoteOnlyRecords.size) {
|
if (remoteOnlyRecords.size) {
|
||||||
conflictCount = await processRemoteRecords(version, remoteOnlyRecords);
|
const fetchResult = await fetchRemoteRecords(version, remoteOnlyRecords);
|
||||||
|
conflictCount = await processRemoteRecords(version, fetchResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-merge, if our local records contain any storage IDs that were not
|
// Post-merge, if our local records contain any storage IDs that were not
|
||||||
|
@ -1302,10 +1323,15 @@ async function processManifest(
|
||||||
return conflictCount;
|
return conflictCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processRemoteRecords(
|
export type FetchRemoteRecordsResultType = Readonly<{
|
||||||
|
missingKeys: Set<string>;
|
||||||
|
decryptedItems: ReadonlyArray<MergeableItemType>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
async function fetchRemoteRecords(
|
||||||
storageVersion: number,
|
storageVersion: number,
|
||||||
remoteOnlyRecords: Map<string, RemoteRecord>
|
remoteOnlyRecords: Map<string, RemoteRecord>
|
||||||
): Promise<number> {
|
): Promise<FetchRemoteRecordsResultType> {
|
||||||
const storageKeyBase64 = window.storage.get('storageKey');
|
const storageKeyBase64 = window.storage.get('storageKey');
|
||||||
if (!storageKeyBase64) {
|
if (!storageKeyBase64) {
|
||||||
throw new Error('No storage key');
|
throw new Error('No storage key');
|
||||||
|
@ -1318,8 +1344,8 @@ async function processRemoteRecords(
|
||||||
const storageKey = Bytes.fromBase64(storageKeyBase64);
|
const storageKey = Bytes.fromBase64(storageKeyBase64);
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`storageService.process(${storageVersion}): fetching remote keys ` +
|
`storageService.fetchRemoteRecords(${storageVersion}): ` +
|
||||||
`count=${remoteOnlyRecords.size}`
|
`fetching remote keys count=${remoteOnlyRecords.size}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const credentials = window.storage.get('storageCredentials');
|
const credentials = window.storage.get('storageCredentials');
|
||||||
|
@ -1349,7 +1375,7 @@ async function processRemoteRecords(
|
||||||
|
|
||||||
const missingKeys = new Set<string>(remoteOnlyRecords.keys());
|
const missingKeys = new Set<string>(remoteOnlyRecords.keys());
|
||||||
|
|
||||||
const decryptedStorageItems = await pMap(
|
const decryptedItems = await pMap(
|
||||||
storageItems,
|
storageItems,
|
||||||
async (
|
async (
|
||||||
storageRecordWrapper: Proto.IStorageItem
|
storageRecordWrapper: Proto.IStorageItem
|
||||||
|
@ -1410,17 +1436,24 @@ async function processRemoteRecords(
|
||||||
);
|
);
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`storageService.process(${storageVersion}): missing remote ` +
|
`storageService.fetchRemoteRecords(${storageVersion}): missing remote ` +
|
||||||
`keys=${JSON.stringify(redactedMissingKeys)} ` +
|
`keys=${JSON.stringify(redactedMissingKeys)} ` +
|
||||||
`count=${missingKeys.size}`
|
`count=${missingKeys.size}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return { decryptedItems, missingKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processRemoteRecords(
|
||||||
|
storageVersion: number,
|
||||||
|
{ decryptedItems, missingKeys }: FetchRemoteRecordsResultType
|
||||||
|
): Promise<number> {
|
||||||
const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type;
|
const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type;
|
||||||
const droppedKeys = new Set<string>();
|
const droppedKeys = new Set<string>();
|
||||||
|
|
||||||
// Drop all GV1 records for which we have GV2 record in the same manifest
|
// Drop all GV1 records for which we have GV2 record in the same manifest
|
||||||
const masterKeys = new Map<string, string>();
|
const masterKeys = new Map<string, string>();
|
||||||
for (const { itemType, storageID, storageRecord } of decryptedStorageItems) {
|
for (const { itemType, storageID, storageRecord } of decryptedItems) {
|
||||||
if (itemType === ITEM_TYPE.GROUPV2 && storageRecord.groupV2?.masterKey) {
|
if (itemType === ITEM_TYPE.GROUPV2 && storageRecord.groupV2?.masterKey) {
|
||||||
masterKeys.set(
|
masterKeys.set(
|
||||||
Bytes.toBase64(storageRecord.groupV2.masterKey),
|
Bytes.toBase64(storageRecord.groupV2.masterKey),
|
||||||
|
@ -1431,7 +1464,7 @@ async function processRemoteRecords(
|
||||||
|
|
||||||
let accountItem: MergeableItemType | undefined;
|
let accountItem: MergeableItemType | undefined;
|
||||||
|
|
||||||
const prunedStorageItems = decryptedStorageItems.filter(item => {
|
const prunedStorageItems = decryptedItems.filter(item => {
|
||||||
const { itemType, storageID, storageRecord } = item;
|
const { itemType, storageID, storageRecord } = item;
|
||||||
if (itemType === ITEM_TYPE.ACCOUNT) {
|
if (itemType === ITEM_TYPE.ACCOUNT) {
|
||||||
if (accountItem !== undefined) {
|
if (accountItem !== undefined) {
|
||||||
|
@ -1775,7 +1808,8 @@ async function upload(fromSync = false): Promise<void> {
|
||||||
previousManifest,
|
previousManifest,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
await uploadManifest(version, generatedManifest);
|
const encryptedManifest = await encryptManifest(version, generatedManifest);
|
||||||
|
await uploadManifest(version, generatedManifest, encryptedManifest);
|
||||||
|
|
||||||
// Clear pending delete keys after successful upload
|
// Clear pending delete keys after successful upload
|
||||||
await window.storage.put('storage-service-pending-deletes', []);
|
await window.storage.put('storage-service-pending-deletes', []);
|
||||||
|
@ -1819,6 +1853,67 @@ export async function eraseAllStorageServiceState({
|
||||||
log.info('storageService.eraseAllStorageServiceState: complete');
|
log.info('storageService.eraseAllStorageServiceState: complete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reprocessUnknownFields(): Promise<void> {
|
||||||
|
ourProfileKeyService.blockGetWithPromise(
|
||||||
|
storageJobQueue(async () => {
|
||||||
|
const version = window.storage.get('manifestVersion') ?? 0;
|
||||||
|
|
||||||
|
log.info(`storageService.reprocessUnknownFields(${version}): starting`);
|
||||||
|
|
||||||
|
const { recordsByID, insertKeys } = await generateManifest(
|
||||||
|
version,
|
||||||
|
undefined,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const newRecords = Array.from(
|
||||||
|
filter(
|
||||||
|
map(recordsByID, ([key, item]): MergeableItemType | undefined => {
|
||||||
|
if (!insertKeys.has(key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
strictAssert(
|
||||||
|
item.storageRecord !== undefined,
|
||||||
|
'Inserted records must have storageRecord'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!item.storageRecord.__unknownFields?.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
|
||||||
|
storageRecord: Proto.StorageRecord.decode(
|
||||||
|
Proto.StorageRecord.encode(item.storageRecord).finish()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
isNotNil
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const conflictCount = await processRemoteRecords(version, {
|
||||||
|
decryptedItems: newRecords,
|
||||||
|
missingKeys: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
`storageService.reprocessUnknownFields(${version}): done, ` +
|
||||||
|
`conflictCount=${conflictCount}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasConflicts = conflictCount !== 0;
|
||||||
|
if (hasConflicts) {
|
||||||
|
log.info(
|
||||||
|
`storageService.reprocessUnknownFields(${version}): uploading`
|
||||||
|
);
|
||||||
|
await upload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const storageServiceUploadJob = debounce(() => {
|
export const storageServiceUploadJob = debounce(() => {
|
||||||
if (!storageServiceEnabled) {
|
if (!storageServiceEnabled) {
|
||||||
log.info('storageService.storageServiceUploadJob: called before enabled');
|
log.info('storageService.storageServiceUploadJob: called before enabled');
|
||||||
|
|
|
@ -50,7 +50,7 @@ import type {
|
||||||
StickerPackInfoType,
|
StickerPackInfoType,
|
||||||
} from '../sql/Interface';
|
} from '../sql/Interface';
|
||||||
import dataInterface from '../sql/Client';
|
import dataInterface from '../sql/Client';
|
||||||
import { MY_STORIES_ID } from '../types/Stories';
|
import { MY_STORIES_ID, StorySendMode } from '../types/Stories';
|
||||||
import * as RemoteConfig from '../RemoteConfig';
|
import * as RemoteConfig from '../RemoteConfig';
|
||||||
|
|
||||||
const MY_STORIES_BYTES = uuidToBytes(MY_STORIES_ID);
|
const MY_STORIES_BYTES = uuidToBytes(MY_STORIES_ID);
|
||||||
|
@ -406,6 +406,18 @@ export function toGroupV2Record(
|
||||||
conversation.get('dontNotifyForMentionsIfMuted')
|
conversation.get('dontNotifyForMentionsIfMuted')
|
||||||
);
|
);
|
||||||
groupV2Record.hideStory = Boolean(conversation.get('hideStory'));
|
groupV2Record.hideStory = Boolean(conversation.get('hideStory'));
|
||||||
|
const storySendMode = conversation.get('storySendMode');
|
||||||
|
if (storySendMode !== undefined) {
|
||||||
|
if (storySendMode === StorySendMode.IfActive) {
|
||||||
|
groupV2Record.storySendMode = Proto.GroupV2Record.StorySendMode.DEFAULT;
|
||||||
|
} else if (storySendMode === StorySendMode.Never) {
|
||||||
|
groupV2Record.storySendMode = Proto.GroupV2Record.StorySendMode.DISABLED;
|
||||||
|
} else if (storySendMode === StorySendMode.Always) {
|
||||||
|
groupV2Record.storySendMode = Proto.GroupV2Record.StorySendMode.ENABLED;
|
||||||
|
} else {
|
||||||
|
throw missingCaseError(storySendMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
applyUnknownFields(groupV2Record, conversation);
|
applyUnknownFields(groupV2Record, conversation);
|
||||||
|
|
||||||
|
@ -785,6 +797,23 @@ export async function mergeGroupV2Record(
|
||||||
const oldStorageID = conversation.get('storageID');
|
const oldStorageID = conversation.get('storageID');
|
||||||
const oldStorageVersion = conversation.get('storageVersion');
|
const oldStorageVersion = conversation.get('storageVersion');
|
||||||
|
|
||||||
|
const recordStorySendMode =
|
||||||
|
groupV2Record.storySendMode ?? Proto.GroupV2Record.StorySendMode.DEFAULT;
|
||||||
|
let storySendMode: StorySendMode;
|
||||||
|
if (recordStorySendMode === Proto.GroupV2Record.StorySendMode.DEFAULT) {
|
||||||
|
storySendMode = StorySendMode.IfActive;
|
||||||
|
} else if (
|
||||||
|
recordStorySendMode === Proto.GroupV2Record.StorySendMode.DISABLED
|
||||||
|
) {
|
||||||
|
storySendMode = StorySendMode.Never;
|
||||||
|
} else if (
|
||||||
|
recordStorySendMode === Proto.GroupV2Record.StorySendMode.ENABLED
|
||||||
|
) {
|
||||||
|
storySendMode = StorySendMode.Always;
|
||||||
|
} else {
|
||||||
|
throw missingCaseError(recordStorySendMode);
|
||||||
|
}
|
||||||
|
|
||||||
conversation.set({
|
conversation.set({
|
||||||
hideStory: Boolean(groupV2Record.hideStory),
|
hideStory: Boolean(groupV2Record.hideStory),
|
||||||
isArchived: Boolean(groupV2Record.archived),
|
isArchived: Boolean(groupV2Record.archived),
|
||||||
|
@ -794,6 +823,7 @@ export async function mergeGroupV2Record(
|
||||||
),
|
),
|
||||||
storageID,
|
storageID,
|
||||||
storageVersion,
|
storageVersion,
|
||||||
|
storySendMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
conversation.setMuteExpiration(
|
conversation.setMuteExpiration(
|
||||||
|
|
14
ts/signal.ts
14
ts/signal.ts
|
@ -69,12 +69,7 @@ import { initializeGroupCredentialFetcher } from './services/groupCredentialFetc
|
||||||
import { initializeNetworkObserver } from './services/networkObserver';
|
import { initializeNetworkObserver } from './services/networkObserver';
|
||||||
import { initializeUpdateListener } from './services/updateListener';
|
import { initializeUpdateListener } from './services/updateListener';
|
||||||
import { calling } from './services/calling';
|
import { calling } from './services/calling';
|
||||||
import {
|
import * as storage from './services/storage';
|
||||||
enableStorageService,
|
|
||||||
eraseAllStorageServiceState,
|
|
||||||
runStorageServiceSyncJob,
|
|
||||||
storageServiceUploadJob,
|
|
||||||
} from './services/storage';
|
|
||||||
|
|
||||||
import type { LoggerType } from './types/Logging';
|
import type { LoggerType } from './types/Logging';
|
||||||
import type {
|
import type {
|
||||||
|
@ -449,13 +444,12 @@ export const setup = (options: {
|
||||||
|
|
||||||
const Services = {
|
const Services = {
|
||||||
calling,
|
calling,
|
||||||
enableStorageService,
|
|
||||||
eraseAllStorageServiceState,
|
|
||||||
initializeGroupCredentialFetcher,
|
initializeGroupCredentialFetcher,
|
||||||
initializeNetworkObserver,
|
initializeNetworkObserver,
|
||||||
initializeUpdateListener,
|
initializeUpdateListener,
|
||||||
runStorageServiceSyncJob,
|
|
||||||
storageServiceUploadJob,
|
// Testing
|
||||||
|
storage,
|
||||||
};
|
};
|
||||||
|
|
||||||
const State = {
|
const State = {
|
||||||
|
|
|
@ -49,6 +49,7 @@ import type { BodyRangeType } from '../../types/Util';
|
||||||
import { CallMode } from '../../types/Calling';
|
import { CallMode } from '../../types/Calling';
|
||||||
import type { MediaItemType } from '../../types/MediaItem';
|
import type { MediaItemType } from '../../types/MediaItem';
|
||||||
import type { UUIDStringType } from '../../types/UUID';
|
import type { UUIDStringType } from '../../types/UUID';
|
||||||
|
import { StorySendMode } from '../../types/Stories';
|
||||||
import {
|
import {
|
||||||
getGroupSizeRecommendedLimit,
|
getGroupSizeRecommendedLimit,
|
||||||
getGroupSizeHardLimit,
|
getGroupSizeHardLimit,
|
||||||
|
@ -215,7 +216,7 @@ export type ConversationType = {
|
||||||
groupVersion?: 1 | 2;
|
groupVersion?: 1 | 2;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
groupLink?: string;
|
groupLink?: string;
|
||||||
isGroupStorySendReady?: boolean;
|
storySendMode?: StorySendMode;
|
||||||
messageRequestsEnabled?: boolean;
|
messageRequestsEnabled?: boolean;
|
||||||
acceptedMessageRequest: boolean;
|
acceptedMessageRequest: boolean;
|
||||||
secretParams?: string;
|
secretParams?: string;
|
||||||
|
@ -2098,10 +2099,17 @@ function toggleGroupsForStorySend(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldStorySendMode = conversation.getStorySendMode();
|
||||||
|
const newStorySendMode =
|
||||||
|
oldStorySendMode === StorySendMode.Always
|
||||||
|
? StorySendMode.Never
|
||||||
|
: StorySendMode.Always;
|
||||||
|
|
||||||
conversation.set({
|
conversation.set({
|
||||||
isGroupStorySendReady: !conversation.get('isGroupStorySendReady'),
|
storySendMode: newStorySendMode,
|
||||||
});
|
});
|
||||||
await window.Signal.Data.updateConversation(conversation.attributes);
|
await window.Signal.Data.updateConversation(conversation.attributes);
|
||||||
|
conversation.captureChange('storySendMode');
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ import type {
|
||||||
MessagesByConversationType,
|
MessagesByConversationType,
|
||||||
PreJoinConversationType,
|
PreJoinConversationType,
|
||||||
} from '../ducks/conversations';
|
} from '../ducks/conversations';
|
||||||
|
import type { StoriesStateType } from '../ducks/stories';
|
||||||
import type { UsernameSaveState } from '../ducks/conversationsEnums';
|
import type { UsernameSaveState } from '../ducks/conversationsEnums';
|
||||||
import {
|
import {
|
||||||
ComposerStep,
|
ComposerStep,
|
||||||
|
@ -43,6 +44,7 @@ import {
|
||||||
isGroupV1,
|
isGroupV1,
|
||||||
isGroupV2,
|
isGroupV2,
|
||||||
} from '../../util/whatTypeOfConversation';
|
} from '../../util/whatTypeOfConversation';
|
||||||
|
import { isGroupInStoryMode } from '../../util/isGroupInStoryMode';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getIntl,
|
getIntl,
|
||||||
|
@ -531,18 +533,37 @@ export const getComposableGroups = createSelector(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getConversationIdsWithStories = createSelector(
|
||||||
|
(state: StateType): StoriesStateType => state.stories,
|
||||||
|
(stories: StoriesStateType): Set<string> => {
|
||||||
|
return new Set(stories.stories.map(({ conversationId }) => conversationId));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const getNonGroupStories = createSelector(
|
export const getNonGroupStories = createSelector(
|
||||||
getComposableGroups,
|
getComposableGroups,
|
||||||
(groups: Array<ConversationType>): Array<ConversationType> =>
|
getConversationIdsWithStories,
|
||||||
groups.filter(group => !group.isGroupStorySendReady)
|
(
|
||||||
|
groups: Array<ConversationType>,
|
||||||
|
conversationIdsWithStories: Set<string>
|
||||||
|
): Array<ConversationType> => {
|
||||||
|
return groups.filter(
|
||||||
|
group => !isGroupInStoryMode(group, conversationIdsWithStories)
|
||||||
|
);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getGroupStories = createSelector(
|
export const getGroupStories = createSelector(
|
||||||
getConversationLookup,
|
getConversationLookup,
|
||||||
(conversationLookup: ConversationLookupType): Array<ConversationType> =>
|
getConversationIdsWithStories,
|
||||||
Object.values(conversationLookup).filter(
|
(
|
||||||
conversation => conversation.isGroupStorySendReady
|
conversationLookup: ConversationLookupType,
|
||||||
)
|
conversationIdsWithStories: Set<string>
|
||||||
|
): Array<ConversationType> => {
|
||||||
|
return Object.values(conversationLookup).filter(conversation =>
|
||||||
|
isGroupInStoryMode(conversation, conversationIdsWithStories)
|
||||||
|
);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const getNormalizedComposerConversationSearchTerm = createSelector(
|
const getNormalizedComposerConversationSearchTerm = createSelector(
|
||||||
|
|
3
ts/types/StorageService.d.ts
vendored
3
ts/types/StorageService.d.ts
vendored
|
@ -8,6 +8,9 @@ export type ExtendedStorageID = {
|
||||||
|
|
||||||
export type RemoteRecord = ExtendedStorageID & {
|
export type RemoteRecord = ExtendedStorageID & {
|
||||||
itemType: number;
|
itemType: number;
|
||||||
|
|
||||||
|
// For compatibility with MergeableItemType
|
||||||
|
storageRecord?: void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UnknownRecord = RemoteRecord;
|
export type UnknownRecord = RemoteRecord;
|
||||||
|
|
|
@ -145,6 +145,12 @@ export enum HasStories {
|
||||||
Unread = 'Unread',
|
Unread = 'Unread',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum StorySendMode {
|
||||||
|
IfActive = 'IfActive',
|
||||||
|
Always = 'Always',
|
||||||
|
Never = 'Never',
|
||||||
|
}
|
||||||
|
|
||||||
const getStoriesAvailable = () =>
|
const getStoriesAvailable = () =>
|
||||||
isEnabled('desktop.stories') ||
|
isEnabled('desktop.stories') ||
|
||||||
isEnabled('desktop.internalUser') ||
|
isEnabled('desktop.internalUser') ||
|
||||||
|
|
23
ts/util/isGroupInStoryMode.ts
Normal file
23
ts/util/isGroupInStoryMode.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
import { StorySendMode } from '../types/Stories';
|
||||||
|
import { assertDev } from './assert';
|
||||||
|
|
||||||
|
export function isGroupInStoryMode(
|
||||||
|
{ id, type, storySendMode }: ConversationType,
|
||||||
|
conversationIdsWithStories: Set<string>
|
||||||
|
): boolean {
|
||||||
|
if (type !== 'group') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
assertDev(
|
||||||
|
storySendMode !== undefined,
|
||||||
|
'isGroupInStoryMode: groups must have storySendMode field'
|
||||||
|
);
|
||||||
|
if (storySendMode === StorySendMode.IfActive) {
|
||||||
|
return conversationIdsWithStories.has(id);
|
||||||
|
}
|
||||||
|
return storySendMode === StorySendMode.Always;
|
||||||
|
}
|
9
ts/window.d.ts
vendored
9
ts/window.d.ts
vendored
|
@ -5,7 +5,6 @@
|
||||||
|
|
||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file */
|
||||||
|
|
||||||
import type { Cancelable } from 'lodash';
|
|
||||||
import type { Store } from 'redux';
|
import type { Store } from 'redux';
|
||||||
import type * as Backbone from 'backbone';
|
import type * as Backbone from 'backbone';
|
||||||
import type * as Underscore from 'underscore';
|
import type * as Underscore from 'underscore';
|
||||||
|
@ -29,6 +28,7 @@ import type {
|
||||||
} from './challenge';
|
} from './challenge';
|
||||||
import type { WebAPIConnectType } from './textsecure/WebAPI';
|
import type { WebAPIConnectType } from './textsecure/WebAPI';
|
||||||
import type { CallingClass } from './services/calling';
|
import type { CallingClass } from './services/calling';
|
||||||
|
import type * as StorageService from './services/storage';
|
||||||
import type * as Groups from './groups';
|
import type * as Groups from './groups';
|
||||||
import type * as Crypto from './Crypto';
|
import type * as Crypto from './Crypto';
|
||||||
import type * as Curve from './Curve';
|
import type * as Curve from './Curve';
|
||||||
|
@ -143,17 +143,12 @@ export type SignalCoreType = {
|
||||||
RemoteConfig: typeof RemoteConfig;
|
RemoteConfig: typeof RemoteConfig;
|
||||||
Services: {
|
Services: {
|
||||||
calling: CallingClass;
|
calling: CallingClass;
|
||||||
enableStorageService: () => void;
|
|
||||||
eraseAllStorageServiceState: (options?: {
|
|
||||||
keepUnknownFields?: boolean | undefined;
|
|
||||||
}) => Promise<void>;
|
|
||||||
initializeGroupCredentialFetcher: () => Promise<void>;
|
initializeGroupCredentialFetcher: () => Promise<void>;
|
||||||
initializeNetworkObserver: (network: ReduxActions['network']) => void;
|
initializeNetworkObserver: (network: ReduxActions['network']) => void;
|
||||||
initializeUpdateListener: (updates: ReduxActions['updates']) => void;
|
initializeUpdateListener: (updates: ReduxActions['updates']) => void;
|
||||||
retryPlaceholders?: Util.RetryPlaceholders;
|
retryPlaceholders?: Util.RetryPlaceholders;
|
||||||
lightSessionResetQueue?: PQueue;
|
lightSessionResetQueue?: PQueue;
|
||||||
runStorageServiceSyncJob: (() => void) & Cancelable;
|
storage: typeof StorageService;
|
||||||
storageServiceUploadJob: (() => void) & Cancelable;
|
|
||||||
};
|
};
|
||||||
Migrations: ReturnType<typeof initializeMigrations>;
|
Migrations: ReturnType<typeof initializeMigrations>;
|
||||||
Types: {
|
Types: {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue