From 6a7d45b6fce6e0dffe2addc6e30429c2487a513d Mon Sep 17 00:00:00 2001 From: Chris Svenningsen Date: Fri, 2 Oct 2020 11:30:43 -0700 Subject: [PATCH] Pinned Chats --- _locales/en/messages.json | 12 ++++ .../ConversationHeader.stories.tsx | 1 + .../conversation/ConversationHeader.tsx | 13 +++++ ts/models/conversations.ts | 46 +++++++++++++++ ts/services/storageRecordOps.ts | 52 +++++++++++++++++ ts/textsecure.d.ts | 1 + ts/util/lint/exceptions.json | 2 +- ts/views/conversation_view.ts | 20 ++++++- ts/window.d.ts | 56 ++++++++++--------- 9 files changed, 176 insertions(+), 27 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 58eab08c9d..08dec5ee75 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -213,6 +213,18 @@ "message": "Move Conversation to Inbox", "description": "Undoes Archive Conversation action, and moves archived conversation back to the main conversation list" }, + "pinConversation": { + "message": "Pin Conversation", + "description": "Shown in menu for conversation, and pins the conversation to the top of the conversation list" + }, + "unpinConversation": { + "message": "Unpin Conversation", + "description": "Undoes Archive Conversation action, and unpins the conversation from the top of the conversation list" + }, + "pinnedConversationsFull": { + "message": "You can only pin up to 4 chats", + "descriptin": "Shown in a toast when a user attempts to pin more than the maximum number of chats" + }, "chooseDirectory": { "message": "Choose folder", "description": "Button to allow the user to find a folder on disk" diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index 72576e6c04..2e0a26b6fb 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -45,6 +45,7 @@ const actionProps: PropsActionsType = { onArchive: action('onArchive'), onMoveToInbox: action('onMoveToInbox'), + onSetPin: action('onSetPin'), }; const housekeepingProps: PropsHousekeepingType = { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index fdacdbfdd4..d719d19442 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -35,6 +35,7 @@ export interface PropsDataType { isVerified?: boolean; isMe?: boolean; isArchived?: boolean; + isPinned?: boolean; disableTimerChanges?: boolean; expirationSettingName?: string; @@ -51,6 +52,7 @@ export interface PropsActionsType { onSearchInConversation: () => void; onOutgoingAudioCallInConversation: () => void; onOutgoingVideoCallInConversation: () => void; + onSetPin: (value: boolean) => void; onShowSafetyNumber: () => void; onShowAllMedia: () => void; @@ -313,6 +315,7 @@ export class ConversationHeader extends React.Component { i18n, isAccepted, isMe, + isPinned, type, isArchived, muteExpirationLabel, @@ -324,6 +327,7 @@ export class ConversationHeader extends React.Component { onShowGroupMembers, onShowSafetyNumber, onArchive, + onSetPin, onMoveToInbox, timerOptions, } = this.props; @@ -402,6 +406,15 @@ export class ConversationHeader extends React.Component { ) : ( {i18n('archiveConversation')} )} + {isPinned ? ( + onSetPin(false)}> + {i18n('unpinConversation')} + + ) : ( + onSetPin(true)}> + {i18n('pinConversation')} + + )} {i18n('deleteMessages')} ); diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 2f5dca60cf..9fd35e0acb 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -2490,6 +2490,9 @@ export class ConversationModel extends window.Backbone.Model< const after = this.get('isArchived'); if (Boolean(before) !== Boolean(after)) { + if (after) { + this.unpin(); + } this.captureChange(); } } @@ -3686,6 +3689,49 @@ export class ConversationModel extends window.Backbone.Model< // eslint-disable-next-line no-useless-return return; } + + pin(): void { + const pinnedConversationIds = new Set( + window.storage.get>('pinnedConversationIds', []) + ); + + this.set('isPinned', true); + this.set('pinIndex', pinnedConversationIds.size); + window.Signal.Data.updateConversation(this.attributes); + + if (this.get('isArchived')) { + this.setArchived(false); + } + + pinnedConversationIds.add(this.id); + + this.writePinnedConversations([...pinnedConversationIds]); + } + + unpin(): void { + const pinnedConversationIds = new Set( + window.storage.get>('pinnedConversationIds', []) + ); + + this.set('isPinned', false); + this.set('pinIndex', undefined); + window.Signal.Data.updateConversation(this.attributes); + + pinnedConversationIds.delete(this.id); + + this.writePinnedConversations([...pinnedConversationIds]); + } + + writePinnedConversations(pinnedConversationIds: Array): void { + window.storage.put('pinnedConversationIds', pinnedConversationIds); + + const myId = window.ConversationController.getOurConversationId(); + const me = window.ConversationController.get(myId); + + if (me) { + me.captureChange(); + } + } } window.Whisper.Conversation = ConversationModel; diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 389850c534..06a34f18f2 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -11,6 +11,7 @@ import { ContactRecordClass, GroupV1RecordClass, GroupV2RecordClass, + PinnedConversationClass, } from '../textsecure.d'; import { deriveGroupFields, waitThenMaybeUpdateGroup } from '../groups'; import { ConversationModel } from '../models/conversations'; @@ -140,6 +141,55 @@ export async function toAccountRecord( window.storage.get('typingIndicators') ); accountRecord.linkPreviews = Boolean(window.storage.get('linkPreviews')); + accountRecord.pinnedConversations = window.storage + .get>('pinnedConversationIds', []) + .map(id => { + const pinnedConversation = window.ConversationController.get(id); + + if (pinnedConversation) { + const pinnedConversationRecord = new window.textsecure.protobuf.AccountRecord.PinnedConversation(); + + if (pinnedConversation.get('type') === 'private') { + pinnedConversationRecord.identifier = 'contact'; + pinnedConversationRecord.contact = { + uuid: pinnedConversation.get('uuid'), + e164: pinnedConversation.get('e164'), + }; + } else if (pinnedConversation.isGroupV1()) { + pinnedConversationRecord.identifier = 'legacyGroupId'; + const groupId = pinnedConversation.get('groupId'); + if (!groupId) { + throw new Error( + 'toAccountRecord: trying to pin a v1 Group without groupId' + ); + } + pinnedConversationRecord.legacyGroupId = fromEncodedBinaryToArrayBuffer( + groupId + ); + } else if ((pinnedConversation.get('groupVersion') || 0) > 1) { + pinnedConversationRecord.identifier = 'groupMasterKey'; + const masterKey = pinnedConversation.get('masterKey'); + if (!masterKey) { + throw new Error( + 'toAccountRecord: trying to pin a v2 Group without masterKey' + ); + } + pinnedConversationRecord.groupMasterKey = base64ToArrayBuffer( + masterKey + ); + } + + return pinnedConversationRecord; + } + + return undefined; + }) + .filter( + ( + pinnedConversationClass + ): pinnedConversationClass is PinnedConversationClass => + pinnedConversationClass !== undefined + ); applyUnknownFields(accountRecord, conversation); @@ -608,6 +658,8 @@ export async function mergeAccountRecord( conversation.set({ isPinned: true, pinIndex: index }); updateConversation(conversation.attributes); }); + + window.storage.put('pinnedConversationIds', remotelyPinnedConversationIds); } const ourID = window.ConversationController.getOurConversationId(); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index a0874ec3d0..168f989a29 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -976,6 +976,7 @@ export declare class PinnedConversationClass { } export declare class AccountRecordClass { + static PinnedConversation: typeof PinnedConversationClass; static decode: ( data: ArrayBuffer | ByteBufferClass, encoding?: string diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index f2edad1b5c..9b5e141778 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -13030,7 +13030,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/ConversationHeader.tsx", "line": " this.menuTriggerRef = React.createRef();", - "lineNumber": 82, + "lineNumber": 84, "reasonCategory": "usageTrusted", "updated": "2020-05-20T20:10:43.540Z", "reasonDetail": "Used to reference popup menu" diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index fdacfd16b9..6a33acadb8 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -202,6 +202,12 @@ Whisper.ReactionFailedToast = Whisper.ToastView.extend({ }, }); +Whisper.PinnedConversationsFullToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('pinnedConversationsFull') }; + }, +}); + const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({ @@ -411,6 +417,18 @@ Whisper.ConversationView = Whisper.View.extend({ return expires.format('M/D/YY, hh:mm A'); }, + setPin(value: boolean) { + if (value) { + if (window.storage.get('pinnedConversationIds').length >= 4) { + this.showToast(Whisper.PinnedConversationsFullToast); + return; + } + this.model.pin(); + } else { + this.model.unpin(); + } + }, + setupHeader() { const getHeaderProps = (_unknown?: unknown) => { const expireTimer = this.model.get('expireTimer'); @@ -449,7 +467,7 @@ Whisper.ConversationView = Whisper.View.extend({ searchInConversation(this.model.id, name); }, onSetMuteNotifications: (ms: number) => this.setMuteNotifications(ms), - + onSetPin: this.setPin.bind(this), // These are view only and don't update the Conversation model, so they // need a manual update call. onOutgoingAudioCallInConversation: async () => { diff --git a/ts/window.d.ts b/ts/window.d.ts index 59794c6aa7..a9b1be25bb 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -133,7 +133,10 @@ declare global { addBlockedNumber: (number: string) => void; addBlockedUuid: (uuid: string) => void; fetch: () => void; - get: (key: string, defaultValue?: T) => T | undefined; + get: { + (key: string): T | undefined; + (key: string, defaultValue: T): T; + }; getItemsState: () => WhatIsThis; isBlocked: (number: string) => boolean; isGroupBlocked: (group: unknown) => boolean; @@ -603,7 +606,9 @@ export type WhisperType = { ClearDataView: WhatIsThis; ReactWrapperView: WhatIsThis; activeConfirmationView: WhatIsThis; - ToastView: WhatIsThis; + ToastView: typeof Whisper.View & { + show: (view: Backbone.View, el: Element) => void; + }; ConversationArchivedToast: WhatIsThis; ConversationUnarchivedToast: WhatIsThis; AppView: WhatIsThis; @@ -679,28 +684,29 @@ export type WhisperType = { deliveryReceiptBatcher: BatcherType; RotateSignedPreKeyListener: WhatIsThis; - ExpiredToast: any; - BlockedToast: any; - BlockedGroupToast: any; - LeftGroupToast: any; - OriginalNotFoundToast: any; - OriginalNoLongerAvailableToast: any; - FoundButNotLoadedToast: any; - VoiceNoteLimit: any; - VoiceNoteMustBeOnlyAttachmentToast: any; - TapToViewExpiredIncomingToast: any; - TapToViewExpiredOutgoingToast: any; - FileSavedToast: any; - ReactionFailedToast: any; - MessageBodyTooLongToast: any; + ExpiredToast: typeof Whisper.ToastView; + BlockedToast: typeof Whisper.ToastView; + BlockedGroupToast: typeof Whisper.ToastView; + LeftGroupToast: typeof Whisper.ToastView; + OriginalNotFoundToast: typeof Whisper.ToastView; + OriginalNoLongerAvailableToast: typeof Whisper.ToastView; + FoundButNotLoadedToast: typeof Whisper.ToastView; + VoiceNoteLimit: typeof Whisper.ToastView; + VoiceNoteMustBeOnlyAttachmentToast: typeof Whisper.ToastView; + TapToViewExpiredIncomingToast: typeof Whisper.ToastView; + TapToViewExpiredOutgoingToast: typeof Whisper.ToastView; + FileSavedToast: typeof Whisper.ToastView; + ReactionFailedToast: typeof Whisper.ToastView; + MessageBodyTooLongToast: typeof Whisper.ToastView; FileSizeToast: any; - UnableToLoadToast: any; - DangerousFileTypeToast: any; - OneNonImageAtATimeToast: any; - CannotMixImageAndNonImageAttachmentsToast: any; - MaxAttachmentsToast: any; - TimerConflictToast: any; - ConversationLoadingScreen: any; - ConversationView: any; - View: any; + UnableToLoadToast: typeof Whisper.ToastView; + DangerousFileTypeToast: typeof Whisper.ToastView; + OneNonImageAtATimeToast: typeof Whisper.ToastView; + CannotMixImageAndNonImageAttachmentsToast: typeof Whisper.ToastView; + MaxAttachmentsToast: typeof Whisper.ToastView; + TimerConflictToast: typeof Whisper.ToastView; + PinnedConversationsFullToast: typeof Whisper.ToastView; + ConversationLoadingScreen: typeof Whisper.View; + ConversationView: typeof Whisper.View; + View: typeof Backbone.View; };