mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-14 23:18:54 +00:00
Initial Poll message receive support
This commit is contained in:
@@ -372,6 +372,23 @@ message DataMessage {
|
||||
optional bytes receiptCredentialPresentation = 1;
|
||||
}
|
||||
|
||||
message PollCreate {
|
||||
optional string question = 1;
|
||||
optional bool allowMultiple = 2;
|
||||
repeated string options = 3;
|
||||
}
|
||||
|
||||
message PollTerminate {
|
||||
optional uint64 targetSentTimestamp = 1;
|
||||
}
|
||||
|
||||
message PollVote {
|
||||
optional bytes targetAuthorAciBinary = 1;
|
||||
optional uint64 targetSentTimestamp = 2;
|
||||
repeated uint32 optionIndexes = 3;
|
||||
optional uint32 voteCount = 4;
|
||||
}
|
||||
|
||||
optional string body = 1;
|
||||
repeated AttachmentPointer attachments = 2;
|
||||
reserved /*groupV1*/ 3;
|
||||
@@ -394,7 +411,10 @@ message DataMessage {
|
||||
optional Payment payment = 20;
|
||||
optional StoryContext storyContext = 21;
|
||||
optional GiftBadge giftBadge = 22;
|
||||
// NEXT ID: 24
|
||||
optional PollCreate pollCreate = 24;
|
||||
optional PollTerminate pollTerminate = 25;
|
||||
optional PollVote pollVote = 26;
|
||||
// NEXT ID: 27
|
||||
}
|
||||
|
||||
message NullMessage {
|
||||
|
||||
@@ -37,6 +37,9 @@ const KnownConfigKeys = [
|
||||
'desktop.funPicker', // alpha
|
||||
'desktop.funPicker.beta',
|
||||
'desktop.funPicker.prod',
|
||||
'desktop.pollReceive.alpha',
|
||||
'desktop.pollReceive.beta',
|
||||
'desktop.pollReceive.prod',
|
||||
'desktop.usePqRatchet',
|
||||
'global.attachments.maxBytes',
|
||||
'global.attachments.maxReceiveBytes',
|
||||
|
||||
189
ts/background.ts
189
ts/background.ts
@@ -58,6 +58,12 @@ import { updateIdentityKey } from './services/profiles.js';
|
||||
import { RoutineProfileRefresher } from './routineProfileRefresh.js';
|
||||
import { isOlderThan } from './util/timestamp.js';
|
||||
import { isValidReactionEmoji } from './reactions/isValidReactionEmoji.js';
|
||||
import { safeParsePartial } from './util/schemas.js';
|
||||
import {
|
||||
PollVoteSchema,
|
||||
PollTerminateSchema,
|
||||
isPollReceiveEnabled,
|
||||
} from './types/Polls.js';
|
||||
import type { ConversationModel } from './models/conversations.js';
|
||||
import { getAuthor, isIncoming } from './messages/helpers.js';
|
||||
import { migrateBatchOfMessages } from './messages/migrateMessageData.js';
|
||||
@@ -117,11 +123,16 @@ import * as Deletes from './messageModifiers/Deletes.js';
|
||||
import * as Edits from './messageModifiers/Edits.js';
|
||||
import * as MessageReceipts from './messageModifiers/MessageReceipts.js';
|
||||
import * as MessageRequests from './messageModifiers/MessageRequests.js';
|
||||
import * as Polls from './messageModifiers/Polls.js';
|
||||
import * as Reactions from './messageModifiers/Reactions.js';
|
||||
import * as ViewOnceOpenSyncs from './messageModifiers/ViewOnceOpenSyncs.js';
|
||||
import type { DeleteAttributesType } from './messageModifiers/Deletes.js';
|
||||
import type { EditAttributesType } from './messageModifiers/Edits.js';
|
||||
import type { MessageRequestAttributesType } from './messageModifiers/MessageRequests.js';
|
||||
import type {
|
||||
PollVoteAttributesType,
|
||||
PollTerminateAttributesType,
|
||||
} from './messageModifiers/Polls.js';
|
||||
import type { ReactionAttributesType } from './messageModifiers/Reactions.js';
|
||||
import type { ViewOnceOpenSyncAttributesType } from './messageModifiers/ViewOnceOpenSyncs.js';
|
||||
import { ReadStatus } from './messages/MessageReadStatus.js';
|
||||
@@ -2490,6 +2501,100 @@ export async function startApp(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.pollVote) {
|
||||
if (!isPollReceiveEnabled()) {
|
||||
log.warn('Dropping PollVote because the flag is disabled');
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
const { pollVote, timestamp } = data.message;
|
||||
|
||||
const parsed = safeParsePartial(PollVoteSchema, pollVote);
|
||||
if (!parsed.success) {
|
||||
log.warn(
|
||||
'Dropping PollVote due to validation error:',
|
||||
parsed.error.flatten()
|
||||
);
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedVote = parsed.data;
|
||||
const targetAuthorAci = normalizeAci(
|
||||
validatedVote.targetAuthorAci,
|
||||
'DataMessage.PollVote.targetAuthorAci'
|
||||
);
|
||||
|
||||
const { conversation: fromConversation } =
|
||||
window.ConversationController.maybeMergeContacts({
|
||||
e164: data.source,
|
||||
aci: data.sourceAci,
|
||||
reason: 'onMessageReceived:pollVote',
|
||||
});
|
||||
strictAssert(fromConversation, 'PollVote without fromConversation');
|
||||
|
||||
log.info('Queuing incoming poll vote for', pollVote.targetTimestamp);
|
||||
const attributes: PollVoteAttributesType = {
|
||||
envelopeId: data.envelopeId,
|
||||
removeFromMessageReceiverCache: confirm,
|
||||
fromConversationId: fromConversation.id,
|
||||
source: Polls.PollSource.FromSomeoneElse,
|
||||
targetAuthorAci,
|
||||
targetTimestamp: validatedVote.targetTimestamp,
|
||||
optionIndexes: validatedVote.optionIndexes,
|
||||
voteCount: validatedVote.voteCount,
|
||||
receivedAtDate: data.receivedAtDate,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
drop(Polls.onPollVote(attributes));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.pollTerminate) {
|
||||
if (!isPollReceiveEnabled()) {
|
||||
log.warn('Dropping PollTerminate because the flag is disabled');
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
const { pollTerminate, timestamp } = data.message;
|
||||
|
||||
const parsedTerm = safeParsePartial(PollTerminateSchema, pollTerminate);
|
||||
if (!parsedTerm.success) {
|
||||
log.warn(
|
||||
'Dropping PollTerminate due to validation error:',
|
||||
parsedTerm.error.flatten()
|
||||
);
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
const { conversation: fromConversation } =
|
||||
window.ConversationController.maybeMergeContacts({
|
||||
e164: data.source,
|
||||
aci: data.sourceAci,
|
||||
reason: 'onMessageReceived:pollTerminate',
|
||||
});
|
||||
strictAssert(fromConversation, 'PollTerminate without fromConversation');
|
||||
|
||||
log.info(
|
||||
'Queuing incoming poll termination for',
|
||||
pollTerminate.targetTimestamp
|
||||
);
|
||||
const attributes: PollTerminateAttributesType = {
|
||||
envelopeId: data.envelopeId,
|
||||
removeFromMessageReceiverCache: confirm,
|
||||
fromConversationId: fromConversation.id,
|
||||
source: Polls.PollSource.FromSomeoneElse,
|
||||
targetTimestamp: parsedTerm.data.targetTimestamp,
|
||||
receivedAtDate: data.receivedAtDate,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
drop(Polls.onPollTerminate(attributes));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.delete) {
|
||||
const { delete: del } = data.message;
|
||||
log.info('Queuing incoming DOE for', del.targetSentTimestamp);
|
||||
@@ -2897,6 +3002,90 @@ export async function startApp(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.pollVote) {
|
||||
if (!isPollReceiveEnabled()) {
|
||||
log.warn('Dropping PollVote because the flag is disabled');
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
const { pollVote, timestamp } = data.message;
|
||||
|
||||
const parsed = safeParsePartial(PollVoteSchema, pollVote);
|
||||
if (!parsed.success) {
|
||||
log.warn(
|
||||
'Dropping PollVote (sync) due to validation error:',
|
||||
parsed.error.flatten()
|
||||
);
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedVote = parsed.data;
|
||||
const targetAuthorAci = normalizeAci(
|
||||
validatedVote.targetAuthorAci,
|
||||
'DataMessage.PollVote.targetAuthorAci'
|
||||
);
|
||||
|
||||
const ourConversationId =
|
||||
window.ConversationController.getOurConversationIdOrThrow();
|
||||
|
||||
log.info('Queuing sync poll vote for', pollVote.targetTimestamp);
|
||||
const attributes: PollVoteAttributesType = {
|
||||
envelopeId: data.envelopeId,
|
||||
removeFromMessageReceiverCache: confirm,
|
||||
fromConversationId: ourConversationId,
|
||||
source: Polls.PollSource.FromSync,
|
||||
targetAuthorAci,
|
||||
targetTimestamp: validatedVote.targetTimestamp,
|
||||
optionIndexes: validatedVote.optionIndexes,
|
||||
voteCount: validatedVote.voteCount,
|
||||
receivedAtDate: data.receivedAtDate,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
drop(Polls.onPollVote(attributes));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.pollTerminate) {
|
||||
if (!isPollReceiveEnabled()) {
|
||||
log.warn('Dropping PollTerminate because the flag is disabled');
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
const { pollTerminate, timestamp } = data.message;
|
||||
|
||||
const parsedTerm = safeParsePartial(PollTerminateSchema, pollTerminate);
|
||||
if (!parsedTerm.success) {
|
||||
log.warn(
|
||||
'Dropping PollTerminate (sync) due to validation error:',
|
||||
parsedTerm.error.flatten()
|
||||
);
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
|
||||
const ourConversationId =
|
||||
window.ConversationController.getOurConversationIdOrThrow();
|
||||
|
||||
log.info(
|
||||
'Queuing sync poll termination for',
|
||||
pollTerminate.targetTimestamp
|
||||
);
|
||||
const attributes: PollTerminateAttributesType = {
|
||||
envelopeId: data.envelopeId,
|
||||
removeFromMessageReceiverCache: confirm,
|
||||
fromConversationId: ourConversationId,
|
||||
source: Polls.PollSource.FromSync,
|
||||
targetTimestamp: parsedTerm.data.targetTimestamp,
|
||||
receivedAtDate: data.receivedAtDate,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
drop(Polls.onPollTerminate(attributes));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.message.delete) {
|
||||
const { delete: del } = data.message;
|
||||
strictAssert(
|
||||
|
||||
@@ -96,6 +96,10 @@ import { isPaymentNotificationEvent } from '../../types/Payment.js';
|
||||
import type { AnyPaymentEvent } from '../../types/Payment.js';
|
||||
import { getPaymentEventDescription } from '../../messages/helpers.js';
|
||||
import { PanelType } from '../../types/Panels.js';
|
||||
import {
|
||||
type PollMessageAttribute,
|
||||
isPollReceiveEnabled,
|
||||
} from '../../types/Polls.js';
|
||||
import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser.js';
|
||||
import { RenderLocation } from './MessageTextRenderer.js';
|
||||
import { UserText } from '../UserText.js';
|
||||
@@ -301,6 +305,7 @@ export type PropsData = {
|
||||
attachments?: ReadonlyArray<AttachmentForUIType>;
|
||||
giftBadge?: GiftBadgeType;
|
||||
payment?: AnyPaymentEvent;
|
||||
poll?: PollMessageAttribute;
|
||||
quote?: {
|
||||
conversationColor: ConversationColorType;
|
||||
conversationTitle: string;
|
||||
@@ -2074,6 +2079,23 @@ export class Message extends React.PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
public renderPoll(): JSX.Element | null {
|
||||
const { poll, direction } = this.props;
|
||||
if (!poll || !isPollReceiveEnabled()) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__text',
|
||||
`module-message__text--${direction}`
|
||||
)}
|
||||
>
|
||||
<pre>{JSON.stringify(poll, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
#doubleCheckMissingQuoteReference = () => {
|
||||
return this.props.doubleCheckMissingQuoteReference(this.props.id);
|
||||
};
|
||||
@@ -2973,6 +2995,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||
{this.renderPreview()}
|
||||
{this.renderAttachmentTooBig()}
|
||||
{this.renderPayment()}
|
||||
{this.renderPoll()}
|
||||
{this.renderEmbeddedContact()}
|
||||
{this.renderText()}
|
||||
{this.renderUndownloadableTextAttachment()}
|
||||
|
||||
533
ts/messageModifiers/Polls.ts
Normal file
533
ts/messageModifiers/Polls.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { AciString } from '../types/ServiceId.js';
|
||||
import type {
|
||||
MessageAttributesType,
|
||||
ReadonlyMessageAttributesType,
|
||||
} from '../model-types.d.ts';
|
||||
import type { MessagePollVoteType } from '../types/Polls.js';
|
||||
import { MessageModel } from '../models/messages.js';
|
||||
import { DataReader } from '../sql/Client.js';
|
||||
import * as Errors from '../types/errors.js';
|
||||
import { createLogger } from '../logging/log.js';
|
||||
import { getAuthor, isIncoming, isOutgoing } from '../messages/helpers.js';
|
||||
|
||||
import { isSent } from '../messages/MessageSendState.js';
|
||||
import { getPropForTimestamp } from '../util/editHelpers.js';
|
||||
import { isMe } from '../util/whatTypeOfConversation.js';
|
||||
|
||||
import { strictAssert } from '../util/assert.js';
|
||||
import { getMessageIdForLogging } from '../util/idForLogging.js';
|
||||
|
||||
const log = createLogger('Polls');
|
||||
|
||||
export enum PollSource {
|
||||
FromThisDevice = 'FromThisDevice',
|
||||
FromSync = 'FromSync',
|
||||
FromSomeoneElse = 'FromSomeoneElse',
|
||||
}
|
||||
|
||||
export type PollVoteAttributesType = {
|
||||
envelopeId: string;
|
||||
fromConversationId: string;
|
||||
removeFromMessageReceiverCache: () => unknown;
|
||||
source: PollSource;
|
||||
targetAuthorAci: AciString;
|
||||
targetTimestamp: number;
|
||||
optionIndexes: ReadonlyArray<number>;
|
||||
voteCount: number;
|
||||
timestamp: number;
|
||||
receivedAtDate: number;
|
||||
};
|
||||
|
||||
export type PollTerminateAttributesType = {
|
||||
envelopeId: string;
|
||||
fromConversationId: string;
|
||||
removeFromMessageReceiverCache: () => unknown;
|
||||
source: PollSource;
|
||||
targetTimestamp: number;
|
||||
timestamp: number;
|
||||
receivedAtDate: number;
|
||||
};
|
||||
|
||||
const pollVoteCache = new Map<string, PollVoteAttributesType>();
|
||||
const pollTerminateCache = new Map<string, PollTerminateAttributesType>();
|
||||
|
||||
function removeVote(vote: PollVoteAttributesType): void {
|
||||
pollVoteCache.delete(vote.envelopeId);
|
||||
vote.removeFromMessageReceiverCache();
|
||||
}
|
||||
|
||||
function removeTerminate(terminate: PollTerminateAttributesType): void {
|
||||
pollTerminateCache.delete(terminate.envelopeId);
|
||||
terminate.removeFromMessageReceiverCache();
|
||||
}
|
||||
|
||||
function doesVoteModifierMatchMessage({
|
||||
message,
|
||||
targetTimestamp,
|
||||
targetAuthorAci,
|
||||
targetAuthorId,
|
||||
voteSenderConversationId,
|
||||
}: {
|
||||
message: ReadonlyMessageAttributesType;
|
||||
targetTimestamp: number;
|
||||
targetAuthorAci?: string;
|
||||
targetAuthorId?: string;
|
||||
voteSenderConversationId: string;
|
||||
}): boolean {
|
||||
if (message.sent_at !== targetTimestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const author = getAuthor(message);
|
||||
if (!author) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetAuthorConversation = window.ConversationController.get(
|
||||
targetAuthorAci ?? targetAuthorId
|
||||
);
|
||||
if (!targetAuthorConversation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (author.id !== targetAuthorConversation.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const voteSenderConversation = window.ConversationController.get(
|
||||
voteSenderConversationId
|
||||
);
|
||||
if (!voteSenderConversation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isMe(voteSenderConversation.attributes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isOutgoing(message)) {
|
||||
const sendStateByConversationId = getPropForTimestamp({
|
||||
log,
|
||||
message,
|
||||
prop: 'sendStateByConversationId',
|
||||
targetTimestamp,
|
||||
});
|
||||
|
||||
const sendState = sendStateByConversationId?.[voteSenderConversationId];
|
||||
return !!sendState && isSent(sendState.status);
|
||||
}
|
||||
|
||||
if (isIncoming(message)) {
|
||||
const messageConversation = window.ConversationController.get(
|
||||
message.conversationId
|
||||
);
|
||||
if (!messageConversation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const voteSenderServiceId = voteSenderConversation.getServiceId();
|
||||
return (
|
||||
voteSenderServiceId != null &&
|
||||
messageConversation.hasMember(voteSenderServiceId)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function findPollMessage({
|
||||
targetTimestamp,
|
||||
targetAuthorAci,
|
||||
targetAuthorId,
|
||||
voteSenderConversationId,
|
||||
logId,
|
||||
}: {
|
||||
targetTimestamp: number;
|
||||
targetAuthorAci?: string;
|
||||
targetAuthorId?: string;
|
||||
voteSenderConversationId: string;
|
||||
logId: string;
|
||||
}): Promise<MessageAttributesType | undefined> {
|
||||
const messages = await DataReader.getMessagesBySentAt(targetTimestamp);
|
||||
|
||||
const matchingMessages = messages.filter(message => {
|
||||
if (!message.poll) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return doesVoteModifierMatchMessage({
|
||||
message,
|
||||
targetTimestamp,
|
||||
targetAuthorAci,
|
||||
targetAuthorId,
|
||||
voteSenderConversationId,
|
||||
});
|
||||
});
|
||||
|
||||
if (!matchingMessages.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (matchingMessages.length > 1) {
|
||||
log.warn(
|
||||
`${logId}/findPollMessage: found ${matchingMessages.length} matching messages for the poll!`
|
||||
);
|
||||
}
|
||||
|
||||
return matchingMessages[0];
|
||||
}
|
||||
|
||||
export async function onPollVote(vote: PollVoteAttributesType): Promise<void> {
|
||||
pollVoteCache.set(vote.envelopeId, vote);
|
||||
|
||||
const logId = `Polls.onPollVote(timestamp=${vote.timestamp};target=${vote.targetTimestamp})`;
|
||||
|
||||
try {
|
||||
const matchingMessage = await findPollMessage({
|
||||
targetTimestamp: vote.targetTimestamp,
|
||||
targetAuthorAci: vote.targetAuthorAci,
|
||||
voteSenderConversationId: vote.fromConversationId,
|
||||
logId,
|
||||
});
|
||||
|
||||
if (!matchingMessage) {
|
||||
log.info(
|
||||
`${logId}: No poll message for vote`,
|
||||
'targeting',
|
||||
vote.targetAuthorAci
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingMessageConversation = window.ConversationController.get(
|
||||
matchingMessage.conversationId
|
||||
);
|
||||
|
||||
if (!matchingMessageConversation) {
|
||||
log.info(
|
||||
`${logId}: No target conversation for poll vote`,
|
||||
vote.targetAuthorAci,
|
||||
vote.targetTimestamp
|
||||
);
|
||||
removeVote(vote);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// awaiting is safe since `onPollVote` is never called from inside the queue
|
||||
await matchingMessageConversation.queueJob('Polls.onPollVote', async () => {
|
||||
log.info(`${logId}: handling`);
|
||||
|
||||
// Message is fetched inside the conversation queue so we have the
|
||||
// most recent data
|
||||
const targetMessage = await findPollMessage({
|
||||
targetTimestamp: vote.targetTimestamp,
|
||||
targetAuthorAci: vote.targetAuthorAci,
|
||||
voteSenderConversationId: vote.fromConversationId,
|
||||
logId: `${logId}/conversationQueue`,
|
||||
});
|
||||
|
||||
if (!targetMessage || targetMessage.id !== matchingMessage.id) {
|
||||
log.warn(
|
||||
`${logId}: message no longer a match for vote! Maybe it's been deleted?`
|
||||
);
|
||||
removeVote(vote);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMessageModel = window.MessageCache.register(
|
||||
new MessageModel(targetMessage)
|
||||
);
|
||||
|
||||
await handlePollVote(targetMessageModel, vote);
|
||||
removeVote(vote);
|
||||
});
|
||||
} catch (error) {
|
||||
removeVote(vote);
|
||||
log.error(`${logId} error:`, Errors.toLogFormat(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function onPollTerminate(
|
||||
terminate: PollTerminateAttributesType
|
||||
): Promise<void> {
|
||||
pollTerminateCache.set(terminate.envelopeId, terminate);
|
||||
|
||||
const logId = `Polls.onPollTerminate(timestamp=${terminate.timestamp};target=${terminate.targetTimestamp})`;
|
||||
|
||||
try {
|
||||
// For termination, we need to find the poll by timestamp only
|
||||
// The fromConversationId must be the poll creator
|
||||
const matchingMessage = await findPollMessage({
|
||||
targetTimestamp: terminate.targetTimestamp,
|
||||
targetAuthorId: terminate.fromConversationId,
|
||||
voteSenderConversationId: terminate.fromConversationId,
|
||||
logId,
|
||||
});
|
||||
|
||||
if (!matchingMessage) {
|
||||
log.info(
|
||||
`${logId}: No poll message for termination`,
|
||||
'targeting timestamp',
|
||||
terminate.targetTimestamp
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingMessageConversation = window.ConversationController.get(
|
||||
matchingMessage.conversationId
|
||||
);
|
||||
|
||||
if (!matchingMessageConversation) {
|
||||
log.info(
|
||||
`${logId}: No target conversation for poll termination`,
|
||||
terminate.targetTimestamp
|
||||
);
|
||||
removeTerminate(terminate);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// awaiting is safe since `onPollTerminate` is never called from inside the queue
|
||||
await matchingMessageConversation.queueJob(
|
||||
'Polls.onPollTerminate',
|
||||
async () => {
|
||||
log.info(`${logId}: handling`);
|
||||
|
||||
// Re-fetch to ensure we have the most recent data
|
||||
const targetMessages = await DataReader.getMessagesBySentAt(
|
||||
terminate.targetTimestamp
|
||||
);
|
||||
const targetMessage = targetMessages.find(
|
||||
msg => msg.id === matchingMessage.id
|
||||
);
|
||||
|
||||
if (!targetMessage) {
|
||||
log.warn(
|
||||
`${logId}: message no longer exists! Maybe it's been deleted?`
|
||||
);
|
||||
removeTerminate(terminate);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMessageModel = window.MessageCache.register(
|
||||
new MessageModel(targetMessage)
|
||||
);
|
||||
|
||||
await handlePollTerminate(targetMessageModel, terminate);
|
||||
removeTerminate(terminate);
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
removeTerminate(terminate);
|
||||
log.error(`${logId} error:`, Errors.toLogFormat(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePollVote(
|
||||
message: MessageModel,
|
||||
vote: PollVoteAttributesType,
|
||||
{
|
||||
shouldPersist = true,
|
||||
}: {
|
||||
shouldPersist?: boolean;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
if (message.get('deletedForEveryone')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const poll = message.get('poll');
|
||||
if (!poll) {
|
||||
log.warn('handlePollVote: Message is not a poll');
|
||||
return;
|
||||
}
|
||||
|
||||
if (poll.terminatedAt) {
|
||||
log.info('handlePollVote: Poll is already terminated, ignoring vote');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate option indexes
|
||||
const maxOptionIndex = poll.options.length - 1;
|
||||
const invalidIndexes = vote.optionIndexes.filter(
|
||||
index => index < 0 || index > maxOptionIndex
|
||||
);
|
||||
if (invalidIndexes.length > 0) {
|
||||
log.warn('handlePollVote: Invalid option indexes found, dropping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check multiple choice constraint
|
||||
if (!poll.allowMultiple && vote.optionIndexes.length > 1) {
|
||||
log.warn(
|
||||
'handlePollVote: Multiple votes not allowed for this poll, dropping'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = window.ConversationController.get(
|
||||
message.attributes.conversationId
|
||||
);
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFromThisDevice = vote.source === PollSource.FromThisDevice;
|
||||
const isFromSync = vote.source === PollSource.FromSync;
|
||||
const isFromSomeoneElse = vote.source === PollSource.FromSomeoneElse;
|
||||
strictAssert(
|
||||
isFromThisDevice || isFromSync || isFromSomeoneElse,
|
||||
'Vote can only be from this device, from sync, or from someone else'
|
||||
);
|
||||
|
||||
const newVote: MessagePollVoteType = {
|
||||
fromConversationId: vote.fromConversationId,
|
||||
optionIndexes: vote.optionIndexes,
|
||||
voteCount: vote.voteCount,
|
||||
timestamp: vote.timestamp,
|
||||
};
|
||||
|
||||
// Update or add vote with conflict resolution
|
||||
const currentVotes: Array<MessagePollVoteType> = poll.votes
|
||||
? [...poll.votes]
|
||||
: [];
|
||||
let updatedVotes: Array<MessagePollVoteType>;
|
||||
|
||||
const existingVoteIndex = currentVotes.findIndex(
|
||||
v => v.fromConversationId === vote.fromConversationId
|
||||
);
|
||||
|
||||
if (existingVoteIndex !== -1) {
|
||||
const existingVote = currentVotes[existingVoteIndex];
|
||||
|
||||
if (newVote.voteCount > existingVote.voteCount) {
|
||||
updatedVotes = [...currentVotes];
|
||||
updatedVotes[existingVoteIndex] = newVote;
|
||||
} else {
|
||||
log.info(
|
||||
'handlePollVote: Keeping existing vote with higher or same voteCount'
|
||||
);
|
||||
updatedVotes = currentVotes;
|
||||
}
|
||||
} else {
|
||||
updatedVotes = [...currentVotes, newVote];
|
||||
}
|
||||
|
||||
message.set({
|
||||
poll: {
|
||||
...poll,
|
||||
votes: updatedVotes,
|
||||
},
|
||||
});
|
||||
|
||||
log.info(
|
||||
'handlePollVote:',
|
||||
`Done processing vote for poll ${getMessageIdForLogging(message.attributes)}.`
|
||||
);
|
||||
|
||||
if (shouldPersist) {
|
||||
await window.MessageCache.saveMessage(message.attributes);
|
||||
window.reduxActions.conversations.markOpenConversationRead(conversation.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePollTerminate(
|
||||
message: MessageModel,
|
||||
terminate: PollTerminateAttributesType,
|
||||
{
|
||||
shouldPersist = true,
|
||||
}: {
|
||||
shouldPersist?: boolean;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const { attributes } = message;
|
||||
|
||||
if (message.get('deletedForEveryone')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const poll = message.get('poll');
|
||||
if (!poll) {
|
||||
log.warn('handlePollTerminate: Message is not a poll');
|
||||
return;
|
||||
}
|
||||
|
||||
if (poll.terminatedAt) {
|
||||
log.info('handlePollTerminate: Poll is already terminated');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = window.ConversationController.get(
|
||||
message.attributes.conversationId
|
||||
);
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the terminator is the poll creator
|
||||
const author = getAuthor(attributes);
|
||||
const terminatorConversation = window.ConversationController.get(
|
||||
terminate.fromConversationId
|
||||
);
|
||||
|
||||
if (
|
||||
!author ||
|
||||
!terminatorConversation ||
|
||||
author.id !== terminatorConversation.id
|
||||
) {
|
||||
log.warn(
|
||||
'handlePollTerminate: Termination rejected - not from poll creator'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
message.set({
|
||||
poll: {
|
||||
...poll,
|
||||
terminatedAt: terminate.timestamp,
|
||||
},
|
||||
});
|
||||
|
||||
log.info(
|
||||
'handlePollTerminate:',
|
||||
`Poll ${getMessageIdForLogging(message.attributes)} terminated at ${terminate.timestamp}`
|
||||
);
|
||||
|
||||
if (shouldPersist) {
|
||||
await window.MessageCache.saveMessage(message.attributes);
|
||||
window.reduxActions.conversations.markOpenConversationRead(conversation.id);
|
||||
}
|
||||
}
|
||||
|
||||
export function drainCachedVotesForMessage(
|
||||
message: ReadonlyMessageAttributesType
|
||||
): Array<PollVoteAttributesType> {
|
||||
const matching = Array.from(pollVoteCache.values()).filter(vote => {
|
||||
if (!message.poll) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return doesVoteModifierMatchMessage({
|
||||
message,
|
||||
targetTimestamp: vote.targetTimestamp,
|
||||
targetAuthorAci: vote.targetAuthorAci,
|
||||
voteSenderConversationId: vote.fromConversationId,
|
||||
});
|
||||
});
|
||||
|
||||
matching.forEach(vote => removeVote(vote));
|
||||
return matching;
|
||||
}
|
||||
|
||||
export function drainCachedTerminatesForMessage(
|
||||
message: ReadonlyMessageAttributesType
|
||||
): Array<PollTerminateAttributesType> {
|
||||
const matching = Array.from(pollTerminateCache.values()).filter(term => {
|
||||
return message.poll && message.sent_at === term.targetTimestamp;
|
||||
});
|
||||
|
||||
matching.forEach(term => removeTerminate(term));
|
||||
return matching;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { isNumber } from 'lodash';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { createLogger } from '../logging/log.js';
|
||||
import * as Errors from '../types/errors.js';
|
||||
@@ -56,6 +57,8 @@ import {
|
||||
} from '../util/modifyTargetMessage.js';
|
||||
import { saveAndNotify } from './saveAndNotify.js';
|
||||
import { MessageModel } from '../models/messages.js';
|
||||
import { safeParsePartial } from '../util/schemas.js';
|
||||
import { PollCreateSchema, isPollReceiveEnabled } from '../types/Polls.js';
|
||||
|
||||
import type { SentEventData } from '../textsecure/messageReceiverEvents.js';
|
||||
import type {
|
||||
@@ -481,6 +484,35 @@ export async function handleDataMessage(
|
||||
}
|
||||
}
|
||||
|
||||
let validatedPollCreate: z.infer<typeof PollCreateSchema> | undefined;
|
||||
if (initialMessage.pollCreate) {
|
||||
if (!isPollReceiveEnabled()) {
|
||||
log.warn(`${idLog}: Dropping PollCreate because flag is not enabled`);
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
if (!isGroup(conversation.attributes)) {
|
||||
log.warn(
|
||||
`${idLog}: Dropping PollCreate in non-group conversation ${conversation.idForLogging()}`
|
||||
);
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
const result = safeParsePartial(
|
||||
PollCreateSchema,
|
||||
initialMessage.pollCreate
|
||||
);
|
||||
if (!result.success) {
|
||||
log.warn(
|
||||
`${idLog}: Dropping invalid PollCreate:`,
|
||||
result.error.flatten()
|
||||
);
|
||||
confirm();
|
||||
return;
|
||||
}
|
||||
validatedPollCreate = result.data;
|
||||
}
|
||||
|
||||
const withQuoteReference = {
|
||||
...message.attributes,
|
||||
...initialMessage,
|
||||
@@ -576,6 +608,14 @@ export async function handleDataMessage(
|
||||
quote: dataMessage.quote,
|
||||
schemaVersion: dataMessage.schemaVersion,
|
||||
sticker: dataMessage.sticker,
|
||||
poll: validatedPollCreate
|
||||
? {
|
||||
question: validatedPollCreate.question,
|
||||
options: validatedPollCreate.options,
|
||||
allowMultiple: Boolean(validatedPollCreate.allowMultiple),
|
||||
votes: [],
|
||||
}
|
||||
: undefined,
|
||||
storyId: dataMessage.storyId,
|
||||
});
|
||||
|
||||
|
||||
2
ts/model-types.d.ts
vendored
2
ts/model-types.d.ts
vendored
@@ -35,6 +35,7 @@ import type { StorySendMode } from './types/Stories.js';
|
||||
import type { MIMEType } from './types/MIME.js';
|
||||
import type { DurationInSeconds } from './util/durations/index.js';
|
||||
import type { AnyPaymentEvent } from './types/Payment.js';
|
||||
import type { PollMessageAttribute } from './types/Polls.js';
|
||||
|
||||
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||
import MemberRoleEnum = Proto.Member.Role;
|
||||
@@ -205,6 +206,7 @@ export type MessageAttributesType = {
|
||||
payment?: AnyPaymentEvent;
|
||||
quote?: QuotedMessageType;
|
||||
reactions?: ReadonlyArray<MessageReactionType>;
|
||||
poll?: PollMessageAttribute;
|
||||
requiredProtocolVersion?: number;
|
||||
sms?: boolean;
|
||||
sourceDevice?: number;
|
||||
|
||||
@@ -780,6 +780,7 @@ export const getPropsForMessage = (
|
||||
expirationStartTimestamp,
|
||||
}),
|
||||
giftBadge: message.giftBadge,
|
||||
poll: message.poll,
|
||||
id: message.id,
|
||||
isBlocked: conversation.isBlocked || false,
|
||||
isEditedMessage: Boolean(message.editHistory),
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import { getGraphemes, count, isSingleGrapheme } from '../../util/grapheme.js';
|
||||
import {
|
||||
getGraphemes,
|
||||
count,
|
||||
hasAtMostGraphemes,
|
||||
isSingleGrapheme,
|
||||
} from '../../util/grapheme.js';
|
||||
|
||||
describe('grapheme utilities', () => {
|
||||
describe('getGraphemes', () => {
|
||||
@@ -79,4 +84,21 @@ describe('grapheme utilities', () => {
|
||||
assert.isFalse(isSingleGrapheme('😍a'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAtMostGraphemes', () => {
|
||||
it('returns true when the string is within the limit', () => {
|
||||
assert.isTrue(hasAtMostGraphemes('', 0));
|
||||
assert.isTrue(hasAtMostGraphemes('👩❤️👩', 1));
|
||||
assert.isTrue(hasAtMostGraphemes('👌🏽👌🏾👌🏿', 3));
|
||||
});
|
||||
|
||||
it('returns false when the string exceeds the limit', () => {
|
||||
assert.isFalse(hasAtMostGraphemes('👌🏽👌🏾👌🏿', 2));
|
||||
assert.isFalse(hasAtMostGraphemes('abc', 2));
|
||||
});
|
||||
|
||||
it('returns false for negative limits', () => {
|
||||
assert.isFalse(hasAtMostGraphemes('anything', -1));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
20
ts/textsecure/Types.d.ts
vendored
20
ts/textsecure/Types.d.ts
vendored
@@ -185,6 +185,23 @@ export type ProcessedReaction = {
|
||||
targetTimestamp?: number;
|
||||
};
|
||||
|
||||
export type ProcessedPollCreate = {
|
||||
question?: string;
|
||||
options?: Array<string>;
|
||||
allowMultiple?: boolean;
|
||||
};
|
||||
|
||||
export type ProcessedPollVote = {
|
||||
targetAuthorAci?: AciString;
|
||||
targetTimestamp?: number;
|
||||
optionIndexes?: Array<number>;
|
||||
voteCount?: number;
|
||||
};
|
||||
|
||||
export type ProcessedPollTerminate = {
|
||||
targetTimestamp?: number;
|
||||
};
|
||||
|
||||
export type ProcessedDelete = {
|
||||
targetSentTimestamp?: number;
|
||||
};
|
||||
@@ -226,6 +243,9 @@ export type ProcessedDataMessage = {
|
||||
isStory?: boolean;
|
||||
isViewOnce: boolean;
|
||||
reaction?: ProcessedReaction;
|
||||
pollCreate?: ProcessedPollCreate;
|
||||
pollVote?: ProcessedPollVote;
|
||||
pollTerminate?: ProcessedPollTerminate;
|
||||
delete?: ProcessedDelete;
|
||||
bodyRanges?: ReadonlyArray<ProcessedBodyRange>;
|
||||
groupCallUpdate?: ProcessedGroupCallUpdate;
|
||||
|
||||
@@ -22,6 +22,9 @@ import type {
|
||||
ProcessedPreview,
|
||||
ProcessedSticker,
|
||||
ProcessedReaction,
|
||||
ProcessedPollCreate,
|
||||
ProcessedPollVote,
|
||||
ProcessedPollTerminate,
|
||||
ProcessedDelete,
|
||||
ProcessedGiftBadge,
|
||||
ProcessedStoryContext,
|
||||
@@ -309,6 +312,53 @@ export function processReaction(
|
||||
};
|
||||
}
|
||||
|
||||
export function processPollCreate(
|
||||
pollCreate?: Proto.DataMessage.IPollCreate | null
|
||||
): ProcessedPollCreate | undefined {
|
||||
if (!pollCreate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
question: dropNull(pollCreate.question),
|
||||
options: pollCreate.options?.filter(isNotNil) || [],
|
||||
allowMultiple: Boolean(pollCreate.allowMultiple),
|
||||
};
|
||||
}
|
||||
|
||||
export function processPollVote(
|
||||
pollVote?: Proto.DataMessage.IPollVote | null
|
||||
): ProcessedPollVote | undefined {
|
||||
if (!pollVote) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const targetAuthorAci = fromAciUuidBytesOrString(
|
||||
pollVote.targetAuthorAciBinary,
|
||||
undefined,
|
||||
'PollVote.targetAuthorAci'
|
||||
);
|
||||
|
||||
return {
|
||||
targetAuthorAci,
|
||||
targetTimestamp: pollVote.targetSentTimestamp?.toNumber(),
|
||||
optionIndexes: pollVote.optionIndexes?.filter(isNotNil) || [],
|
||||
voteCount: pollVote.voteCount || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function processPollTerminate(
|
||||
pollTerminate?: Proto.DataMessage.IPollTerminate | null
|
||||
): ProcessedPollTerminate | undefined {
|
||||
if (!pollTerminate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
targetTimestamp: pollTerminate.targetSentTimestamp?.toNumber(),
|
||||
};
|
||||
}
|
||||
|
||||
export function processDelete(
|
||||
del?: Proto.DataMessage.IDelete | null
|
||||
): ProcessedDelete | undefined {
|
||||
@@ -407,6 +457,9 @@ export function processDataMessage(
|
||||
requiredProtocolVersion: dropNull(message.requiredProtocolVersion),
|
||||
isViewOnce: Boolean(message.isViewOnce),
|
||||
reaction: processReaction(message.reaction),
|
||||
pollCreate: processPollCreate(message.pollCreate),
|
||||
pollVote: processPollVote(message.pollVote),
|
||||
pollTerminate: processPollTerminate(message.pollTerminate),
|
||||
delete: processDelete(message.delete),
|
||||
bodyRanges: filterAndClean(message.bodyRanges),
|
||||
groupCallUpdate: dropNull(message.groupCallUpdate),
|
||||
|
||||
109
ts/types/Polls.ts
Normal file
109
ts/types/Polls.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { z } from 'zod';
|
||||
import { isAciString } from '../util/isAciString.js';
|
||||
import { hasAtMostGraphemes } from '../util/grapheme.js';
|
||||
import {
|
||||
Environment,
|
||||
getEnvironment,
|
||||
isMockEnvironment,
|
||||
} from '../environment.js';
|
||||
import * as RemoteConfig from '../RemoteConfig.js';
|
||||
import { isAlpha, isBeta, isProduction } from '../util/version.js';
|
||||
|
||||
// PollCreate schema (processed shape)
|
||||
// - question: required, 1..100 chars
|
||||
// - options: required, 2..10 items; each 1..100 chars
|
||||
// - allowMultiple: optional boolean
|
||||
export const PollCreateSchema = z
|
||||
.object({
|
||||
question: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(value => hasAtMostGraphemes(value, 100), {
|
||||
message: 'question must contain at most 100 characters',
|
||||
}),
|
||||
options: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(value => hasAtMostGraphemes(value, 100), {
|
||||
message: 'option must contain at most 100 characters',
|
||||
})
|
||||
)
|
||||
.min(2)
|
||||
.max(10)
|
||||
.readonly(),
|
||||
allowMultiple: z.boolean().optional(),
|
||||
})
|
||||
.describe('PollCreate');
|
||||
|
||||
// PollVote schema (processed shape)
|
||||
// - targetAuthorAci: required, non-empty ACI string
|
||||
// - targetTimestamp: required, positive int
|
||||
// - optionIndexes: required, 1..10 ints in [0, 9]
|
||||
// - voteCount: optional, int in [0, 1_000_000]
|
||||
export const PollVoteSchema = z
|
||||
.object({
|
||||
targetAuthorAci: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(isAciString, 'targetAuthorAci must be a valid ACI string'),
|
||||
targetTimestamp: z.number().int().positive(),
|
||||
optionIndexes: z.array(z.number().int().min(0).max(9)).min(1).max(10),
|
||||
voteCount: z.number().int().min(0),
|
||||
})
|
||||
.describe('PollVote');
|
||||
|
||||
// PollTerminate schema (processed shape)
|
||||
// - targetTimestamp: required, positive int
|
||||
export const PollTerminateSchema = z
|
||||
.object({
|
||||
targetTimestamp: z.number().int().positive(),
|
||||
})
|
||||
.describe('PollTerminate');
|
||||
|
||||
export type MessagePollVoteType = {
|
||||
fromConversationId: string;
|
||||
optionIndexes: ReadonlyArray<number>;
|
||||
voteCount: number;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type PollMessageAttribute = {
|
||||
question: string;
|
||||
options: ReadonlyArray<string>;
|
||||
allowMultiple: boolean;
|
||||
votes?: ReadonlyArray<MessagePollVoteType>;
|
||||
terminatedAt?: number;
|
||||
};
|
||||
|
||||
export function isPollReceiveEnabled(): boolean {
|
||||
const env = getEnvironment();
|
||||
|
||||
if (
|
||||
env === Environment.Development ||
|
||||
env === Environment.Test ||
|
||||
isMockEnvironment()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const version = window.getVersion?.();
|
||||
|
||||
if (version != null) {
|
||||
if (isProduction(version)) {
|
||||
return RemoteConfig.isEnabled('desktop.pollReceive.prod');
|
||||
}
|
||||
if (isBeta(version)) {
|
||||
return RemoteConfig.isEnabled('desktop.pollReceive.beta');
|
||||
}
|
||||
if (isAlpha(version)) {
|
||||
return RemoteConfig.isEnabled('desktop.pollReceive.alpha');
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import memoizee from 'memoizee';
|
||||
|
||||
import { map, size, take, join } from './iterables.js';
|
||||
|
||||
const getSegmenter = memoizee((): Intl.Segmenter => new Intl.Segmenter());
|
||||
|
||||
export function getGraphemes(str: string): Iterable<string> {
|
||||
const segments = new Intl.Segmenter().segment(str);
|
||||
const segments = getSegmenter().segment(str);
|
||||
return map(segments, s => s.segment);
|
||||
}
|
||||
|
||||
export function count(str: string): number {
|
||||
const segments = new Intl.Segmenter().segment(str);
|
||||
const segments = getSegmenter().segment(str);
|
||||
return size(segments);
|
||||
}
|
||||
|
||||
@@ -18,7 +22,7 @@ export function truncateAndSize(
|
||||
str: string,
|
||||
toSize?: number
|
||||
): [string, number] {
|
||||
const segments = new Intl.Segmenter().segment(str);
|
||||
const segments = getSegmenter().segment(str);
|
||||
const originalSize = size(segments);
|
||||
if (toSize === undefined || originalSize <= toSize) {
|
||||
return [str, originalSize];
|
||||
@@ -36,6 +40,23 @@ export function isSingleGrapheme(str: string): boolean {
|
||||
if (str === '') {
|
||||
return false;
|
||||
}
|
||||
const segments = new Intl.Segmenter().segment(str);
|
||||
const segments = getSegmenter().segment(str);
|
||||
return segments.containing(0).segment === str;
|
||||
}
|
||||
|
||||
export function hasAtMostGraphemes(str: string, max: number): boolean {
|
||||
if (max < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let countSoFar = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
for (const _ of getSegmenter().segment(str)) {
|
||||
countSoFar += 1;
|
||||
if (countSoFar > max) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export function isMessageEmpty(attributes: MessageAttributesType): boolean {
|
||||
const hasAttachment = (attributes.attachments || []).length > 0;
|
||||
const hasEmbeddedContact = (attributes.contact || []).length > 0;
|
||||
const isSticker = Boolean(attributes.sticker);
|
||||
const isPoll = Boolean(attributes.poll);
|
||||
|
||||
// Rendered sync messages
|
||||
const isCallHistoryValue = isCallHistory(attributes);
|
||||
@@ -69,6 +70,7 @@ export function isMessageEmpty(attributes: MessageAttributesType): boolean {
|
||||
hasAttachment ||
|
||||
hasEmbeddedContact ||
|
||||
isSticker ||
|
||||
isPoll ||
|
||||
isPayment ||
|
||||
// Rendered sync messages
|
||||
isCallHistoryValue ||
|
||||
|
||||
@@ -40,6 +40,12 @@ import {
|
||||
import { getMessageIdForLogging } from './idForLogging.js';
|
||||
import { markViewOnceMessageViewed } from '../services/MessageUpdater.js';
|
||||
import { handleReaction } from '../messageModifiers/Reactions.js';
|
||||
import {
|
||||
drainCachedTerminatesForMessage as drainCachedPollTerminatesForMessage,
|
||||
drainCachedVotesForMessage as drainCachedPollVotesForMessage,
|
||||
handlePollTerminate,
|
||||
handlePollVote,
|
||||
} from '../messageModifiers/Polls.js';
|
||||
|
||||
const log = createLogger('modifyTargetMessage');
|
||||
|
||||
@@ -315,6 +321,28 @@ export async function modifyTargetMessage(
|
||||
})
|
||||
);
|
||||
|
||||
const pollVotes = drainCachedPollVotesForMessage(message.attributes);
|
||||
if (pollVotes.length) {
|
||||
changed = true;
|
||||
await Promise.all(
|
||||
pollVotes.map(vote =>
|
||||
handlePollVote(message, vote, { shouldPersist: false })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const pollTerminates = drainCachedPollTerminatesForMessage(
|
||||
message.attributes
|
||||
);
|
||||
if (pollTerminates.length) {
|
||||
changed = true;
|
||||
await Promise.all(
|
||||
pollTerminates.map(term =>
|
||||
handlePollTerminate(message, term, { shouldPersist: false })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Does message message have any pending, previously-received associated
|
||||
// delete for everyone messages?
|
||||
const deletes = Deletes.forMessage(message.attributes);
|
||||
|
||||
Reference in New Issue
Block a user