diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index b5e2d8219b25..05b9533c43b1 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -33,6 +33,9 @@ export type ConfigKeyType = | 'desktop.experimentalTransportEnabled.beta' | 'desktop.experimentalTransportEnabled.prod' | 'desktop.cdsiViaLibsignal' + | 'desktop.releaseNotes' + | 'desktop.releaseNotes.beta' + | 'desktop.releaseNotes.dev' | 'global.attachments.maxBytes' | 'global.attachments.maxReceiveBytes' | 'global.calling.maxGroupCallRingSize' diff --git a/ts/background.ts b/ts/background.ts index 03af700e2969..329192758d1a 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -198,6 +198,7 @@ import { restoreRemoteConfigFromStorage } from './RemoteConfig'; import { getParametersForRedux, loadAll } from './services/allLoaders'; import { checkFirstEnvelope } from './util/checkFirstEnvelope'; import { BLOCKED_UUIDS_ID } from './textsecure/storage/Blocked'; +import { ReleaseNotesFetcher } from './services/releaseNotesFetcher'; export function isOverHourIntoPast(timestamp: number): boolean { return isNumber(timestamp) && isOlderThan(timestamp, HOUR); @@ -2160,6 +2161,8 @@ export async function startApp(): Promise { } drop(usernameIntegrity.start()); + + drop(ReleaseNotesFetcher.init(window.Whisper.events, newVersion)); } let initialStartupCount = 0; diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 62a1a7909df5..48b27d0d4b51 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -631,6 +631,19 @@ function HeaderMenu({ )} + {conversation.isArchived ? ( + + {i18n('icu:moveConversationToInbox')} + + ) : ( + + {i18n('icu:archiveConversation')} + + )} + + + {i18n('icu:deleteConversation')} + ); } diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index 51cc76cbc84b..db376df73ab1 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -55,6 +55,7 @@ import { getChangesForPropAtTimestamp, } from '../../util/editHelpers'; import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp'; +import { isSignalConversation } from '../../util/isSignalConversation'; const MAX_CONCURRENT_ATTACHMENT_UPLOADS = 5; @@ -88,6 +89,13 @@ export async function sendNormalMessage( return; } + if (isSignalConversation(messageConversation)) { + log.error( + `Message conversation '${messageConversation?.idForLogging()}' is the Signal serviceId, not sending` + ); + return; + } + if (!isOutgoing(message.attributes)) { log.error( `message ${messageId} was not an outgoing message to begin with. This is probably a bogus job. Giving up on sending it` diff --git a/ts/jobs/helpers/shouldSendToConversation.ts b/ts/jobs/helpers/shouldSendToConversation.ts index dbe0d126af79..8d78a2e84ae4 100644 --- a/ts/jobs/helpers/shouldSendToConversation.ts +++ b/ts/jobs/helpers/shouldSendToConversation.ts @@ -5,6 +5,7 @@ import type { ConversationModel } from '../../models/conversations'; import type { LoggerType } from '../../types/Logging'; import { getRecipients } from '../../util/getRecipients'; import { isConversationAccepted } from '../../util/isConversationAccepted'; +import { isSignalConversation } from '../../util/isSignalConversation'; import { getUntrustedConversationServiceIds } from './getUntrustedConversationServiceIds'; export function shouldSendToConversation( @@ -35,5 +36,12 @@ export function shouldSendToConversation( return false; } + if (isSignalConversation(conversation.attributes)) { + log.info( + `conversation ${conversation.idForLogging()} is Signal conversation; refusing to send` + ); + return false; + } + return true; } diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 08edfc7e232c..1dbf4a37ba5e 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1343,6 +1343,10 @@ export class ConversationModel extends window.Backbone return; } + if (isSignalConversation(this.attributes)) { + return; + } + // Coalesce multiple sendTypingMessage calls into one. // // `lastIsTyping` is set to the last `isTyping` value passed to the diff --git a/ts/services/releaseNotesFetcher.ts b/ts/services/releaseNotesFetcher.ts new file mode 100644 index 000000000000..9ed2687c93f0 --- /dev/null +++ b/ts/services/releaseNotesFetcher.ts @@ -0,0 +1,330 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import semver from 'semver'; +import { last } from 'lodash'; + +import * as durations from '../util/durations'; +import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; +import * as Registration from '../util/registration'; +import * as log from '../logging/log'; +import * as Errors from '../types/errors'; +import { HTTPError } from '../textsecure/Errors'; +import { drop } from '../util/drop'; +import { strictAssert } from '../util/assert'; +import type { MessageAttributesType } from '../model-types'; +import { ReadStatus } from '../messages/MessageReadStatus'; +import { incrementMessageCounter } from '../util/incrementMessageCounter'; +import { SeenStatus } from '../MessageSeenStatus'; +import { saveNewMessageBatcher } from '../util/messageBatcher'; +import { generateMessageId } from '../util/generateMessageId'; +import { BodyRange } from '../types/BodyRange'; +import * as RemoteConfig from '../RemoteConfig'; +import { isBeta, isProduction } from '../util/version'; +import type { + ReleaseNotesManifestResponseType, + ReleaseNoteResponseType, +} from '../textsecure/WebAPI'; +import type { WithRequiredProperties } from '../types/Util'; + +const FETCH_INTERVAL = 3 * durations.DAY; +const ERROR_RETRY_DELAY = 3 * durations.HOUR; +const NEXT_FETCH_TIME_STORAGE_KEY = 'releaseNotesNextFetchTime'; +const PREVIOUS_MANIFEST_HASH_STORAGE_KEY = 'releaseNotesPreviousManifestHash'; +const VERSION_WATERMARK_STORAGE_KEY = 'releaseNotesVersionWatermark'; + +type MinimalEventsType = { + on(event: 'timetravel', callback: () => void): void; +}; + +type ManifestReleaseNoteType = WithRequiredProperties< + ReleaseNotesManifestResponseType['announcements'][0], + 'desktopMinVersion' +>; + +export type ReleaseNoteType = ReleaseNoteResponseType & + Pick; + +let initComplete = false; + +export class ReleaseNotesFetcher { + private timeout: NodeJS.Timeout | undefined; + private isRunning = false; + + protected async scheduleUpdateForNow(): Promise { + const now = Date.now(); + await window.textsecure.storage.put(NEXT_FETCH_TIME_STORAGE_KEY, now); + } + + protected setTimeoutForNextRun(): void { + const now = Date.now(); + const time = window.textsecure.storage.get( + NEXT_FETCH_TIME_STORAGE_KEY, + now + ); + + log.info( + 'ReleaseNotesFetcher: Next update scheduled for', + new Date(time).toISOString() + ); + + let waitTime = time - now; + if (waitTime < 0) { + waitTime = 0; + } + + clearTimeoutIfNecessary(this.timeout); + this.timeout = setTimeout(() => this.runWhenOnline(), waitTime); + } + + private getOrInitializeVersionWatermark(): string { + const versionWatermark = window.textsecure.storage.get( + VERSION_WATERMARK_STORAGE_KEY + ); + if (versionWatermark) { + return versionWatermark; + } + + log.info( + 'ReleaseNotesFetcher: Initializing version high watermark to current version' + ); + const currentVersion = window.getVersion(); + drop( + window.textsecure.storage.put( + VERSION_WATERMARK_STORAGE_KEY, + currentVersion + ) + ); + return currentVersion; + } + + private async getReleaseNote( + note: ManifestReleaseNoteType + ): Promise { + if (!window.textsecure.server) { + return undefined; + } + + const { uuid, ctaId, link } = note; + const result = await window.textsecure.server.getReleaseNote({ + uuid, + }); + strictAssert( + result.uuid === uuid, + 'UUID of localized release note should match requested UUID' + ); + + return { + ...result, + uuid, + ctaId, + link, + }; + } + + private async processReleaseNotes( + notes: ReadonlyArray + ): Promise { + const sortedNotes = [...notes].sort( + (a: ManifestReleaseNoteType, b: ManifestReleaseNoteType) => + semver.compare(a.desktopMinVersion, b.desktopMinVersion) + ); + const hydratedNotes = []; + for (const note of sortedNotes) { + // eslint-disable-next-line no-await-in-loop + hydratedNotes.push(await this.getReleaseNote(note)); + } + if (!hydratedNotes.length) { + log.warn('ReleaseNotesFetcher: No hydrated notes available, stopping'); + return; + } + + log.info('ReleaseNotesFetcher: Ensuring Signal conversation'); + const signalConversation = + await window.ConversationController.getOrCreateSignalConversation(); + + const messages: Array = []; + hydratedNotes.forEach(async (note, index) => { + if (!note) { + return; + } + + const { title, body } = note; + const messageBody = `${title}\n\n${body}`; + const bodyRanges = [ + { start: 0, length: title.length, style: BodyRange.Style.BOLD }, + ]; + const timestamp = Date.now() + index; + + const message: MessageAttributesType = { + ...generateMessageId(incrementMessageCounter()), + body: messageBody, + bodyRanges, + conversationId: signalConversation.id, + readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, + received_at_ms: timestamp, + sent_at: timestamp, + serverTimestamp: timestamp, + sourceDevice: 1, + sourceServiceId: signalConversation.getServiceId(), + timestamp, + type: 'incoming', + }; + + window.MessageCache.toMessageAttributes(message); + signalConversation.trigger('newmessage', message); + + messages.push(message); + }); + + await Promise.all( + messages.map(message => saveNewMessageBatcher.add(message)) + ); + + signalConversation.set({ active_at: Date.now(), isArchived: false }); + drop(signalConversation.updateUnread()); + + const newestNote = last(sortedNotes); + strictAssert(newestNote, 'processReleaseNotes requires at least 1 note'); + + const versionWatermark = newestNote.desktopMinVersion; + log.info( + `ReleaseNotesFetcher: Updating version watermark to ${versionWatermark}` + ); + drop( + window.textsecure.storage.put( + VERSION_WATERMARK_STORAGE_KEY, + versionWatermark + ) + ); + } + + private async scheduleForNextRun(): Promise { + const now = Date.now(); + const nextTime = now + FETCH_INTERVAL; + await window.textsecure.storage.put(NEXT_FETCH_TIME_STORAGE_KEY, nextTime); + } + + private async run(): Promise { + if (this.isRunning) { + log.warn('ReleaseNotesFetcher: Already running, preventing reentrancy'); + return; + } + + this.isRunning = true; + log.info('ReleaseNotesFetcher: Starting'); + try { + const versionWatermark = this.getOrInitializeVersionWatermark(); + log.info(`ReleaseNotesFetcher: Version watermark is ${versionWatermark}`); + + if (!window.textsecure.server) { + log.info('ReleaseNotesFetcher: WebAPI unavailable'); + throw new Error('WebAPI unavailable'); + } + + const hash = await window.textsecure.server.getReleaseNotesManifestHash(); + if (!hash) { + throw new Error('Release notes manifest hash missing'); + } + + const previousHash = window.textsecure.storage.get( + PREVIOUS_MANIFEST_HASH_STORAGE_KEY + ); + if (hash !== previousHash) { + log.info('ReleaseNotesFetcher: Manifest hash changed, fetching'); + const manifest = + await window.textsecure.server.getReleaseNotesManifest(); + const validNotes = manifest.announcements.filter( + (note): note is ManifestReleaseNoteType => + note.desktopMinVersion != null && + semver.gt(note.desktopMinVersion, versionWatermark) + ); + if (validNotes.length) { + log.info( + `ReleaseNotesFetcher: Processing ${validNotes.length} new release notes` + ); + drop(this.processReleaseNotes(validNotes)); + } else { + log.info('ReleaseNotesFetcher: No new release notes'); + } + + drop( + window.textsecure.storage.put( + PREVIOUS_MANIFEST_HASH_STORAGE_KEY, + hash + ) + ); + } else { + log.info('ReleaseNotesFetcher: Manifest hash unchanged'); + } + + await this.scheduleForNextRun(); + this.setTimeoutForNextRun(); + } catch (error) { + const errorString = + error instanceof HTTPError + ? error.code.toString() + : Errors.toLogFormat(error); + log.error( + `ReleaseNotesFetcher: Error, trying again later. ${errorString}` + ); + setTimeout(() => this.setTimeoutForNextRun(), ERROR_RETRY_DELAY); + } finally { + this.isRunning = false; + } + } + + private runWhenOnline() { + if (window.textsecure.server?.isOnline()) { + drop(this.run()); + } else { + log.info( + 'ReleaseNotesFetcher: We are offline; will fetch when we are next online' + ); + const listener = () => { + window.Whisper.events.off('online', listener); + this.setTimeoutForNextRun(); + }; + window.Whisper.events.on('online', listener); + } + } + + public static async init( + events: MinimalEventsType, + isNewVersion: boolean + ): Promise { + if (initComplete || !this.isEnabled()) { + return; + } + + initComplete = true; + + const listener = new ReleaseNotesFetcher(); + + if (isNewVersion) { + await listener.scheduleUpdateForNow(); + } + listener.setTimeoutForNextRun(); + + events.on('timetravel', () => { + if (Registration.isDone()) { + listener.setTimeoutForNextRun(); + } + }); + } + + public static isEnabled(): boolean { + const version = window.getVersion(); + + if (isProduction(version)) { + return RemoteConfig.isEnabled('desktop.releaseNotes'); + } + + if (isBeta(version)) { + return RemoteConfig.isEnabled('desktop.releaseNotes.beta'); + } + + return RemoteConfig.isEnabled('desktop.releaseNotes.dev'); + } +} diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index f0e674d9c4e2..cb1d00f8e8dc 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -380,10 +380,6 @@ export const _getLeftPaneLists = ( }; } - if (isSignalConversation(conversation)) { - continue; - } - // We always show pinned conversations if (conversation.isPinned) { pinnedConversations.push(conversation); diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index aeed03a362e7..aadd8851ff2e 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -43,6 +43,7 @@ import { getKeysForServiceId } from './getKeysForServiceId'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; import type { GroupSendToken } from '../types/GroupSendEndorsements'; +import { isSignalServiceId } from '../util/isSignalConversation'; export const enum SenderCertificateMode { WithE164, @@ -686,6 +687,15 @@ export default class OutgoingMessage { } async sendToServiceId(serviceId: ServiceIdString): Promise { + if (isSignalServiceId(serviceId)) { + this.registerError( + serviceId, + 'Failed to send to Signal serviceId', + new Error("Can't send to Signal serviceId") + ); + return; + } + try { const ourAci = window.textsecure.storage.user.getCheckedAci(); const deviceIds = await window.textsecure.storage.protocol.getDeviceIds({ diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 055317ca843a..a2430ad1ad3a 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -657,6 +657,8 @@ const URL_CALLS = { callLinkCreateAuth: 'v1/call-link/create-auth', registration: 'v1/registration', registerCapabilities: 'v1/devices/capabilities', + releaseNotesManifest: 'dynamic/release-notes/release-notes-v2.json', + releaseNotes: 'static/release-notes', reportMessage: 'v1/messages/report', setBackupId: 'v1/archives/backupid', setBackupSignatureKey: 'v1/archives/keys', @@ -1209,6 +1211,56 @@ export type GetBackupInfoResponseType = z.infer< typeof getBackupInfoResponseSchema >; +export type GetReleaseNoteOptionsType = Readonly<{ + uuid: string; +}>; + +export const releaseNoteSchema = z.object({ + uuid: z.string(), + title: z.string(), + body: z.string(), + linkText: z.string().optional(), + callToActionText: z.string().optional(), + includeBoostMessage: z.boolean().optional().default(true), + bodyRanges: z + .array( + z.object({ + style: z.string(), + start: z.number(), + length: z.number(), + }) + ) + .optional(), + media: z.string().optional(), + mediaHeight: z.coerce + .number() + .optional() + .transform(x => x || undefined), + mediaWidth: z.coerce + .number() + .optional() + .transform(x => x || undefined), + mediaContentType: z.string().optional(), +}); + +export type ReleaseNoteResponseType = z.infer; + +export const releaseNotesManifestSchema = z.object({ + announcements: z + .object({ + uuid: z.string(), + countries: z.string().optional(), + desktopMinVersion: z.string().optional(), + link: z.string().optional(), + ctaId: z.string().optional(), + }) + .array(), +}); + +export type ReleaseNotesManifestResponseType = z.infer< + typeof releaseNotesManifestSchema +>; + export type CallLinkCreateAuthResponseType = Readonly<{ credential: string; }>; @@ -1339,6 +1391,11 @@ export type WebAPIType = { getSenderCertificate: ( withUuid?: boolean ) => Promise; + getReleaseNote: ( + options: GetReleaseNoteOptionsType + ) => Promise; + getReleaseNotesManifest: () => Promise; + getReleaseNotesManifestHash: () => Promise; getSticker: (packId: string, stickerId: number) => Promise; getStickerPackManifest: (packId: string) => Promise; getStorageCredentials: MessageSender['getStorageCredentials']; @@ -1807,6 +1864,9 @@ export function initialize({ getProfile, getProfileUnauth, getProvisioningResource, + getReleaseNote, + getReleaseNotesManifest, + getReleaseNotesManifestHash, getTransferArchive, getSenderCertificate, getSocketStatus, @@ -2099,6 +2159,44 @@ export function initialize({ languages: Record>; }; } + async function getReleaseNote({ + uuid, + }: GetReleaseNoteOptionsType): Promise { + const rawRes = await _ajax({ + call: 'releaseNotes', + host: resourcesUrl, + httpType: 'GET', + responseType: 'json', + urlParameters: `/${uuid}/en.json`, + }); + return parseUnknown(releaseNoteSchema, rawRes); + } + + async function getReleaseNotesManifest(): Promise { + const rawRes = await _ajax({ + call: 'releaseNotesManifest', + host: resourcesUrl, + httpType: 'GET', + responseType: 'json', + }); + return parseUnknown(releaseNotesManifestSchema, rawRes); + } + + async function getReleaseNotesManifestHash(): Promise { + const { response } = await _ajax({ + call: 'releaseNotesManifest', + host: resourcesUrl, + httpType: 'HEAD', + responseType: 'byteswithdetails', + }); + + const etag = response.headers.get('etag'); + if (etag == null) { + return undefined; + } + + return etag; + } async function getStorageManifest( options: StorageServiceCallOptionsType = {} diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 93da2d23ea07..f4bb5a774d19 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -196,6 +196,9 @@ export type StorageAccessType = { // Note: Upon capability deprecation - change the value type to `never` and // remove it in `ts/background.ts` }; + releaseNotesNextFetchTime: number; + releaseNotesVersionWatermark: string; + releaseNotesPreviousManifestHash: string; // If present - we are downloading backup backupDownloadPath: string; diff --git a/ts/util/isSignalConversation.ts b/ts/util/isSignalConversation.ts index a0016598ff27..bdbc65339cf9 100644 --- a/ts/util/isSignalConversation.ts +++ b/ts/util/isSignalConversation.ts @@ -16,3 +16,7 @@ export function isSignalConversation(conversation: { return window.ConversationController.isSignalConversationId(id); } + +export function isSignalServiceId(serviceId: ServiceIdString): boolean { + return serviceId === SIGNAL_ACI; +} diff --git a/ts/util/sendReceipts.ts b/ts/util/sendReceipts.ts index e2fbbc769db8..bcfcc00ba596 100644 --- a/ts/util/sendReceipts.ts +++ b/ts/util/sendReceipts.ts @@ -12,6 +12,7 @@ import { isConversationUnregistered } from './isConversationUnregistered'; import { missingCaseError } from './missingCaseError'; import type { ConversationModel } from '../models/conversations'; import { mapEmplace } from './mapEmplace'; +import { isSignalConversation } from './isSignalConversation'; const CHUNK_SIZE = 100; @@ -124,7 +125,12 @@ export async function sendReceipts({ ); return; } - + if (isSignalConversation(sender.attributes)) { + log.info( + `conversation ${sender.idForLogging()} is Signal conversation; refusing to send` + ); + return; + } log.info(`Sending receipt of type ${type} to ${sender.idForLogging()}`); const conversation = window.ConversationController.get(conversationId);