[SES-2960] - Control messages for leaving groups (#898)

* Removed the "overridenSnippet" facility

* Add missing control messages
pull/1709/head
SessionHero01 3 months ago committed by GitHub
parent d1ab9ccc66
commit 7b1a25d0ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1056,6 +1056,16 @@ open class Storage @Inject constructor(
return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup)
}
override fun insertGroupInfoErrorQuit(closedGroup: AccountId): Long? {
val sentTimestamp = clock.currentTimeMills()
val senderPublicKey = getUserPublicKey() ?: return null
val groupName = configFactory.withGroupConfigs(closedGroup) { it.groupInfo.getName() }
?: configFactory.getGroup(closedGroup)?.name
val updateData = UpdateMessageData.buildGroupLeaveUpdate(UpdateMessageData.Kind.GroupErrorQuit(groupName.orEmpty()))
return insertUpdateControlMessage(updateData, sentTimestamp, senderPublicKey, closedGroup)
}
override fun updateGroupInfoChange(messageId: Long, newType: UpdateMessageData.Kind) {
val mmsDB = mmsDatabase
val newMessage = UpdateMessageData.buildGroupLeaveUpdate(newType)

@ -19,6 +19,7 @@ package org.thoughtcrime.securesms.database.model;
import android.content.Context;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
import androidx.annotation.NonNull;
@ -33,9 +34,12 @@ import org.session.libsession.messaging.utilities.UpdateMessageBuilder;
import org.session.libsession.messaging.utilities.UpdateMessageData;
import org.session.libsession.utilities.IdentityKeyMismatch;
import org.session.libsession.utilities.NetworkFailure;
import org.session.libsession.utilities.ThemeUtil;
import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;
import network.loki.messenger.R;
/**
* The base class for message record models that are displayed in
* conversations, as opposed to models that are displayed in a thread list.
@ -137,7 +141,7 @@ public abstract class MessageRecord extends DisplayRecord {
return "";
}
return new SpannableString(UpdateMessageBuilder.buildGroupUpdateMessage(
SpannableString text = new SpannableString(UpdateMessageBuilder.buildGroupUpdateMessage(
context,
updateMessageData,
MessagingModuleConfiguration.getShared().getConfigFactory(),
@ -145,6 +149,14 @@ public abstract class MessageRecord extends DisplayRecord {
getTimestamp(),
getExpireStarted())
);
if (updateMessageData.isGroupErrorQuitKind()) {
text.setSpan(new ForegroundColorSpan(ThemeUtil.getThemedColor(context, R.attr.danger)), 0, text.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
} else if (updateMessageData.isGroupLeavingKind()) {
text.setSpan(new ForegroundColorSpan(ThemeUtil.getThemedColor(context, android.R.attr.textColorTertiary)), 0, text.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
}
return text;
} else if (isExpirationTimerUpdate()) {
int seconds = (int) (getExpiresIn() / 1000);
boolean isGroup = DatabaseComponent.get(context).threadDatabase().getRecipientForThreadId(getThreadId()).isGroupOrCommunityRecipient();

@ -125,7 +125,16 @@ public class ThreadRecord extends DisplayRecord {
return "";
}
else if (isGroupUpdateMessage()) {
return lastMessage.getDisplayBody(context).toString();
CharSequence body = lastMessage.getDisplayBody(context);
UpdateMessageData updatedMessage = lastMessage.getGroupUpdateMessage();
// For group leaving and error quit messages, we will leave the message as formatted
if (updatedMessage != null && (updatedMessage.isGroupLeavingKind() || updatedMessage.isGroupErrorQuitKind())) {
return body;
}
// Otherwise we'll need to remove all the formatting and just display the text
return body.toString();
} else if (isOpenGroupInvitation()) {
return context.getString(R.string.communityInvitation);
} else if (MmsSmsColumns.Types.isLegacyType(type)) {

@ -426,72 +426,80 @@ class GroupManagerV2Impl @Inject constructor(
withContext(SupervisorJob()) {
val group = configFactory.getGroup(groupId)
if (group?.destroyed != true) {
// Only send the left/left notification group message when we are not kicked and we are not the only admin (only admin has a special treatment)
val weAreTheOnlyAdmin = configFactory.withGroupConfigs(groupId) { config ->
val allMembers = config.groupMembers.all()
allMembers.count { it.admin } == 1 &&
allMembers.first { it.admin }
.accountIdString() == storage.getUserPublicKey()
}
if (group != null && !group.kicked && !weAreTheOnlyAdmin) {
val destination = Destination.ClosedGroup(groupId.hexString)
val sendMessageTasks = mutableListOf<Deferred<*>>()
// Always send a "XXX left" message to the group if we can
sendMessageTasks += async {
MessageSender.send(
GroupUpdated(
GroupUpdateMessage.newBuilder()
.setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance())
.build()
),
destination,
isSyncMessage = false
).await()
storage.insertGroupInfoLeaving(groupId)
try {
if (group?.destroyed != true) {
// Only send the left/left notification group message when we are not kicked and we are not the only admin (only admin has a special treatment)
val weAreTheOnlyAdmin = configFactory.withGroupConfigs(groupId) { config ->
val allMembers = config.groupMembers.all()
allMembers.count { it.admin } == 1 &&
allMembers.first { it.admin }
.accountIdString() == storage.getUserPublicKey()
}
if (group != null && !group.kicked && !weAreTheOnlyAdmin) {
val destination = Destination.ClosedGroup(groupId.hexString)
val sendMessageTasks = mutableListOf<Deferred<*>>()
// Always send a "XXX left" message to the group if we can
sendMessageTasks += async {
MessageSender.send(
GroupUpdated(
GroupUpdateMessage.newBuilder()
.setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance())
.build()
),
destination,
isSyncMessage = false
).await()
}
// If we are not the only admin, send a left message for other admin to handle the member removal
sendMessageTasks += async {
MessageSender.send(
GroupUpdated(
GroupUpdateMessage.newBuilder()
.setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance())
.build()
),
destination,
isSyncMessage = false
).await()
}
sendMessageTasks.awaitAll()
}
// If we are not the only admin, send a left message for other admin to handle the member removal
sendMessageTasks += async {
MessageSender.send(
GroupUpdated(
GroupUpdateMessage.newBuilder()
.setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance())
.build()
),
destination,
isSyncMessage = false
).await()
}
// If we are the only admin, leaving this group will destroy the group
if (weAreTheOnlyAdmin) {
configFactory.withMutableGroupConfigs(groupId) { configs ->
configs.groupInfo.destroyGroup()
sendMessageTasks.awaitAll()
}
// Must wait until the config is pushed, otherwise if we go through the rest
// of the code it will destroy the conversation, destroying the necessary configs
// along the way, we won't be able to push the "destroyed" state anymore.
configFactory.waitUntilGroupConfigsPushed(groupId)
}
}
// If we are the only admin, leaving this group will destroy the group
if (weAreTheOnlyAdmin) {
configFactory.withMutableGroupConfigs(groupId) { configs ->
configs.groupInfo.destroyGroup()
}
pollerFactory.pollerFor(groupId)?.stop()
// Must wait until the config is pushed, otherwise if we go through the rest
// of the code it will destroy the conversation, destroying the necessary configs
// along the way, we won't be able to push the "destroyed" state anymore.
configFactory.waitUntilGroupConfigsPushed(groupId)
}
}
// Delete conversation and group configs
storage.getThreadId(Address.fromSerialized(groupId.hexString))
?.let(storage::deleteConversation)
configFactory.removeGroup(groupId)
lokiAPIDatabase.clearLastMessageHashes(groupId.hexString)
lokiAPIDatabase.clearReceivedMessageHashValues(groupId.hexString)
pollerFactory.pollerFor(groupId)?.stop()
// Delete conversation and group configs
storage.getThreadId(Address.fromSerialized(groupId.hexString))
?.let(storage::deleteConversation)
configFactory.removeGroup(groupId)
lokiAPIDatabase.clearLastMessageHashes(groupId.hexString)
lokiAPIDatabase.clearReceivedMessageHashValues(groupId.hexString)
} catch (e: Exception) {
storage.insertGroupInfoErrorQuit(groupId)
throw e
}
}
} }
}
}
override suspend fun promoteMember(
group: AccountId,

@ -48,7 +48,7 @@ class ConversationView : LinearLayout {
// endregion
// region Updating
fun bind(thread: ThreadRecord, isTyping: Boolean, overriddenSnippet: CharSequence?) {
fun bind(thread: ThreadRecord, isTyping: Boolean) {
this.thread = thread
if (thread.isPinned) {
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
@ -102,18 +102,14 @@ class ConversationView : LinearLayout {
}
binding.muteIndicatorImageView.setImageResource(drawableRes)
if (overriddenSnippet != null) {
binding.snippetTextView.text = overriddenSnippet
} else {
binding.snippetTextView.text = highlightMentions(
text = thread.getDisplayBody(context),
formatOnly = true, // no styling here, only text formatting
threadID = thread.threadId,
context = context
)
}
binding.snippetTextView.text = highlightMentions(
text = thread.getDisplayBody(context),
formatOnly = true, // no styling here, only text formatting
threadID = thread.threadId,
context = context
)
binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead && overriddenSnippet == null) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
binding.snippetTextView.typeface = if (unreadCount > 0 && !thread.isRead) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
binding.snippetTextView.visibility = if (isTyping) View.GONE else View.VISIBLE
if (isTyping) {
binding.typingIndicatorView.root.startAnimation()

@ -582,7 +582,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
val recipient = thread.recipient
if (recipient.isGroupV2Recipient) {
val statusChannel = ConversationMenuHelper.leaveGroup(
ConversationMenuHelper.leaveGroup(
context = this,
thread = recipient,
threadID = threadID,
@ -591,25 +591,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
groupManager = groupManagerV2,
)
if (statusChannel != null) {
lifecycleScope.launch {
statusChannel.consumeEach { status ->
when (status) {
ConversationMenuHelper.GroupLeavingStatus.Leaving -> {
homeViewModel.onLeavingGroupStarted(threadID)
}
ConversationMenuHelper.GroupLeavingStatus.Left -> {
homeViewModel.onLeavingGroupFinished(threadID, isError = false)
}
ConversationMenuHelper.GroupLeavingStatus.Error -> {
homeViewModel.onLeavingGroupFinished(threadID, isError = true)
}
}
}
}
}
return
}

@ -2,18 +2,14 @@ package org.thoughtcrime.securesms.home
import android.annotation.SuppressLint
import android.content.Context
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID
import com.bumptech.glide.RequestManager
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewMessageRequestBannerBinding
import org.session.libsession.utilities.getColorFromAttr
import org.thoughtcrime.securesms.dependencies.ConfigFactory
class HomeAdapter(
@ -80,18 +76,7 @@ class HomeAdapter(
is ConversationViewHolder -> {
val item = data.items[position] as HomeViewModel.Item.Thread
val overrideText = item.overriddenSnippet?.let { msg ->
SpannableStringBuilder(msg.text).apply {
setSpan(
ForegroundColorSpan(holder.view.context.getColorFromAttr(msg.colorAttr)),
0,
msg.text.length,
SpannableStringBuilder.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}
holder.view.bind(item.thread, item.isTyping, overrideText)
holder.view.bind(item.thread, item.isTyping)
}
}
}

@ -62,11 +62,6 @@ class HomeDiffUtil(
// once when the initial recipient data is loaded
if (isSameItem) { isSameItem = (oldItem.initialRecipientHash == newItem.initialRecipientHash) }
// Check if we would have different "overridden" message summary
if (isSameItem) {
isSameItem = (old.overriddenSnippet == new.overriddenSnippet)
}
// Note: Two instances of 'SpannableString' may not equate even though their content matches
if (isSameItem) { isSameItem = (oldItem.getDisplayBody(context).toString() == newItem.getDisplayBody(context).toString()) }

@ -6,14 +6,12 @@ import androidx.annotation.AttrRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.squareup.phrase.Phrase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@ -26,10 +24,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import network.loki.messenger.R
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN
import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.ThreadDatabase
@ -45,7 +40,6 @@ class HomeViewModel @Inject constructor(
private val threadDb: ThreadDatabase,
private val contentResolver: ContentResolver,
private val prefs: TextSecurePreferences,
@ApplicationContextQualifier private val context: Context,
private val typingStatusRepository: TypingStatusRepository,
private val configFactory: ConfigFactory
) : ViewModel() {
@ -55,8 +49,6 @@ class HomeViewModel @Inject constructor(
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val overrideMessageSnippets = MutableStateFlow(emptyMap<Long, MessageSnippetOverride>())
/**
* A [StateFlow] that emits the list of threads and the typing status of each thread.
*
@ -66,10 +58,9 @@ class HomeViewModel @Inject constructor(
val data: StateFlow<Data?> = combine(
observeConversationList(),
observeTypingStatus(),
overrideMessageSnippets,
messageRequests(),
hasHiddenNoteToSelf()
) { threads, typingStatus, overrideMessageSnippets, messageRequests, hideNoteToSelf ->
) { threads, typingStatus, messageRequests, hideNoteToSelf ->
Data(
items = buildList {
messageRequests?.let { add(it) }
@ -83,7 +74,6 @@ class HomeViewModel @Inject constructor(
Item.Thread(
thread = thread,
isTyping = typingStatus.contains(thread.threadId),
overriddenSnippet = overrideMessageSnippets[thread.threadId]
)
}
}
@ -148,7 +138,6 @@ class HomeViewModel @Inject constructor(
data class Thread(
val thread: ThreadRecord,
val isTyping: Boolean,
val overriddenSnippet: MessageSnippetOverride?
) : Item
data class MessageRequests(val count: Int) : Item
@ -159,36 +148,6 @@ class HomeViewModel @Inject constructor(
hidden: Boolean,
) = if (count > 0 && !hidden) Item.MessageRequests(count) else null
fun onLeavingGroupStarted(threadId: Long) {
val message = MessageSnippetOverride(
text = context.getString(R.string.leaving),
colorAttr = android.R.attr.textColorTertiary
)
overrideMessageSnippets.update { it + (threadId to message) }
}
fun onLeavingGroupFinished(threadId: Long, isError: Boolean) {
if (isError) {
val errorMessage = MessageSnippetOverride(
text = Phrase.from(context, R.string.groupLeaveErrorFailed)
.put(GROUP_NAME_KEY,
data.value?.items
?.asSequence()
?.filterIsInstance<Item.Thread>()
?.find { it.thread.threadId == threadId }
?.thread?.recipient?.name
?: context.getString(R.string.unknown)
)
.format(),
colorAttr = R.attr.danger
)
overrideMessageSnippets.update { it + (threadId to errorMessage) }
} else {
overrideMessageSnippets.update { it - threadId }
}
}
fun hideNoteToSelf() {
prefs.setHasHiddenNoteToSelf(true)

@ -169,6 +169,7 @@ interface StorageProtocol {
fun getClosedGroupDisplayInfo(groupAccountId: String): GroupDisplayInfo?
fun insertGroupInfoChange(message: GroupUpdated, closedGroup: AccountId): Long?
fun insertGroupInfoLeaving(closedGroup: AccountId): Long?
fun insertGroupInfoErrorQuit(closedGroup: AccountId): Long?
fun insertGroupInviteControlMessage(
sentTimestamp: Long,
senderPublicKey: String,

@ -11,14 +11,11 @@ import org.session.libsession.messaging.calls.CallMessageType.CALL_MISSED
import org.session.libsession.messaging.calls.CallMessageType.CALL_OUTGOING
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage
import org.session.libsession.utilities.Address
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED
import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage.Kind.SCREENSHOT
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.ConfigFactoryProtocol
import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.getExpirationTypeDisplayValue
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.truncateIdForDisplay
import org.session.libsignal.utilities.Log
import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY
@ -311,7 +308,9 @@ object UpdateMessageBuilder {
}
}
is UpdateMessageData.Kind.GroupErrorQuit -> {
return context.getString(R.string.groupLeaveErrorFailed)
return Phrase.from(context, R.string.groupLeaveErrorFailed)
.put(GROUP_NAME_KEY, updateData.groupName)
.format()
}
}
}

@ -63,7 +63,9 @@ class UpdateMessageData () {
constructor(): this("", "")
}
data object GroupLeaving: Kind()
data object GroupErrorQuit: Kind()
data class GroupErrorQuit(val groupName: String): Kind() {
constructor(): this("")
}
class GroupInvitation(
val groupAccountId: String,
val invitingAdminId: String,
@ -101,7 +103,7 @@ class UpdateMessageData () {
SignalServiceGroup.Type.MEMBER_REMOVED -> UpdateMessageData(Kind.GroupMemberRemoved(members, name))
SignalServiceGroup.Type.QUIT -> UpdateMessageData(Kind.GroupMemberLeft(members, name))
SignalServiceGroup.Type.LEAVING -> UpdateMessageData(Kind.GroupLeaving)
SignalServiceGroup.Type.ERROR_QUIT -> UpdateMessageData(Kind.GroupErrorQuit)
SignalServiceGroup.Type.ERROR_QUIT -> UpdateMessageData(Kind.GroupErrorQuit(groupName = name))
SignalServiceGroup.Type.UNKNOWN,
SignalServiceGroup.Type.UPDATE,
SignalServiceGroup.Type.DELIVER,

Loading…
Cancel
Save