diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7f68b26e1..837c9ac75 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -193,6 +193,14 @@ "message": "Archived Conversations", "description": "Shown in place of the search box when showing archived conversation list" }, + "LeftPane--pinned": { + "message": "Pinned", + "description": "Shown as a header for pinned conversations in the left pane" + }, + "LeftPane--chats": { + "message": "Chats", + "description": "Shown as a header for non-pinned conversations in the left pane" + }, "archiveHelperText": { "message": "These conversations are archived and will only appear in the Inbox if new messages are received.", "description": "Shown at the top of the archived conversations list in the left pane" diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 9b2b8e673..beff99d34 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -90,15 +90,29 @@ message GroupV2Record { } message AccountRecord { - optional bytes profileKey = 1; - optional string givenName = 2; - optional string familyName = 3; - optional string avatarUrl = 4; - optional bool noteToSelfArchived = 5; - optional bool readReceipts = 6; - optional bool sealedSenderIndicators = 7; - optional bool typingIndicators = 8; - optional bool proxiedLinkPreviews = 9; - optional bool noteToSelfUnread = 10; - optional bool linkPreviews = 11; + message PinnedConversation { + message Contact { + optional string uuid = 1; + optional string e164 = 2; + } + + oneof identifier { + Contact contact = 1; + bytes legacyGroupId = 3; + bytes groupMasterKey = 4; + } + } + + optional bytes profileKey = 1; + optional string givenName = 2; + optional string familyName = 3; + optional string avatarUrl = 4; + optional bool noteToSelfArchived = 5; + optional bool readReceipts = 6; + optional bool sealedSenderIndicators = 7; + optional bool typingIndicators = 8; + optional bool proxiedLinkPreviews = 9; + optional bool noteToSelfUnread = 10; + optional bool linkPreviews = 11; + repeated PinnedConversation pinnedConversations = 14; } diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 40c399cf5..878506083 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -6176,6 +6176,18 @@ button.module-image__border-overlay:focus { } } +.module-left-pane__header-row { + @include font-body-1-bold; + + display: inline-flex; + align-items: center; + padding-left: 16px; + + @include dark-theme { + color: $color-gray-05; + } +} + .module-left-pane__to-inbox-button { @include button-reset; diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 968a19849..408603dbf 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -35,6 +35,7 @@ export function stringFromBytes(buffer: ArrayBuffer): string { export function hexFromBytes(buffer: ArrayBuffer): string { return window.dcodeIO.ByteBuffer.wrap(buffer).toString('hex'); } + export function bytesFromHexString(string: string): ArrayBuffer { return window.dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer(); } diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index f293be15c..1aa8b3917 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -49,6 +49,7 @@ export type PropsData = { text: string; deletedForEveryone?: boolean; }; + isPinned?: boolean; }; type PropsHousekeeping = { diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index 1856774df..563b20aac 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -40,12 +40,32 @@ const defaultArchivedConversations: Array = [ }, ]; +const pinnedConversations: Array = [ + { + id: 'philly-convo', + isPinned: true, + isSelected: false, + lastUpdated: Date.now(), + title: 'Philip Glass', + type: 'direct', + }, + { + id: 'robbo-convo', + isPinned: true, + isSelected: false, + lastUpdated: Date.now(), + title: 'Robert Moog', + type: 'direct', + }, +]; + const createProps = (overrideProps: Partial = {}): PropsType => ({ archivedConversations: overrideProps.archivedConversations || defaultArchivedConversations, conversations: overrideProps.conversations || defaultConversations, i18n, openConversationInternal: action('openConversationInternal'), + pinnedConversations: overrideProps.pinnedConversations || [], renderExpiredBuildDialog: () =>
, renderMainHeader: () =>
, renderMessageSearchResult: () =>
, @@ -69,6 +89,14 @@ story.add('Conversation States (Active, Selected, Archived)', () => { return ; }); +story.add('Pinned Conversations', () => { + const props = createProps({ + pinnedConversations, + }); + + return ; +}); + story.add('Archived Conversations Shown', () => { const props = createProps({ showArchived: true, diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 6976aab9e..873f43856 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -17,6 +17,7 @@ import { cleanId } from './_util'; export interface PropsType { conversations?: Array; archivedConversations?: Array; + pinnedConversations?: Array; selectedConversationId?: string; searchResults?: SearchResultsProps; showArchived?: boolean; @@ -51,6 +52,43 @@ type RowRendererParamsType = { style: CSSProperties; }; +enum RowType { + ArchiveButton, + ArchivedConversation, + Conversation, + Header, + PinnedConversation, + Undefined, +} + +enum HeaderType { + Pinned, + Chats, +} + +interface ArchiveButtonRow { + type: RowType.ArchiveButton; +} + +interface ConversationRow { + index: number; + type: + | RowType.ArchivedConversation + | RowType.Conversation + | RowType.PinnedConversation; +} + +interface HeaderRow { + headerType: HeaderType; + type: RowType.Header; +} + +interface UndefinedRow { + type: RowType.Undefined; +} + +type Row = ArchiveButtonRow | ConversationRow | HeaderRow | UndefinedRow; + export class LeftPane extends React.Component { public listRef = React.createRef(); @@ -60,31 +98,77 @@ export class LeftPane extends React.Component { public setFocusToLastNeeded = false; - public renderRow = ({ - index, - key, - style, - }: RowRendererParamsType): JSX.Element => { + public calculateRowHeight = ({ index }: { index: number }): number => { + const { type } = this.getRowFromIndex(index); + return type === RowType.Header ? 40 : 68; + }; + + public getRowFromIndex = (index: number): Row => { const { archivedConversations, conversations, - i18n, - openConversationInternal, + pinnedConversations, showArchived, } = this.props; - if (!conversations || !archivedConversations) { - throw new Error( - 'renderRow: Tried to render without conversations or archivedConversations' - ); + + if (!conversations || !pinnedConversations || !archivedConversations) { + return { + type: RowType.Undefined, + }; } - if (!showArchived && index === conversations.length) { - return this.renderArchivedButton({ key, style }); + if (showArchived) { + return { + index, + type: RowType.ArchivedConversation, + }; } - const conversation = showArchived - ? archivedConversations[index] - : conversations[index]; + let conversationIndex = index; + + if (pinnedConversations.length) { + if (index === 0) { + return { + headerType: HeaderType.Pinned, + type: RowType.Header, + }; + } + + if (index <= pinnedConversations.length) { + return { + index: index - 1, + type: RowType.PinnedConversation, + }; + } + + if (index === pinnedConversations.length + 1) { + return { + headerType: HeaderType.Chats, + type: RowType.Header, + }; + } + + conversationIndex -= pinnedConversations.length + 2; + } + + if (conversationIndex === conversations.length) { + return { + type: RowType.ArchiveButton, + }; + } + + return { + index: conversationIndex, + type: RowType.Conversation, + }; + }; + + public renderConversationRow( + conversation: ConversationListItemPropsType, + key: string, + style: CSSProperties + ): JSX.Element { + const { i18n, openConversationInternal } = this.props; return (
{ />
); + } + + public renderHeaderRow = ( + index: number, + key: string, + style: CSSProperties + ): JSX.Element => { + const { i18n } = this.props; + + switch (index) { + case HeaderType.Pinned: { + return ( +
+ {i18n('LeftPane--pinned')} +
+ ); + } + case HeaderType.Chats: { + return ( +
+ {i18n('LeftPane--chats')} +
+ ); + } + default: { + window.log.warn('LeftPane: invalid HeaderRowIndex received'); + return <>; + } + } }; - public renderArchivedButton = ({ + public renderRow = ({ + index, key, style, - }: { - key: string; - style: CSSProperties; - }): JSX.Element => { + }: RowRendererParamsType): JSX.Element => { + const { + archivedConversations, + conversations, + pinnedConversations, + } = this.props; + + if (!conversations || !pinnedConversations || !archivedConversations) { + throw new Error( + 'renderRow: Tried to render without conversations or pinnedConversations or archivedConversations' + ); + } + + const row = this.getRowFromIndex(index); + + switch (row.type) { + case RowType.ArchiveButton: { + return this.renderArchivedButton(key, style); + } + case RowType.ArchivedConversation: { + return this.renderConversationRow( + archivedConversations[row.index], + key, + style + ); + } + case RowType.Conversation: { + return this.renderConversationRow(conversations[row.index], key, style); + } + case RowType.Header: { + return this.renderHeaderRow(row.headerType, key, style); + } + case RowType.PinnedConversation: { + return this.renderConversationRow( + pinnedConversations[row.index], + key, + style + ); + } + default: + window.log.warn('LeftPane: unknown RowType received'); + return <>; + } + }; + + public renderArchivedButton = ( + key: string, + style: CSSProperties + ): JSX.Element => { const { archivedConversations, i18n, @@ -199,6 +358,14 @@ export class LeftPane extends React.Component { this.listRef.current.scrollToRow(row); }; + public recomputeRowHeights = (): void => { + if (!this.listRef || !this.listRef.current) { + return; + } + + this.listRef.current.recomputeRowHeights(); + }; + public getScrollContainer = (): HTMLDivElement | null => { if (!this.listRef || !this.listRef.current) { return null; @@ -269,16 +436,34 @@ export class LeftPane extends React.Component { ); public getLength = (): number => { - const { archivedConversations, conversations, showArchived } = this.props; + const { + archivedConversations, + conversations, + pinnedConversations, + showArchived, + } = this.props; - if (!conversations || !archivedConversations) { + if (!conversations || !archivedConversations || !pinnedConversations) { return 0; } - // That extra 1 element added to the list is the 'archived conversations' button - return showArchived - ? archivedConversations.length - : conversations.length + (archivedConversations.length ? 1 : 0); + if (showArchived) { + return archivedConversations.length; + } + + let { length } = conversations; + + // includes two additional rows for pinned/chats headers + if (pinnedConversations.length) { + length += pinnedConversations.length + 2; + } + + // includes one additional row for 'archived conversations' button + if (archivedConversations.length) { + length += 1; + } + + return length; }; public renderList = ({ @@ -290,6 +475,7 @@ export class LeftPane extends React.Component { i18n, conversations, openConversationInternal, + pinnedConversations, renderMessageSearchResult, startNewConversation, searchResults, @@ -310,7 +496,7 @@ export class LeftPane extends React.Component { ); } - if (!conversations || !archivedConversations) { + if (!conversations || !archivedConversations || !pinnedConversations) { throw new Error( 'render: must provided conversations and archivedConverstions if no search results are provided' ); @@ -345,7 +531,7 @@ export class LeftPane extends React.Component { onScroll={this.onScroll} ref={this.listRef} rowCount={length} - rowHeight={68} + rowHeight={this.calculateRowHeight} rowRenderer={this.renderRow} tabIndex={-1} width={width || 0} @@ -412,4 +598,16 @@ export class LeftPane extends React.Component {
); } + + componentDidUpdate(oldProps: PropsType): void { + const { pinnedConversations: oldPinned } = oldProps; + const { pinnedConversations: pinned } = this.props; + + const oldLength = (oldPinned && oldPinned.length) || 0; + const newLength = (pinned && pinned.length) || 0; + + if (oldLength !== newLength) { + this.recomputeRowHeights(); + } + } } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index e194deafd..aa200b006 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -145,12 +145,14 @@ export type ConversationAttributesType = { draftAttachments: Array; draftTimestamp: number | null; inbox_position: number; + isPinned: boolean; lastMessageDeletedForEveryone: unknown; lastMessageStatus: LastMessageStatus | null; messageCount: number; messageCountBeforeMessageRequests: number; messageRequestResponseType: number; muteExpiresAt: number; + pinIndex?: number; profileAvatar: WhatIsThis; profileKeyCredential: unknown | null; profileKeyVersion: string; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index b7c00a8bc..d2bcaa72d 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -748,6 +748,7 @@ export class ConversationModel extends window.Backbone.Model< isArchived: this.get('isArchived')!, isBlocked: this.isBlocked(), isMe: this.isMe(), + isPinned: this.get('isPinned'), isVerified: this.isVerified(), lastMessage: { status: this.get('lastMessageStatus')!, @@ -762,6 +763,7 @@ export class ConversationModel extends window.Backbone.Model< muteExpiresAt: this.get('muteExpiresAt')!, name: this.get('name')!, phoneNumber: this.getNumber()!, + pinIndex: this.get('pinIndex'), profileName: this.getProfileName()!, sharedGroupNames: this.get('sharedGroupNames')!, shouldShowDraft, diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 87f066fcd..dcd779155 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -648,7 +648,9 @@ async function processManifest( const decryptedStorageItems = await pMap( storageItems.items, - async (storageRecordWrapper: StorageItemClass) => { + async ( + storageRecordWrapper: StorageItemClass + ): Promise => { const { key, value: storageItemCiphertext } = storageRecordWrapper; if (!key || !storageItemCiphertext) { @@ -695,11 +697,19 @@ async function processManifest( { concurrency: 50 } ); + // Merge Account records last + const sortedStorageItems = ([] as Array).concat( + ..._.partition( + decryptedStorageItems, + storageRecord => storageRecord.storageRecord.account === undefined + ) + ); + try { window.log.info( - `storageService.processManifest: Attempting to merge ${decryptedStorageItems.length} records` + `storageService.processManifest: Attempting to merge ${sortedStorageItems.length} records` ); - const mergedRecords = await pMap(decryptedStorageItems, mergeRecord, { + const mergedRecords = await pMap(sortedStorageItems, mergeRecord, { concurrency: 5, }); window.log.info( diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index edcd77e90..588e004a8 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -14,6 +14,7 @@ import { } from '../textsecure.d'; import { deriveGroupFields, waitThenMaybeUpdateGroup } from '../groups'; import { ConversationModel } from '../models/conversations'; +import { ConversationAttributesTypeType } from '../model-types.d'; const { updateConversation } = dataInterface; @@ -496,6 +497,7 @@ export async function mergeAccountRecord( avatarUrl, linkPreviews, noteToSelfArchived, + pinnedConversations: remotelyPinnedConversationClasses, profileKey, readReceipts, sealedSenderIndicators, @@ -520,6 +522,104 @@ export async function mergeAccountRecord( window.storage.put('profileKey', profileKey.toArrayBuffer()); } + if (remotelyPinnedConversationClasses) { + const locallyPinnedConversations = window.ConversationController._conversations.filter( + conversation => Boolean(conversation.get('isPinned')) + ); + + const remotelyPinnedConversationPromises = remotelyPinnedConversationClasses.map( + async pinnedConversation => { + let conversationId; + let conversationType: ConversationAttributesTypeType = 'private'; + + switch (pinnedConversation.identifier) { + case 'contact': { + if (!pinnedConversation.contact) { + throw new Error('mergeAccountRecord: no contact found'); + } + conversationId = window.ConversationController.ensureContactIds( + pinnedConversation.contact + ); + conversationType = 'private'; + break; + } + case 'legacyGroupId': { + if (!pinnedConversation.legacyGroupId) { + throw new Error('mergeAccountRecord: no legacyGroupId found'); + } + conversationId = pinnedConversation.legacyGroupId.toBinary(); + conversationType = 'group'; + break; + } + case 'groupMasterKey': { + if (!pinnedConversation.groupMasterKey) { + throw new Error('mergeAccountRecord: no groupMasterKey found'); + } + const masterKeyBuffer = pinnedConversation.groupMasterKey.toArrayBuffer(); + const groupFields = deriveGroupFields(masterKeyBuffer); + const groupId = arrayBufferToBase64(groupFields.id); + + conversationId = groupId; + conversationType = 'group'; + break; + } + default: { + window.log.error('mergeAccountRecord: Invalid identifier received'); + } + } + + if (!conversationId) { + window.log.error( + `mergeAccountRecord: missing conversation id. looking based on ${pinnedConversation.identifier}` + ); + return undefined; + } + + if (conversationType === 'private') { + return window.ConversationController.getOrCreateAndWait( + conversationId, + conversationType + ); + } + + return window.ConversationController.get(conversationId); + } + ); + + const remotelyPinnedConversations = ( + await Promise.all(remotelyPinnedConversationPromises) + ).filter( + (conversation): conversation is ConversationModel => + conversation !== undefined + ); + + const remotelyPinnedConversationIds = remotelyPinnedConversations.map( + ({ id }) => id + ); + + const conversationsToUnpin = locallyPinnedConversations.filter( + ({ id }) => !remotelyPinnedConversationIds.includes(id) + ); + + window.log.info( + `mergeAccountRecord: unpinning ${conversationsToUnpin.length} conversations` + ); + + window.log.info( + `mergeAccountRecord: pinning ${conversationsToUnpin.length} conversations` + ); + + conversationsToUnpin.forEach(conversation => { + conversation.set({ isPinned: false, pinIndex: undefined }); + updateConversation(conversation.attributes); + }); + + remotelyPinnedConversations.forEach((conversation, index) => { + conversation.set({ isPinned: true, pinIndex: index }); + updateConversation(conversation.attributes); + }); + } + const ourID = window.ConversationController.getOurConversationId(); if (!ourID) { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 36694bca0..e76166a6f 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -45,6 +45,7 @@ export type ConversationType = { color?: ColorType; isArchived?: boolean; isBlocked?: boolean; + isPinned?: boolean; isVerified?: boolean; activeAt?: number; timestamp?: number; @@ -54,6 +55,7 @@ export type ConversationType = { text: string; }; phoneNumber?: string; + pinIndex?: number; membersCount?: number; muteExpiresAt?: number; type: ConversationTypeType; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 5dab1dddc..5c8c0261b 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -128,9 +128,11 @@ export const _getLeftPaneLists = ( ): { conversations: Array; archivedConversations: Array; + pinnedConversations: Array; } => { const conversations: Array = []; const archivedConversations: Array = []; + const pinnedConversations: Array = []; const values = Object.values(lookup); const max = values.length; @@ -146,6 +148,8 @@ export const _getLeftPaneLists = ( if (conversation.isArchived) { archivedConversations.push(conversation); + } else if (conversation.isPinned) { + pinnedConversations.push(conversation); } else { conversations.push(conversation); } @@ -154,8 +158,9 @@ export const _getLeftPaneLists = ( conversations.sort(comparator); archivedConversations.sort(comparator); + pinnedConversations.sort((a, b) => (a.pinIndex || 0) - (b.pinIndex || 0)); - return { conversations, archivedConversations }; + return { conversations, archivedConversations, pinnedConversations }; }; export const getLeftPaneLists = createSelector( diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 1b60d2004..a0874ec3d 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -961,6 +961,20 @@ export declare class GroupV2RecordClass { __unknownFields?: ArrayBuffer; } +export declare class PinnedConversationClass { + toArrayBuffer: () => ArrayBuffer; + + // identifier is produced by the oneof field in the PinnedConversation protobuf + // and determined which one of the following optional fields are in use + identifier: 'contact' | 'legacyGroupId' | 'groupMasterKey'; + contact?: { + uuid?: string; + e164?: string; + }; + legacyGroupId?: ProtoBinaryType; + groupMasterKey?: ProtoBinaryType; +} + export declare class AccountRecordClass { static decode: ( data: ArrayBuffer | ByteBufferClass, @@ -977,6 +991,7 @@ export declare class AccountRecordClass { sealedSenderIndicators?: boolean | null; typingIndicators?: boolean | null; linkPreviews?: boolean | null; + pinnedConversations?: PinnedConversationClass[]; __unknownFields?: ArrayBuffer; } diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index e7046c490..f608d5ac0 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -12932,7 +12932,7 @@ "rule": "React-createRef", "path": "ts/components/LeftPane.js", "line": " this.listRef = react_1.default.createRef();", - "lineNumber": 16, + "lineNumber": 30, "reasonCategory": "usageTrusted", "updated": "2020-09-11T17:24:56.124Z", "reasonDetail": "Used for scroll calculations" @@ -12941,7 +12941,7 @@ "rule": "React-createRef", "path": "ts/components/LeftPane.js", "line": " this.containerRef = react_1.default.createRef();", - "lineNumber": 17, + "lineNumber": 31, "reasonCategory": "usageTrusted", "updated": "2020-09-11T17:24:56.124Z", "reasonDetail": "Used for scroll calculations"