diff --git a/app/main.ts b/app/main.ts index 61221a548..88842a3a8 100644 --- a/app/main.ts +++ b/app/main.ts @@ -399,6 +399,7 @@ async function prepareUrl( serverUrl: config.get('serverUrl'), storageUrl: config.get('storageUrl'), updatesUrl: config.get('updatesUrl'), + resourcesUrl: config.get('resourcesUrl'), cdnUrl0: config.get('cdn').get('0'), cdnUrl2: config.get('cdn').get('2'), certificateAuthority: config.get('certificateAuthority'), diff --git a/config/default.json b/config/default.json index 51027f30a..e21ecc254 100644 --- a/config/default.json +++ b/config/default.json @@ -9,6 +9,7 @@ }, "contentProxyUrl": "http://contentproxy.signal.org:443", "updatesUrl": "https://updates2.signal.org/desktop", + "resourcesUrl": "https://updates2.signal.org", "updatesPublicKey": "05fd7dd3de7149dc0a127909fee7de0f7620ddd0de061b37a2c303e37de802a401", "sfuUrl": "https://sfu.voip.signal.org/", "updatesEnabled": false, diff --git a/images/icons/v2/official-20.svg b/images/icons/v2/official-20.svg new file mode 100644 index 000000000..74b149364 --- /dev/null +++ b/images/icons/v2/official-20.svg @@ -0,0 +1,4 @@ + + + + diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 10a6dc0dd..0507a433e 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -169,7 +169,7 @@ message AccountRecord { optional bool displayBadgesOnProfile = 23; optional bool keepMutedChatsArchived = 25; optional bool hasSetMyStoriesPrivacy = 26; - reserved /* hasViewedOnboardingStory */ 27; + optional bool hasViewedOnboardingStory = 27; reserved 28; // deprecatedStoriesDisabled optional bool storiesDisabled = 29; optional OptionalBool storyViewReceiptsEnabled = 30; diff --git a/sticker-creator/window/phase3-sticker-functions.ts b/sticker-creator/window/phase3-sticker-functions.ts index 111f5d66b..49ed5dd41 100644 --- a/sticker-creator/window/phase3-sticker-functions.ts +++ b/sticker-creator/window/phase3-sticker-functions.ts @@ -30,6 +30,7 @@ const WebAPI = initializeWebAPI({ url: config.serverUrl, storageUrl: config.storageUrl, updatesUrl: config.updatesUrl, + resourcesUrl: config.resourcesUrl, directoryConfig: config.directoryConfig, cdnUrlObject: { 0: config.cdnUrl0, diff --git a/stylesheets/components/Avatar.scss b/stylesheets/components/Avatar.scss index ecb6e34dc..f09517599 100644 --- a/stylesheets/components/Avatar.scss +++ b/stylesheets/components/Avatar.scss @@ -160,4 +160,18 @@ border-color: $color-ultramarine-dawn; } } + + &--signal-official { + .module-Avatar__contents { + align-items: center; + background-color: $color-ultramarine; + display: flex; + justify-content: center; + } + + .module-Avatar__image { + height: 66%; + width: 66%; + } + } } diff --git a/stylesheets/components/StoryListItem.scss b/stylesheets/components/StoryListItem.scss index 98a14f6cf..38db787bc 100644 --- a/stylesheets/components/StoryListItem.scss +++ b/stylesheets/components/StoryListItem.scss @@ -58,6 +58,8 @@ &--title { @include font-body-1-bold; color: $color-gray-05; + display: flex; + align-items: center; } &--timestamp, @@ -175,4 +177,12 @@ width: 12px; @include color-svg('../images/icons/v2/chevron-right-20.svg', $color-white); } + + &__signal-official { + background: url('../images/icons/v2/official-20.svg') no-repeat center; + display: inline-block; + height: 16px; + margin-left: 6px; + width: 16px; + } } diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 4f86e5ec6..8f2b5b43f 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -28,6 +28,7 @@ import { sleep } from './util/sleep'; import { isNotNil } from './util/isNotNil'; import { MINUTE, SECOND } from './util/durations'; import { getUuidsForE164s } from './util/getUuidsForE164s'; +import { SIGNAL_ACI, SIGNAL_AVATAR_PATH } from './types/Conversation'; type ConvoMatchType = | { @@ -129,8 +130,8 @@ const { export function start(): void { const conversations = new window.Whisper.ConversationCollection(); - window.getConversations = () => conversations; window.ConversationController = new ConversationController(conversations); + window.getConversations = () => conversations; } export class ConversationController { @@ -144,6 +145,8 @@ export class ConversationController { private _combineConversationsQueue = new PQueue({ concurrency: 1 }); + private _signalConversationId: undefined | string; + constructor(private _conversations: ConversationModelCollectionType) { const debouncedUpdateUnreadCount = debounce( this.updateUnreadCount.bind(this), @@ -406,6 +409,43 @@ export class ConversationController { return conversation; } + getOrCreateSignalConversation(): ConversationModel { + const conversation = this.getOrCreate(SIGNAL_ACI, 'private', { + muteExpiresAt: Number.MAX_SAFE_INTEGER, + profileAvatar: { path: SIGNAL_AVATAR_PATH }, + profileName: 'Signal', + profileSharing: true, + }); + + this._signalConversationId = conversation.id; + + return conversation; + } + + getSignalConversationId(): string { + if (this._signalConversationId) { + return this._signalConversationId; + } + + let conversation = this.get(SIGNAL_ACI); + + if (!conversation) { + conversation = this.getOrCreateSignalConversation(); + } + + this._signalConversationId = conversation.id; + + return conversation.id; + } + + isSignalConversation(uuidOrId: string): boolean { + if (uuidOrId === SIGNAL_ACI) { + return true; + } + + return this.getSignalConversationId() === uuidOrId; + } + areWePrimaryDevice(): boolean { const ourDeviceId = window.textsecure.storage.user.getDeviceId(); diff --git a/ts/background.ts b/ts/background.ts index 3e8bccf6e..1efe3c4cf 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -156,6 +156,7 @@ import MessageSender from './textsecure/SendMessage'; import type AccountManager from './textsecure/AccountManager'; import { onStoryRecipientUpdate } from './util/onStoryRecipientUpdate'; import { StoryViewModeType, StoryViewTargetType } from './types/Stories'; +import { downloadOnboardingStory } from './util/downloadOnboardingStory'; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; @@ -1031,6 +1032,7 @@ export async function startApp(): Promise { (async () => { menuOptions = await window.SignalContext.getMenuOptions(); })(), + downloadOnboardingStory(), ]); await window.ConversationController.checkForConflicts(); } catch (error) { diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 57d61ae6a..13b7ba6c2 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -25,6 +25,7 @@ import { getBadgeImageFileLocalPath } from '../badges/getBadgeImageFileLocalPath import { getInitials } from '../util/getInitials'; import { isBadgeVisible } from '../badges/isBadgeVisible'; import { shouldBlurAvatar } from '../util/shouldBlurAvatar'; +import { SIGNAL_AVATAR_PATH } from '../types/Conversation'; export enum AvatarBlur { NoBlur, @@ -295,7 +296,10 @@ export const Avatar: FunctionComponent = ({ 'module-Avatar', Boolean(storyRing) && 'module-Avatar--with-story', storyRing === HasStories.Unread && 'module-Avatar--with-story--unread', - className + className, + avatarPath === SIGNAL_AVATAR_PATH + ? 'module-Avatar--signal-official' + : undefined )} style={{ minWidth: size, diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 375dd89d2..1c9097b87 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -92,6 +92,7 @@ export type OwnProps = Readonly<{ ) => unknown; compositionApi?: MutableRefObject; conversationId: string; + uuid?: string; draftAttachments: ReadonlyArray; errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType; errorRecording: (e: ErrorDialogAudioRecorderType) => unknown; @@ -101,6 +102,7 @@ export type OwnProps = Readonly<{ isFetchingUUID?: boolean; isGroupV1AndDisabled?: boolean; isMissingMandatoryProfileSharing?: boolean; + isSignalConversation?: boolean; recordingState: RecordingState; isSMSOnly?: boolean; left?: boolean; @@ -176,6 +178,7 @@ export const CompositionArea = ({ processAttachments, removeAttachment, theme, + isSignalConversation, // AttachmentList draftAttachments, @@ -481,6 +484,11 @@ export const CompositionArea = ({ }; }, [setLarge]); + if (isSignalConversation) { + // TODO DESKTOP-4547 + return
; + } + if ( isBlocked || areWePending || diff --git a/ts/components/Stories.tsx b/ts/components/Stories.tsx index abcebd49a..c48ebf51b 100644 --- a/ts/components/Stories.tsx +++ b/ts/components/Stories.tsx @@ -30,8 +30,11 @@ export type PropsType = { addStoryData: AddStoryData; deleteStoryForEveryone: (story: StoryViewType) => unknown; getPreferredBadge: PreferredBadgeSelectorType; + hasViewReceiptSetting: boolean; hiddenStories: Array; i18n: LocalizerType; + isStoriesSettingsVisible: boolean; + isViewingStory: boolean; me: ConversationType; myStories: Array; onForwardStory: (storyId: string) => unknown; @@ -46,19 +49,19 @@ export type PropsType = { stories: Array; toggleHideStories: (conversationId: string) => unknown; toggleStoriesView: () => unknown; - viewUserStories: ViewUserStoriesActionCreatorType; viewStory: ViewStoryActionCreatorType; - isViewingStory: boolean; - isStoriesSettingsVisible: boolean; - hasViewReceiptSetting: boolean; + viewUserStories: ViewUserStoriesActionCreatorType; }; export const Stories = ({ addStoryData, deleteStoryForEveryone, getPreferredBadge, + hasViewReceiptSetting, hiddenStories, i18n, + isStoriesSettingsVisible, + isViewingStory, me, myStories, onForwardStory, @@ -73,11 +76,8 @@ export const Stories = ({ stories, toggleHideStories, toggleStoriesView, - viewUserStories, viewStory, - isViewingStory, - isStoriesSettingsVisible, - hasViewReceiptSetting, + viewUserStories, }: PropsType): JSX.Element => { const width = getWidthFromPreferredWidth(preferredWidthFromStorage, { requiresFullWidth: true, diff --git a/ts/components/StoryListItem.tsx b/ts/components/StoryListItem.tsx index 36bb83314..dd5ecfe2f 100644 --- a/ts/components/StoryListItem.tsx +++ b/ts/components/StoryListItem.tsx @@ -3,15 +3,16 @@ import React, { useState } from 'react'; import classNames from 'classnames'; -import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationStoryType, StoryViewType } from '../types/Stories'; -import { StoryViewTargetType, HasStories } from '../types/Stories'; +import type { ConversationType } from '../state/ducks/conversations'; import type { LocalizerType } from '../types/Util'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories'; import { Avatar, AvatarSize } from './Avatar'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenu } from './ContextMenu'; +import { SIGNAL_ACI } from '../types/Conversation'; +import { StoryViewTargetType, HasStories } from '../types/Stories'; import { MessageTimestamp } from './conversation/MessageTimestamp'; import { StoryImage } from './StoryImage'; @@ -97,6 +98,8 @@ export const StoryListItem = ({ const { firstName, title } = sender; + const isSignalOfficial = sender.uuid === SIGNAL_ACI; + let avatarStoryRing: HasStories | undefined; if (attachment) { avatarStoryRing = isUnread ? HasStories.Unread : HasStories.Read; @@ -109,40 +112,46 @@ export const StoryListItem = ({ repliesElement =
; } + const menuOptions = [ + { + icon: 'StoryListItem__icon--hide', + label: isHidden + ? i18n('StoryListItem__unhide') + : i18n('StoryListItem__hide'), + onClick: () => { + if (isHidden) { + onHideStory(conversationId); + } else { + setHasConfirmHideStory(true); + } + }, + }, + ]; + + if (!isSignalOfficial) { + menuOptions.push({ + icon: 'StoryListItem__icon--info', + label: i18n('StoryListItem__info'), + onClick: () => + viewUserStories({ + conversationId, + viewTarget: StoryViewTargetType.Details, + }), + }); + + menuOptions.push({ + icon: 'StoryListItem__icon--chat', + label: i18n('StoryListItem__go-to-chat'), + onClick: () => onGoToConversation(conversationId), + }); + } + return ( <> { - if (isHidden) { - onHideStory(conversationId); - } else { - setHasConfirmHideStory(true); - } - }, - }, - { - icon: 'StoryListItem__icon--info', - label: i18n('StoryListItem__info'), - onClick: () => - viewUserStories({ - conversationId, - viewTarget: StoryViewTargetType.Details, - }), - }, - { - icon: 'StoryListItem__icon--chat', - label: i18n('StoryListItem__go-to-chat'), - onClick: () => onGoToConversation(conversationId), - }, - ]} + menuOptions={menuOptions} moduleClassName={classNames('StoryListItem', { 'StoryListItem--hidden': isHidden, })} @@ -162,13 +171,18 @@ export const StoryListItem = ({ <>
{group ? group.title : title} + {isSignalOfficial && ( + + )}
- + {!isSignalOfficial && ( + + )} {repliesElement}
diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 0026f9bd1..a08dd7a16 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -69,6 +69,7 @@ export type PropsType = { hasAllStoriesMuted: boolean; hasViewReceiptSetting: boolean; i18n: LocalizerType; + isSignalConversation?: boolean; loadStoryReplies: (conversationId: string, messageId: string) => unknown; markStoryRead: (mId: string) => unknown; numStories: number; @@ -121,6 +122,7 @@ export const StoryViewer = ({ hasAllStoriesMuted, hasViewReceiptSetting, i18n, + isSignalConversation, loadStoryReplies, markStoryRead, numStories, @@ -454,47 +456,52 @@ export const StoryViewer = ({ const isSent = Boolean(sendState); - const contextMenuOptions: ReadonlyArray> = - isSent - ? [ - { - icon: 'StoryListItem__icon--info', - label: i18n('StoryListItem__info'), - onClick: () => setCurrentViewTarget(StoryViewTargetType.Details), - }, - { - icon: 'StoryListItem__icon--delete', - label: i18n('StoryListItem__delete'), - onClick: () => setConfirmDeleteStory(story), - }, - ] - : [ - { - icon: 'StoryListItem__icon--info', - label: i18n('StoryListItem__info'), - onClick: () => setCurrentViewTarget(StoryViewTargetType.Details), - }, - { - icon: 'StoryListItem__icon--hide', - label: isHidden - ? i18n('StoryListItem__unhide') - : i18n('StoryListItem__hide'), - onClick: () => { - if (isHidden) { - onHideStory(conversationId); - } else { - setHasConfirmHideStory(true); - } - }, - }, - { - icon: 'StoryListItem__icon--chat', - label: i18n('StoryListItem__go-to-chat'), - onClick: () => { - onGoToConversation(conversationId); - }, - }, - ]; + let contextMenuOptions: + | ReadonlyArray> + | undefined; + + if (isSent) { + contextMenuOptions = [ + { + icon: 'StoryListItem__icon--info', + label: i18n('StoryListItem__info'), + onClick: () => setCurrentViewTarget(StoryViewTargetType.Details), + }, + { + icon: 'StoryListItem__icon--delete', + label: i18n('StoryListItem__delete'), + onClick: () => setConfirmDeleteStory(story), + }, + ]; + } else if (!isSignalConversation) { + contextMenuOptions = [ + { + icon: 'StoryListItem__icon--info', + label: i18n('StoryListItem__info'), + onClick: () => setCurrentViewTarget(StoryViewTargetType.Details), + }, + { + icon: 'StoryListItem__icon--hide', + label: isHidden + ? i18n('StoryListItem__unhide') + : i18n('StoryListItem__hide'), + onClick: () => { + if (isHidden) { + onHideStory(conversationId); + } else { + setHasConfirmHideStory(true); + } + }, + }, + { + icon: 'StoryListItem__icon--chat', + label: i18n('StoryListItem__go-to-chat'), + onClick: () => { + onGoToConversation(conversationId); + }, + }, + ]; + } return ( @@ -685,14 +692,16 @@ export const StoryViewer = ({ } type="button" /> - + {contextMenuOptions && ( + + )}
diff --git a/ts/components/conversation/ContactName.tsx b/ts/components/conversation/ContactName.tsx index 230fe76fa..b50e6e8fc 100644 --- a/ts/components/conversation/ContactName.tsx +++ b/ts/components/conversation/ContactName.tsx @@ -11,6 +11,7 @@ import { getClassNamesFor } from '../../util/getClassNamesFor'; export type PropsType = { contactNameColor?: ContactNameColorType; firstName?: string; + isSignalConversation?: boolean; module?: string; preferFirstName?: boolean; title: string; @@ -19,6 +20,7 @@ export type PropsType = { export const ContactName = ({ contactNameColor, firstName, + isSignalConversation, module, preferFirstName, title, @@ -41,6 +43,9 @@ export const ContactName = ({ dir="auto" > + {isSignalConversation && ( + + )} ); }; diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 26bc31193..5c50a776d 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -27,6 +27,7 @@ import { getMuteOptions } from '../../util/getMuteOptions'; import * as expirationTimer from '../../util/expirationTimer'; import { missingCaseError } from '../../util/missingCaseError'; import { isInSystemContacts } from '../../util/isInSystemContacts'; +import { isConversationMuted } from '../../util/isConversationMuted'; import { useStartCallShortcuts, useKeyboardShortcuts, @@ -47,6 +48,7 @@ export type PropsDataType = { outgoingCallButtonStyle: OutgoingCallButtonStyle; showBackButton?: boolean; isSMSOnly?: boolean; + isSignalConversation?: boolean; theme: ThemeType; } & Pick< ConversationType, @@ -329,6 +331,7 @@ export class ConversationHeader extends React.Component { isArchived, isMissingMandatoryProfileSharing, isPinned, + isSignalConversation, left, markedUnread, muteExpiresAt, @@ -347,10 +350,37 @@ export class ConversationHeader extends React.Component { const muteOptions = getMuteOptions(muteExpiresAt, i18n); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const disappearingTitle = i18n('icu:disappearingMessages') as any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const muteTitle = i18n('muteNotificationsTitle') as any; + const muteTitle = {i18n('muteNotificationsTitle')}; + + if (isSignalConversation) { + const isMuted = muteExpiresAt && isConversationMuted({ muteExpiresAt }); + + return ( + + + {isMuted ? ( + { + onSetMuteNotifications(0); + }} + > + {i18n('unmute')} + + ) : ( + { + onSetMuteNotifications(Number.MAX_SAFE_INTEGER); + }} + > + {i18n('muteAlways')} + + )} + + + ); + } + + const disappearingTitle = {i18n('icu:disappearingMessages')}; const isGroup = type === 'group'; const disableTimerChanges = Boolean( @@ -545,6 +575,7 @@ export class ConversationHeader extends React.Component { i18n, id, isSMSOnly, + isSignalConversation, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, onSetDisappearingMessages, @@ -594,7 +625,7 @@ export class ConversationHeader extends React.Component { > {this.renderBackButton()} {this.renderHeader()} - {!isSMSOnly && ( + {!isSMSOnly && !isSignalConversation && (

- {isMe ? i18n('noteToSelf') : } + {isMe ? ( + i18n('noteToSelf') + ) : ( + + )}

{about && !isMe && (
@@ -207,20 +216,23 @@ export const ConversationHero = ({ )}
) : null} - {renderMembershipRow({ - acceptedMessageRequest, - conversationType, - i18n, - isMe, - onClickMessageRequestWarning() { - setIsShowingMessageRequestWarning(true); - }, - phoneNumber, - sharedGroupNames, - })} -
- {i18n('messageHistoryUnsynced')} -
+ {!isSignalConversation && + renderMembershipRow({ + acceptedMessageRequest, + conversationType, + i18n, + isMe, + onClickMessageRequestWarning() { + setIsShowingMessageRequestWarning(true); + }, + phoneNumber, + sharedGroupNames, + })} + {!isSignalConversation && ( +
+ {i18n('messageHistoryUnsynced')} +
+ )}
{isShowingMessageRequestWarning && ( ; type PropsDataType = ContactListItemConversationType & { @@ -63,13 +65,18 @@ export const ContactListItem: FunctionComponent = React.memo( title, type, unblurredAvatarPath, + uuid, }) { const headerName = isMe ? ( {i18n('noteToSelf')} ) : ( - + ); const messageText = diff --git a/ts/components/conversationList/ConversationListItem.tsx b/ts/components/conversationList/ConversationListItem.tsx index 083306f39..e5fb9a743 100644 --- a/ts/components/conversationList/ConversationListItem.tsx +++ b/ts/components/conversationList/ConversationListItem.tsx @@ -18,6 +18,7 @@ import { TypingAnimation } from '../conversation/TypingAnimation'; import type { LocalizerType, ThemeType } from '../../types/Util'; import type { ConversationType } from '../../state/ducks/conversations'; import type { BadgeType } from '../../badges/types'; +import { isSignalConversation } from '../../util/isSignalConversation'; const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`; @@ -58,6 +59,7 @@ export type PropsData = Pick< | 'typingContactId' | 'unblurredAvatarPath' | 'unreadCount' + | 'uuid' > & { badge?: BadgeType; }; @@ -96,6 +98,7 @@ export const ConversationListItem: FunctionComponent = React.memo( typingContactId, unblurredAvatarPath, unreadCount, + uuid, }) { const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt); const headerName = ( @@ -105,7 +108,11 @@ export const ConversationListItem: FunctionComponent = React.memo( {i18n('noteToSelf')} ) : ( - + )} {isMuted &&
} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index cd72e1913..945635043 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -130,6 +130,7 @@ import { getConversationIdForLogging } from '../util/idForLogging'; import { getSendTarget } from '../util/getSendTarget'; import { getRecipients } from '../util/getRecipients'; import { validateConversation } from '../util/validateConversation'; +import { isSignalConversation } from '../util/isSignalConversation'; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; @@ -3357,6 +3358,10 @@ export class ConversationModel extends window.Backbone return; } + if (isSignalConversation(this.attributes)) { + return; + } + if (hasUserInitiatedMessages) { await this.maybeRemoveUniversalTimer(); return; @@ -3920,6 +3925,10 @@ export class ConversationModel extends window.Backbone return; } + if (isSignalConversation(this.attributes)) { + return; + } + const now = timestamp || Date.now(); log.info( @@ -4446,6 +4455,10 @@ export class ConversationModel extends window.Backbone ): Promise { const isSetByOther = providedSource || providedSentAt !== undefined; + if (isSignalConversation(this.attributes)) { + return; + } + if (isGroupV2(this.attributes)) { if (isSetByOther) { throw new Error( @@ -5086,6 +5099,9 @@ export class ConversationModel extends window.Backbone getAbsoluteAvatarPath(): string | undefined { const avatarPath = this.getAvatarPath(); + if (isSignalConversation(this.attributes)) { + return avatarPath; + } return avatarPath ? getAbsoluteAttachmentPath(avatarPath) : undefined; } @@ -5199,6 +5215,10 @@ export class ConversationModel extends window.Backbone // [X] dontNotifyForMentionsIfMuted // [x] firstUnregisteredAt captureChange(logMessage: string): void { + if (isSignalConversation(this.attributes)) { + return; + } + log.info('storageService[captureChange]', logMessage, this.idForLogging()); this.set({ needsStorageServiceSync: true }); diff --git a/ts/services/profiles.ts b/ts/services/profiles.ts index e1218d164..1cdd88b5a 100644 --- a/ts/services/profiles.ts +++ b/ts/services/profiles.ts @@ -79,6 +79,10 @@ export class ProfileService { ); } + if (window.ConversationController.isSignalConversation(conversationId)) { + return; + } + if (this.isPaused) { throw new Error( `ProfileService.get: Cannot add job to paused queue for conversation ${preCheckConversation.idForLogging()}` diff --git a/ts/services/storage.ts b/ts/services/storage.ts index c27164918..ebcbe3e81 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -63,6 +63,7 @@ import type { } from '../sql/Interface'; import { MY_STORIES_ID } from '../types/Stories'; import { isNotNil } from '../util/isNotNil'; +import { isSignalConversation } from '../util/isSignalConversation'; type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier; @@ -238,6 +239,10 @@ async function generateManifest( let identifierType; let storageRecord; + if (isSignalConversation(conversation.attributes)) { + continue; + } + const conversationType = typeofConversation(conversation.attributes); if (conversationType === ConversationTypes.Me) { storageRecord = new Proto.StorageRecord(); diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 4c4a2a287..bda909193 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -52,6 +52,7 @@ import type { import dataInterface from '../sql/Client'; import { MY_STORIES_ID, StorySendMode } from '../types/Stories'; import * as RemoteConfig from '../RemoteConfig'; +import { findAndDeleteOnboardingStoryIfExists } from '../util/findAndDeleteOnboardingStoryIfExists'; const MY_STORIES_BYTES = uuidToBytes(MY_STORIES_ID); @@ -375,6 +376,13 @@ export function toAccountRecord( accountRecord.hasSetMyStoriesPrivacy = hasSetMyStoriesPrivacy; } + const hasViewedOnboardingStory = window.storage.get( + 'hasViewedOnboardingStory' + ); + if (hasViewedOnboardingStory !== undefined) { + accountRecord.hasViewedOnboardingStory = hasViewedOnboardingStory; + } + const hasStoriesDisabled = window.storage.get('hasStoriesDisabled'); accountRecord.storiesDisabled = hasStoriesDisabled === true; @@ -1118,6 +1126,7 @@ export async function mergeAccountRecord( displayBadgesOnProfile, keepMutedChatsArchived, hasSetMyStoriesPrivacy, + hasViewedOnboardingStory, storiesDisabled, storyViewReceiptsEnabled, } = accountRecord; @@ -1313,6 +1322,16 @@ export async function mergeAccountRecord( window.storage.put('displayBadgesOnProfile', Boolean(displayBadgesOnProfile)); window.storage.put('keepMutedChatsArchived', Boolean(keepMutedChatsArchived)); window.storage.put('hasSetMyStoriesPrivacy', Boolean(hasSetMyStoriesPrivacy)); + { + const hasViewedOnboardingStoryBool = Boolean(hasViewedOnboardingStory); + window.storage.put( + 'hasViewedOnboardingStory', + hasViewedOnboardingStoryBool + ); + if (hasViewedOnboardingStoryBool) { + findAndDeleteOnboardingStoryIfExists(); + } + } { const hasStoriesDisabled = Boolean(storiesDisabled); window.storage.put('hasStoriesDisabled', hasStoriesDisabled); diff --git a/ts/services/storyLoader.ts b/ts/services/storyLoader.ts index 308de2ed8..f285e6077 100644 --- a/ts/services/storyLoader.ts +++ b/ts/services/storyLoader.ts @@ -16,6 +16,7 @@ import { isNotNil } from '../util/isNotNil'; import { strictAssert } from '../util/assert'; import { dropNull } from '../util/dropNull'; import { isGroup } from '../util/whatTypeOfConversation'; +import { SIGNAL_ACI } from '../types/Conversation'; let storyData: | Array< @@ -146,9 +147,10 @@ async function repairUnexpiredStories(): Promise { const storiesWithExpiry = storyData .filter( story => - !story.expirationStartTimestamp || - !story.expireTimer || - story.expireTimer > DAY_AS_SECONDS + story.sourceUuid !== SIGNAL_ACI && + (!story.expirationStartTimestamp || + !story.expireTimer || + story.expireTimer > DAY_AS_SECONDS) ) .map(story => ({ ...story, diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index fb3dd3af9..b132bce03 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -20,6 +20,7 @@ import type { StoryViewTargetType, StoryViewType } from '../../types/Stories'; import type { SyncType } from '../../jobs/helpers/syncHelpers'; import type { UUIDStringType } from '../../types/UUID'; import * as log from '../../logging/log'; +import { SIGNAL_ACI } from '../../types/Conversation'; import dataInterface from '../../sql/Client'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { SafetyNumberChangeSource } from '../../components/SafetyNumberChangeDialog'; @@ -31,6 +32,7 @@ import { deleteStoryForEveryone as doDeleteStoryForEveryone } from '../../util/d import { deleteGroupStoryReplyForEveryone as doDeleteGroupStoryReplyForEveryone } from '../../util/deleteGroupStoryReplyForEveryone'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; import { getMessageById } from '../../messages/getMessageById'; +import { markOnboardingStoryAsRead } from '../../util/markOnboardingStoryAsRead'; import { markViewed } from '../../services/MessageUpdater'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; import { replaceIndex } from '../../util/replaceIndex'; @@ -345,9 +347,19 @@ function markStoryRead( return; } + const isSignalOnboardingStory = message.get('sourceUuid') === SIGNAL_ACI; + + if (isSignalOnboardingStory) { + markOnboardingStoryAsRead(); + return; + } + const storyReadDate = Date.now(); message.set(markViewed(message.attributes, storyReadDate)); + window.Signal.Data.saveMessage(message.attributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); const viewedReceipt = { messageId, diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 1b1571812..5cffe1ccf 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -61,6 +61,7 @@ import type { AccountSelectorType } from './accounts'; import { getAccountSelector } from './accounts'; import * as log from '../../logging/log'; import { TimelineMessageLoadingState } from '../../util/timelineUtil'; +import { isSignalConversation } from '../../util/isSignalConversation'; import { reduce } from '../../util/iterables'; let placeholderContact: ConversationType; @@ -296,6 +297,10 @@ export const _getLeftPaneLists = ( }; } + if (isSignalConversation(conversation)) { + continue; + } + // We always show pinned conversations if (conversation.isPinned) { pinnedConversations.push(conversation); @@ -450,7 +455,8 @@ function hasDisplayInfo(conversation: ConversationType): boolean { function canComposeConversation(conversation: ConversationType): boolean { return Boolean( - !conversation.isBlocked && + !isSignalConversation(conversation) && + !conversation.isBlocked && !isConversationUnregistered(conversation) && hasDisplayInfo(conversation) && isTrusted(conversation) @@ -462,6 +468,7 @@ export const getAllComposableConversations = createSelector( (conversationLookup: ConversationLookupType): Array => Object.values(conversationLookup).filter( conversation => + !isSignalConversation(conversation) && !conversation.isBlocked && !conversation.isGroupV1AndDisabled && !isConversationUnregistered(conversation) && diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 5e21801c7..e2052a907 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -97,6 +97,7 @@ import { DAY, HOUR, SECOND } from '../../util/durations'; import { getStoryReplyText } from '../../util/getStoryReplyText'; import { isIncoming, isOutgoing, isStory } from '../../messages/helpers'; import { calculateExpirationTimestamp } from '../../util/expirationTimer'; +import { isSignalConversation } from '../../util/isSignalConversation'; export { isIncoming, isOutgoing, isStory }; @@ -1648,6 +1649,10 @@ function canReplyOrReact( return false; } + if (isSignalConversation(conversation)) { + return false; + } + if (isOutgoing(message)) { return ( isMessageJustForMe(sendStateByConversationId, ourConversationId) || diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts index 72f513c5a..7ddb66fb8 100644 --- a/ts/state/selectors/stories.ts +++ b/ts/state/selectors/stories.ts @@ -38,6 +38,7 @@ import { getStoriesEnabled } from './items'; import { calculateExpirationTimestamp } from '../../util/expirationTimer'; import { getMessageIdForLogging } from '../../util/idForLogging'; import * as log from '../../logging/log'; +import { SIGNAL_ACI } from '../../types/Conversation'; export const getStoriesState = (state: StateType): StoriesStateType => state.stories; @@ -67,6 +68,20 @@ function sortByRecencyAndUnread( storyA: ConversationStoryType, storyB: ConversationStoryType ): number { + if ( + storyA.storyView.sender.uuid === SIGNAL_ACI && + storyA.storyView.isUnread + ) { + return -1; + } + + if ( + storyB.storyView.sender.uuid === SIGNAL_ACI && + storyB.storyView.isUnread + ) { + return 1; + } + if (storyA.storyView.isUnread && storyB.storyView.isUnread) { return storyA.storyView.timestamp > storyB.storyView.timestamp ? -1 : 1; } @@ -160,6 +175,7 @@ export function getStoryView( 'profileName', 'sharedGroupNames', 'title', + 'uuid', ]); const { @@ -330,9 +346,13 @@ export const getStories = createSelector( return; } - // if for some reason this story is already experied (bug) - // log it and skip it - if ((calculateExpirationTimestamp(story) ?? 0) < Date.now()) { + // if for some reason this story is already expired (bug) + // log it and skip it. Unless it's the onboarding story, that story + // doesn't have an expiration until it is viewed. + if ( + !story.sourceUuid === SIGNAL_ACI && + (calculateExpirationTimestamp(story) ?? 0) < Date.now() + ) { const messageIdForLogging = getMessageIdForLogging({ ...pick(story, 'type', 'sourceUuid', 'sourceDevice'), sent_at: story.timestamp, diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 6b0e75b38..bb3ca43b5 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -28,6 +28,7 @@ import { getRecentlyInstalledStickerPack, getRecentStickers, } from '../selectors/stickers'; +import { isSignalConversation } from '../../util/isSignalConversation'; type ExternalProps = { id: string; @@ -122,6 +123,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { ...conversation, conversationType: conversation.type, isSMSOnly: Boolean(isConversationSMSOnly(conversation)), + isSignalConversation: isSignalConversation(conversation), isFetchingUUID: conversation.isFetchingUUID, isMissingMandatoryProfileSharing: isMissingRequiredProfileSharing(conversation), diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index f7f6d4526..5e25227fa 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -24,6 +24,7 @@ import { isConversationSMSOnly } from '../../util/isConversationSMSOnly'; import { mapDispatchToProps } from '../actions'; import { missingCaseError } from '../../util/missingCaseError'; import { strictAssert } from '../../util/assert'; +import { isSignalConversation } from '../../util/isSignalConversation'; export type OwnProps = { id: string; @@ -118,6 +119,7 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => { isMissingMandatoryProfileSharing: isMissingRequiredProfileSharing(conversation), isSMSOnly: isConversationSMSOnly(conversation), + isSignalConversation: isSignalConversation(conversation), i18n: getIntl(state), showBackButton: state.conversations.selectedConversationPanelDepth > 0, outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state), diff --git a/ts/state/smart/HeroRow.tsx b/ts/state/smart/HeroRow.tsx index 9274cc29e..803a02964 100644 --- a/ts/state/smart/HeroRow.tsx +++ b/ts/state/smart/HeroRow.tsx @@ -10,6 +10,7 @@ import type { StateType } from '../reducer'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { getIntl, getTheme } from '../selectors/user'; import { getHasStoriesSelector } from '../selectors/stories'; +import { isSignalConversation } from '../../util/isSignalConversation'; type ExternalProps = { id: string; @@ -30,6 +31,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { conversationType: conversation.type, hasStories: getHasStoriesSelector(state)(id), badge: getPreferredBadgeSelector(state)(conversation.badges), + isSignalConversation: isSignalConversation(conversation), theme: getTheme(state), }; }; diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx index 52537d726..0eeb15b06 100644 --- a/ts/state/smart/StoryViewer.tsx +++ b/ts/state/smart/StoryViewer.tsx @@ -25,6 +25,7 @@ import { getStoryByIdSelector, } from '../selectors/stories'; import { isInFullScreenCall } from '../selectors/calling'; +import { isSignalConversation } from '../../util/isSignalConversation'; import { renderEmojiPicker } from './renderEmojiPicker'; import { strictAssert } from '../../util/assert'; import { useActions as useEmojisActions } from '../ducks/emojis'; @@ -92,6 +93,9 @@ export function SmartStoryViewer(): JSX.Element | null { hasAllStoriesMuted={hasAllStoriesMuted} hasViewReceiptSetting={hasViewReceiptSetting} i18n={i18n} + isSignalConversation={isSignalConversation({ + id: conversationStory.conversationId, + })} numStories={selectedStoryData.numStories} onHideStory={toggleHideStories} onGoToConversation={senderId => { diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 36b7b50b2..93d63f65e 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -480,6 +480,8 @@ const URL_CALLS = { getGroupAvatarUpload: 'v1/groups/avatar/form', getGroupCredentials: 'v1/certificate/auth/group', getIceServers: 'v1/accounts/turn', + getOnboardingStoryManifest: + 'dynamic/desktop/stories/onboarding/manifest.json', getStickerPackUpload: 'v1/sticker/pack/form', groupLog: 'v1/groups/logs', groupJoinedAtVersion: 'v1/groups/joined_at_version', @@ -542,6 +544,7 @@ type InitializeOptionsType = { url: string; storageUrl: string; updatesUrl: string; + resourcesUrl: string; cdnUrlObject: { readonly '0': string; readonly [propName: string]: string; @@ -815,6 +818,10 @@ export type WebAPIType = { options: GroupCredentialsType ) => Promise; deleteUsername: (abortSignal?: AbortSignal) => Promise; + downloadOnboardingStories: ( + version: string, + imageFiles: Array + ) => Promise>; getAttachment: (cdnKey: string, cdnNumber?: number) => Promise; getAvatar: (path: string) => Promise; getDevices: () => Promise; @@ -846,6 +853,10 @@ export type WebAPIType = { options?: { accessKey?: string } ) => Promise; getMyKeys: (uuidKind: UUIDKind) => Promise; + getOnboardingStoryManifest: () => Promise<{ + version: string; + languages: Record>; + }>; getProfile: ( identifier: string, options: GetProfileOptionsType @@ -1030,6 +1041,7 @@ export function initialize({ url, storageUrl, updatesUrl, + resourcesUrl, directoryConfig, cdnUrlObject, certificateAuthority, @@ -1046,6 +1058,9 @@ export function initialize({ if (!is.string(updatesUrl)) { throw new Error('WebAPI.initialize: Invalid updatesUrl'); } + if (!is.string(resourcesUrl)) { + throw new Error('WebAPI.initialize: Invalid updatesUrl (general)'); + } if (!is.object(cdnUrlObject)) { throw new Error('WebAPI.initialize: Invalid cdnUrlObject'); } @@ -1141,26 +1156,23 @@ export function initialize({ // Thanks, function hoisting! return { - getSocketStatus, - checkSockets, - onOnline, - onOffline, - reconnect, - registerRequestHandler, - unregisterRequestHandler, - onHasStoriesDisabledChange, authenticate, - logout, cdsLookup, checkAccountExistence, + checkSockets, confirmCode, + confirmUsername, createGroup, deleteUsername, - finishRegistration, + downloadOnboardingStories, fetchLinkPreviewImage, fetchLinkPreviewMetadata, + finishRegistration, + getAccountForUsername, getAttachment, getAvatar, + getBadgeImageFile, + getBoostBadgesFromServer, getConfig, getDevices, getGroup, @@ -1174,44 +1186,49 @@ export function initialize({ getKeysForIdentifier, getKeysForIdentifierUnauth, getMyKeys, + getOnboardingStoryManifest, getProfile, - getAccountForUsername, getProfileUnauth, - getBadgeImageFile, - getBoostBadgesFromServer, getProvisioningResource, getSenderCertificate, + getSocketStatus, getSticker, getStickerPackManifest, getStorageCredentials, getStorageManifest, getStorageRecords, + logout, makeProxiedRequest, makeSfuRequest, modifyGroup, modifyStorageRecords, + onHasStoriesDisabledChange, + onOffline, + onOnline, postBatchIdentityCheck, putAttachment, putProfile, putStickers, - reserveUsername, - confirmUsername, + reconnect, registerCapabilities, registerKeys, + registerRequestHandler, registerSupportForUnauthenticatedDelivery, reportMessage, requestVerificationSMS, requestVerificationVoice, + reserveUsername, + sendChallengeResponse, sendMessages, sendMessagesUnauth, sendWithSenderKey, setSignedPreKey, startRegistration, + unregisterRequestHandler, updateDeviceName, uploadAvatar, uploadGroupAvatar, whoami, - sendChallengeResponse, }; function _ajax( @@ -1406,6 +1423,20 @@ export function initialize({ })) as StorageServiceCredentials; } + async function getOnboardingStoryManifest() { + const res = await _ajax({ + call: 'getOnboardingStoryManifest', + host: resourcesUrl, + httpType: 'GET', + responseType: 'json', + }); + + return res as { + version: string; + languages: Record>; + }; + } + async function getStorageManifest( options: StorageServiceCallOptionsType = {} ): Promise { @@ -1644,6 +1675,28 @@ export function initialize({ }); } + async function downloadOnboardingStories( + manifestVersion: string, + imageFiles: Array + ): Promise> { + return Promise.all( + imageFiles.map(fileName => + _outerAjax( + `${resourcesUrl}/static/desktop/stories/onboarding/${manifestVersion}/${fileName}.jpg`, + { + certificateAuthority, + contentType: 'application/octet-stream', + proxyUrl, + responseType: 'bytes', + timeout: 0, + type: 'GET', + version, + } + ) + ) + ); + } + async function getBoostBadgesFromServer( userLanguages: ReadonlyArray ): Promise { diff --git a/ts/types/Conversation.ts b/ts/types/Conversation.ts index 9f541d2cf..c350de8d3 100644 --- a/ts/types/Conversation.ts +++ b/ts/types/Conversation.ts @@ -1,8 +1,9 @@ // Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { computeHash } from '../Crypto'; import type { ConversationAttributesType } from '../model-types.d'; +import { UUID } from './UUID'; +import { computeHash } from '../Crypto'; export type BuildAvatarUpdaterOptions = Readonly<{ deleteAttachmentData: (path: string) => Promise; @@ -87,3 +88,6 @@ export async function deleteExternalFiles( await deleteAttachmentData(profileAvatar.path); } } + +export const SIGNAL_ACI = UUID.cast('11111111-1111-4111-8111-111111111111'); +export const SIGNAL_AVATAR_PATH = 'images/icon_256.png'; diff --git a/ts/types/RendererConfig.ts b/ts/types/RendererConfig.ts index 1fd898050..10565a6cd 100644 --- a/ts/types/RendererConfig.ts +++ b/ts/types/RendererConfig.ts @@ -49,6 +49,7 @@ export const rendererConfigSchema = z.object({ storageUrl: configRequiredStringSchema, theme: themeSettingSchema, updatesUrl: configRequiredStringSchema, + resourcesUrl: configRequiredStringSchema, userDataPath: configRequiredStringSchema, version: configRequiredStringSchema, directoryConfig: directoryConfigSchema, diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 0edccba53..f0eb1f299 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -65,8 +65,10 @@ export type StorageAccessType = { defaultConversationColor: DefaultConversationColorType; customColors: CustomColorsItemType; device_name: string; + existingOnboardingStoryMessageIds: Array | undefined; hasRegisterSupportForUnauthenticatedDelivery: boolean; hasSetMyStoriesPrivacy: boolean; + hasViewedOnboardingStory: boolean; hasStoriesDisabled: boolean; storyViewReceiptsEnabled: boolean; identityKeyMap: IdentityKeyMap; diff --git a/ts/types/Stories.ts b/ts/types/Stories.ts index e580181cc..dd5bb0e01 100644 --- a/ts/types/Stories.ts +++ b/ts/types/Stories.ts @@ -91,6 +91,7 @@ export type StoryViewType = { | 'profileName' | 'sharedGroupNames' | 'title' + | 'uuid' >; sendState?: Array; timestamp: number; diff --git a/ts/util/downloadOnboardingStory.ts b/ts/util/downloadOnboardingStory.ts new file mode 100644 index 000000000..b422b5874 --- /dev/null +++ b/ts/util/downloadOnboardingStory.ts @@ -0,0 +1,136 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { AttachmentType } from '../types/Attachment'; +import type { MessageAttributesType } from '../model-types.d'; +import type { MessageModel } from '../models/messages'; +import * as log from '../logging/log'; +import { IMAGE_JPEG } from '../types/MIME'; +import { ReadStatus } from '../messages/MessageReadStatus'; +import { SeenStatus } from '../MessageSeenStatus'; +import { UUID } from '../types/UUID'; +import { findAndDeleteOnboardingStoryIfExists } from './findAndDeleteOnboardingStoryIfExists'; +import { runStorageServiceSyncJob } from '../services/storage'; +import { saveNewMessageBatcher } from './messageBatcher'; +import { strictAssert } from './assert'; + +// * Check if we've viewed onboarding story. Short circuit. +// * Run storage service sync (just in case) and check again. +// * If it has been viewed and it's downloaded on this device, delete & return. +// * Check if we've already downloaded the onboarding story. +// * Download onboarding story, create db entry, mark as downloaded. +// * If story has been viewed mark as viewed on AccountRecord. +// * If we viewed it >24 hours ago, delete. +export async function downloadOnboardingStory(): Promise { + const hasViewedOnboardingStory = window.storage.get( + 'hasViewedOnboardingStory' + ); + + if (hasViewedOnboardingStory) { + await findAndDeleteOnboardingStoryIfExists(); + return; + } + + runStorageServiceSyncJob(); + + window.Whisper.events.once( + 'storageService:syncComplete', + continueDownloadingOnboardingStory + ); +} + +async function continueDownloadingOnboardingStory(): Promise { + const { server } = window.textsecure; + + strictAssert(server, 'server not initialized'); + + const hasViewedOnboardingStory = window.storage.get( + 'hasViewedOnboardingStory' + ); + + if (hasViewedOnboardingStory) { + await findAndDeleteOnboardingStoryIfExists(); + return; + } + + const existingOnboardingStoryMessageIds = window.storage.get( + 'existingOnboardingStoryMessageIds' + ); + + if (existingOnboardingStoryMessageIds) { + log.info('downloadOnboardingStory: has existingOnboardingStoryMessageIds'); + return; + } + + const userLocale = window.i18n.getLocale(); + + const manifest = await server.getOnboardingStoryManifest(); + + log.info('downloadOnboardingStory: got manifest version:', manifest.version); + + const imageFilenames = + userLocale in manifest.languages + ? manifest.languages[userLocale] + : manifest.languages.en; + + const imageBuffers = await server.downloadOnboardingStories( + manifest.version, + imageFilenames + ); + + log.info('downloadOnboardingStory: downloaded stories:', imageBuffers.length); + + const attachments: Array = await Promise.all( + imageBuffers.map(data => { + const attachment: AttachmentType = { + contentType: IMAGE_JPEG, + data, + size: data.byteLength, + }; + + return window.Signal.Migrations.processNewAttachment(attachment); + }) + ); + + const signalConversation = + await window.ConversationController.getOrCreateSignalConversation(); + + const storyMessages: Array = attachments.map( + (attachment, index) => { + const timestamp = Date.now() + index; + + const partialMessage: MessageAttributesType = { + attachments: [attachment], + canReplyToStory: false, + conversationId: signalConversation.id, + id: UUID.generate().toString(), + readStatus: ReadStatus.Unread, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: timestamp, + seenStatus: SeenStatus.Unseen, + sent_at: timestamp, + serverTimestamp: timestamp, + sourceUuid: signalConversation.get('uuid'), + timestamp, + type: 'story', + }; + return new window.Whisper.Message(partialMessage); + } + ); + + await Promise.all( + storyMessages.map(message => saveNewMessageBatcher.add(message.attributes)) + ); + + // Sync to redux + storyMessages.forEach(message => { + message.trigger('change'); + }); + + window.storage.put( + 'existingOnboardingStoryMessageIds', + storyMessages.map(message => message.id) + ); + + log.info('downloadOnboardingStory: done'); +} diff --git a/ts/util/findAndDeleteOnboardingStoryIfExists.ts b/ts/util/findAndDeleteOnboardingStoryIfExists.ts new file mode 100644 index 000000000..be47866f8 --- /dev/null +++ b/ts/util/findAndDeleteOnboardingStoryIfExists.ts @@ -0,0 +1,45 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as log from '../logging/log'; +import { getMessageById } from '../messages/getMessageById'; +import { calculateExpirationTimestamp } from './expirationTimer'; + +export async function findAndDeleteOnboardingStoryIfExists(): Promise { + const existingOnboardingStoryMessageIds = window.storage.get( + 'existingOnboardingStoryMessageIds' + ); + + if (!existingOnboardingStoryMessageIds) { + return; + } + + const hasExpired = await (async () => { + for (const id of existingOnboardingStoryMessageIds) { + // eslint-disable-next-line no-await-in-loop + const message = await getMessageById(id); + if (!message) { + continue; + } + + const expires = calculateExpirationTimestamp(message.attributes) ?? 0; + + return expires < Date.now(); + } + + return true; + })(); + + if (!hasExpired) { + log.info( + 'findAndDeleteOnboardingStoryIfExists: current msg has not expired' + ); + return; + } + + log.info('findAndDeleteOnboardingStoryIfExists: removing onboarding stories'); + + await window.Signal.Data.removeMessages(existingOnboardingStoryMessageIds); + + window.storage.put('existingOnboardingStoryMessageIds', undefined); +} diff --git a/ts/util/isSignalConversation.ts b/ts/util/isSignalConversation.ts new file mode 100644 index 000000000..ce709d4e3 --- /dev/null +++ b/ts/util/isSignalConversation.ts @@ -0,0 +1,17 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { SIGNAL_ACI } from '../types/Conversation'; + +export function isSignalConversation(conversation: { + id: string; + uuid?: string; +}): boolean { + const { id, uuid } = conversation; + + if (uuid) { + return uuid === SIGNAL_ACI; + } + + return window.ConversationController.isSignalConversation(id); +} diff --git a/ts/util/markOnboardingStoryAsRead.ts b/ts/util/markOnboardingStoryAsRead.ts new file mode 100644 index 000000000..4d20e0c07 --- /dev/null +++ b/ts/util/markOnboardingStoryAsRead.ts @@ -0,0 +1,48 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { DAY } from './durations'; +import { getMessageById } from '../messages/getMessageById'; +import { isNotNil } from './isNotNil'; +import { markViewed } from '../services/MessageUpdater'; +import { storageServiceUploadJob } from '../services/storage'; + +export async function markOnboardingStoryAsRead(): Promise { + const existingOnboardingStoryMessageIds = window.storage.get( + 'existingOnboardingStoryMessageIds' + ); + + if (!existingOnboardingStoryMessageIds) { + return; + } + + const messages = await Promise.all( + existingOnboardingStoryMessageIds.map(getMessageById) + ); + + const storyReadDate = Date.now(); + + const messageAttributes = messages + .map(message => { + if (!message) { + return; + } + + message.set({ + expireTimer: DAY, + }); + + message.set(markViewed(message.attributes, storyReadDate)); + + return message.attributes; + }) + .filter(isNotNil); + + window.Signal.Data.saveMessages(messageAttributes, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + }); + + window.storage.put('hasViewedOnboardingStory', true); + + storageServiceUploadJob(); +} diff --git a/ts/windows/main/phase2-dependencies.ts b/ts/windows/main/phase2-dependencies.ts index faa261c35..a26f7b7da 100644 --- a/ts/windows/main/phase2-dependencies.ts +++ b/ts/windows/main/phase2-dependencies.ts @@ -26,6 +26,7 @@ window.WebAPI = window.textsecure.WebAPI.initialize({ url: config.serverUrl, storageUrl: config.storageUrl, updatesUrl: config.updatesUrl, + resourcesUrl: config.resourcesUrl, directoryConfig: config.directoryConfig, cdnUrlObject: { 0: config.cdnUrl0,