{
public override render(): JSX.Element | null {
const {
conversationColor,
- curveTopLeft,
- curveTopRight,
customColor,
isIncoming,
onClick,
@@ -506,9 +532,7 @@ export class Quote extends React.Component
{
: this.getClassName(`--outgoing-${conversationColor}`),
!onClick && this.getClassName('--no-click'),
referencedMessageNotFound &&
- this.getClassName('--with-reference-warning'),
- curveTopLeft && this.getClassName('--curve-top-left'),
- curveTopRight && this.getClassName('--curve-top-right')
+ this.getClassName('--with-reference-warning')
)}
style={{ ...getCustomColorStyle(customColor, true) }}
>
diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx
index 985f5771b5b4..a00c191cf612 100644
--- a/ts/components/conversation/Timeline.stories.tsx
+++ b/ts/components/conversation/Timeline.stories.tsx
@@ -55,6 +55,7 @@ const items: Record = {
canRetryDeleteForEveryone: true,
conversationColor: 'forest',
conversationId: 'conversation-id',
+ conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'incoming',
id: 'id-1',
@@ -80,6 +81,7 @@ const items: Record = {
canRetryDeleteForEveryone: true,
conversationColor: 'forest',
conversationId: 'conversation-id',
+ conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'incoming',
id: 'id-2',
@@ -119,6 +121,7 @@ const items: Record = {
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
+ conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'incoming',
id: 'id-3',
@@ -219,6 +222,7 @@ const items: Record = {
canRetryDeleteForEveryone: true,
conversationColor: 'plum',
conversationId: 'conversation-id',
+ conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'outgoing',
id: 'id-6',
@@ -245,6 +249,7 @@ const items: Record = {
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
+ conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'outgoing',
id: 'id-7',
@@ -271,6 +276,7 @@ const items: Record = {
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
+ conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'outgoing',
id: 'id-8',
@@ -297,6 +303,7 @@ const items: Record = {
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
+ conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'outgoing',
id: 'id-9',
@@ -323,6 +330,7 @@ const items: Record = {
canRetryDeleteForEveryone: true,
conversationColor: 'crimson',
conversationId: 'conversation-id',
+ conversationTitle: 'Conversation Title',
conversationType: 'group',
direction: 'outgoing',
id: 'id-10',
@@ -379,6 +387,7 @@ const actions = () => ({
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
openLink: action('openLink'),
+ openGiftBadge: action('openGiftBadge'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),
showExpiredIncomingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx
index e2fe4cfff825..086c392f0447 100644
--- a/ts/components/conversation/Timeline.tsx
+++ b/ts/components/conversation/Timeline.tsx
@@ -248,6 +248,7 @@ const getActions = createSelector(
'deleteMessageForEveryone',
'showMessageDetail',
'openConversation',
+ 'openGiftBadge',
'showContactDetail',
'showContactModal',
'kickOffAttachmentDownload',
diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx
index f1eb2444ab44..1553c083fec7 100644
--- a/ts/components/conversation/TimelineItem.stories.tsx
+++ b/ts/components/conversation/TimelineItem.stories.tsx
@@ -75,6 +75,7 @@ const getDefaultProps = () => ({
messageExpanded: action('messageExpanded'),
showMessageDetail: action('showMessageDetail'),
openConversation: action('openConversation'),
+ openGiftBadge: action('openGiftBadge'),
showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'),
showForwardMessageModal: action('showForwardMessageModal'),
diff --git a/ts/messageModifiers/ViewSyncs.ts b/ts/messageModifiers/ViewSyncs.ts
index 1c050375e2e7..3186066d8bd9 100644
--- a/ts/messageModifiers/ViewSyncs.ts
+++ b/ts/messageModifiers/ViewSyncs.ts
@@ -11,6 +11,7 @@ import { markViewed } from '../services/MessageUpdater';
import { isIncoming, isStory } from '../state/selectors/message';
import { notificationService } from '../services/notifications';
import * as log from '../logging/log';
+import { GiftBadgeStates } from '../components/conversation/Message';
export type ViewSyncAttributesType = {
senderId: string;
@@ -92,6 +93,16 @@ export class ViewSyncs extends Collection {
message.set(markViewed(message.attributes, sync.get('viewedAt')));
}
+ const giftBadge = message.get('giftBadge');
+ if (giftBadge) {
+ message.set({
+ giftBadge: {
+ ...giftBadge,
+ state: GiftBadgeStates.Redeemed,
+ },
+ });
+ }
+
this.remove(sync);
} catch (error) {
log.error(
diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts
index ed7658062485..5d21bffd0aa9 100644
--- a/ts/model-types.d.ts
+++ b/ts/model-types.d.ts
@@ -4,29 +4,20 @@
import * as Backbone from 'backbone';
import { GroupV2ChangeType } from './groups';
-import { LocalizerType, BodyRangeType, BodyRangesType } from './types/Util';
+import { BodyRangeType, BodyRangesType } from './types/Util';
import { CallHistoryDetailsFromDiskType } from './types/Calling';
import { CustomColorType } from './types/Colors';
import { DeviceType } from './textsecure/Types';
-import { SendOptionsType } from './textsecure/SendMessage';
import { SendMessageChallengeData } from './textsecure/Errors';
-import { UserMessage } from './types/Message';
import { MessageModel } from './models/messages';
import { ConversationModel } from './models/conversations';
import { ProfileNameChangeType } from './util/getStringForProfileChange';
import { CapabilitiesType } from './textsecure/WebAPI';
import { ReadStatus } from './messages/MessageReadStatus';
-import {
- SendState,
- SendStateByConversationId,
-} from './messages/MessageSendState';
+import { SendStateByConversationId } from './messages/MessageSendState';
import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions';
import { ConversationColorType } from './types/Colors';
-import {
- AttachmentDraftType,
- AttachmentType,
- ThumbnailType,
-} from './types/Attachment';
+import { AttachmentDraftType, AttachmentType } from './types/Attachment';
import { EmbeddedContactType } from './types/EmbeddedContact';
import { SignalService as Proto } from './protobuf';
import { AvatarDataType } from './types/Avatar';
@@ -36,6 +27,7 @@ import { ReactionSource } from './reactions/ReactionSource';
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
import MemberRoleEnum = Proto.Member.Role;
import { SeenStatus } from './MessageSeenStatus';
+import { GiftBadgeStates } from './components/conversation/Message';
export type WhatIsThis = any;
@@ -80,10 +72,11 @@ export type QuotedMessageType = {
authorUuid?: string;
bodyRanges?: BodyRangesType;
id: number;
- referencedMessageNotFound: boolean;
+ isGiftBadge?: boolean;
isViewOnce: boolean;
- text?: string;
messageId: string;
+ referencedMessageNotFound: boolean;
+ text?: string;
};
type StoryReplyContextType = {
@@ -187,6 +180,12 @@ export type MessageAttributesType = {
contact?: Array;
conversationId: string;
storyReactionEmoji?: string;
+ giftBadge?: {
+ expiration: number;
+ level: number;
+ receiptCredentialPresentation: string;
+ state: GiftBadgeStates;
+ };
expirationTimerUpdate?: {
expireTimer: number;
diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts
index cf0f82f25e14..636acff33e1c 100644
--- a/ts/models/conversations.ts
+++ b/ts/models/conversations.ts
@@ -93,6 +93,7 @@ import { SignalService as Proto } from '../protobuf';
import {
getMessagePropStatus,
hasErrors,
+ isGiftBadge,
isIncoming,
isStory,
isTapToView,
@@ -1818,7 +1819,6 @@ export class ConversationModel extends window.Backbone
const { customColor, customColorId } = this.getCustomColorData();
// TODO: DESKTOP-720
- /* eslint-disable @typescript-eslint/no-non-null-assertion */
return {
id: this.id,
uuid: this.get('uuid'),
@@ -1832,6 +1832,7 @@ export class ConversationModel extends window.Backbone
aboutText: this.get('about'),
aboutEmoji: this.get('aboutEmoji'),
acceptedMessageRequest: this.getAccepted(),
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
activeAt: this.get('active_at')!,
areWePending: Boolean(
ourConversationId && this.isMemberPending(ourConversationId)
@@ -1857,14 +1858,14 @@ export class ConversationModel extends window.Backbone
draftPreview,
draftText,
familyName: this.get('profileFamilyName'),
- firstName: this.get('profileName')!,
+ firstName: this.get('profileName'),
groupDescription: this.get('description'),
groupVersion,
groupId: this.get('groupId'),
groupLink: this.getGroupLink(),
hideStory: Boolean(this.get('hideStory')),
inboxPosition,
- isArchived: this.get('isArchived')!,
+ isArchived: this.get('isArchived'),
isBlocked: this.isBlocked(),
isMe: isMe(this.attributes),
isGroupV1AndDisabled: this.isGroupV1AndDisabled(),
@@ -1873,9 +1874,10 @@ export class ConversationModel extends window.Backbone
isVerified: this.isVerified(),
isFetchingUUID: this.isFetchingUUID,
lastMessage,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
lastUpdated: this.get('timestamp')!,
left: Boolean(this.get('left')),
- markedUnread: this.get('markedUnread')!,
+ markedUnread: this.get('markedUnread'),
membersCount: this.getMembersCount(),
memberships: this.getMemberships(),
messageCount: this.get('messageCount') || 0,
@@ -1891,23 +1893,23 @@ export class ConversationModel extends window.Backbone
announcementsOnly: Boolean(this.get('announcementsOnly')),
announcementsOnlyReady: this.canBeAnnouncementGroup(),
expireTimer: this.get('expireTimer'),
- muteExpiresAt: this.get('muteExpiresAt')!,
+ muteExpiresAt: this.get('muteExpiresAt'),
dontNotifyForMentionsIfMuted: this.get('dontNotifyForMentionsIfMuted'),
- name: this.get('name')!,
- phoneNumber: this.getNumber()!,
- profileName: this.getProfileName()!,
+ name: this.get('name'),
+ phoneNumber: this.getNumber(),
+ profileName: this.getProfileName(),
profileSharing: this.get('profileSharing'),
publicParams: this.get('publicParams'),
secretParams: this.get('secretParams'),
shouldShowDraft,
sortedGroupMembers,
timestamp,
- title: this.getTitle()!,
+ title: this.getTitle(),
typingContactId: typingMostRecent?.senderId,
searchableTitle: isMe(this.attributes)
? window.i18n('noteToSelf')
: this.getTitle(),
- unreadCount: this.get('unreadCount')! || 0,
+ unreadCount: this.get('unreadCount') || 0,
...(isDirectConversation(this.attributes)
? {
type: 'direct' as const,
@@ -1920,7 +1922,6 @@ export class ConversationModel extends window.Backbone
sharedGroupNames: [],
}),
};
- /* eslint-enable @typescript-eslint/no-non-null-assertion */
}
updateE164(e164?: string | null): void {
@@ -3762,6 +3763,7 @@ export class ConversationModel extends window.Backbone
bodyRanges: quotedMessage.get('bodyRanges'),
id: quotedMessage.get('sent_at'),
isViewOnce: isTapToView(quotedMessage.attributes),
+ isGiftBadge: isGiftBadge(quotedMessage.attributes),
messageId: quotedMessage.get('id'),
referencedMessageNotFound: false,
text: body || embeddedContactName,
diff --git a/ts/models/messages.ts b/ts/models/messages.ts
index c7fe758a7b4a..5774fa2af088 100644
--- a/ts/models/messages.ts
+++ b/ts/models/messages.ts
@@ -38,6 +38,7 @@ import type {
} from '../textsecure/Types.d';
import { SendMessageProtoError } from '../textsecure/Errors';
import * as expirationTimer from '../util/expirationTimer';
+import { getUserLanguages } from '../util/userLanguages';
import type { ReactionType } from '../types/Reactions';
import { UUID, UUIDKind } from '../types/UUID';
@@ -86,6 +87,7 @@ import {
isDeliveryIssue,
isEndSession,
isExpirationTimerUpdate,
+ isGiftBadge,
isGroupUpdate,
isGroupV1Migration,
isGroupV2Change,
@@ -153,6 +155,8 @@ import { shouldShowStoriesView } from '../state/selectors/stories';
import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage';
import { SeenStatus } from '../MessageSeenStatus';
import { isNewReactionReplacingPrevious } from '../reactions/util';
+import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer';
+import { GiftBadgeStates } from '../components/conversation/Message';
/* eslint-disable camelcase */
/* eslint-disable more/no-then */
@@ -762,6 +766,26 @@ export class MessageModel extends window.Backbone.Model {
};
}
+ const giftBadge = this.get('giftBadge');
+ if (giftBadge) {
+ const emoji = '🎁';
+
+ if (isIncoming(this.attributes)) {
+ return {
+ emoji,
+ text: window.i18n('message--giftBadge--preview--sent'),
+ };
+ }
+
+ return {
+ emoji,
+ text:
+ giftBadge.state === GiftBadgeStates.Unopened
+ ? window.i18n('message--giftBadge--preview--unopened')
+ : window.i18n('message--giftBadge--preview--redeemed'),
+ };
+ }
+
if (body) {
return { text: body };
}
@@ -1093,6 +1117,7 @@ export class MessageModel extends window.Backbone.Model {
const isCallHistoryValue = isCallHistory(attributes);
const isChatSessionRefreshedValue = isChatSessionRefreshed(attributes);
const isDeliveryIssueValue = isDeliveryIssue(attributes);
+ const isGiftBadgeValue = isGiftBadge(attributes);
const isGroupUpdateValue = isGroupUpdate(attributes);
const isGroupV2ChangeValue = isGroupV2Change(attributes);
const isEndSessionValue = isEndSession(attributes);
@@ -1124,6 +1149,7 @@ export class MessageModel extends window.Backbone.Model {
isCallHistoryValue ||
isChatSessionRefreshedValue ||
isDeliveryIssueValue ||
+ isGiftBadgeValue ||
isGroupUpdateValue ||
isGroupV2ChangeValue ||
isEndSessionValue ||
@@ -1812,6 +1838,7 @@ export class MessageModel extends window.Backbone.Model {
// Just placeholder values for the fields
referencedMessageNotFound: false,
+ isGiftBadge: quote.type === Proto.DataMessage.Quote.Type.GIFT_BADGE,
isViewOnce: false,
messageId: '',
};
@@ -1869,6 +1896,23 @@ export class MessageModel extends window.Backbone.Model {
return;
}
+ const isMessageAGiftBadge = isGiftBadge(originalMessage.attributes);
+ if (isMessageAGiftBadge !== quote.isGiftBadge) {
+ log.warn(
+ `copyQuoteContentFromOriginal: Quote.isGiftBadge: ${quote.isGiftBadge}, isGiftBadge(message): ${isMessageAGiftBadge}`
+ );
+ // eslint-disable-next-line no-param-reassign
+ quote.isGiftBadge = isMessageAGiftBadge;
+ }
+ if (isMessageAGiftBadge) {
+ // eslint-disable-next-line no-param-reassign
+ quote.text = undefined;
+ // eslint-disable-next-line no-param-reassign
+ quote.attachments = [];
+
+ return;
+ }
+
// eslint-disable-next-line no-param-reassign
quote.isViewOnce = false;
@@ -2310,6 +2354,7 @@ export class MessageModel extends window.Backbone.Model {
decrypted_at: now,
errors: [],
flags: dataMessage.flags,
+ giftBadge: initialMessage.giftBadge,
hasAttachments: dataMessage.hasAttachments,
hasFileAttachments: dataMessage.hasFileAttachments,
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
@@ -2612,9 +2657,50 @@ export class MessageModel extends window.Backbone.Model {
conversation.incrementMessageCount();
window.Signal.Data.updateConversation(conversation.attributes);
+ const reduxState = window.reduxStore.getState();
+
+ const giftBadge = message.get('giftBadge');
+ if (giftBadge) {
+ const { level } = giftBadge;
+ const existingBadgesById = reduxState.badges.byId;
+
+ const badgeId = `BOOST-${level}`;
+ if (!existingBadgesById[badgeId]) {
+ const { updatesUrl } = window.SignalContext.config;
+ strictAssert(
+ typeof updatesUrl === 'string',
+ 'getProfile: expected updatesUrl to be a defined string'
+ );
+ const userLanguages = getUserLanguages(
+ navigator.languages,
+ window.getLocale()
+ );
+ const response =
+ await window.textsecure.messaging.server.getBoostBadgesFromServer(
+ userLanguages
+ );
+ const boostBadges = parseBoostBadgeListFromServer(
+ response,
+ updatesUrl
+ );
+ const badge = boostBadges[badgeId];
+ if (!badge) {
+ log.error(
+ `handleDataMessage: gift badge ${badgeId} not found on server`
+ );
+ } else {
+ await window.reduxActions.badges.updateOrCreate([
+ {
+ ...badge,
+ id: badgeId,
+ },
+ ]);
+ }
+ }
+ }
+
// Only queue attachments for downloads if this is a story or
// outgoing message or we've accepted the conversation
- const reduxState = window.reduxStore.getState();
const attachments = this.get('attachments') || [];
let queueStoryForDownload = false;
diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts
index 84c022679d18..2469129554d2 100644
--- a/ts/state/selectors/message.ts
+++ b/ts/state/selectors/message.ts
@@ -502,6 +502,7 @@ export const getPropsForQuote = createSelectorCreator(memoizeByRoot, isEqual)(
authorUuid,
id: sentAt,
isViewOnce,
+ isGiftBadge: isTargetGiftBadge,
referencedMessageNotFound,
text = '',
} = quote;
@@ -534,6 +535,7 @@ export const getPropsForQuote = createSelectorCreator(memoizeByRoot, isEqual)(
rawAttachment: firstAttachment
? processQuoteAttachment(firstAttachment)
: undefined,
+ isGiftBadge: Boolean(isTargetGiftBadge),
isViewOnce,
referencedMessageNotFound,
sentAt: Number(sentAt),
@@ -569,6 +571,7 @@ type ShallowPropsType = Pick<
| 'contactNameColor'
| 'conversationColor'
| 'conversationId'
+ | 'conversationTitle'
| 'conversationType'
| 'customColor'
| 'deletedForEveryone'
@@ -576,6 +579,7 @@ type ShallowPropsType = Pick<
| 'displayLimit'
| 'expirationLength'
| 'expirationTimestamp'
+ | 'giftBadge'
| 'id'
| 'isBlocked'
| 'isMessageRequestAccepted'
@@ -654,6 +658,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)(
contactNameColor,
conversationColor,
conversationId,
+ conversationTitle: conversation.title,
conversationType: isGroup ? 'group' : 'direct',
customColor,
deletedForEveryone: message.deletedForEveryone || false,
@@ -661,6 +666,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)(
displayLimit: message.displayLimit,
expirationLength,
expirationTimestamp,
+ giftBadge: message.giftBadge,
id: message.id,
isBlocked: conversation.isBlocked || false,
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
@@ -1080,6 +1086,14 @@ function getPropsForVerificationNotification(
};
}
+// Gift Badge
+
+export function isGiftBadge(
+ message: Pick
+): boolean {
+ return Boolean(message.giftBadge);
+}
+
// Group Update (V1)
export function isGroupUpdate(
diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx
index 1842dfc6a2cb..9f9ae4de50b3 100644
--- a/ts/state/smart/MessageDetail.tsx
+++ b/ts/state/smart/MessageDetail.tsx
@@ -45,6 +45,7 @@ const mapStateToProps = (
markAttachmentAsCorrupted,
markViewed,
openConversation,
+ openGiftBadge,
openLink,
reactToMessage,
replyToMessage,
@@ -89,6 +90,7 @@ const mapStateToProps = (
markAttachmentAsCorrupted,
markViewed,
openConversation,
+ openGiftBadge,
openLink,
reactToMessage,
renderAudioAttachment,
diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx
index 85b193f33465..f42bb5605a1c 100644
--- a/ts/state/smart/Timeline.tsx
+++ b/ts/state/smart/Timeline.tsx
@@ -83,6 +83,7 @@ export type TimelinePropsType = ExternalProps &
| 'onDelete'
| 'onUnblock'
| 'openConversation'
+ | 'openGiftBadge'
| 'openLink'
| 'reactToMessage'
| 'removeMember'
diff --git a/ts/state/smart/TimelineItem.tsx b/ts/state/smart/TimelineItem.tsx
index ef125b5e8e05..b7c3b66e11f8 100644
--- a/ts/state/smart/TimelineItem.tsx
+++ b/ts/state/smart/TimelineItem.tsx
@@ -102,8 +102,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
id: messageId,
containerElementRef,
conversationId,
- conversationColor: conversation?.conversationColor,
- customColor: conversation?.customColor,
+ conversationColor: conversation.conversationColor,
+ customColor: conversation.customColor,
getPreferredBadge: getPreferredBadgeSelector(state),
isNextItemCallingNotification,
isSelected,
diff --git a/ts/test-both/processDataMessage_test.ts b/ts/test-both/processDataMessage_test.ts
index ba2599cf33f5..ee1fc471998e 100644
--- a/ts/test-both/processDataMessage_test.ts
+++ b/ts/test-both/processDataMessage_test.ts
@@ -200,6 +200,7 @@ describe('processDataMessage', () => {
},
],
bodyRanges: [],
+ type: 0,
});
});
diff --git a/ts/test-both/state/ducks/composer_test.ts b/ts/test-both/state/ducks/composer_test.ts
index 1caaaf3f6ccc..3842410261c5 100644
--- a/ts/test-both/state/ducks/composer_test.ts
+++ b/ts/test-both/state/ducks/composer_test.ts
@@ -19,6 +19,7 @@ describe('both/state/ducks/composer', () => {
attachments: [],
id: 456,
isViewOnce: false,
+ isGiftBadge: false,
messageId: '789',
referencedMessageNotFound: false,
},
diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts
index 4fb0f8e04ad2..12c3796f73a3 100644
--- a/ts/textsecure/SendMessage.ts
+++ b/ts/textsecure/SendMessage.ts
@@ -118,11 +118,12 @@ export type StickerType = {
};
export type QuoteType = {
- id?: number;
- authorUuid?: string;
- text?: string;
attachments?: Array;
+ authorUuid?: string;
bodyRanges?: BodyRangesType;
+ id?: number;
+ isGiftBadge?: boolean;
+ text?: string;
};
export type ReactionType = {
@@ -494,6 +495,12 @@ class Message {
proto.quote = new Quote();
const { quote } = proto;
+ if (this.quote.isGiftBadge) {
+ quote.type = Proto.DataMessage.Quote.Type.GIFT_BADGE;
+ } else {
+ quote.type = Proto.DataMessage.Quote.Type.NORMAL;
+ }
+
quote.id =
this.quote.id === undefined ? null : Long.fromNumber(this.quote.id);
quote.authorUuid = this.quote.authorUuid || null;
diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts
index 620b58dd04eb..13a05c7fe54f 100644
--- a/ts/textsecure/Types.d.ts
+++ b/ts/textsecure/Types.d.ts
@@ -5,6 +5,7 @@ import type { SignalService as Proto } from '../protobuf';
import type { IncomingWebSocketRequest } from './WebsocketResources';
import type { UUID } from '../types/UUID';
import type { TextAttachmentType } from '../types/Attachment';
+import { GiftBadgeStates } from '../components/conversation/Message';
export {
IdentityKeyType,
@@ -143,6 +144,7 @@ export type ProcessedQuote = {
text?: string;
attachments: ReadonlyArray;
bodyRanges: ReadonlyArray;
+ type: Proto.DataMessage.Quote.Type;
};
export type ProcessedAvatar = {
@@ -186,6 +188,13 @@ export type ProcessedGroupCallUpdate = Proto.DataMessage.IGroupCallUpdate;
export type ProcessedStoryContext = Proto.DataMessage.IStoryContext;
+export type ProcessedGiftBadge = {
+ receiptCredentialPresentation: string;
+ level: number;
+ expiration: number;
+ state: GiftBadgeStates;
+};
+
export type ProcessedDataMessage = {
body?: string;
attachments: ReadonlyArray;
@@ -207,6 +216,7 @@ export type ProcessedDataMessage = {
bodyRanges?: ReadonlyArray;
groupCallUpdate?: ProcessedGroupCallUpdate;
storyContext?: ProcessedStoryContext;
+ giftBadge?: ProcessedGiftBadge;
};
export type ProcessedUnidentifiedDeliveryStatus = Omit<
diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts
index b5724cea77dd..5c39122967dc 100644
--- a/ts/textsecure/WebAPI.ts
+++ b/ts/textsecure/WebAPI.ts
@@ -527,6 +527,7 @@ const URL_CALLS = {
accountExistence: 'v1/accounts/account',
attachmentId: 'v2/attachments/form/upload',
attestation: 'v1/attestation',
+ boostBadges: 'v1/subscription/boost/badges',
challenge: 'v1/challenge',
config: 'v1/config',
deliveryCert: 'v1/certificate/delivery',
@@ -660,6 +661,7 @@ export type WebAPIConnectType = {
export type CapabilitiesType = {
announcementGroup: boolean;
+ giftBadges: boolean;
'gv1-migration': boolean;
senderKey: boolean;
changeNumber: boolean;
@@ -667,6 +669,7 @@ export type CapabilitiesType = {
};
export type CapabilitiesUploadType = {
announcementGroup: true;
+ giftBadges: true;
'gv2-3': true;
'gv1-migration': true;
senderKey: true;
@@ -864,6 +867,9 @@ export type WebAPIType = {
options: GetProfileUnauthOptionsType
) => Promise;
getBadgeImageFile: (imageUrl: string) => Promise;
+ getBoostBadgesFromServer: (
+ userLanguages: ReadonlyArray
+ ) => Promise;
getProvisioningResource: (
handler: IRequestHandler
) => Promise;
@@ -1186,6 +1192,7 @@ export function initialize({
getProfileForUsername,
getProfileUnauth,
getBadgeImageFile,
+ getBoostBadgesFromServer,
getProvisioningResource,
getSenderCertificate,
getSticker,
@@ -1630,6 +1637,19 @@ export function initialize({
});
}
+ async function getBoostBadgesFromServer(
+ userLanguages: ReadonlyArray
+ ): Promise {
+ return _ajax({
+ call: 'boostBadges',
+ httpType: 'GET',
+ headers: {
+ 'Accept-Language': formatAcceptLanguageHeader(userLanguages),
+ },
+ responseType: 'json',
+ });
+ }
+
async function getAvatar(path: string) {
// Using _outerAJAX, since it's not hardcoded to the Signal Server. Unlike our
// attachment CDN, it uses our self-signed certificate, so we pass it in.
@@ -1744,6 +1764,7 @@ export function initialize({
) {
const capabilities: CapabilitiesUploadType = {
announcementGroup: true,
+ giftBadges: true,
'gv2-3': true,
'gv1-migration': true,
senderKey: true,
diff --git a/ts/textsecure/processDataMessage.ts b/ts/textsecure/processDataMessage.ts
index d89b1dcd50d6..3f40fda2aded 100644
--- a/ts/textsecure/processDataMessage.ts
+++ b/ts/textsecure/processDataMessage.ts
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import Long from 'long';
+import { ReceiptCredentialPresentation } from '@signalapp/libsignal-client/zkgroup';
import { assert, strictAssert } from '../util/assert';
import { dropNull, shallowDropNull } from '../util/dropNull';
@@ -21,8 +22,10 @@ import type {
ProcessedSticker,
ProcessedReaction,
ProcessedDelete,
+ ProcessedGiftBadge,
} from './Types.d';
import { WarnOnlyError } from './Errors';
+import { GiftBadgeStates } from '../components/conversation/Message';
const FLAGS = Proto.DataMessage.Flags;
export const ATTACHMENT_MAX = 32;
@@ -130,6 +133,7 @@ export function processQuote(
};
}),
bodyRanges: quote.bodyRanges ?? [],
+ type: quote.type || Proto.DataMessage.Quote.Type.NORMAL,
};
}
@@ -227,6 +231,32 @@ export function processDelete(
};
}
+export function processGiftBadge(
+ timestamp: number,
+ giftBadge: Proto.DataMessage.IGiftBadge | null | undefined
+): ProcessedGiftBadge | undefined {
+ if (
+ !giftBadge ||
+ !giftBadge.receiptCredentialPresentation ||
+ giftBadge.receiptCredentialPresentation.length === 0
+ ) {
+ return undefined;
+ }
+
+ const receipt = new ReceiptCredentialPresentation(
+ Buffer.from(giftBadge.receiptCredentialPresentation)
+ );
+
+ return {
+ expiration: timestamp + Number(receipt.getReceiptExpirationTime()),
+ level: Number(receipt.getReceiptLevel()),
+ receiptCredentialPresentation: Bytes.toBase64(
+ giftBadge.receiptCredentialPresentation
+ ),
+ state: GiftBadgeStates.Unopened,
+ };
+}
+
export async function processDataMessage(
message: Proto.IDataMessage,
envelopeTimestamp: number
@@ -276,6 +306,7 @@ export async function processDataMessage(
bodyRanges: message.bodyRanges ?? [],
groupCallUpdate: dropNull(message.groupCallUpdate),
storyContext: dropNull(message.storyContext),
+ giftBadge: processGiftBadge(timestamp, message.giftBadge),
};
const isEndSession = Boolean(result.flags & FLAGS.END_SESSION);
diff --git a/ts/util/showToast.tsx b/ts/util/showToast.tsx
index 71aeae3215e0..d65c2f8a986c 100644
--- a/ts/util/showToast.tsx
+++ b/ts/util/showToast.tsx
@@ -9,6 +9,10 @@ import type { ToastAlreadyRequestedToJoin } from '../components/ToastAlreadyRequ
import type { ToastBlocked } from '../components/ToastBlocked';
import type { ToastBlockedGroup } from '../components/ToastBlockedGroup';
import type { ToastCannotMixImageAndNonImageAttachments } from '../components/ToastCannotMixImageAndNonImageAttachments';
+import type {
+ ToastCannotOpenGiftBadge,
+ ToastPropsType as ToastCannotOpenGiftBadgePropsType,
+} from '../components/ToastCannotOpenGiftBadge';
import type { ToastCannotStartGroupCall } from '../components/ToastCannotStartGroupCall';
import type { ToastCaptchaFailed } from '../components/ToastCaptchaFailed';
import type { ToastCaptchaSolved } from '../components/ToastCaptchaSolved';
@@ -60,6 +64,10 @@ export function showToast(
Toast: typeof ToastCannotMixImageAndNonImageAttachments
): void;
export function showToast(Toast: typeof ToastCannotStartGroupCall): void;
+export function showToast(
+ Toast: typeof ToastCannotOpenGiftBadge,
+ props: Omit
+): void;
export function showToast(Toast: typeof ToastCaptchaFailed): void;
export function showToast(Toast: typeof ToastCaptchaSolved): void;
export function showToast(
diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts
index a5a9e5ab69cb..927532db5daa 100644
--- a/ts/views/conversation_view.ts
+++ b/ts/views/conversation_view.ts
@@ -97,6 +97,7 @@ import { ToastReportedSpamAndBlocked } from '../components/ToastReportedSpamAndB
import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpiredIncoming';
import { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing';
import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment';
+import { ToastCannotOpenGiftBadge } from '../components/ToastCannotOpenGiftBadge';
import { autoScale } from '../util/handleImageAttachment';
import { copyGroupLink } from '../util/copyGroupLink';
import { deleteDraftAttachment } from '../util/deleteDraftAttachment';
@@ -163,6 +164,7 @@ type MessageActionsType = {
markAttachmentAsCorrupted: (options: AttachmentOptions) => unknown;
markViewed: (messageId: string) => unknown;
openConversation: (conversationId: string, messageId?: string) => unknown;
+ openGiftBadge: (messageId: string) => unknown;
openLink: (url: string) => unknown;
reactToMessage: (
messageId: string,
@@ -859,6 +861,17 @@ export class ConversationView extends window.Backbone.View {
const showIdentity = (conversationId: string) => {
this.showSafetyNumber(conversationId);
};
+ const openGiftBadge = (messageId: string): void => {
+ const message = window.MessageController.getById(messageId);
+ if (!message) {
+ throw new Error(`openGiftBadge: Message ${messageId} missing!`);
+ }
+
+ showToast(ToastCannotOpenGiftBadge, {
+ isIncoming: isIncoming(message.attributes),
+ });
+ };
+
const openLink = openLinkInWebBrowser;
const downloadNewVersion = () => {
openLinkInWebBrowser('https://signal.org/download');
@@ -888,6 +901,7 @@ export class ConversationView extends window.Backbone.View {
markAttachmentAsCorrupted,
markViewed: onMarkViewed,
openConversation,
+ openGiftBadge,
openLink,
reactToMessage,
replyToMessage,