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",
|
"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"
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
1
ts/textsecure.d.ts
vendored
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
56
ts/window.d.ts
vendored
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue