Pinned Chats

This commit is contained in:
Chris Svenningsen 2020-10-02 11:30:43 -07:00 committed by Josh Perez
parent 6e1a83ae4e
commit 6a7d45b6fc
9 changed files with 176 additions and 27 deletions

View file

@ -213,6 +213,18 @@
"message": "Move Conversation to Inbox", "message": "Move Conversation to Inbox",
"description": "Undoes Archive Conversation action, and moves archived conversation back to the main conversation list" "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": { "chooseDirectory": {
"message": "Choose folder", "message": "Choose folder",
"description": "Button to allow the user to find a folder on disk" "description": "Button to allow the user to find a folder on disk"

View file

@ -45,6 +45,7 @@ const actionProps: PropsActionsType = {
onArchive: action('onArchive'), onArchive: action('onArchive'),
onMoveToInbox: action('onMoveToInbox'), onMoveToInbox: action('onMoveToInbox'),
onSetPin: action('onSetPin'),
}; };
const housekeepingProps: PropsHousekeepingType = { const housekeepingProps: PropsHousekeepingType = {

View file

@ -35,6 +35,7 @@ export interface PropsDataType {
isVerified?: boolean; isVerified?: boolean;
isMe?: boolean; isMe?: boolean;
isArchived?: boolean; isArchived?: boolean;
isPinned?: boolean;
disableTimerChanges?: boolean; disableTimerChanges?: boolean;
expirationSettingName?: string; expirationSettingName?: string;
@ -51,6 +52,7 @@ export interface PropsActionsType {
onSearchInConversation: () => void; onSearchInConversation: () => void;
onOutgoingAudioCallInConversation: () => void; onOutgoingAudioCallInConversation: () => void;
onOutgoingVideoCallInConversation: () => void; onOutgoingVideoCallInConversation: () => void;
onSetPin: (value: boolean) => void;
onShowSafetyNumber: () => void; onShowSafetyNumber: () => void;
onShowAllMedia: () => void; onShowAllMedia: () => void;
@ -313,6 +315,7 @@ export class ConversationHeader extends React.Component<PropsType> {
i18n, i18n,
isAccepted, isAccepted,
isMe, isMe,
isPinned,
type, type,
isArchived, isArchived,
muteExpirationLabel, muteExpirationLabel,
@ -324,6 +327,7 @@ export class ConversationHeader extends React.Component<PropsType> {
onShowGroupMembers, onShowGroupMembers,
onShowSafetyNumber, onShowSafetyNumber,
onArchive, onArchive,
onSetPin,
onMoveToInbox, onMoveToInbox,
timerOptions, timerOptions,
} = this.props; } = this.props;
@ -402,6 +406,15 @@ export class ConversationHeader extends React.Component<PropsType> {
) : ( ) : (
<MenuItem onClick={onArchive}>{i18n('archiveConversation')}</MenuItem> <MenuItem onClick={onArchive}>{i18n('archiveConversation')}</MenuItem>
)} )}
{isPinned ? (
<MenuItem onClick={() => onSetPin(false)}>
{i18n('unpinConversation')}
</MenuItem>
) : (
<MenuItem onClick={() => onSetPin(true)}>
{i18n('pinConversation')}
</MenuItem>
)}
<MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem> <MenuItem onClick={onDeleteMessages}>{i18n('deleteMessages')}</MenuItem>
</ContextMenu> </ContextMenu>
); );

View file

@ -2490,6 +2490,9 @@ export class ConversationModel extends window.Backbone.Model<
const after = this.get('isArchived'); const after = this.get('isArchived');
if (Boolean(before) !== Boolean(after)) { if (Boolean(before) !== Boolean(after)) {
if (after) {
this.unpin();
}
this.captureChange(); this.captureChange();
} }
} }
@ -3686,6 +3689,49 @@ export class ConversationModel extends window.Backbone.Model<
// eslint-disable-next-line no-useless-return // eslint-disable-next-line no-useless-return
return; return;
} }
pin(): void {
const pinnedConversationIds = new Set(
window.storage.get<Array<string>>('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<Array<string>>('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<string>): 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; window.Whisper.Conversation = ConversationModel;

View file

@ -11,6 +11,7 @@ import {
ContactRecordClass, ContactRecordClass,
GroupV1RecordClass, GroupV1RecordClass,
GroupV2RecordClass, GroupV2RecordClass,
PinnedConversationClass,
} 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';
@ -140,6 +141,55 @@ export async function toAccountRecord(
window.storage.get('typingIndicators') window.storage.get('typingIndicators')
); );
accountRecord.linkPreviews = Boolean(window.storage.get('linkPreviews')); accountRecord.linkPreviews = Boolean(window.storage.get('linkPreviews'));
accountRecord.pinnedConversations = window.storage
.get<Array<string>>('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); applyUnknownFields(accountRecord, conversation);
@ -608,6 +658,8 @@ export async function mergeAccountRecord(
conversation.set({ isPinned: true, pinIndex: index }); conversation.set({ isPinned: true, pinIndex: index });
updateConversation(conversation.attributes); updateConversation(conversation.attributes);
}); });
window.storage.put('pinnedConversationIds', remotelyPinnedConversationIds);
} }
const ourID = window.ConversationController.getOurConversationId(); const ourID = window.ConversationController.getOurConversationId();

1
ts/textsecure.d.ts vendored
View file

@ -976,6 +976,7 @@ export declare class PinnedConversationClass {
} }
export declare class AccountRecordClass { export declare class AccountRecordClass {
static PinnedConversation: typeof PinnedConversationClass;
static decode: ( static decode: (
data: ArrayBuffer | ByteBufferClass, data: ArrayBuffer | ByteBufferClass,
encoding?: string encoding?: string

View file

@ -13030,7 +13030,7 @@
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.tsx", "path": "ts/components/conversation/ConversationHeader.tsx",
"line": " this.menuTriggerRef = React.createRef();", "line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 82, "lineNumber": 84,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-05-20T20:10:43.540Z", "updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Used to reference popup menu" "reasonDetail": "Used to reference popup menu"

View file

@ -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; const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({ Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({
@ -411,6 +417,18 @@ Whisper.ConversationView = Whisper.View.extend({
return expires.format('M/D/YY, hh:mm A'); 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() { setupHeader() {
const getHeaderProps = (_unknown?: unknown) => { const getHeaderProps = (_unknown?: unknown) => {
const expireTimer = this.model.get('expireTimer'); const expireTimer = this.model.get('expireTimer');
@ -449,7 +467,7 @@ Whisper.ConversationView = Whisper.View.extend({
searchInConversation(this.model.id, name); searchInConversation(this.model.id, name);
}, },
onSetMuteNotifications: (ms: number) => this.setMuteNotifications(ms), onSetMuteNotifications: (ms: number) => this.setMuteNotifications(ms),
onSetPin: this.setPin.bind(this),
// These are view only and don't update the Conversation model, so they // These are view only and don't update the Conversation model, so they
// need a manual update call. // need a manual update call.
onOutgoingAudioCallInConversation: async () => { onOutgoingAudioCallInConversation: async () => {

56
ts/window.d.ts vendored
View file

@ -133,7 +133,10 @@ declare global {
addBlockedNumber: (number: string) => void; addBlockedNumber: (number: string) => void;
addBlockedUuid: (uuid: string) => void; addBlockedUuid: (uuid: string) => void;
fetch: () => void; fetch: () => void;
get: <T = any>(key: string, defaultValue?: T) => T | undefined; get: {
<T = any>(key: string): T | undefined;
<T>(key: string, defaultValue: T): T;
};
getItemsState: () => WhatIsThis; getItemsState: () => WhatIsThis;
isBlocked: (number: string) => boolean; isBlocked: (number: string) => boolean;
isGroupBlocked: (group: unknown) => boolean; isGroupBlocked: (group: unknown) => boolean;
@ -603,7 +606,9 @@ export type WhisperType = {
ClearDataView: WhatIsThis; ClearDataView: WhatIsThis;
ReactWrapperView: WhatIsThis; ReactWrapperView: WhatIsThis;
activeConfirmationView: WhatIsThis; activeConfirmationView: WhatIsThis;
ToastView: WhatIsThis; ToastView: typeof Whisper.View & {
show: (view: Backbone.View, el: Element) => void;
};
ConversationArchivedToast: WhatIsThis; ConversationArchivedToast: WhatIsThis;
ConversationUnarchivedToast: WhatIsThis; ConversationUnarchivedToast: WhatIsThis;
AppView: WhatIsThis; AppView: WhatIsThis;
@ -679,28 +684,29 @@ export type WhisperType = {
deliveryReceiptBatcher: BatcherType<WhatIsThis>; deliveryReceiptBatcher: BatcherType<WhatIsThis>;
RotateSignedPreKeyListener: WhatIsThis; RotateSignedPreKeyListener: WhatIsThis;
ExpiredToast: any; ExpiredToast: typeof Whisper.ToastView;
BlockedToast: any; BlockedToast: typeof Whisper.ToastView;
BlockedGroupToast: any; BlockedGroupToast: typeof Whisper.ToastView;
LeftGroupToast: any; LeftGroupToast: typeof Whisper.ToastView;
OriginalNotFoundToast: any; OriginalNotFoundToast: typeof Whisper.ToastView;
OriginalNoLongerAvailableToast: any; OriginalNoLongerAvailableToast: typeof Whisper.ToastView;
FoundButNotLoadedToast: any; FoundButNotLoadedToast: typeof Whisper.ToastView;
VoiceNoteLimit: any; VoiceNoteLimit: typeof Whisper.ToastView;
VoiceNoteMustBeOnlyAttachmentToast: any; VoiceNoteMustBeOnlyAttachmentToast: typeof Whisper.ToastView;
TapToViewExpiredIncomingToast: any; TapToViewExpiredIncomingToast: typeof Whisper.ToastView;
TapToViewExpiredOutgoingToast: any; TapToViewExpiredOutgoingToast: typeof Whisper.ToastView;
FileSavedToast: any; FileSavedToast: typeof Whisper.ToastView;
ReactionFailedToast: any; ReactionFailedToast: typeof Whisper.ToastView;
MessageBodyTooLongToast: any; MessageBodyTooLongToast: typeof Whisper.ToastView;
FileSizeToast: any; FileSizeToast: any;
UnableToLoadToast: any; UnableToLoadToast: typeof Whisper.ToastView;
DangerousFileTypeToast: any; DangerousFileTypeToast: typeof Whisper.ToastView;
OneNonImageAtATimeToast: any; OneNonImageAtATimeToast: typeof Whisper.ToastView;
CannotMixImageAndNonImageAttachmentsToast: any; CannotMixImageAndNonImageAttachmentsToast: typeof Whisper.ToastView;
MaxAttachmentsToast: any; MaxAttachmentsToast: typeof Whisper.ToastView;
TimerConflictToast: any; TimerConflictToast: typeof Whisper.ToastView;
ConversationLoadingScreen: any; PinnedConversationsFullToast: typeof Whisper.ToastView;
ConversationView: any; ConversationLoadingScreen: typeof Whisper.View;
View: any; ConversationView: typeof Whisper.View;
View: typeof Backbone.View;
}; };