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

View File

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

View File

@ -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<PropsType> {
i18n,
isAccepted,
isMe,
isPinned,
type,
isArchived,
muteExpirationLabel,
@ -324,6 +327,7 @@ export class ConversationHeader extends React.Component<PropsType> {
onShowGroupMembers,
onShowSafetyNumber,
onArchive,
onSetPin,
onMoveToInbox,
timerOptions,
} = this.props;
@ -402,6 +406,15 @@ export class ConversationHeader extends React.Component<PropsType> {
) : (
<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>
</ContextMenu>
);

View File

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

View File

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

1
ts/textsecure.d.ts vendored
View File

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

View File

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

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;
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 () => {

56
ts/window.d.ts vendored
View File

@ -133,7 +133,10 @@ declare global {
addBlockedNumber: (number: string) => void;
addBlockedUuid: (uuid: string) => 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;
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<WhatIsThis>;
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;
};