Read Pinned Chats

Co-authored-by: Sidney Keese <sidney@carbonfive.com>
This commit is contained in:
Chris Svenningsen 2020-09-29 15:07:03 -07:00 committed by Josh Perez
parent 3ca547f3dd
commit 63b2644cb4
15 changed files with 444 additions and 46 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -49,6 +49,7 @@ export type PropsData = {
text: string;
deletedForEveryone?: boolean;
};
isPinned?: boolean;
};
type PropsHousekeeping = {

View File

@ -40,12 +40,32 @@ const defaultArchivedConversations: Array<PropsData> = [
},
];
const pinnedConversations: Array<PropsData> = [
{
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> = {}): PropsType => ({
archivedConversations:
overrideProps.archivedConversations || defaultArchivedConversations,
conversations: overrideProps.conversations || defaultConversations,
i18n,
openConversationInternal: action('openConversationInternal'),
pinnedConversations: overrideProps.pinnedConversations || [],
renderExpiredBuildDialog: () => <div />,
renderMainHeader: () => <div />,
renderMessageSearchResult: () => <div />,
@ -69,6 +89,14 @@ story.add('Conversation States (Active, Selected, Archived)', () => {
return <LeftPane {...props} />;
});
story.add('Pinned Conversations', () => {
const props = createProps({
pinnedConversations,
});
return <LeftPane {...props} />;
});
story.add('Archived Conversations Shown', () => {
const props = createProps({
showArchived: true,

View File

@ -17,6 +17,7 @@ import { cleanId } from './_util';
export interface PropsType {
conversations?: Array<ConversationListItemPropsType>;
archivedConversations?: Array<ConversationListItemPropsType>;
pinnedConversations?: Array<ConversationListItemPropsType>;
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<PropsType> {
public listRef = React.createRef<List>();
@ -60,31 +98,77 @@ export class LeftPane extends React.Component<PropsType> {
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 (
<div
@ -99,15 +183,90 @@ export class LeftPane extends React.Component<PropsType> {
/>
</div>
);
}
public renderHeaderRow = (
index: number,
key: string,
style: CSSProperties
): JSX.Element => {
const { i18n } = this.props;
switch (index) {
case HeaderType.Pinned: {
return (
<div className="module-left-pane__header-row" key={key} style={style}>
{i18n('LeftPane--pinned')}
</div>
);
}
case HeaderType.Chats: {
return (
<div className="module-left-pane__header-row" key={key} style={style}>
{i18n('LeftPane--chats')}
</div>
);
}
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<PropsType> {
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<PropsType> {
);
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<PropsType> {
i18n,
conversations,
openConversationInternal,
pinnedConversations,
renderMessageSearchResult,
startNewConversation,
searchResults,
@ -310,7 +496,7 @@ export class LeftPane extends React.Component<PropsType> {
);
}
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<PropsType> {
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<PropsType> {
</div>
);
}
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();
}
}
}

2
ts/model-types.d.ts vendored
View File

@ -145,12 +145,14 @@ export type ConversationAttributesType = {
draftAttachments: Array<unknown>;
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;

View File

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

View File

@ -648,7 +648,9 @@ async function processManifest(
const decryptedStorageItems = await pMap(
storageItems.items,
async (storageRecordWrapper: StorageItemClass) => {
async (
storageRecordWrapper: StorageItemClass
): Promise<MergeableItemType> => {
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<MergeableItemType>).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(

View File

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

View File

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

View File

@ -128,9 +128,11 @@ export const _getLeftPaneLists = (
): {
conversations: Array<ConversationType>;
archivedConversations: Array<ConversationType>;
pinnedConversations: Array<ConversationType>;
} => {
const conversations: Array<ConversationType> = [];
const archivedConversations: Array<ConversationType> = [];
const pinnedConversations: Array<ConversationType> = [];
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(

15
ts/textsecure.d.ts vendored
View File

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

View File

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