Merge pull request #35 from Bilb/fix-groups-issues-5

fix: more x4 fixes for group
pull/3281/head
Audric Ackermann 4 months ago committed by GitHub
commit 811d334e5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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`

@ -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"

@ -811,11 +811,20 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
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,

@ -727,11 +727,14 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
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,

@ -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<string> = [];
@ -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

@ -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();
}
}
}

@ -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<StoreGroupExtraData> = compactedMessages.map(updateMessage => {
const wrapped = MessageWrapper.wrapContentIntoEnvelope(
SignalService.Envelope.Type.CLOSED_GROUP_MESSAGE,

@ -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<SnodeNamespaces, Map<string, RetrieveMessageItem>> = 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<GroupPollingEntry>;
@ -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<Snode> = [];
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<RetrieveRequestResult>
results: RetrieveMessagesResultsMergedBatched
): Array<RetrieveMessageItemWithNamespace> {
return flatten(
compact(
@ -996,7 +1044,7 @@ function retrieveItemWithNamespace(
function filterMessagesPerTypeOfConvo<T extends ConversationTypeEnum>(
type: T,
retrieveResults: RetrieveMessagesResultsBatched
retrieveResults: RetrieveMessagesResultsMergedBatched
): {
confMessages: Array<RetrieveMessageItemWithNamespace> | null;
revokedMessages: Array<RetrieveMessageItemWithNamespace> | null;

@ -34,12 +34,21 @@ export type RetrieveMessagesResultsContent = {
t: number;
};
export type RetrieveRequestResult = {
type RetrieveMessagesResultsContentMerged = Pick<RetrieveMessagesResultsContent, 'messages'>;
type RetrieveRequestResult<
T extends RetrieveMessagesResultsContent | RetrieveMessagesResultsContentMerged,
> = {
code: number;
messages: RetrieveMessagesResultsContent;
messages: T;
namespace: SnodeNamespaces;
};
export type RetrieveMessagesResultsBatched = Array<RetrieveRequestResult>;
export type RetrieveMessagesResultsBatched = Array<
RetrieveRequestResult<RetrieveMessagesResultsContent>
>;
export type RetrieveMessagesResultsMergedBatched = Array<
RetrieveRequestResult<RetrieveMessagesResultsContentMerged>
>;
export type WithRevokeSubRequest = {
revokeSubRequest?: SubaccountRevokeSubRequest;

@ -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;
/**

@ -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,

@ -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;

@ -41,8 +41,9 @@ export async function toRawMessage(
): Promise<OutgoingRawMessage> {
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,

@ -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

@ -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;
}

Loading…
Cancel
Save