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", "message": "Archived Conversations",
"description": "Shown in place of the search box when showing archived conversation list" "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": { "archiveHelperText": {
"message": "These conversations are archived and will only appear in the Inbox if new messages are received.", "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" "description": "Shown at the top of the archived conversations list in the left pane"

View file

@ -90,15 +90,29 @@ message GroupV2Record {
} }
message AccountRecord { message AccountRecord {
optional bytes profileKey = 1; message PinnedConversation {
optional string givenName = 2; message Contact {
optional string familyName = 3; optional string uuid = 1;
optional string avatarUrl = 4; optional string e164 = 2;
optional bool noteToSelfArchived = 5; }
optional bool readReceipts = 6;
optional bool sealedSenderIndicators = 7; oneof identifier {
optional bool typingIndicators = 8; Contact contact = 1;
optional bool proxiedLinkPreviews = 9; bytes legacyGroupId = 3;
optional bool noteToSelfUnread = 10; bytes groupMasterKey = 4;
optional bool linkPreviews = 11; }
}
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 { .module-left-pane__to-inbox-button {
@include button-reset; @include button-reset;

View file

@ -35,6 +35,7 @@ export function stringFromBytes(buffer: ArrayBuffer): string {
export function hexFromBytes(buffer: ArrayBuffer): string { export function hexFromBytes(buffer: ArrayBuffer): string {
return window.dcodeIO.ByteBuffer.wrap(buffer).toString('hex'); return window.dcodeIO.ByteBuffer.wrap(buffer).toString('hex');
} }
export function bytesFromHexString(string: string): ArrayBuffer { export function bytesFromHexString(string: string): ArrayBuffer {
return window.dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer(); return window.dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer();
} }

View file

@ -49,6 +49,7 @@ export type PropsData = {
text: string; text: string;
deletedForEveryone?: boolean; deletedForEveryone?: boolean;
}; };
isPinned?: boolean;
}; };
type PropsHousekeeping = { 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 => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
archivedConversations: archivedConversations:
overrideProps.archivedConversations || defaultArchivedConversations, overrideProps.archivedConversations || defaultArchivedConversations,
conversations: overrideProps.conversations || defaultConversations, conversations: overrideProps.conversations || defaultConversations,
i18n, i18n,
openConversationInternal: action('openConversationInternal'), openConversationInternal: action('openConversationInternal'),
pinnedConversations: overrideProps.pinnedConversations || [],
renderExpiredBuildDialog: () => <div />, renderExpiredBuildDialog: () => <div />,
renderMainHeader: () => <div />, renderMainHeader: () => <div />,
renderMessageSearchResult: () => <div />, renderMessageSearchResult: () => <div />,
@ -69,6 +89,14 @@ story.add('Conversation States (Active, Selected, Archived)', () => {
return <LeftPane {...props} />; return <LeftPane {...props} />;
}); });
story.add('Pinned Conversations', () => {
const props = createProps({
pinnedConversations,
});
return <LeftPane {...props} />;
});
story.add('Archived Conversations Shown', () => { story.add('Archived Conversations Shown', () => {
const props = createProps({ const props = createProps({
showArchived: true, showArchived: true,

View file

@ -17,6 +17,7 @@ import { cleanId } from './_util';
export interface PropsType { export interface PropsType {
conversations?: Array<ConversationListItemPropsType>; conversations?: Array<ConversationListItemPropsType>;
archivedConversations?: Array<ConversationListItemPropsType>; archivedConversations?: Array<ConversationListItemPropsType>;
pinnedConversations?: Array<ConversationListItemPropsType>;
selectedConversationId?: string; selectedConversationId?: string;
searchResults?: SearchResultsProps; searchResults?: SearchResultsProps;
showArchived?: boolean; showArchived?: boolean;
@ -51,6 +52,43 @@ type RowRendererParamsType = {
style: CSSProperties; 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> { export class LeftPane extends React.Component<PropsType> {
public listRef = React.createRef<List>(); public listRef = React.createRef<List>();
@ -60,31 +98,77 @@ export class LeftPane extends React.Component<PropsType> {
public setFocusToLastNeeded = false; public setFocusToLastNeeded = false;
public renderRow = ({ public calculateRowHeight = ({ index }: { index: number }): number => {
index, const { type } = this.getRowFromIndex(index);
key, return type === RowType.Header ? 40 : 68;
style, };
}: RowRendererParamsType): JSX.Element => {
public getRowFromIndex = (index: number): Row => {
const { const {
archivedConversations, archivedConversations,
conversations, conversations,
i18n, pinnedConversations,
openConversationInternal,
showArchived, showArchived,
} = this.props; } = this.props;
if (!conversations || !archivedConversations) {
throw new Error( if (!conversations || !pinnedConversations || !archivedConversations) {
'renderRow: Tried to render without conversations or archivedConversations' return {
); type: RowType.Undefined,
};
} }
if (!showArchived && index === conversations.length) { if (showArchived) {
return this.renderArchivedButton({ key, style }); return {
index,
type: RowType.ArchivedConversation,
};
} }
const conversation = showArchived let conversationIndex = index;
? archivedConversations[index]
: conversations[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 ( return (
<div <div
@ -99,15 +183,90 @@ export class LeftPane extends React.Component<PropsType> {
/> />
</div> </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, key,
style, style,
}: { }: RowRendererParamsType): JSX.Element => {
key: string; const {
style: CSSProperties; archivedConversations,
}): JSX.Element => { 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 { const {
archivedConversations, archivedConversations,
i18n, i18n,
@ -199,6 +358,14 @@ export class LeftPane extends React.Component<PropsType> {
this.listRef.current.scrollToRow(row); this.listRef.current.scrollToRow(row);
}; };
public recomputeRowHeights = (): void => {
if (!this.listRef || !this.listRef.current) {
return;
}
this.listRef.current.recomputeRowHeights();
};
public getScrollContainer = (): HTMLDivElement | null => { public getScrollContainer = (): HTMLDivElement | null => {
if (!this.listRef || !this.listRef.current) { if (!this.listRef || !this.listRef.current) {
return null; return null;
@ -269,16 +436,34 @@ export class LeftPane extends React.Component<PropsType> {
); );
public getLength = (): number => { 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; return 0;
} }
// That extra 1 element added to the list is the 'archived conversations' button if (showArchived) {
return showArchived return archivedConversations.length;
? archivedConversations.length }
: conversations.length + (archivedConversations.length ? 1 : 0);
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 = ({ public renderList = ({
@ -290,6 +475,7 @@ export class LeftPane extends React.Component<PropsType> {
i18n, i18n,
conversations, conversations,
openConversationInternal, openConversationInternal,
pinnedConversations,
renderMessageSearchResult, renderMessageSearchResult,
startNewConversation, startNewConversation,
searchResults, searchResults,
@ -310,7 +496,7 @@ export class LeftPane extends React.Component<PropsType> {
); );
} }
if (!conversations || !archivedConversations) { if (!conversations || !archivedConversations || !pinnedConversations) {
throw new Error( throw new Error(
'render: must provided conversations and archivedConverstions if no search results are provided' '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} onScroll={this.onScroll}
ref={this.listRef} ref={this.listRef}
rowCount={length} rowCount={length}
rowHeight={68} rowHeight={this.calculateRowHeight}
rowRenderer={this.renderRow} rowRenderer={this.renderRow}
tabIndex={-1} tabIndex={-1}
width={width || 0} width={width || 0}
@ -412,4 +598,16 @@ export class LeftPane extends React.Component<PropsType> {
</div> </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>; draftAttachments: Array<unknown>;
draftTimestamp: number | null; draftTimestamp: number | null;
inbox_position: number; inbox_position: number;
isPinned: boolean;
lastMessageDeletedForEveryone: unknown; lastMessageDeletedForEveryone: unknown;
lastMessageStatus: LastMessageStatus | null; lastMessageStatus: LastMessageStatus | null;
messageCount: number; messageCount: number;
messageCountBeforeMessageRequests: number; messageCountBeforeMessageRequests: number;
messageRequestResponseType: number; messageRequestResponseType: number;
muteExpiresAt: number; muteExpiresAt: number;
pinIndex?: number;
profileAvatar: WhatIsThis; profileAvatar: WhatIsThis;
profileKeyCredential: unknown | null; profileKeyCredential: unknown | null;
profileKeyVersion: string; profileKeyVersion: string;

View file

@ -748,6 +748,7 @@ export class ConversationModel extends window.Backbone.Model<
isArchived: this.get('isArchived')!, isArchived: this.get('isArchived')!,
isBlocked: this.isBlocked(), isBlocked: this.isBlocked(),
isMe: this.isMe(), isMe: this.isMe(),
isPinned: this.get('isPinned'),
isVerified: this.isVerified(), isVerified: this.isVerified(),
lastMessage: { lastMessage: {
status: this.get('lastMessageStatus')!, status: this.get('lastMessageStatus')!,
@ -762,6 +763,7 @@ export class ConversationModel extends window.Backbone.Model<
muteExpiresAt: this.get('muteExpiresAt')!, muteExpiresAt: this.get('muteExpiresAt')!,
name: this.get('name')!, name: this.get('name')!,
phoneNumber: this.getNumber()!, phoneNumber: this.getNumber()!,
pinIndex: this.get('pinIndex'),
profileName: this.getProfileName()!, profileName: this.getProfileName()!,
sharedGroupNames: this.get('sharedGroupNames')!, sharedGroupNames: this.get('sharedGroupNames')!,
shouldShowDraft, shouldShowDraft,

View file

@ -648,7 +648,9 @@ async function processManifest(
const decryptedStorageItems = await pMap( const decryptedStorageItems = await pMap(
storageItems.items, storageItems.items,
async (storageRecordWrapper: StorageItemClass) => { async (
storageRecordWrapper: StorageItemClass
): Promise<MergeableItemType> => {
const { key, value: storageItemCiphertext } = storageRecordWrapper; const { key, value: storageItemCiphertext } = storageRecordWrapper;
if (!key || !storageItemCiphertext) { if (!key || !storageItemCiphertext) {
@ -695,11 +697,19 @@ async function processManifest(
{ concurrency: 50 } { concurrency: 50 }
); );
// Merge Account records last
const sortedStorageItems = ([] as Array<MergeableItemType>).concat(
..._.partition(
decryptedStorageItems,
storageRecord => storageRecord.storageRecord.account === undefined
)
);
try { try {
window.log.info( 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, concurrency: 5,
}); });
window.log.info( window.log.info(

View file

@ -14,6 +14,7 @@ import {
} from '../textsecure.d'; } from '../textsecure.d';
import { deriveGroupFields, waitThenMaybeUpdateGroup } from '../groups'; import { deriveGroupFields, waitThenMaybeUpdateGroup } from '../groups';
import { ConversationModel } from '../models/conversations'; import { ConversationModel } from '../models/conversations';
import { ConversationAttributesTypeType } from '../model-types.d';
const { updateConversation } = dataInterface; const { updateConversation } = dataInterface;
@ -496,6 +497,7 @@ export async function mergeAccountRecord(
avatarUrl, avatarUrl,
linkPreviews, linkPreviews,
noteToSelfArchived, noteToSelfArchived,
pinnedConversations: remotelyPinnedConversationClasses,
profileKey, profileKey,
readReceipts, readReceipts,
sealedSenderIndicators, sealedSenderIndicators,
@ -520,6 +522,104 @@ export async function mergeAccountRecord(
window.storage.put('profileKey', profileKey.toArrayBuffer()); 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(); const ourID = window.ConversationController.getOurConversationId();
if (!ourID) { if (!ourID) {

View file

@ -45,6 +45,7 @@ export type ConversationType = {
color?: ColorType; color?: ColorType;
isArchived?: boolean; isArchived?: boolean;
isBlocked?: boolean; isBlocked?: boolean;
isPinned?: boolean;
isVerified?: boolean; isVerified?: boolean;
activeAt?: number; activeAt?: number;
timestamp?: number; timestamp?: number;
@ -54,6 +55,7 @@ export type ConversationType = {
text: string; text: string;
}; };
phoneNumber?: string; phoneNumber?: string;
pinIndex?: number;
membersCount?: number; membersCount?: number;
muteExpiresAt?: number; muteExpiresAt?: number;
type: ConversationTypeType; type: ConversationTypeType;

View file

@ -128,9 +128,11 @@ export const _getLeftPaneLists = (
): { ): {
conversations: Array<ConversationType>; conversations: Array<ConversationType>;
archivedConversations: Array<ConversationType>; archivedConversations: Array<ConversationType>;
pinnedConversations: Array<ConversationType>;
} => { } => {
const conversations: Array<ConversationType> = []; const conversations: Array<ConversationType> = [];
const archivedConversations: Array<ConversationType> = []; const archivedConversations: Array<ConversationType> = [];
const pinnedConversations: Array<ConversationType> = [];
const values = Object.values(lookup); const values = Object.values(lookup);
const max = values.length; const max = values.length;
@ -146,6 +148,8 @@ export const _getLeftPaneLists = (
if (conversation.isArchived) { if (conversation.isArchived) {
archivedConversations.push(conversation); archivedConversations.push(conversation);
} else if (conversation.isPinned) {
pinnedConversations.push(conversation);
} else { } else {
conversations.push(conversation); conversations.push(conversation);
} }
@ -154,8 +158,9 @@ export const _getLeftPaneLists = (
conversations.sort(comparator); conversations.sort(comparator);
archivedConversations.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( export const getLeftPaneLists = createSelector(

15
ts/textsecure.d.ts vendored
View file

@ -961,6 +961,20 @@ export declare class GroupV2RecordClass {
__unknownFields?: ArrayBuffer; __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 { export declare class AccountRecordClass {
static decode: ( static decode: (
data: ArrayBuffer | ByteBufferClass, data: ArrayBuffer | ByteBufferClass,
@ -977,6 +991,7 @@ export declare class AccountRecordClass {
sealedSenderIndicators?: boolean | null; sealedSenderIndicators?: boolean | null;
typingIndicators?: boolean | null; typingIndicators?: boolean | null;
linkPreviews?: boolean | null; linkPreviews?: boolean | null;
pinnedConversations?: PinnedConversationClass[];
__unknownFields?: ArrayBuffer; __unknownFields?: ArrayBuffer;
} }

View file

@ -12932,7 +12932,7 @@
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/LeftPane.js", "path": "ts/components/LeftPane.js",
"line": " this.listRef = react_1.default.createRef();", "line": " this.listRef = react_1.default.createRef();",
"lineNumber": 16, "lineNumber": 30,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-09-11T17:24:56.124Z", "updated": "2020-09-11T17:24:56.124Z",
"reasonDetail": "Used for scroll calculations" "reasonDetail": "Used for scroll calculations"
@ -12941,7 +12941,7 @@
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/LeftPane.js", "path": "ts/components/LeftPane.js",
"line": " this.containerRef = react_1.default.createRef();", "line": " this.containerRef = react_1.default.createRef();",
"lineNumber": 17, "lineNumber": 31,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-09-11T17:24:56.124Z", "updated": "2020-09-11T17:24:56.124Z",
"reasonDetail": "Used for scroll calculations" "reasonDetail": "Used for scroll calculations"