diff --git a/ts/components/conversation/SubtleNotification.tsx b/ts/components/conversation/SubtleNotification.tsx index 18de49397..0e27a29a4 100644 --- a/ts/components/conversation/SubtleNotification.tsx +++ b/ts/components/conversation/SubtleNotification.tsx @@ -41,7 +41,11 @@ const Container = styled.div<{ noExtraPadding: boolean }>` flex-direction: row; justify-content: center; background-color: var(--background-secondary-color); - padding: ${props => (props.noExtraPadding ? '' : 'var(--margins-lg)')}; + + // add padding only if we have a child. + &:has(*:not(:empty)) { + padding: ${props => (props.noExtraPadding ? '' : 'var(--margins-lg)')}; + } `; const TextInner = styled.div` diff --git a/ts/components/conversation/composition/CompositionTextArea.tsx b/ts/components/conversation/composition/CompositionTextArea.tsx index 7439900be..b533982b7 100644 --- a/ts/components/conversation/composition/CompositionTextArea.tsx +++ b/ts/components/conversation/composition/CompositionTextArea.tsx @@ -12,6 +12,7 @@ import { renderEmojiQuickResultRow, searchEmojiForQuery } from './EmojiQuickResu import { renderUserMentionRow, styleForCompositionBoxSuggestions } from './UserMentions'; import { HTMLDirection, useHTMLDirection } from '../../../util/i18n/rtlSupport'; import { ConvoHub } from '../../../session/conversations'; +import { Constants } from '../../../session'; const sendMessageStyle = (dir?: HTMLDirection) => { return { @@ -87,7 +88,10 @@ export const CompositionTextArea = (props: Props) => { throw new Error('selectedConversationKey is needed'); } - const newDraft = event.target.value ?? ''; + const newDraft = (event.target.value ?? '').slice( + 0, + Constants.CONVERSATION.MAX_MESSAGE_CHAR_COUNT + ); setDraft(newDraft); updateDraftForConversation({ conversationKey: selectedConversationKey, draft: newDraft }); }; @@ -121,6 +125,7 @@ export const CompositionTextArea = (props: Props) => { spellCheck={true} dir={htmlDirection} inputRef={textAreaRef} + maxLength={Constants.CONVERSATION.MAX_MESSAGE_CHAR_COUNT} disabled={!typingEnabled} rows={1} data-testid="message-input-text-area" diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index a8515d74a..5f71d712d 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -811,11 +811,20 @@ export class ConversationModel extends Backbone.Model { networkTimestamp ); + const attachmentsWithVoiceMessage = attachments + ? attachments.map(attachment => { + if (attachment.isVoiceMessage) { + return { ...attachment, flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE }; + } + return attachment; + }) + : undefined; + const messageModel = await this.addSingleOutgoingMessage({ body, quote: isEmpty(quote) ? undefined : quote, preview, - attachments, + attachments: attachmentsWithVoiceMessage, sent_at: networkTimestamp, // overridden later, but we need one to have the sorting done in the UI even when the sending is pending expirationType: DisappearingMessages.changeToDisappearingMessageType( this, diff --git a/ts/models/message.ts b/ts/models/message.ts index 93af19a90..2d90d2266 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -727,11 +727,14 @@ export class MessageModel extends Backbone.Model { thumbnail, fileName, caption, + isVoiceMessage: isVoiceMessageFromDb, } = attachment; const isVoiceMessageBool = + !!isVoiceMessageFromDb || // eslint-disable-next-line no-bitwise - Boolean(flags && flags & SignalService.AttachmentPointer.Flags.VOICE_MESSAGE) || false; + !!(flags && flags & SignalService.AttachmentPointer.Flags.VOICE_MESSAGE) || + false; return { id, diff --git a/ts/node/sql.ts b/ts/node/sql.ts index 7a6032045..92a7dfa65 100644 --- a/ts/node/sql.ts +++ b/ts/node/sql.ts @@ -22,6 +22,7 @@ import { last, map, omit, + some, uniq, } from 'lodash'; @@ -1107,7 +1108,7 @@ async function getAllMessagesWithAttachmentsInConversationSentBefore( .all({ conversationId, beforeMs: deleteAttachBeforeSeconds * 1000 }); const messages = map(rows, row => jsonToObject(row.json)); const messagesWithAttachments = messages.filter(m => { - return getExternalFilesForMessage(m, false).some(a => !isEmpty(a) && isString(a)); // when we remove an attachment, we set the path to '' so it should be excluded here + return hasUserVisibleAttachments(m); }); return messagesWithAttachments; } @@ -2092,7 +2093,7 @@ function getMessagesWithFileAttachments(conversationId: string, limit: number) { return map(rows, row => jsonToObject(row.json)); } -function getExternalFilesForMessage(message: any, includePreview = true) { +function getExternalFilesForMessage(message: any) { const { attachments, quote, preview } = message; const files: Array = []; @@ -2110,7 +2111,6 @@ function getExternalFilesForMessage(message: any, includePreview = true) { files.push(screenshot.path); } }); - if (quote && quote.attachments && quote.attachments.length) { forEach(quote.attachments, attachment => { const { thumbnail } = attachment; @@ -2121,7 +2121,7 @@ function getExternalFilesForMessage(message: any, includePreview = true) { }); } - if (includePreview && preview && preview.length) { + if (preview && preview.length) { forEach(preview, item => { const { image } = item; @@ -2134,6 +2134,30 @@ function getExternalFilesForMessage(message: any, includePreview = true) { return files; } +/** + * This looks like `getExternalFilesForMessage`, but it does not include some type of attachments not visible from the right panel. + * It should only be used when we look for messages to mark as deleted when an admin + * triggers a "delete messages with attachments since". + * Note: quoted attachments are referencing the original message, so we don't need to include them here. + * Note: previews are not considered user visible (because not visible from the right panel), + * so we don't need to include them here + * Note: voice messages are not considered user visible (because not visible from the right panel), + */ +function hasUserVisibleAttachments(message: any) { + const { attachments } = message; + + return some(attachments, attachment => { + const { path: file, flags, thumbnail, screenshot } = attachment; + + return ( + // eslint-disable-next-line no-bitwise + (file && !(flags & SignalService.AttachmentPointer.Flags.VOICE_MESSAGE)) || + thumbnail?.path || + screenshot?.path + ); + }); +} + function getExternalFilesForConversation( conversationAvatar: | string diff --git a/ts/receiver/configMessage.ts b/ts/receiver/configMessage.ts index 4d2c8d78e..0011f1d1c 100644 --- a/ts/receiver/configMessage.ts +++ b/ts/receiver/configMessage.ts @@ -725,7 +725,8 @@ async function handleSingleGroupUpdate({ ); } - if (!ConvoHub.use().get(groupPk)) { + const convoExisting = ConvoHub.use().get(groupPk); + if (!convoExisting) { const created = await ConvoHub.use().getOrCreateAndWait(groupPk, ConversationTypeEnum.GROUPV2); const joinedAt = groupInWrapper.joinedAtSeconds * 1000 || CONVERSATION.LAST_JOINED_FALLBACK_TIMESTAMP; @@ -743,6 +744,12 @@ async function handleSingleGroupUpdate({ }); await created.commit(); getSwarmPollingInstance().addGroupId(PubKey.cast(groupPk)); + } else { + // Note: the priority is the only **field** we want to sync from our user group wrapper for 03-groups + const changes = await convoExisting.setPriorityFromWrapper(groupInWrapper.priority, false); + if (changes) { + await convoExisting.commit(); + } } } diff --git a/ts/session/apis/snode_api/factories/StoreGroupRequestFactory.ts b/ts/session/apis/snode_api/factories/StoreGroupRequestFactory.ts index 255c2e143..e41bcb91a 100644 --- a/ts/session/apis/snode_api/factories/StoreGroupRequestFactory.ts +++ b/ts/session/apis/snode_api/factories/StoreGroupRequestFactory.ts @@ -1,5 +1,5 @@ import { UserGroupsGet } from 'libsession_util_nodejs'; -import { compact, isEmpty } from 'lodash'; +import { compact, isEmpty, uniqBy } from 'lodash'; import { SignalService } from '../../../../protobuf'; import { MetaGroupWrapperActions } from '../../../../webworker/workers/browser/libsession_worker_interface'; import { GroupUpdateInfoChangeMessage } from '../../../messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateInfoChangeMessage'; @@ -40,6 +40,13 @@ async function makeGroupMessageSubRequest( throw new Error('makeGroupMessageSubRequest: not all messages are for the same destination'); } + const allTimestamps = uniqBy(compactedMessages, m => m.createAtNetworkTimestamp); + if (allTimestamps.length !== compactedMessages.length) { + throw new Error( + 'tried to send batch request with messages having the same timestamp, this is not supported on all platforms.' + ); + } + const messagesToEncrypt: Array = compactedMessages.map(updateMessage => { const wrapped = MessageWrapper.wrapContentIntoEnvelope( SignalService.Envelope.Type.CLOSED_GROUP_MESSAGE, diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts index 189b84cdd..0d06fbb0e 100644 --- a/ts/session/apis/snode_api/swarmPolling.ts +++ b/ts/session/apis/snode_api/swarmPolling.ts @@ -13,6 +13,7 @@ import { last, omit, sample, + sampleSize, toNumber, uniqBy, } from 'lodash'; @@ -53,12 +54,19 @@ import { RetrieveMessageItem, RetrieveMessageItemWithNamespace, RetrieveMessagesResultsBatched, - RetrieveRequestResult, + type RetrieveMessagesResultsMergedBatched, } from './types'; import { ConversationTypeEnum } from '../../../models/types'; import { Snode } from '../../../data/types'; const minMsgCountShouldRetry = 95; +/** + * We retrieve from multiple snodes at the same time, and merge their reported messages because it's easy + * for a snode to be out of sync. + * Sometimes, being out of sync means that we won't be able to retrieve a message at all (revoked_subaccount). + * We need a proper fix server side, but in the meantime, that's all we can do. + */ +const RETRIEVE_SNODES_COUNT = 2; function extractWebSocketContent( message: string, @@ -105,6 +113,33 @@ function entryToKey(entry: GroupPollingEntry) { return entry.pubkey.key; } +function mergeMultipleRetrieveResults( + results: RetrieveMessagesResultsBatched +): RetrieveMessagesResultsMergedBatched { + const mapped: Map> = new Map(); + for (let resultIndex = 0; resultIndex < results.length; resultIndex++) { + const result = results[resultIndex]; + if (!mapped.has(result.namespace)) { + mapped.set(result.namespace, new Map()); + } + if (result.messages.messages) { + for (let msgIndex = 0; msgIndex < result.messages.messages.length; msgIndex++) { + const msg = result.messages.messages[msgIndex]; + if (!mapped.get(result.namespace)!.has(msg.hash)) { + mapped.get(result.namespace)!.set(msg.hash, msg); + } + } + } + } + + // Convert the merged map back to an array + return Array.from(mapped.entries()).map(([namespace, messagesMap]) => ({ + code: results.find(m => m.namespace === namespace)?.code || 200, + namespace, + messages: { messages: Array.from(messagesMap.values()) }, + })); +} + export class SwarmPolling { private groupPolling: Array; @@ -414,27 +449,40 @@ export class SwarmPolling { public async pollOnceForKey([pubkey, type]: PollForUs | PollForLegacy | PollForGroup) { const namespaces = this.getNamespacesToPollFrom(type); const swarmSnodes = await SnodePool.getSwarmFor(pubkey); - let resultsFromAllNamespaces: RetrieveMessagesResultsBatched | null; + let resultsFromAllNamespaces: RetrieveMessagesResultsMergedBatched | null; - let toPollFrom: Snode | undefined; + let toPollFrom: Array = []; try { - toPollFrom = sample(swarmSnodes); + toPollFrom = sampleSize(swarmSnodes, RETRIEVE_SNODES_COUNT); - if (!toPollFrom) { + if (toPollFrom.length !== RETRIEVE_SNODES_COUNT) { throw new Error( - `SwarmPolling: pollOnceForKey: no snode in swarm for ${ed25519Str(pubkey)}` + `SwarmPolling: pollOnceForKey: not snodes in swarm for ${ed25519Str(pubkey)}. Expected to have at least ${RETRIEVE_SNODES_COUNT}.` ); } - // Note: always print something so we know if the polling is hanging - window.log.info( - `SwarmPolling: about to pollNodeForKey of ${ed25519Str(pubkey)} from snode: ${ed25519Str(toPollFrom.pubkey_ed25519)} namespaces: ${namespaces} ` - ); - resultsFromAllNamespaces = await this.pollNodeForKey(toPollFrom, pubkey, namespaces, type); - // Note: always print something so we know if the polling is hanging + const resultsFromAllSnodesSettled = await Promise.allSettled( + toPollFrom.map(async snode => { + // Note: always print something so we know if the polling is hanging + window.log.info( + `SwarmPolling: about to pollNodeForKey of ${ed25519Str(pubkey)} from snode: ${ed25519Str(snode.pubkey_ed25519)} namespaces: ${namespaces} ` + ); + const thisSnodeResults = await this.pollNodeForKey(snode, pubkey, namespaces, type); + // Note: always print something so we know if the polling is hanging + window.log.info( + `SwarmPolling: pollNodeForKey of ${ed25519Str(pubkey)} from snode: ${ed25519Str(snode.pubkey_ed25519)} namespaces: ${namespaces} returned: ${thisSnodeResults?.length}` + ); + return thisSnodeResults; + }) + ); window.log.info( - `SwarmPolling: pollNodeForKey of ${ed25519Str(pubkey)} from snode: ${ed25519Str(toPollFrom.pubkey_ed25519)} namespaces: ${namespaces} returned: ${resultsFromAllNamespaces?.length}` + `SwarmPolling: pollNodeForKey of ${ed25519Str(pubkey)} namespaces: ${namespaces} returned ${resultsFromAllSnodesSettled.filter(m => m.status === 'fulfilled').length}/${RETRIEVE_SNODES_COUNT} fulfilled promises` + ); + resultsFromAllNamespaces = mergeMultipleRetrieveResults( + compact( + resultsFromAllSnodesSettled.filter(m => m.status === 'fulfilled').flatMap(m => m.value) + ) ); } catch (e) { window.log.warn( @@ -490,7 +538,7 @@ export class SwarmPolling { const newMessages = await this.handleSeenMessages(uniqOtherMsgs); window.log.info( - `SwarmPolling: handleSeenMessages: ${newMessages.length} out of ${uniqOtherMsgs.length} are not seen yet about pk:${ed25519Str(pubkey)} snode: ${toPollFrom ? ed25519Str(toPollFrom.pubkey_ed25519) : 'undefined'}` + `SwarmPolling: handleSeenMessages: ${newMessages.length} out of ${uniqOtherMsgs.length} are not seen yet about pk:${ed25519Str(pubkey)} snode: ${JSON.stringify(toPollFrom.map(m => ed25519Str(m.pubkey_ed25519)))}` ); if (type === ConversationTypeEnum.GROUPV2) { if (!PubKey.is03Pubkey(pubkey)) { @@ -975,7 +1023,7 @@ const retrieveItemSchema = z.object({ }); function retrieveItemWithNamespace( - results: Array + results: RetrieveMessagesResultsMergedBatched ): Array { return flatten( compact( @@ -996,7 +1044,7 @@ function retrieveItemWithNamespace( function filterMessagesPerTypeOfConvo( type: T, - retrieveResults: RetrieveMessagesResultsBatched + retrieveResults: RetrieveMessagesResultsMergedBatched ): { confMessages: Array | null; revokedMessages: Array | null; diff --git a/ts/session/apis/snode_api/types.ts b/ts/session/apis/snode_api/types.ts index 160648388..8e21b3e42 100644 --- a/ts/session/apis/snode_api/types.ts +++ b/ts/session/apis/snode_api/types.ts @@ -34,12 +34,21 @@ export type RetrieveMessagesResultsContent = { t: number; }; -export type RetrieveRequestResult = { +type RetrieveMessagesResultsContentMerged = Pick; + +type RetrieveRequestResult< + T extends RetrieveMessagesResultsContent | RetrieveMessagesResultsContentMerged, +> = { code: number; - messages: RetrieveMessagesResultsContent; + messages: T; namespace: SnodeNamespaces; }; -export type RetrieveMessagesResultsBatched = Array; +export type RetrieveMessagesResultsBatched = Array< + RetrieveRequestResult +>; +export type RetrieveMessagesResultsMergedBatched = Array< + RetrieveRequestResult +>; export type WithRevokeSubRequest = { revokeSubRequest?: SubaccountRevokeSubRequest; diff --git a/ts/session/constants.ts b/ts/session/constants.ts index 5dafa06ae..0ef7e7486 100644 --- a/ts/session/constants.ts +++ b/ts/session/constants.ts @@ -77,6 +77,11 @@ export const CONVERSATION = { MAX_GLOBAL_UNREAD_COUNT: 99, // the global one does not look good with 4 digits (999+) so we have a smaller one for it /** NOTE some existing groups might not have joinedAtSeconds and we need a fallback value that is not falsy in order to poll and show up in the conversations list */ LAST_JOINED_FALLBACK_TIMESTAMP: 1, + /** + * the maximum chars that can be typed/pasted in the composition box. + * Same as android. + */ + MAX_MESSAGE_CHAR_COUNT: 2000, } as const; /** diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts index 29fe7079c..1d2c84e15 100644 --- a/ts/session/conversations/ConversationController.ts +++ b/ts/session/conversations/ConversationController.ts @@ -695,7 +695,7 @@ async function leaveClosedGroup(groupPk: PubkeyType | GroupPubkeyType, fromSyncM const createAtNetworkTimestamp = NetworkTime.now(); // Send the update to the 03 group const ourLeavingMessage = new GroupUpdateMemberLeftMessage({ - createAtNetworkTimestamp, + createAtNetworkTimestamp: createAtNetworkTimestamp + 1, // we just need it to be different than the one of ourLeavingNotificationMessage groupPk, expirationType: null, // we keep that one **not** expiring expireTimer: null, diff --git a/ts/session/sending/MessageWrapper.ts b/ts/session/sending/MessageWrapper.ts index f519b478f..1077679a9 100644 --- a/ts/session/sending/MessageWrapper.ts +++ b/ts/session/sending/MessageWrapper.ts @@ -4,7 +4,7 @@ import { MessageEncrypter } from '../crypto/MessageEncrypter'; import { PubKey } from '../types'; function encryptionBasedOnConversation(destination: PubKey) { - if (ConvoHub.use().get(destination.key)?.isClosedGroup()) { + if (PubKey.is03Pubkey(destination.key) || ConvoHub.use().get(destination.key)?.isClosedGroup()) { return SignalService.Envelope.Type.CLOSED_GROUP_MESSAGE; } return SignalService.Envelope.Type.SESSION_MESSAGE; diff --git a/ts/session/utils/Messages.ts b/ts/session/utils/Messages.ts index 6355f65b0..985ee93c3 100644 --- a/ts/session/utils/Messages.ts +++ b/ts/session/utils/Messages.ts @@ -41,8 +41,9 @@ export async function toRawMessage( ): Promise { const ttl = message.ttl(); const plainTextBuffer = message.plainTextBuffer(); + const is03group = PubKey.is03Pubkey(destinationPubKey.key); - const encryption = getEncryptionTypeFromMessageType(message, isGroup); + const encryption = getEncryptionTypeFromMessageType(message, isGroup || is03group); const rawMessage: OutgoingRawMessage = { identifier: message.identifier, diff --git a/ts/state/selectors/selectedConversation.ts b/ts/state/selectors/selectedConversation.ts index f6178c64d..7d790ca1b 100644 --- a/ts/state/selectors/selectedConversation.ts +++ b/ts/state/selectors/selectedConversation.ts @@ -17,7 +17,7 @@ import { } from './conversations'; import { getLibMembersPubkeys, useLibGroupName } from './groups'; import { getCanWrite, getModerators, getSubscriberCount } from './sogsRoomInfo'; -import { getLibGroupDestroyed, useLibGroupDestroyed } from './userGroups'; +import { getLibGroupDestroyed, getLibGroupKicked, useLibGroupDestroyed } from './userGroups'; const getIsSelectedPrivate = (state: StateType): boolean => { return Boolean(getSelectedConversation(state)?.isPrivate) || false; @@ -59,6 +59,7 @@ export const getSelectedConversationIsPublic = (state: StateType): boolean => { export function getSelectedCanWrite(state: StateType) { const selectedConvoPubkey = getSelectedConversationKey(state); const isSelectedGroupDestroyed = getLibGroupDestroyed(state, selectedConvoPubkey); + const isSelectedGroupKicked = getLibGroupKicked(state, selectedConvoPubkey); if (!selectedConvoPubkey) { return false; } @@ -76,6 +77,7 @@ export function getSelectedCanWrite(state: StateType) { return !( isBlocked || isKickedFromGroup || + isSelectedGroupKicked || isSelectedGroupDestroyed || readOnlySogs || isBlindedAndDisabledMsgRequests diff --git a/ts/state/selectors/userGroups.ts b/ts/state/selectors/userGroups.ts index 6d3b3db12..7f89c680d 100644 --- a/ts/state/selectors/userGroups.ts +++ b/ts/state/selectors/userGroups.ts @@ -26,7 +26,7 @@ export function useLibGroupInviteGroupName(convoId?: string) { return useSelector((state: StateType) => getGroupById(state, convoId)?.name); } -function getLibGroupKicked(state: StateType, convoId?: string) { +export function getLibGroupKicked(state: StateType, convoId?: string) { return getGroupById(state, convoId)?.kicked; }