From f7627ddbfd966367f6c46bebfdcf7cb90d7e5223 Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Fri, 17 Jul 2026 11:04:31 -0500 Subject: [PATCH] Adding Announcements and Channels Signed-off-by: rapterjet2004 --- .../com/nextcloud/talk/chat/ChatActivity.kt | 15 +++- .../talk/chat/MessageInputFragment.kt | 26 ++++++- .../talk/chat/viewmodels/ChatViewModel.kt | 27 ++++--- .../ConversationCreationActivity.kt | 70 ++++++++++++++----- .../viewmodel/ConversationInfoViewModel.kt | 10 ++- .../talk/models/domain/ConversationModel.kt | 10 +++ .../json/conversations/ConversationEnums.kt | 5 ++ .../nextcloud/talk/utils/CapabilitiesUtil.kt | 3 +- .../res/layout/fragment_message_input.xml | 11 +++ app/src/main/res/values/strings.xml | 5 ++ 10 files changed, 150 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index fa07b43ab8..bca824a786 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -133,6 +133,7 @@ import com.nextcloud.talk.location.LocationPickerActivity import com.nextcloud.talk.models.ExternalSignalingServer import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.ConversationModel.Companion.checkIfVoiceRoom +import com.nextcloud.talk.models.domain.ConversationModel.Companion.isChannel import com.nextcloud.talk.models.json.capabilities.SpreedCapability import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.conversations.ConversationEnums @@ -1937,7 +1938,11 @@ class ChatActivity : !isChatThread() && !ConversationUtils.isNoteToSelfConversation(conversation) && !isReadOnlyConversation() && - !shouldShowLobby() + !shouldShowLobby() && + !( + conversation.isChannel() && + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.ANNOUNCEMENT_PRESET) + ) private fun isSearchAvailable(capabilitiesReady: Boolean, conversation: ConversationModel?): Boolean = capabilitiesReady && @@ -2209,8 +2214,12 @@ class ChatActivity : } private fun checkShowMessageInputView() { + val permissions = participantPermissionsFlow.value + val isChannel = currentConversation.isChannel() && + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.ANNOUNCEMENT_PRESET) + if (isReadOnlyConversation() || - participantPermissionsFlow.value?.hasChatPermission() == false + (permissions?.hasChatPermission() == false && (!isChannel || permissions.hasReactPermission() == false)) ) { binding.fragmentContainerActivityChat.visibility = View.GONE } else { @@ -2783,7 +2792,7 @@ class ChatActivity : ) } - public override fun onDestroy() { + override fun onDestroy() { super.onDestroy() logConversationInfos("onDestroy") diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index b5a16950c0..4d57d0775d 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -70,6 +70,7 @@ import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.databinding.FragmentMessageInputBinding import com.nextcloud.talk.jobs.UploadAndShareFilesWorker +import com.nextcloud.talk.models.domain.ConversationModel.Companion.isChannel import com.nextcloud.talk.models.json.capabilities.SpreedCapability import com.nextcloud.talk.models.json.chat.ChatUtils import com.nextcloud.talk.models.json.mention.Mention @@ -185,6 +186,12 @@ class MessageInputFragment : Fragment() { val threadTitle = text.toString() chatActivity.chatViewModel.messageDraft.threadTitle = threadTitle } + + viewLifecycleOwner.lifecycleScope.launch { + chatActivity.participantPermissionsFlow.collect { + handleButtonsVisibility() + } + } } @Suppress("LongMethod") @@ -331,7 +338,6 @@ class MessageInputFragment : Fragment() { override fun onSuccess(result: Drawable) { binding.fragmentCallStarted.callAuthorChip.chipIcon = result - } }) .build() @@ -718,6 +724,16 @@ class MessageInputFragment : Fragment() { visibility = if (isVisible) View.VISIBLE else View.GONE } + val isReactionOnly = isReactionOnlyMode() + if (isReactionOnly) { + binding.fragmentMessageInputView.setVisible(false) + binding.reactionOnlyAffordance.setVisible(true) + return + } else { + binding.fragmentMessageInputView.setVisible(true) + binding.reactionOnlyAffordance.setVisible(false) + } + val isEditModeActive = binding.fragmentEditView.editMessageView.isVisible val isThreadCreateModeActive = binding.fragmentCreateThreadView.createThreadView.isVisible val inputContainsText = binding.fragmentMessageInputView.messageInput.text.isNotEmpty() @@ -1288,6 +1304,14 @@ class MessageInputFragment : Fragment() { return jsonId != null } + private fun isReactionOnlyMode(): Boolean { + val conversation = chatActivity.currentConversation + val permissions = chatActivity.participantPermissionsFlow.value + val isChannel = conversation.isChannel() && + CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.ANNOUNCEMENT_PRESET) + return isChannel && permissions?.hasChatPermission() == false && permissions.hasReactPermission() == true + } + companion object { fun newInstance() = MessageInputFragment() private val TAG: String = MessageInputFragment::class.java.simpleName diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 0eb664ba8f..4aaa84f5c1 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -35,6 +35,7 @@ import com.nextcloud.talk.conversationlist.DirectShareHelper import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel.Companion.FOLLOWED_THREADS_EXIST +import com.nextcloud.talk.dagger.modules.ApplicationScope import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.user.model.User @@ -45,6 +46,7 @@ import com.nextcloud.talk.logger.Logger import com.nextcloud.talk.messagesearch.MessageSearchHelper import com.nextcloud.talk.models.MessageDraft import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.domain.ConversationModel.Companion.isChannel import com.nextcloud.talk.models.domain.ReactionAddedModel import com.nextcloud.talk.models.domain.ReactionDeletedModel import com.nextcloud.talk.models.domain.SearchMessageEntry @@ -80,7 +82,6 @@ import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers -import com.nextcloud.talk.dagger.modules.ApplicationScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -510,8 +511,10 @@ class ChatViewModel @AssistedInject constructor( @OptIn(ExperimentalCoroutinesApi::class) private val messagesFlow: Flow> = - conversationAndUserFlow - .flatMapLatest { (conversation, user) -> + combine(conversationAndUserFlow, spreedCapabilities) { (conversation, user), capabilities -> + Triple(conversation, user, capabilities) + } + .flatMapLatest { (conversation, user, capabilities) -> combine(chatMode, contextAnchorMessageId) { mode, anchorMessageId -> mode to anchorMessageId } @@ -530,10 +533,10 @@ class ChatViewModel @AssistedInject constructor( .distinctUntilChanged() .mapToChatMessages(user.userId!!) } - } - .map { messages -> - messages.let(::handleSystemMessages) - .let(::handleThreadMessages) + .map { messages -> + handleSystemMessages(messages, conversation, capabilities) + .let(::handleThreadMessages) + } } .distinctUntilChanged() @@ -1340,7 +1343,15 @@ class ChatViewModel @AssistedInject constructor( } Log.d(TAG, "fetchNewMessagesWithRetry: no new messages after $POST_UPLOAD_FETCH_MAX_ATTEMPTS attempts") } - private fun handleSystemMessages(chatMessageList: List): List { + private fun handleSystemMessages( + chatMessageList: List, + conversation: ConversationModel?, + capabilities: SpreedCapability? + ): List { + if (conversation.isChannel() && hasSpreedFeatureCapability(capabilities, SpreedFeatures.ANNOUNCEMENT_PRESET)) { + return chatMessageList.filter { !it.isSystemMessage } + } + fun shouldRemoveMessage(currentMessage: MutableMap.MutableEntry): Boolean = isInfoMessageAboutDeletion(currentMessage) || isReactionsMessage(currentMessage) || diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index 9a7e2f0d18..2f02a9c6a2 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -43,7 +43,9 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Campaign import androidx.compose.material.icons.outlined.Chat +import androidx.compose.material.icons.outlined.Podcasts import androidx.compose.material.icons.outlined.VolumeUp import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -381,30 +383,62 @@ fun ConversationNameAndDescription(conversationCreationViewModel: ConversationCr @Composable fun ConversationPresets(conversationCreationViewModel: ConversationCreationViewModel) { val preset by conversationCreationViewModel.conversationPreset + val hasAnnouncementPreset = CapabilitiesUtil.hasSpreedFeatureCapability( + conversationCreationViewModel.currentUser.capabilities?.spreedCapability, + SpreedFeatures.ANNOUNCEMENT_PRESET + ) - Row( + Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - SelectableCard( - modifier = Modifier.weight(1f), - title = stringResource(R.string.default_room), - subtitle = stringResource(R.string.default_room_preset), - icon = Icons.Outlined.Chat, - isSelected = preset == "default", - onClick = { conversationCreationViewModel.conversationPreset.value = "default" } - ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SelectableCard( + modifier = Modifier.weight(1f), + title = stringResource(R.string.default_room), + subtitle = stringResource(R.string.default_room_preset), + icon = Icons.Outlined.Chat, + isSelected = preset == "default", + onClick = { conversationCreationViewModel.conversationPreset.value = "default" } + ) - SelectableCard( - modifier = Modifier.weight(1f), - title = stringResource(R.string.voice_room), - subtitle = stringResource(R.string.voice_room_preset), - icon = Icons.Outlined.VolumeUp, - isSelected = preset == "voiceroom", - onClick = { conversationCreationViewModel.conversationPreset.value = "voiceroom" } - ) + SelectableCard( + modifier = Modifier.weight(1f), + title = stringResource(R.string.voice_room), + subtitle = stringResource(R.string.voice_room_preset), + icon = Icons.Outlined.VolumeUp, + isSelected = preset == "voiceroom", + onClick = { conversationCreationViewModel.conversationPreset.value = "voiceroom" } + ) + } + + if (hasAnnouncementPreset) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + SelectableCard( + modifier = Modifier.weight(1f), + title = stringResource(R.string.nc_channel), + subtitle = stringResource(R.string.nc_channel_description), + icon = Icons.Outlined.Podcasts, + isSelected = preset == "channel", + onClick = { conversationCreationViewModel.conversationPreset.value = "channel" } + ) + + SelectableCard( + modifier = Modifier.weight(1f), + title = stringResource(R.string.nc_announcement), + subtitle = stringResource(R.string.nc_announcement_description), + icon = Icons.Outlined.Campaign, + isSelected = preset == "announcement", + onClick = { conversationCreationViewModel.conversationPreset.value = "announcement" } + ) + } + } } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt index 927c9a943a..ff5541f71d 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt @@ -23,6 +23,7 @@ import com.nextcloud.talk.conversationinfo.Participants import com.nextcloud.talk.conversationinfo.model.ParticipantModel import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.domain.ConversationModel.Companion.isChannel import com.nextcloud.talk.models.domain.converters.DomainEnumNotificationLevelConverter import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser import com.nextcloud.talk.models.json.capabilities.SpreedCapability @@ -417,6 +418,10 @@ class ConversationInfoViewModel @Inject constructor( val showMessageExpiration = isModerator && hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MESSAGE_EXPIRATION) + val isChannel = conversationModel.isChannel() && + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.ANNOUNCEMENT_PRESET) + val showParticipants = if (isChannel) isModerator else true + val credentials = ApiUtils.getCredentials(user.username, user.token) ?: "" _uiState.update { state -> @@ -466,12 +471,15 @@ class ConversationInfoViewModel @Inject constructor( isArchived = isArchived, canLeave = canLeave, canDelete = canDelete, + showParticipants = showParticipants, showClearHistory = showClearHistory, showEditButton = showEditButton ) } - loadParticipants(user, token) + if (showParticipants) { + loadParticipants(user, token) + } } @Suppress("Detekt.TooGenericExceptionCaught") diff --git a/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt b/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt index 6b1092aee8..40116cc4ee 100644 --- a/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt +++ b/app/src/main/java/com/nextcloud/talk/models/domain/ConversationModel.kt @@ -80,6 +80,16 @@ data class ConversationModel( it and ConversationEnums.Preset.VOICE_ROOM.ordinal != 0 } ?: false + fun ConversationModel?.isChannel(): Boolean = + this?.attributes?.let { + it and ConversationEnums.ATTRIBUTE_IS_CHANNEL != 0 + } ?: false + + fun ConversationModel?.isAnnouncement(): Boolean = + this?.attributes?.let { + it and ConversationEnums.ATTRIBUTE_IS_ANNOUNCEMENT != 0 + } ?: false + @Suppress("LongMethod") fun mapToConversationModel(conversation: Conversation, user: User): ConversationModel = ConversationModel( diff --git a/app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationEnums.kt b/app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationEnums.kt index ce0347ae84..f7c0e0e42b 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationEnums.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/conversations/ConversationEnums.kt @@ -54,4 +54,9 @@ class ConversationEnums { DEFAULT, VOICE_ROOM } + + companion object { + const val ATTRIBUTE_IS_CHANNEL = 8 + const val ATTRIBUTE_IS_ANNOUNCEMENT = 16 + } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt index e172544680..c53ad94afd 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt @@ -65,7 +65,8 @@ enum class SpreedFeatures(val value: String) { PINNED_MESSAGES("pinned-messages"), SCHEDULED_MESSAGES("scheduled-messages"), REACT_PERMISSION("react-permission"), - CONVERSATION_PRESETS("conversation-presets") + CONVERSATION_PRESETS("conversation-presets"), + ANNOUNCEMENT_PRESET("announcement-preset") } @Suppress("TooManyFunctions") diff --git a/app/src/main/res/layout/fragment_message_input.xml b/app/src/main/res/layout/fragment_message_input.xml index 63f3c12e18..5b35ed0b75 100644 --- a/app/src/main/res/layout/fragment_message_input.xml +++ b/app/src/main/res/layout/fragment_message_input.xml @@ -71,6 +71,17 @@ android:animateLayoutChanges="true" android:inputType="textLongMessage|textAutoComplete" android:maxLength="1000" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21af10e0da..bfb030386a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -83,6 +83,11 @@ How to translate with transifex: Search Clear search + Only moderators can post in this channel + Channel + Only moderators can post, everybody else can only react + Announcement + A channel where non-moderators can not leave Check out the certificate Do you trust the until now unknown SSL certificate, issued by %1$s for %2$s, valid from %3$s to %4$s?