From ba41f962c5f04c537ff3c80d363f0efe2562062d Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Fri, 17 Jul 2026 11:04:31 -0500 Subject: [PATCH 01/10] Adding Announcements and Channels Signed-off-by: rapterjet2004 --- .../com/nextcloud/talk/chat/ChatActivity.kt | 15 +++- .../talk/chat/MessageInputFragment.kt | 25 +++++++ .../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 | 1 + .../res/layout/fragment_message_input.xml | 11 +++ app/src/main/res/values/strings.xml | 5 ++ 10 files changed, 149 insertions(+), 30 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 3ed5ee713d7..7158b02dafb 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 @@ -1971,7 +1972,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 && @@ -2243,8 +2248,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 { @@ -2816,7 +2825,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 93c79a90b8e..faeaf728339 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 @@ -184,6 +185,12 @@ class MessageInputFragment : Fragment() { val threadTitle = text.toString() chatActivity.chatViewModel.messageDraft.threadTitle = threadTitle } + + viewLifecycleOwner.lifecycleScope.launch { + chatActivity.participantPermissionsFlow.collect { + handleButtonsVisibility() + } + } } @Suppress("LongMethod") @@ -716,6 +723,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() @@ -1287,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 ff5ae076f87..5693a77c343 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 @@ -511,8 +512,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 } @@ -531,10 +534,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() @@ -1367,7 +1370,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 9a7e2f0d181..2f02a9c6a20 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 a1e1bde1e9b..fd8f5075e09 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 @@ -421,6 +422,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 -> @@ -471,12 +476,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 e0ae62452fc..22c48872f53 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 @@ -81,6 +81,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 1066c423c91..8197aac4533 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 @@ -55,4 +55,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 6e11eed629c..86b3104c018 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt @@ -67,6 +67,7 @@ enum class SpreedFeatures(val value: String) { REACT_PERMISSION("react-permission"), CONVERSATION_PRESETS("conversation-presets"), CLASSIFIED_CONVERSATIONS("classified-conversations"), + ANNOUNCEMENT_PRESET("announcement-preset"), CONVERSATION_TAGS("conversation-tags") } diff --git a/app/src/main/res/layout/fragment_message_input.xml b/app/src/main/res/layout/fragment_message_input.xml index 63f3c12e18c..5b35ed0b757 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 9d65e0423cb..34fd0614b52 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? From 528c85b8cd723540221bef5e791796d7dc12142a Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Fri, 31 Jul 2026 10:49:38 -0500 Subject: [PATCH 02/10] Fixing state spreed capability bug with MessageInputFragment.kt Signed-off-by: rapterjet2004 --- .../talk/chat/MessageInputFragment.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) 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 faeaf728339..ae7d056111a 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -205,6 +205,7 @@ class MessageInputFragment : Fragment() { initVoiceRecordButton() initThreadHandling() updateScheduledMessagesAvailability(hasScheduledMessages) + setReactionsOnly(state.spreedCapabilities) } is ChatViewModel.GetCapabilitiesInitialLoadState -> { @@ -215,6 +216,7 @@ class MessageInputFragment : Fragment() { initThreadHandling() updateScheduledMessagesAvailability(hasScheduledMessages) restoreState() + setReactionsOnly(state.spreedCapabilities) } else -> {} @@ -350,6 +352,17 @@ class MessageInputFragment : Fragment() { } } + private fun setReactionsOnly(spreedCapabilities: SpreedCapability) { + val isReactionOnly = isReactionOnlyMode(spreedCapabilities) + if (isReactionOnly) { + binding.fragmentMessageInputView.setVisible(false) + binding.reactionOnlyAffordance.setVisible(true) + } else { + binding.fragmentMessageInputView.setVisible(true) + binding.reactionOnlyAffordance.setVisible(false) + } + } + private fun handleUI(isOnline: Boolean, connectionGained: Boolean) { if (isOnline) { if (connectionGained) { @@ -713,26 +726,16 @@ class MessageInputFragment : Fragment() { } } + fun View.setVisible(isVisible: Boolean) { + visibility = if (isVisible) View.VISIBLE else View.GONE + } + private fun handleButtonsVisibility() { if (!this::binding.isInitialized) { Log.w(TAG, "binding not initialized in handleButtonsVisibility") return } - fun View.setVisible(isVisible: Boolean) { - 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() @@ -1304,7 +1307,7 @@ class MessageInputFragment : Fragment() { return jsonId != null } - private fun isReactionOnlyMode(): Boolean { + private fun isReactionOnlyMode(spreedCapabilities: SpreedCapability): Boolean { val conversation = chatActivity.currentConversation val permissions = chatActivity.participantPermissionsFlow.value val isChannel = conversation.isChannel() && From cce442412d81587c5fd62801e8eeb8fcff4281df Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Mon, 3 Aug 2026 10:53:44 -0500 Subject: [PATCH 03/10] Channel creation now works Signed-off-by: rapterjet2004 --- .gitignore | 1 + .idea/codeStyles/Project.xml | 25 ---- .../com/nextcloud/talk/api/NcApiCoroutines.kt | 11 +- .../network/OfflineFirstChatRepository.kt | 4 +- .../ConversationCreationActivity.kt | 30 ++++- .../data/ConversationCreationRepository.kt | 2 + .../ConversationCreationRepositoryImpl.kt | 8 +- .../ConversationCreationViewModel.kt | 125 +++++++----------- .../conversationinfo/CreateRoomRequest.kt | 41 ++---- .../talk/utils/ParticipantPermissions.kt | 3 + app/src/main/res/values/strings.xml | 1 + 11 files changed, 113 insertions(+), 138 deletions(-) diff --git a/.gitignore b/.gitignore index 98b5bf608ec..42351320980 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ freeline_project_description.json /gradle/verification-keyring.gpg /.claude/settings.local.json +/.artifacts/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 28b11af49de..1ae96897a26 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -2,31 +2,6 @@ -