Pinned Chats
This commit is contained in:
parent
6e1a83ae4e
commit
6a7d45b6fc
9 changed files with 176 additions and 27 deletions
|
@ -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"
|
||||
|
|
|
@ -45,6 +45,7 @@ const actionProps: PropsActionsType = {
|
|||
|
||||
onArchive: action('onArchive'),
|
||||
onMoveToInbox: action('onMoveToInbox'),
|
||||
onSetPin: action('onSetPin'),
|
||||
};
|
||||
|
||||
const housekeepingProps: PropsHousekeepingType = {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
1
ts/textsecure.d.ts
vendored
|
@ -976,6 +976,7 @@ export declare class PinnedConversationClass {
|
|||
}
|
||||
|
||||
export declare class AccountRecordClass {
|
||||
static PinnedConversation: typeof PinnedConversationClass;
|
||||
static decode: (
|
||||
data: ArrayBuffer | ByteBufferClass,
|
||||
encoding?: string
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
56
ts/window.d.ts
vendored
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue