mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 09:20:19 +01:00
Release polls behind feature flag.
This commit is contained in:
@@ -41,6 +41,8 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.database.model.toBodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.BadGroupIdException
|
||||
@@ -83,6 +85,7 @@ import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.mms.MmsException
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.notifications.v2.ConversationId
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.Recipient.HiddenState
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -168,6 +171,9 @@ object DataMessageProcessor {
|
||||
message.isMediaMessage -> insertResult = handleMediaMessage(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime, localMetrics)
|
||||
message.body != null -> insertResult = handleTextMessage(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime, localMetrics)
|
||||
message.groupCallUpdate != null -> handleGroupCallUpdateMessage(envelope, message, senderRecipient.id, groupId)
|
||||
message.pollCreate != null -> insertResult = handlePollCreate(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime)
|
||||
message.pollTerminate != null -> insertResult = handlePollTerminate(context, envelope, metadata, message, senderRecipient, earlyMessageCacheEntry, threadRecipient, groupId, receivedTime)
|
||||
message.pollVote != null -> messageId = handlePollVote(context, envelope, message, senderRecipient, earlyMessageCacheEntry)
|
||||
}
|
||||
|
||||
messageId = messageId ?: insertResult?.messageId?.let { MessageId(it) }
|
||||
@@ -1040,6 +1046,178 @@ object DataMessageProcessor {
|
||||
)
|
||||
}
|
||||
|
||||
fun handlePollCreate(
|
||||
context: Context,
|
||||
envelope: Envelope,
|
||||
metadata: EnvelopeMetadata,
|
||||
message: DataMessage,
|
||||
senderRecipient: Recipient,
|
||||
threadRecipient: Recipient,
|
||||
groupId: GroupId.V2?,
|
||||
receivedTime: Long
|
||||
): InsertResult? {
|
||||
log(envelope.timestamp!!, "Handle poll creation")
|
||||
val poll: DataMessage.PollCreate = message.pollCreate!!
|
||||
|
||||
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime)
|
||||
|
||||
if (groupId == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollCreate] Polls can only be sent to groups. author: $senderRecipient")
|
||||
return null
|
||||
}
|
||||
|
||||
val groupRecord = SignalDatabase.groups.getGroup(groupId).orNull()
|
||||
if (groupRecord == null || !groupRecord.members.contains(senderRecipient.id)) {
|
||||
warn(envelope.timestamp!!, "[handlePollCreate] Poll author is not in the group. author $senderRecipient")
|
||||
return null
|
||||
}
|
||||
|
||||
val pollMessage = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = senderRecipient.id,
|
||||
sentTimeMillis = envelope.timestamp!!,
|
||||
serverTimeMillis = envelope.serverTimestamp!!,
|
||||
receivedTimeMillis = receivedTime,
|
||||
groupId = groupId,
|
||||
expiresIn = message.expireTimerDuration.inWholeMilliseconds,
|
||||
isUnidentified = metadata.sealedSender,
|
||||
serverGuid = envelope.serverGuid,
|
||||
poll = Poll(
|
||||
question = poll.question!!,
|
||||
allowMultipleVotes = poll.allowMultiple!!,
|
||||
pollOptions = poll.options,
|
||||
authorId = senderRecipient.id.toLong()
|
||||
),
|
||||
body = poll.question!!
|
||||
)
|
||||
|
||||
val insertResult: InsertResult? = SignalDatabase.messages.insertMessageInbox(pollMessage).orNull()
|
||||
return if (insertResult != null) {
|
||||
AppDependencies.messageNotifier.updateNotification(context, ConversationId.forConversation(insertResult.threadId))
|
||||
insertResult
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun handlePollTerminate(
|
||||
context: Context,
|
||||
envelope: Envelope,
|
||||
metadata: EnvelopeMetadata,
|
||||
message: DataMessage,
|
||||
senderRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry? = null,
|
||||
threadRecipient: Recipient,
|
||||
groupId: GroupId.V2?,
|
||||
receivedTime: Long
|
||||
): InsertResult? {
|
||||
val pollTerminate: DataMessage.PollTerminate = message.pollTerminate!!
|
||||
val targetSentTimestamp = pollTerminate.targetSentTimestamp!!
|
||||
|
||||
log(envelope.timestamp!!, "Handle poll termination for poll $targetSentTimestamp")
|
||||
|
||||
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime)
|
||||
|
||||
val messageId = handlePollValidation(envelope = envelope, targetSentTimestamp = targetSentTimestamp, senderRecipient = senderRecipient, earlyMessageCacheEntry = earlyMessageCacheEntry, targetAuthor = senderRecipient)
|
||||
if (messageId == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val poll = SignalDatabase.polls.getPoll(messageId.id)
|
||||
if (poll == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollTerminate] Poll was not found. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
val pollMessage = IncomingMessage(
|
||||
type = MessageType.POLL_TERMINATE,
|
||||
from = senderRecipient.id,
|
||||
sentTimeMillis = envelope.timestamp!!,
|
||||
serverTimeMillis = envelope.serverTimestamp!!,
|
||||
receivedTimeMillis = receivedTime,
|
||||
groupId = groupId,
|
||||
expiresIn = message.expireTimerDuration.inWholeMilliseconds,
|
||||
isUnidentified = metadata.sealedSender,
|
||||
serverGuid = envelope.serverGuid,
|
||||
messageExtras = MessageExtras(pollTerminate = PollTerminate(poll.question, poll.messageId, targetSentTimestamp))
|
||||
)
|
||||
|
||||
val insertResult: InsertResult? = SignalDatabase.messages.insertMessageInbox(pollMessage).orNull()
|
||||
|
||||
return if (insertResult != null) {
|
||||
AppDependencies.messageNotifier.updateNotification(context, ConversationId.forConversation(insertResult.threadId))
|
||||
insertResult
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun handlePollVote(
|
||||
context: Context,
|
||||
envelope: Envelope,
|
||||
message: DataMessage,
|
||||
senderRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry?
|
||||
): MessageId? {
|
||||
val pollVote: DataMessage.PollVote = message.pollVote!!
|
||||
val targetSentTimestamp = pollVote.targetSentTimestamp!!
|
||||
|
||||
log(envelope.timestamp!!, "Handle poll vote for poll $targetSentTimestamp")
|
||||
|
||||
val targetAuthorServiceId: ServiceId = ServiceId.parseOrThrow(pollVote.targetAuthorAciBinary!!)
|
||||
if (targetAuthorServiceId.isUnknown) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Vote was to an unknown UUID! Ignoring the message.")
|
||||
return null
|
||||
}
|
||||
|
||||
val messageId = handlePollValidation(envelope, targetSentTimestamp, senderRecipient, earlyMessageCacheEntry, Recipient.externalPush(targetAuthorServiceId))
|
||||
if (messageId == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val targetMessage = SignalDatabase.messages.getMessageRecord(messageId.id)
|
||||
val pollId = SignalDatabase.polls.getPollId(messageId.id)
|
||||
if (pollId == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Poll was not found. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
val existingVoteCount = SignalDatabase.polls.getCurrentPollVoteCount(pollId, senderRecipient.id.toLong())
|
||||
val currentVoteCount = pollVote.voteCount?.toLong() ?: 0
|
||||
if (currentVoteCount <= existingVoteCount) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Incoming vote count was not higher. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
val allOptionIds = SignalDatabase.polls.getPollOptionIds(pollId)
|
||||
if (pollVote.optionIndexes.any { it < 0 || it >= allOptionIds.size }) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Invalid option indexes. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
if (!SignalDatabase.polls.canAllowMultipleVotes(pollId) && pollVote.optionIndexes.size > 1) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Can not vote multiple times. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
if (SignalDatabase.polls.hasEnded(pollId)) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Poll has already ended. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
SignalDatabase.polls.insertVotes(
|
||||
pollId = pollId,
|
||||
pollOptionIds = pollVote.optionIndexes.map { index -> allOptionIds[index] },
|
||||
voterId = senderRecipient.id.toLong(),
|
||||
voteCount = pollVote.voteCount?.toLong() ?: 0,
|
||||
messageId = messageId
|
||||
)
|
||||
|
||||
AppDependencies.messageNotifier.updateNotification(context, ConversationId.fromMessageRecord(targetMessage))
|
||||
|
||||
return messageId
|
||||
}
|
||||
|
||||
fun notifyTypingStoppedFromIncomingMessage(context: Context, senderRecipient: Recipient, threadRecipientId: RecipientId, device: Int) {
|
||||
val threadId = SignalDatabase.threads.getThreadIdIfExistsFor(threadRecipientId)
|
||||
|
||||
@@ -1167,6 +1345,53 @@ object DataMessageProcessor {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* When ending or voting on a poll, checks validity of the message. Specifically
|
||||
* that the message exists, was only sent to a group, and the sender
|
||||
* is a member of the group. Returns the messageId of the poll if valid, null otherwise.
|
||||
*/
|
||||
private fun handlePollValidation(
|
||||
envelope: Envelope,
|
||||
targetSentTimestamp: Long,
|
||||
senderRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry?,
|
||||
targetAuthor: Recipient
|
||||
): MessageId? {
|
||||
val targetMessage = SignalDatabase.messages.getMessageFor(targetSentTimestamp, targetAuthor.id)
|
||||
if (targetMessage == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollValidation] Could not find matching message! Putting it in the early message cache. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
|
||||
if (earlyMessageCacheEntry != null) {
|
||||
AppDependencies.earlyMessageCache.store(senderRecipient.id, targetSentTimestamp, earlyMessageCacheEntry)
|
||||
PushProcessEarlyMessagesJob.enqueue()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (targetMessage.isRemoteDelete) {
|
||||
warn(envelope.timestamp!!, "[handlePollValidation] Found a matching message, but it's flagged as remotely deleted. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
val targetThread = SignalDatabase.threads.getThreadRecord(targetMessage.threadId)
|
||||
if (targetThread == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollValidation] Could not find a thread for the message. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
val groupRecord = SignalDatabase.groups.getGroup(targetThread.recipient.id).orNull()
|
||||
if (groupRecord == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollValidation] Target thread needs to be a group. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
if (!groupRecord.members.contains(senderRecipient.id)) {
|
||||
warn(envelope.timestamp!!, "[handlePollValidation] Sender is not in the group. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
return MessageId(targetMessage.id)
|
||||
}
|
||||
|
||||
fun getContacts(message: DataMessage): List<Contact> {
|
||||
return message.contact.map { ContactModelMapper.remoteToLocal(it) }
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ object SignalServiceProtoUtil {
|
||||
bodyRanges.isNotEmpty() ||
|
||||
sticker != null ||
|
||||
reaction != null ||
|
||||
hasRemoteDelete
|
||||
hasRemoteDelete ||
|
||||
pollCreate != null ||
|
||||
pollVote != null ||
|
||||
pollTerminate != null
|
||||
}
|
||||
|
||||
val DataMessage.hasDisallowedAnnouncementOnlyContent: Boolean
|
||||
|
||||
@@ -41,6 +41,8 @@ import org.thoughtcrime.securesms.database.model.StickerPackId
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.database.model.toBodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.BadGroupIdException
|
||||
@@ -87,6 +89,7 @@ import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -241,6 +244,12 @@ object SyncMessageProcessor {
|
||||
}
|
||||
dataMessage.hasRemoteDelete -> DataMessageProcessor.handleRemoteDelete(context, envelope, dataMessage, senderRecipient.id, earlyMessageCacheEntry)
|
||||
dataMessage.isMediaMessage -> threadId = handleSynchronizeSentMediaMessage(context, sent, envelope.timestamp!!, senderRecipient, threadRecipient)
|
||||
dataMessage.pollCreate != null -> threadId = handleSynchronizedPollCreate(envelope, dataMessage, sent, senderRecipient)
|
||||
dataMessage.pollVote != null -> {
|
||||
DataMessageProcessor.handlePollVote(context, envelope, dataMessage, senderRecipient, earlyMessageCacheEntry)
|
||||
threadId = SignalDatabase.threads.getOrCreateThreadIdFor(getSyncMessageDestination(sent))
|
||||
}
|
||||
dataMessage.pollTerminate != null -> threadId = handleSynchronizedPollEnd(envelope, dataMessage, sent, senderRecipient, earlyMessageCacheEntry)
|
||||
else -> threadId = handleSynchronizeSentTextMessage(sent, envelope.timestamp!!)
|
||||
}
|
||||
|
||||
@@ -1725,6 +1734,120 @@ object SyncMessageProcessor {
|
||||
MultiDeviceAttachmentBackfillUpdateJob.enqueue(request.targetMessage!!, request.targetConversation!!, messageId)
|
||||
}
|
||||
|
||||
private fun handleSynchronizedPollCreate(
|
||||
envelope: Envelope,
|
||||
message: DataMessage,
|
||||
sent: Sent,
|
||||
senderRecipient: Recipient
|
||||
): Long {
|
||||
log(envelope.timestamp!!, "Synchronize sent poll creation message.")
|
||||
|
||||
val recipient = getSyncMessageDestination(sent)
|
||||
if (!recipient.isGroup) {
|
||||
warn(envelope.timestamp!!, "Poll creation messages should only be synced in groups. Dropping.")
|
||||
return -1
|
||||
}
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
|
||||
val expiresInMillis = message.expireTimerDuration.inWholeMilliseconds
|
||||
if (recipient.expiresInSeconds != message.expireTimerDuration.inWholeSeconds.toInt() || ((message.expireTimerVersion ?: -1) > recipient.expireTimerVersion)) {
|
||||
handleSynchronizeSentExpirationUpdate(sent, sideEffect = true)
|
||||
}
|
||||
|
||||
val poll: DataMessage.PollCreate = message.pollCreate!!
|
||||
val outgoingMessage = OutgoingMessage.pollMessage(
|
||||
threadRecipient = recipient,
|
||||
sentTimeMillis = sent.timestamp!!,
|
||||
expiresIn = recipient.expiresInSeconds.seconds.inWholeMilliseconds,
|
||||
poll = Poll(
|
||||
question = poll.question!!,
|
||||
allowMultipleVotes = poll.allowMultiple!!,
|
||||
pollOptions = poll.options,
|
||||
authorId = senderRecipient.id.toLong()
|
||||
),
|
||||
question = poll.question!!
|
||||
)
|
||||
|
||||
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null).messageId
|
||||
updateGroupReceiptStatus(sent, messageId, recipient.requireGroupId())
|
||||
|
||||
log(envelope.timestamp!!, "Inserted sync poll create message as messageId $messageId")
|
||||
|
||||
SignalDatabase.messages.markAsSent(messageId, true)
|
||||
|
||||
if (expiresInMillis > 0) {
|
||||
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
|
||||
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: 0, expiresInMillis)
|
||||
}
|
||||
|
||||
return threadId
|
||||
}
|
||||
|
||||
private fun handleSynchronizedPollEnd(
|
||||
envelope: Envelope,
|
||||
message: DataMessage,
|
||||
sent: Sent,
|
||||
senderRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry?
|
||||
): Long {
|
||||
log(envelope.timestamp!!, "Synchronize sent poll terminate message")
|
||||
|
||||
val recipient = getSyncMessageDestination(sent)
|
||||
if (!recipient.isGroup) {
|
||||
warn(envelope.timestamp!!, "Poll termination messages should only be synced in groups. Dropping.")
|
||||
return -1
|
||||
}
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
|
||||
val expiresInMillis = message.expireTimerDuration.inWholeMilliseconds
|
||||
if (recipient.expiresInSeconds != message.expireTimerDuration.inWholeSeconds.toInt() || ((message.expireTimerVersion ?: -1) > recipient.expireTimerVersion)) {
|
||||
handleSynchronizeSentExpirationUpdate(sent, sideEffect = true)
|
||||
}
|
||||
|
||||
val pollTerminate = message.pollTerminate!!
|
||||
val targetMessage = SignalDatabase.messages.getMessageFor(pollTerminate.targetSentTimestamp!!, Recipient.self().id)
|
||||
if (targetMessage == null) {
|
||||
warn(envelope.timestamp!!, "Unable to find target message for poll termination. Putting in early message cache.")
|
||||
if (earlyMessageCacheEntry != null) {
|
||||
AppDependencies.earlyMessageCache.store(senderRecipient.id, pollTerminate.targetSentTimestamp!!, earlyMessageCacheEntry)
|
||||
PushProcessEarlyMessagesJob.enqueue()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
val poll = SignalDatabase.polls.getPoll(targetMessage.id)
|
||||
if (poll == null) {
|
||||
warn(envelope.timestamp!!, "Unable to find poll for poll termination. Dropping.")
|
||||
return -1
|
||||
}
|
||||
|
||||
val outgoingMessage = OutgoingMessage.pollTerminateMessage(
|
||||
threadRecipient = recipient,
|
||||
sentTimeMillis = sent.timestamp!!,
|
||||
expiresIn = recipient.expiresInSeconds.seconds.inWholeMilliseconds,
|
||||
messageExtras = MessageExtras(
|
||||
pollTerminate = PollTerminate(
|
||||
question = poll.question,
|
||||
messageId = poll.messageId,
|
||||
targetTimestamp = pollTerminate.targetSentTimestamp!!
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null).messageId
|
||||
SignalDatabase.messages.markAsSent(messageId, true)
|
||||
|
||||
log(envelope.timestamp!!, "Inserted sync poll end message as messageId $messageId")
|
||||
|
||||
if (expiresInMillis > 0) {
|
||||
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
|
||||
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: 0, expiresInMillis)
|
||||
}
|
||||
|
||||
return threadId
|
||||
}
|
||||
|
||||
private fun ConversationIdentifier.toRecipientId(): RecipientId? {
|
||||
return when {
|
||||
threadGroupId != null -> {
|
||||
|
||||
Reference in New Issue
Block a user