Initial logic for release notes

This commit is contained in:
ayumi-signal 2024-11-27 14:11:53 -08:00 committed by GitHub
parent 34ef8dc2c8
commit c5301688a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 491 additions and 5 deletions

View file

@ -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'

View file

@ -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<void> {
}
drop(usernameIntegrity.start());
drop(ReleaseNotesFetcher.init(window.Whisper.events, newVersion));
}
let initialStartupCount = 0;

View file

@ -631,6 +631,19 @@ function HeaderMenu({
</MenuItem>
)}
</SubMenu>
{conversation.isArchived ? (
<MenuItem onClick={onConversationUnarchive}>
{i18n('icu:moveConversationToInbox')}
</MenuItem>
) : (
<MenuItem onClick={onConversationArchive}>
{i18n('icu:archiveConversation')}
</MenuItem>
)}
<MenuItem onClick={onConversationDeleteMessages}>
{i18n('icu:deleteConversation')}
</MenuItem>
</ContextMenu>
);
}

View file

@ -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`

View file

@ -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;
}

View file

@ -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

View file

@ -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<ReleaseNotesManifestResponseType['announcements'][0], 'ctaId' | 'link'>;
let initComplete = false;
export class ReleaseNotesFetcher {
private timeout: NodeJS.Timeout | undefined;
private isRunning = false;
protected async scheduleUpdateForNow(): Promise<void> {
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<ReleaseNoteType | undefined> {
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<ManifestReleaseNoteType>
): Promise<void> {
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<MessageAttributesType> = [];
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<void> {
const now = Date.now();
const nextTime = now + FETCH_INTERVAL;
await window.textsecure.storage.put(NEXT_FETCH_TIME_STORAGE_KEY, nextTime);
}
private async run(): Promise<void> {
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<void> {
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');
}
}

View file

@ -380,10 +380,6 @@ export const _getLeftPaneLists = (
};
}
if (isSignalConversation(conversation)) {
continue;
}
// We always show pinned conversations
if (conversation.isPinned) {
pinnedConversations.push(conversation);

View file

@ -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<void> {
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({

View file

@ -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<typeof releaseNoteSchema>;
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<GetSenderCertificateResultType>;
getReleaseNote: (
options: GetReleaseNoteOptionsType
) => Promise<ReleaseNoteResponseType>;
getReleaseNotesManifest: () => Promise<ReleaseNotesManifestResponseType>;
getReleaseNotesManifestHash: () => Promise<string | undefined>;
getSticker: (packId: string, stickerId: number) => Promise<Uint8Array>;
getStickerPackManifest: (packId: string) => Promise<StickerPackManifestType>;
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<string, Array<string>>;
};
}
async function getReleaseNote({
uuid,
}: GetReleaseNoteOptionsType): Promise<ReleaseNoteResponseType> {
const rawRes = await _ajax({
call: 'releaseNotes',
host: resourcesUrl,
httpType: 'GET',
responseType: 'json',
urlParameters: `/${uuid}/en.json`,
});
return parseUnknown(releaseNoteSchema, rawRes);
}
async function getReleaseNotesManifest(): Promise<ReleaseNotesManifestResponseType> {
const rawRes = await _ajax({
call: 'releaseNotesManifest',
host: resourcesUrl,
httpType: 'GET',
responseType: 'json',
});
return parseUnknown(releaseNotesManifestSchema, rawRes);
}
async function getReleaseNotesManifestHash(): Promise<string | undefined> {
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 = {}

View file

@ -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;

View file

@ -16,3 +16,7 @@ export function isSignalConversation(conversation: {
return window.ConversationController.isSignalConversationId(id);
}
export function isSignalServiceId(serviceId: ServiceIdString): boolean {
return serviceId === SIGNAL_ACI;
}

View file

@ -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);