diff --git a/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/26.json b/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/26.json new file mode 100644 index 00000000000..6be9b509372 --- /dev/null +++ b/app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/26.json @@ -0,0 +1,816 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "4d93b9d96f466fccde5901e5274e941c", + "entities": [ + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` TEXT, `username` TEXT, `baseUrl` TEXT, `token` TEXT, `displayName` TEXT, `pushConfigurationState` TEXT, `capabilities` TEXT, `serverVersion` TEXT DEFAULT '', `clientCertificate` TEXT, `externalSignalingServer` TEXT, `current` INTEGER NOT NULL, `scheduledForDeletion` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "pushConfigurationState", + "columnName": "pushConfigurationState", + "affinity": "TEXT" + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT" + }, + { + "fieldPath": "serverVersion", + "columnName": "serverVersion", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "clientCertificate", + "columnName": "clientCertificate", + "affinity": "TEXT" + }, + { + "fieldPath": "externalSignalingServer", + "columnName": "externalSignalingServer", + "affinity": "TEXT" + }, + { + "fieldPath": "current", + "columnName": "current", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledForDeletion", + "columnName": "scheduledForDeletion", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ArbitraryStorage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountIdentifier` INTEGER NOT NULL, `key` TEXT NOT NULL, `object` TEXT, `value` TEXT, PRIMARY KEY(`accountIdentifier`, `key`))", + "fields": [ + { + "fieldPath": "accountIdentifier", + "columnName": "accountIdentifier", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "storageObject", + "columnName": "object", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountIdentifier", + "key" + ] + } + }, + { + "tableName": "Conversations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `displayName` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `avatarVersion` TEXT NOT NULL, `callFlag` INTEGER NOT NULL, `callRecording` INTEGER NOT NULL, `callStartTime` INTEGER NOT NULL, `canDeleteConversation` INTEGER NOT NULL, `canLeaveConversation` INTEGER NOT NULL, `canStartCall` INTEGER NOT NULL, `description` TEXT NOT NULL, `hasCall` INTEGER NOT NULL, `hasPassword` INTEGER NOT NULL, `isCustomAvatar` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastActivity` INTEGER NOT NULL, `lastCommonReadMessage` INTEGER NOT NULL, `lastMessage` TEXT, `lastPing` INTEGER NOT NULL, `lastReadMessage` INTEGER NOT NULL, `lobbyState` TEXT NOT NULL, `lobbyTimer` INTEGER NOT NULL, `messageExpiration` INTEGER NOT NULL, `name` TEXT NOT NULL, `notificationCalls` INTEGER NOT NULL, `notificationLevel` TEXT NOT NULL, `objectType` TEXT NOT NULL, `objectId` TEXT NOT NULL, `participantType` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `readOnly` TEXT NOT NULL, `recordingConsent` INTEGER NOT NULL, `remoteServer` TEXT, `remoteToken` TEXT, `sessionId` TEXT NOT NULL, `status` TEXT, `statusClearAt` INTEGER, `statusIcon` TEXT, `statusMessage` TEXT, `type` TEXT NOT NULL, `unreadMention` INTEGER NOT NULL, `unreadMentionDirect` INTEGER NOT NULL, `unreadMessages` INTEGER NOT NULL, `hasArchived` INTEGER NOT NULL, `hasSensitive` INTEGER NOT NULL, `hasImportant` INTEGER NOT NULL, `tagIds` TEXT, `hiddenPinnedId` INTEGER, `lastPinnedId` INTEGER, `attributes` INTEGER, `messageDraft` TEXT, `hiddenUpcomingEvent` TEXT, PRIMARY KEY(`internalId`), FOREIGN KEY(`accountId`) REFERENCES `User`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "internalId", + "columnName": "internalId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorId", + "columnName": "actorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorType", + "columnName": "actorType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatarVersion", + "columnName": "avatarVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "callFlag", + "columnName": "callFlag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callRecording", + "columnName": "callRecording", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "callStartTime", + "columnName": "callStartTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDeleteConversation", + "columnName": "canDeleteConversation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canLeaveConversation", + "columnName": "canLeaveConversation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canStartCall", + "columnName": "canStartCall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasCall", + "columnName": "hasCall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPassword", + "columnName": "hasPassword", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasCustomAvatar", + "columnName": "isCustomAvatar", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastActivity", + "columnName": "lastActivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastCommonReadMessage", + "columnName": "lastCommonReadMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessage", + "columnName": "lastMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "lastPing", + "columnName": "lastPing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessage", + "columnName": "lastReadMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lobbyState", + "columnName": "lobbyState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lobbyTimer", + "columnName": "lobbyTimer", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageExpiration", + "columnName": "messageExpiration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationCalls", + "columnName": "notificationCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLevel", + "columnName": "notificationLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectType", + "columnName": "objectType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectId", + "columnName": "objectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantType", + "columnName": "participantType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conversationReadOnlyState", + "columnName": "readOnly", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordingConsentRequired", + "columnName": "recordingConsent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteServer", + "columnName": "remoteServer", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteToken", + "columnName": "remoteToken", + "affinity": "TEXT" + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "statusClearAt", + "columnName": "statusClearAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "statusIcon", + "columnName": "statusIcon", + "affinity": "TEXT" + }, + { + "fieldPath": "statusMessage", + "columnName": "statusMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unreadMention", + "columnName": "unreadMention", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMentionDirect", + "columnName": "unreadMentionDirect", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMessages", + "columnName": "unreadMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasArchived", + "columnName": "hasArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSensitive", + "columnName": "hasSensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasImportant", + "columnName": "hasImportant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tagIds", + "columnName": "tagIds", + "affinity": "TEXT" + }, + { + "fieldPath": "hiddenPinnedId", + "columnName": "hiddenPinnedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastPinnedId", + "columnName": "lastPinnedId", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageDraft", + "columnName": "messageDraft", + "affinity": "TEXT" + }, + { + "fieldPath": "hiddenUpcomingEvent", + "columnName": "hiddenUpcomingEvent", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "internalId" + ] + }, + "indices": [ + { + "name": "index_Conversations_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Conversations_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "User", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`internalId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `token` TEXT NOT NULL, `id` INTEGER NOT NULL, `internalConversationId` TEXT NOT NULL, `threadId` INTEGER, `isThread` INTEGER NOT NULL, `actorDisplayName` TEXT NOT NULL, `message` TEXT NOT NULL, `actorId` TEXT NOT NULL, `actorType` TEXT NOT NULL, `deleted` INTEGER NOT NULL, `expirationTimestamp` INTEGER NOT NULL, `isReplyable` INTEGER NOT NULL, `isTemporary` INTEGER NOT NULL, `lastEditActorDisplayName` TEXT, `lastEditActorId` TEXT, `lastEditActorType` TEXT, `lastEditTimestamp` INTEGER, `markdown` INTEGER, `messageParameters` TEXT, `messageType` TEXT NOT NULL, `parent` INTEGER, `reactions` TEXT, `reactionsSelf` TEXT, `referenceId` TEXT, `sendStatus` TEXT, `silent` INTEGER NOT NULL, `systemMessage` TEXT NOT NULL, `threadTitle` TEXT, `threadReplies` INTEGER, `timestamp` INTEGER NOT NULL, `pinnedActorType` TEXT, `pinnedActorId` TEXT, `pinnedActorDisplayName` TEXT, `pinnedAt` INTEGER, `pinnedUntil` INTEGER, `sendAt` INTEGER, PRIMARY KEY(`internalId`), FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "internalId", + "columnName": "internalId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalConversationId", + "columnName": "internalConversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isThread", + "columnName": "isThread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actorDisplayName", + "columnName": "actorDisplayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorId", + "columnName": "actorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorType", + "columnName": "actorType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationTimestamp", + "columnName": "expirationTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyable", + "columnName": "isReplyable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTemporary", + "columnName": "isTemporary", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastEditActorDisplayName", + "columnName": "lastEditActorDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditActorId", + "columnName": "lastEditActorId", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditActorType", + "columnName": "lastEditActorType", + "affinity": "TEXT" + }, + { + "fieldPath": "lastEditTimestamp", + "columnName": "lastEditTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "renderMarkdown", + "columnName": "markdown", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageParameters", + "columnName": "messageParameters", + "affinity": "TEXT" + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentMessageId", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactions", + "columnName": "reactions", + "affinity": "TEXT" + }, + { + "fieldPath": "reactionsSelf", + "columnName": "reactionsSelf", + "affinity": "TEXT" + }, + { + "fieldPath": "referenceId", + "columnName": "referenceId", + "affinity": "TEXT" + }, + { + "fieldPath": "sendStatus", + "columnName": "sendStatus", + "affinity": "TEXT" + }, + { + "fieldPath": "silent", + "columnName": "silent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemMessageType", + "columnName": "systemMessage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threadTitle", + "columnName": "threadTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "threadReplies", + "columnName": "threadReplies", + "affinity": "INTEGER" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinnedActorType", + "columnName": "pinnedActorType", + "affinity": "TEXT" + }, + { + "fieldPath": "pinnedActorId", + "columnName": "pinnedActorId", + "affinity": "TEXT" + }, + { + "fieldPath": "pinnedActorDisplayName", + "columnName": "pinnedActorDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "pinnedAt", + "columnName": "pinnedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "pinnedUntil", + "columnName": "pinnedUntil", + "affinity": "INTEGER" + }, + { + "fieldPath": "sendAt", + "columnName": "sendAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "internalId" + ] + }, + "indices": [ + { + "name": "index_ChatMessages_internalId", + "unique": true, + "columnNames": [ + "internalId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ChatMessages_internalId` ON `${TABLE_NAME}` (`internalId`)" + }, + { + "name": "index_ChatMessages_internalConversationId", + "unique": false, + "columnNames": [ + "internalConversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessages_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)" + } + ], + "foreignKeys": [ + { + "table": "Conversations", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "internalConversationId" + ], + "referencedColumns": [ + "internalId" + ] + } + ] + }, + { + "tableName": "ChatBlocks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalConversationId` TEXT NOT NULL, `accountId` INTEGER, `token` TEXT, `threadId` INTEGER, `oldestMessageId` INTEGER NOT NULL, `newestMessageId` INTEGER NOT NULL, `hasHistory` INTEGER NOT NULL, FOREIGN KEY(`internalConversationId`) REFERENCES `Conversations`(`internalId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalConversationId", + "columnName": "internalConversationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "oldestMessageId", + "columnName": "oldestMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "newestMessageId", + "columnName": "newestMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasHistory", + "columnName": "hasHistory", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ChatBlocks_internalConversationId", + "unique": false, + "columnNames": [ + "internalConversationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatBlocks_internalConversationId` ON `${TABLE_NAME}` (`internalConversationId`)" + } + ], + "foreignKeys": [ + { + "table": "Conversations", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "internalConversationId" + ], + "referencedColumns": [ + "internalId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4d93b9d96f466fccde5901e5274e941c')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt index 56ab616d7d9..3a4b377a7ef 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -28,6 +28,12 @@ import com.nextcloud.talk.models.json.hovercard.HoverCardOverall import com.nextcloud.talk.models.json.reactions.ReactionsOverall import com.nextcloud.talk.models.json.status.StatusOverall import com.nextcloud.talk.models.json.status.predefined.PredefinedStatusOverall +import com.nextcloud.talk.models.json.tags.AssignConversationTagsRequest +import com.nextcloud.talk.models.json.tags.ConversationTagOverall +import com.nextcloud.talk.models.json.tags.ConversationTagsOverall +import com.nextcloud.talk.models.json.tags.CreateConversationTagRequest +import com.nextcloud.talk.models.json.tags.ReorderConversationTagsRequest +import com.nextcloud.talk.models.json.tags.UpdateConversationTagRequest import com.nextcloud.talk.models.json.testNotification.TestNotificationOverall import com.nextcloud.talk.models.json.threads.ThreadOverall import com.nextcloud.talk.models.json.threads.ThreadsOverall @@ -225,6 +231,43 @@ interface NcApiCoroutines { @Url url: String ): GenericOverall + @GET + suspend fun getConversationTags( + @Header("Authorization") authorization: String, + @Url url: String + ): ConversationTagsOverall + + @POST + suspend fun createConversationTag( + @Header("Authorization") authorization: String, + @Url url: String, + @Body body: CreateConversationTagRequest + ): ConversationTagOverall + + @PUT + suspend fun updateConversationTag( + @Header("Authorization") authorization: String, + @Url url: String, + @Body body: UpdateConversationTagRequest + ): ConversationTagOverall + + @DELETE + suspend fun deleteConversationTag(@Header("Authorization") authorization: String, @Url url: String): GenericOverall + + @PUT + suspend fun reorderConversationTags( + @Header("Authorization") authorization: String, + @Url url: String, + @Body body: ReorderConversationTagsRequest + ): ConversationTagsOverall + + @POST + suspend fun assignConversationTags( + @Header("Authorization") authorization: String, + @Url url: String, + @Body body: AssignConversationTagsRequest + ): RoomOverall + @FormUrlEncoded @POST suspend fun notificationCalls( diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 8ee2b55f929..d330d84a0c4 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -53,6 +53,7 @@ import com.nextcloud.talk.conversationlist.ui.ConversationsListScreen import com.nextcloud.talk.conversationlist.ui.ConversationsListScreenCallbacks import com.nextcloud.talk.conversationlist.ui.ConversationsListScreenState import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel +import com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.events.ConversationsListFetchDataEvent @@ -145,6 +146,8 @@ class ConversationsListActivity : BaseActivity() { lateinit var conversationsListViewModel: ConversationsListViewModel + lateinit var conversationTagsViewModel: ConversationTagsViewModel + private var currentUser: User? = null private val snackbarHostState = SnackbarHostState() private val isMaintenanceModeState = MutableStateFlow(false) @@ -198,6 +201,7 @@ class ConversationsListActivity : BaseActivity() { } conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java] + conversationTagsViewModel = ViewModelProvider(this, viewModelFactory)[ConversationTagsViewModel::class.java] setSupportActionBar(null) forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) @@ -209,6 +213,7 @@ class ConversationsListActivity : BaseActivity() { setContent { ConversationsListScreen( viewModel = conversationsListViewModel, + tagsViewModel = conversationTagsViewModel, state = buildScreenState(), callbacks = buildScreenCallbacks() ) @@ -426,6 +431,7 @@ class ConversationsListActivity : BaseActivity() { conversationsListViewModel.checkIfThreadsExist() conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) + conversationTagsViewModel.loadConversationTags() } override fun onPause() { @@ -1140,6 +1146,9 @@ class ConversationsListActivity : BaseActivity() { is ConversationOpsAction.AddToHomeScreen -> addConversationToHomeScreen(conversation) is ConversationOpsAction.Leave -> leaveConversation(conversation) is ConversationOpsAction.Delete -> showDeleteConversationDialog(conversation) + is ConversationOpsAction.ManageTags -> conversationTagsViewModel.setConversationForTagAssignment( + conversation + ) } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/AssignConversationTagsSheet.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/AssignConversationTagsSheet.kt new file mode 100644 index 00000000000..3c1bd8418ab --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/AssignConversationTagsSheet.kt @@ -0,0 +1,199 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import android.content.res.Configuration +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.talk.R +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.models.json.tags.ConversationTag + +@Composable +fun AssignConversationTagsSheetContent( + conversation: ConversationModel, + tags: List, + onToggleTag: (String) -> Unit, + onManageTagsClick: () -> Unit +) { + val headerText = conversation.displayName.takeIf { it.isNotEmpty() } ?: conversation.name + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + ) { + Text( + text = headerText, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.standard_dialog_padding), + vertical = dimensionResource(R.dimen.standard_half_padding) + ), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + if (tags.isEmpty()) { + Text( + text = stringResource(R.string.nc_conversation_tags_empty), + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.standard_dialog_padding), + vertical = dimensionResource(R.dimen.standard_half_padding) + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + tags.forEach { tag -> + AssignableTagRow( + tag = tag, + isAssigned = conversation.tagIds.contains(tag.id), + onToggleTag = onToggleTag + ) + } + } + ManageTagsRow(onManageTagsClick) + } +} + +@Composable +private fun AssignableTagRow(tag: ConversationTag, isAssigned: Boolean, onToggleTag: (String) -> Unit) { + TextButton( + onClick = { onToggleTag(tag.id) }, + modifier = Modifier + .fillMaxWidth() + .height(dimensionResource(R.dimen.bottom_sheet_item_height)), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.standard_dialog_padding)), + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface) + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.Label, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_dialog_padding))) + Text( + text = tag.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Start + ) + Checkbox(checked = isAssigned, onCheckedChange = { onToggleTag(tag.id) }) + } +} + +@Composable +private fun ManageTagsRow(onManageTagsClick: () -> Unit) { + TextButton( + onClick = onManageTagsClick, + modifier = Modifier + .fillMaxWidth() + .height(dimensionResource(R.dimen.bottom_sheet_item_height)), + shape = RectangleShape, + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.standard_dialog_padding)), + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface) + ) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_dialog_padding))) + Text( + text = stringResource(R.string.nc_conversation_tags_manage), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Start + ) + } +} + +private fun previewConversation() = + ConversationModel( + internalId = "1@tok", + accountId = 1L, + token = "tok", + name = "alice", + displayName = "Alice", + description = "", + type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + participantType = Participant.ParticipantType.USER, + sessionId = "", + actorId = "user1", + actorType = "users", + objectType = ConversationEnums.ObjectType.DEFAULT, + notificationLevel = ConversationEnums.NotificationLevel.DEFAULT, + conversationReadOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_WRITE, + lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS, + lobbyTimer = 0L, + canLeaveConversation = true, + canDeleteConversation = true, + unreadMentionDirect = false, + notificationCalls = 0, + avatarVersion = "", + hasCustomAvatar = false, + callStartTime = 0L, + tagIds = listOf("2") + ) + +private fun previewTags() = + listOf( + ConversationTag(id = "1", name = "Work", sortOrder = 0), + ConversationTag(id = "2", name = "Family", sortOrder = 1) + ) + +@Preview(showBackground = true, name = "Light") +@Preview(showBackground = true, name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AssignConversationTagsSheetPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { + AssignConversationTagsSheetContent( + conversation = previewConversation(), + tags = previewTags(), + onToggleTag = {}, + onManageTagsClick = {} + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt index 2172269312c..f8bee4d79fe 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt @@ -9,6 +9,7 @@ package com.nextcloud.talk.conversationlist.ui import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -36,10 +37,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -47,18 +51,27 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback @@ -71,8 +84,10 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest @@ -88,10 +103,141 @@ import kotlin.math.roundToInt private const val MSG_KEY_EXCERPT_LENGTH = 20 -/** - * The full conversation list: pull-to-refresh + LazyColumn. - * Replaces RecyclerView + FlexibleAdapter + SwipeRefreshLayout. - */ +private const val TAGS_REVEAL_PULL_RESISTANCE = 0.5f +private const val TAGS_REVEAL_SNAP_THRESHOLD_FRACTION = 0.35f +private const val TAGS_REVEAL_MIN_SCALE = 0.85f +private const val TAGS_REVEAL_FADE_IN_FRACTION = 0.7f + +@Suppress("MagicNumber") +private val tagsRevealPopOrigin = TransformOrigin(pivotFractionX = 0.5f, pivotFractionY = 1f) + +/** Reports a layout height of only [revealPx] instead of [content]'s natural height, bottom-clipping the rest. */ +@Composable +private fun PullRevealHeader( + revealPx: () -> Float, + naturalHeightPx: () -> Float, + onNaturalHeightPxChanged: (Float) -> Unit, + content: @Composable () -> Unit +) { + Layout( + content = { + Box( + modifier = Modifier.graphicsLayer { + val natural = naturalHeightPx() + val fraction = if (natural > 0f) (revealPx() / natural).coerceIn(0f, 1f) else 0f + alpha = (fraction / TAGS_REVEAL_FADE_IN_FRACTION).coerceIn(0f, 1f) + val scale = TAGS_REVEAL_MIN_SCALE + (1f - TAGS_REVEAL_MIN_SCALE) * fraction + scaleX = scale + scaleY = scale + transformOrigin = tagsRevealPopOrigin + } + ) { + content() + } + }, + modifier = Modifier.clipToBounds() + ) { measurables, constraints -> + val placeable = measurables.first().measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)) + onNaturalHeightPxChanged(placeable.height.toFloat()) + val height = revealPx().roundToInt().coerceIn(0, placeable.height) + layout(placeable.width, height) { + placeable.placeRelative(0, height - placeable.height) + } + } +} + +/** Bundles the reveal-height accessors so [rememberPullRevealNestedScrollConnection] stays under the param limit. */ +private class PullRevealController( + val revealPx: () -> Float, + val naturalHeightPx: () -> Float, + val onRevealPxChanged: (Float) -> Unit, + val refreshDistanceFraction: () -> Float +) + +// This must be the outermost nested-scroll connection (wrapping PullToRefreshBox, not attached +// to the LazyColumn inside it): pre-scroll dispatch runs outermost-first, so it needs first claim +// on "pulling down while at the top" before PullToRefreshBox's own overscroll detection sees it. +// revealPx is plain, synchronously-mutated state rather than an Animatable driven via snapTo from +// a launched coroutine — that queues a new coroutine per scroll callback during a drag, and they +// compete for the Animatable's mutation mutex, causing stutter. A coroutine only runs once per +// gesture, for the release-time settle animation. + +@Composable +private fun rememberPullRevealNestedScrollConnection( + listState: LazyListState, + isEnabled: Boolean, + controller: PullRevealController, + coroutineScope: CoroutineScope +): NestedScrollConnection = + remember(listState, isEnabled) { + object : NestedScrollConnection { + /** Tracks the in-flight settle animation so a fresh drag/fling can cancel a stale one. */ + var settleJob: Job? = null + + // True once this gesture has already pulled the tags row fully open; further pulling + // is then absorbed here instead of leaking into the refresh indicator below, which may + // only be engaged by a separate, later pull. Reset on gesture end in onPreFling. + var reachedMaxThisGesture = false + val revealPx = controller.revealPx + val naturalHeightPx = controller.naturalHeightPx + val onRevealPxChanged = controller.onRevealPxChanged + val refreshDistanceFraction = controller.refreshDistanceFraction + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (!isEnabled) return Offset.Zero + val atOwnTop = listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + return when { + // Refresh indicator has no pull progress of its own yet, so retract the tags + // row first instead of letting it collapse underneath the indicator. + available.y < 0f && revealPx() > 0f && refreshDistanceFraction() <= 0f -> { + settleJob?.cancel() + val newValue = (revealPx() + available.y).coerceAtLeast(0f) + val consumedY = newValue - revealPx() + onRevealPxChanged(newValue) + Offset(0f, consumedY) + } + + available.y > 0f && atOwnTop && revealPx() < naturalHeightPx() -> { + settleJob?.cancel() + val newValue = (revealPx() + available.y * TAGS_REVEAL_PULL_RESISTANCE) + .coerceAtMost(naturalHeightPx()) + onRevealPxChanged(newValue) + if (newValue >= naturalHeightPx()) { + reachedMaxThisGesture = true + } + available + } + + available.y > 0f && atOwnTop && reachedMaxThisGesture -> available + + else -> Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + reachedMaxThisGesture = false + // Fire-and-forget: awaiting the settle animation here would block the fling this + // suspend function is expected to hand off to the LazyColumn below. + val max = naturalHeightPx() + if (isEnabled && revealPx() > 0f && revealPx() < max) { + settleJob?.cancel() + settleJob = coroutineScope.launch { + val target = if (revealPx() >= max * TAGS_REVEAL_SNAP_THRESHOLD_FRACTION) max else 0f + animate( + initialValue = revealPx(), + targetValue = target, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) { value, _ -> onRevealPxChanged(value) } + } + } + return Velocity.Zero + } + } + } + @Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -115,8 +261,29 @@ fun ConversationList( /** Extra bottom padding added as LazyColumn contentPadding so the last item is reachable above the nav bar. */ contentBottomPadding: Dp = 0.dp, onSwipeConversation: (ConversationOpsAction, ConversationModel) -> Unit = { _, _ -> }, - isOnline: Boolean = true + isOnline: Boolean = true, + /** Rubber-band pull-to-reveal content shown above the list; null hides the feature entirely. */ + tagsRowContent: (@Composable () -> Unit)? = null ) { + var tagsRevealPx by remember { mutableFloatStateOf(0f) } + var tagsNaturalHeightPx by remember { mutableFloatStateOf(0f) } + val pullToRefreshState = rememberPullToRefreshState() + val tagsRevealController = remember { + PullRevealController( + revealPx = { tagsRevealPx }, + naturalHeightPx = { tagsNaturalHeightPx }, + onRevealPxChanged = { tagsRevealPx = it }, + refreshDistanceFraction = { pullToRefreshState.distanceFraction } + ) + } + val tagsRevealCoroutineScope = rememberCoroutineScope() + val tagsRevealConnection = rememberPullRevealNestedScrollConnection( + listState = listState, + isEnabled = tagsRowContent != null, + controller = tagsRevealController, + coroutineScope = tagsRevealCoroutineScope + ) + var prevIndex by remember { mutableIntStateOf(listState.firstVisibleItemIndex) } var prevOffset by remember { mutableIntStateOf(listState.firstVisibleItemScrollOffset) } LaunchedEffect(listState) { @@ -131,7 +298,6 @@ fun ConversationList( } } - // Unread-bubble: notify Activity when scrolling stops LaunchedEffect(listState) { snapshotFlow { listState.isScrollInProgress } .collect { isScrolling -> @@ -142,98 +308,125 @@ fun ConversationList( } } - // Unread-bubble: also trigger the check after entries are first loaded (or updated) LaunchedEffect(entries) { if (entries.isEmpty()) { onScrollStopped(0) return@LaunchedEffect } - // Wait until the LazyColumn has measured visible items so the last-visible index is accurate. snapshotFlow { listState.layoutInfo.visibleItemsInfo } .first { it.isNotEmpty() } val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 onScrollStopped(lastVisible) } - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = onRefresh, - modifier = Modifier.fillMaxSize() + Column( + modifier = Modifier + .fillMaxSize() + .nestedScroll(tagsRevealConnection) ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = contentBottomPadding) + if (tagsRowContent != null) { + PullRevealHeader( + revealPx = { tagsRevealPx }, + naturalHeightPx = { tagsNaturalHeightPx }, + onNaturalHeightPxChanged = { tagsNaturalHeightPx = it }, + content = tagsRowContent + ) + } + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + state = pullToRefreshState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + indicator = { + // Offset cancels the tags row's push-down, keeping this pinned near the search bar. + PullToRefreshDefaults.Indicator( + state = pullToRefreshState, + isRefreshing = isRefreshing, + modifier = Modifier + .align(Alignment.TopCenter) + .offset { IntOffset(0, -tagsRevealPx.roundToInt()) } + ) + } ) { - itemsIndexed( - items = entries, - key = { index, entry -> + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = contentBottomPadding) + ) { + itemsIndexed( + items = entries, + key = { index, entry -> + when (entry) { + is ConversationListEntry.Header -> + "header_${entry.title}" + + is ConversationListEntry.ConversationEntry -> + "conv_${entry.model.token}" + + is ConversationListEntry.MessageResultEntry -> + "msg_${entry.result.conversationToken}_" + + "${ + entry.result.messageId + ?: entry.result.messageExcerpt.take(MSG_KEY_EXCERPT_LENGTH) + }" + + is ConversationListEntry.ContactEntry -> + // Contacts can legitimately appear multiple times in search results. + "contact_${entry.participant.actorId}_${entry.participant.actorType}_$index" + + ConversationListEntry.LoadMore -> + "load_more" + } + } + ) { _, entry -> when (entry) { is ConversationListEntry.Header -> - "header_${entry.title}" + ConversationSectionHeader(title = entry.title) is ConversationListEntry.ConversationEntry -> - "conv_${entry.model.token}" + SwipeableConversationItem( + model = entry.model, + onSwipe = { action -> onSwipeConversation(action, entry.model) }, + enabled = isOnline + ) { + ConversationListItem( + model = entry.model, + currentUser = currentUser, + callbacks = ConversationListItemCallbacks( + onClick = { onConversationClick(entry.model) }, + onLongClick = { onConversationLongClick(entry.model) } + ), + searchQuery = searchQuery + ) + } is ConversationListEntry.MessageResultEntry -> - "msg_${entry.result.conversationToken}_" + - "${entry.result.messageId ?: entry.result.messageExcerpt.take(MSG_KEY_EXCERPT_LENGTH)}" + MessageResultListItem( + result = entry.result, + credentials = credentials, + onClick = { onMessageResultClick(entry.result) } + ) is ConversationListEntry.ContactEntry -> - // Contacts can legitimately appear multiple times in search results. - "contact_${entry.participant.actorId}_${entry.participant.actorType}_$index" - - ConversationListEntry.LoadMore -> - "load_more" - } - } - ) { _, entry -> - when (entry) { - is ConversationListEntry.Header -> - ConversationSectionHeader(title = entry.title) - - is ConversationListEntry.ConversationEntry -> - SwipeableConversationItem( - model = entry.model, - onSwipe = { action -> onSwipeConversation(action, entry.model) }, - enabled = isOnline - ) { - ConversationListItem( - model = entry.model, + ContactResultListItem( + participant = entry.participant, currentUser = currentUser, - callbacks = ConversationListItemCallbacks( - onClick = { onConversationClick(entry.model) }, - onLongClick = { onConversationLongClick(entry.model) } - ), - searchQuery = searchQuery + credentials = credentials, + searchQuery = searchQuery, + onClick = { onContactClick(entry.participant) } ) - } - is ConversationListEntry.MessageResultEntry -> - MessageResultListItem( - result = entry.result, - credentials = credentials, - onClick = { onMessageResultClick(entry.result) } - ) - - is ConversationListEntry.ContactEntry -> - ContactResultListItem( - participant = entry.participant, - currentUser = currentUser, - credentials = credentials, - searchQuery = searchQuery, - onClick = { onContactClick(entry.participant) } - ) - - ConversationListEntry.LoadMore -> - LoadMoreListItem(onClick = onLoadMoreClick) + ConversationListEntry.LoadMore -> + LoadMoreListItem(onClick = onLoadMoreClick) + } } } } } } -/** Possible swipe states for conversation row actions. */ private enum class SwipeValue { Settled, StartToEnd, EndToStart } private const val POP_SCALE_PEAK = 1.35f diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOperationsSheet.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOperationsSheet.kt index 79ff9a38117..3ff60856a44 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOperationsSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOperationsSheet.kt @@ -10,6 +10,8 @@ package com.nextcloud.talk.conversationlist.ui import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Label import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -31,6 +33,7 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -55,7 +58,8 @@ private data class ConversationOpsVisibility( val isArchived: Boolean, val showAddToHomeScreen: Boolean, val showLeave: Boolean, - val showDelete: Boolean + val showDelete: Boolean, + val showManageTags: Boolean ) private fun computeVisibility(conversation: ConversationModel, user: User): ConversationOpsVisibility { @@ -63,6 +67,7 @@ private fun computeVisibility(conversation: ConversationModel, user: User): Conv val hasFavorites = CapabilitiesUtil.hasSpreedFeatureCapability(spreedCap, SpreedFeatures.FAVORITES) val hasReadMarker = CapabilitiesUtil.hasSpreedFeatureCapability(spreedCap, SpreedFeatures.CHAT_READ_MARKER) val hasUnread = CapabilitiesUtil.hasSpreedFeatureCapability(spreedCap, SpreedFeatures.CHAT_UNREAD) + val hasConversationTags = CapabilitiesUtil.hasSpreedFeatureCapability(spreedCap, SpreedFeatures.CONVERSATION_TAGS) return ConversationOpsVisibility( showRemoveFromFavorites = hasFavorites && conversation.favorite, showAddToFavorites = hasFavorites && !conversation.favorite, @@ -73,7 +78,8 @@ private fun computeVisibility(conversation: ConversationModel, user: User): Conv isArchived = conversation.hasArchived, showAddToHomeScreen = true, showLeave = conversation.canLeaveConversation, - showDelete = conversation.canDeleteConversation + showDelete = conversation.canDeleteConversation, + showManageTags = hasConversationTags ) } @@ -160,6 +166,12 @@ private fun ConversationOpsManageGroup( stringResource(R.string.nc_rename) ) { onAction(ConversationOpsAction.Rename) } } + if (visibility.showManageTags) { + ConversationOpsMenuItem( + Icons.AutoMirrored.Outlined.Label, + stringResource(R.string.nc_conversation_tags) + ) { onAction(ConversationOpsAction.ManageTags) } + } val archiveIcon = if (visibility.isArchived) R.drawable.ic_unarchive_24px else R.drawable.outline_archive_24 val archiveLabel = if (visibility.isArchived) { stringResource(R.string.unarchive_conversation) @@ -189,6 +201,28 @@ private fun ConversationOpsManageGroup( @Composable private fun ConversationOpsMenuItem(@DrawableRes iconRes: Int, label: String, onClick: () -> Unit) { + ConversationOpsMenuItem(onClick = onClick, label = label) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun ConversationOpsMenuItem(icon: ImageVector, label: String, onClick: () -> Unit) { + ConversationOpsMenuItem(onClick = onClick, label = label) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun ConversationOpsMenuItem(label: String, onClick: () -> Unit, icon: @Composable () -> Unit) { TextButton( onClick = onClick, modifier = Modifier @@ -198,11 +232,7 @@ private fun ConversationOpsMenuItem(@DrawableRes iconRes: Int, label: String, on contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.standard_dialog_padding)), colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface) ) { - Icon( - painter = painterResource(iconRes), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + icon() Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_dialog_padding))) Text( text = label, diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOpsAction.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOpsAction.kt index dff3633ac26..0cff9512468 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOpsAction.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationOpsAction.kt @@ -18,4 +18,5 @@ sealed class ConversationOpsAction { object AddToHomeScreen : ConversationOpsAction() object Leave : ConversationOpsAction() object Delete : ConversationOpsAction() + object ManageTags : ConversationOpsAction() } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt new file mode 100644 index 00000000000..9b11be8b326 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt @@ -0,0 +1,129 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import android.content.res.Configuration +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.talk.R +import com.nextcloud.talk.models.json.tags.ConversationTag +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationTagsRow( + tags: List, + selectedTagId: String?, + onTagSelected: (String?) -> Unit, + onManageTagsClick: () -> Unit, + modifier: Modifier = Modifier +) { + LazyRow( + modifier = modifier, + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.standard_padding)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) + ) { + item { + TagFilterChip( + selected = selectedTagId == null, + label = stringResource(R.string.nc_conversation_tags_filter_all), + onClick = { onTagSelected(null) }, + onLongClick = onManageTagsClick + ) + } + items(tags, key = { it.id }) { tag -> + val isSelected = tag.id == selectedTagId + val isFavorites = tag.type == ConversationTag.TYPE_FAVORITES + TagFilterChip( + selected = isSelected, + label = if (isFavorites) stringResource(R.string.nc_conversation_tags_favorites) else tag.name, + onClick = { onTagSelected(if (isSelected) null else tag.id) }, + onLongClick = onManageTagsClick + ) + } + } +} + +/** Detects long-press via the chip's own interactionSource so its native ripple stays untouched. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TagFilterChip(selected: Boolean, label: String, onClick: () -> Unit, onLongClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + val hapticFeedback = LocalHapticFeedback.current + val longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis + var suppressClick by remember { mutableStateOf(false) } + + LaunchedEffect(interactionSource) { + interactionSource.interactions.collectLatest { interaction -> + if (interaction is PressInteraction.Press) { + suppressClick = false + delay(longPressTimeoutMillis) + suppressClick = true + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick() + } + } + } + + FilterChip( + selected = selected, + onClick = { + if (!suppressClick) onClick() + suppressClick = false + }, + label = { Text(label) }, + interactionSource = interactionSource + ) +} + +private fun previewTags() = + listOf( + ConversationTag(id = "favorites", name = "", type = ConversationTag.TYPE_FAVORITES), + ConversationTag(id = "1", name = "Work", sortOrder = 0), + ConversationTag(id = "2", name = "Family", sortOrder = 1), + ConversationTag(id = "3", name = "Projects", sortOrder = 2) + ) + +@Preview(showBackground = true, name = "Light") +@Preview(showBackground = true, name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ConversationTagsRowPreview() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + ConversationTagsRow( + tags = previewTags(), + selectedTagId = "2", + onTagSelected = {}, + onManageTagsClick = {} + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt index 26535250289..899222af6f9 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -45,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -52,18 +52,22 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nextcloud.talk.R import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel +import com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.models.json.chat.ChatMessageJson import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.models.json.tags.ConversationTag import com.nextcloud.talk.chooseaccount.ChooseAccountDialogCompose import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.DEFAULT import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.DisplayUtils +import com.nextcloud.talk.utils.SpreedFeatures import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -124,6 +128,7 @@ data class ConversationsListScreenCallbacks( @Composable fun ConversationsListScreen( viewModel: ConversationsListViewModel, + tagsViewModel: ConversationTagsViewModel, state: ConversationsListScreenState, callbacks: ConversationsListScreenCallbacks ) { @@ -143,6 +148,13 @@ fun ConversationsListScreen( val showAvatarBadge by viewModel.showAvatarBadge.collectAsStateWithLifecycle() val threadsState by viewModel.threadsExistState.collectAsStateWithLifecycle() val federationHintVisible by viewModel.federationInvitationHintVisible.collectAsStateWithLifecycle() + val conversationTags by tagsViewModel.conversationTagsFlow.collectAsStateWithLifecycle() + val selectedTagFilter by viewModel.selectedTagFilterFlow.collectAsStateWithLifecycle() + val conversationForTagAssignment by tagsViewModel.conversationForTagAssignment.collectAsStateWithLifecycle() + val showManageTagsSheet by tagsViewModel.showManageTagsSheet.collectAsStateWithLifecycle() + val tagActionState by tagsViewModel.tagActionState.collectAsStateWithLifecycle() + val tagAssignmentSheetState = rememberModalBottomSheetState() + val manageTagsSheetState = rememberModalBottomSheetState() // Activity-level state val isMaintenanceMode by state.isMaintenanceModeFlow.collectAsStateWithLifecycle() @@ -169,6 +181,14 @@ fun ConversationsListScreen( val showThreadsButton = threadsState is ConversationsListViewModel.ThreadsExistUiState.Success && (threadsState as ConversationsListViewModel.ThreadsExistUiState.Success).threadsExistence == true + val hasConversationTagsCapability = state.currentUser?.capabilities?.spreedCapability?.let { + CapabilitiesUtil.hasSpreedFeatureCapability(it, SpreedFeatures.CONVERSATION_TAGS) + } == true + val showConversationTagsRow = hasConversationTagsCapability && + conversationTags.isNotEmpty() && + !isSearchActive && + !showShareTo && + !isForward val mode: TopBarMode = when { showShareTo -> TopBarMode.TitleBar( @@ -194,7 +214,8 @@ fun ConversationsListScreen( ) } - val lazyListState = rememberLazyListState() + // Keyed on the tag filter so switching tags starts the list at the top without a visible jump. + val lazyListState = remember(selectedTagFilter) { LazyListState() } DisposableEffect(lazyListState) { callbacks.onLazyListStateAvailable(lazyListState) onDispose { callbacks.onLazyListStateAvailable(null) } @@ -311,7 +332,29 @@ fun ConversationsListScreen( listState = lazyListState, contentBottomPadding = paddingValues.calculateBottomPadding(), onSwipeConversation = callbacks.onConversationOpsAction, - isOnline = isOnline + isOnline = isOnline, + tagsRowContent = if (showConversationTagsRow) { + { + ConversationTagsRow( + tags = conversationTags, + selectedTagId = selectedTagFilter, + onTagSelected = { tagId -> + val isFavorites = conversationTags.any { + it.id == tagId && it.type == ConversationTag.TYPE_FAVORITES + } + viewModel.selectTagFilter(tagId, isFavorites) + }, + onManageTagsClick = { tagsViewModel.setShowManageTagsSheet(true) }, + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = dimensionResource(R.dimen.standard_half_padding) + ) + ) + } + } else { + null + } ) } // Empty-state overlay (centered; handles its own visibility) @@ -372,6 +415,64 @@ fun ConversationsListScreen( ) } } + + val conversationForTags = conversationForTagAssignment + if (conversationForTags != null) { + ModalBottomSheet( + onDismissRequest = { tagsViewModel.setConversationForTagAssignment(null) }, + sheetState = tagAssignmentSheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + AssignConversationTagsSheetContent( + conversation = conversationForTags, + tags = conversationTags.filter { it.type == ConversationTag.TYPE_CUSTOM }, + onToggleTag = { tagId -> + val newTagIds = if (conversationForTags.tagIds.contains(tagId)) { + conversationForTags.tagIds - tagId + } else { + conversationForTags.tagIds + tagId + } + tagsViewModel.assignConversationTags(conversationForTags, newTagIds) + }, + onManageTagsClick = { + tagsViewModel.setConversationForTagAssignment(null) + tagsViewModel.setShowManageTagsSheet(true) + } + ) + } + } + + if (showManageTagsSheet) { + ModalBottomSheet( + onDismissRequest = { tagsViewModel.setShowManageTagsSheet(false) }, + sheetState = manageTagsSheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + ManageConversationTagsSheetContent( + tags = conversationTags, + tagActionState = tagActionState, + callbacks = ManageConversationTagsCallbacks( + onCreateTag = { tagsViewModel.createConversationTag(it) }, + onRenameTag = { tagId, name -> tagsViewModel.renameConversationTag(tagId, name) }, + onDeleteTag = { tagId -> + tagsViewModel.deleteConversationTag(tagId) + viewModel.clearTagFilterIfMatches(tagId) + }, + onMoveTag = { tagId, delta -> + val currentOrder = conversationTags.map { it.id } + val fromIndex = currentOrder.indexOf(tagId) + val toIndex = (fromIndex + delta).coerceIn(0, currentOrder.lastIndex) + if (fromIndex != -1 && fromIndex != toIndex) { + val reordered = currentOrder.toMutableList() + reordered.add(toIndex, reordered.removeAt(fromIndex)) + tagsViewModel.reorderConversationTags(reordered) + } + }, + onResetActionState = { tagsViewModel.resetTagActionState() } + ) + ) + } + } } } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsCallbacks.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsCallbacks.kt new file mode 100644 index 00000000000..8ecd9b1cae1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsCallbacks.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +data class ManageConversationTagsCallbacks( + val onCreateTag: (String) -> Unit, + val onRenameTag: (String, String) -> Unit, + val onDeleteTag: (String) -> Unit, + val onMoveTag: (String, Int) -> Unit, + val onResetActionState: () -> Unit +) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsSheet.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsSheet.kt new file mode 100644 index 00000000000..626eebf6e70 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsSheet.kt @@ -0,0 +1,259 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import android.content.res.Configuration +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowUp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.talk.R +import com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel.TagActionUiState +import com.nextcloud.talk.models.json.tags.ConversationTag + +@Suppress("LongMethod") +@Composable +fun ManageConversationTagsSheetContent( + tags: List, + tagActionState: TagActionUiState, + callbacks: ManageConversationTagsCallbacks +) { + val (onCreateTag, onRenameTag, onDeleteTag, onMoveTag, onResetActionState) = callbacks + var newTagName by remember { mutableStateOf("") } + var editingTagId by remember { mutableStateOf(null) } + var editingName by remember { mutableStateOf("") } + var tagPendingDelete by remember { mutableStateOf(null) } + + LaunchedEffect(tagActionState) { + if (tagActionState is TagActionUiState.Success) { + editingTagId = null + newTagName = "" + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(dimensionResource(R.dimen.standard_dialog_padding)) + ) { + Text( + text = stringResource(R.string.nc_conversation_tags_manage), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + if (tagActionState is TagActionUiState.Error) { + Text( + text = tagErrorMessage(tagActionState.errorType), + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.standard_half_padding)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + + val customTags = tags.filter { it.type == ConversationTag.TYPE_CUSTOM } + + if (customTags.isEmpty()) { + Text( + text = stringResource(R.string.nc_conversation_tags_empty), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = dimensionResource(R.dimen.standard_half_padding)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + tags.forEachIndexed { index, tag -> + val isFavorites = tag.type == ConversationTag.TYPE_FAVORITES + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = dimensionResource(R.dimen.standard_half_padding)), + verticalAlignment = Alignment.CenterVertically + ) { + if (editingTagId == tag.id) { + OutlinedTextField( + value = editingName, + onValueChange = { + editingName = it + onResetActionState() + }, + modifier = Modifier.weight(1f), + singleLine = true + ) + TextButton(onClick = { onRenameTag(tag.id, editingName) }) { + Text(stringResource(R.string.nc_conversation_tags_save)) + } + } else { + Text( + text = if (isFavorites) stringResource(R.string.nc_conversation_tags_favorites) else tag.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + IconButton(onClick = { onMoveTag(tag.id, -1) }, enabled = index > 0) { + Icon(Icons.Outlined.KeyboardArrowUp, contentDescription = null) + } + IconButton(onClick = { onMoveTag(tag.id, 1) }, enabled = index < tags.lastIndex) { + Icon(Icons.Outlined.KeyboardArrowDown, contentDescription = null) + } + if (isFavorites) { + // Reserve the edit/delete buttons' width so arrows still line up. + IconButton(onClick = {}, enabled = false) {} + IconButton(onClick = {}, enabled = false) {} + } else { + IconButton( + onClick = { + editingTagId = tag.id + editingName = tag.name + onResetActionState() + } + ) { + Icon( + Icons.Outlined.Edit, + contentDescription = stringResource(R.string.nc_conversation_tags_rename) + ) + } + IconButton(onClick = { tagPendingDelete = tag }) { + Icon( + Icons.Outlined.Delete, + contentDescription = stringResource(R.string.nc_conversation_tags_delete) + ) + } + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.standard_half_padding)), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = newTagName, + onValueChange = { + newTagName = it + onResetActionState() + }, + modifier = Modifier.weight(1f), + singleLine = true, + label = { Text(stringResource(R.string.nc_conversation_tags_name_hint)) }, + placeholder = { Text(stringResource(R.string.nc_conversation_tags_new)) } + ) + IconButton( + onClick = { + if (newTagName.isNotBlank()) { + onCreateTag(newTagName) + } + } + ) { + Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.nc_conversation_tags_create)) + } + } + } + + val tagToDelete = tagPendingDelete + if (tagToDelete != null) { + AlertDialog( + onDismissRequest = { tagPendingDelete = null }, + title = { Text(stringResource(R.string.nc_conversation_tags_delete_confirm_title)) }, + text = { Text(stringResource(R.string.nc_conversation_tags_delete_confirm_message)) }, + confirmButton = { + TextButton( + onClick = { + onDeleteTag(tagToDelete.id) + tagPendingDelete = null + } + ) { + Text(stringResource(R.string.nc_conversation_tags_delete)) + } + }, + dismissButton = { + TextButton(onClick = { tagPendingDelete = null }) { + Text(stringResource(R.string.nc_cancel)) + } + } + ) + } +} + +@Composable +private fun tagErrorMessage(errorType: String?): String = + when (errorType) { + "name" -> stringResource(R.string.nc_conversation_tags_error_name) + "limit" -> stringResource(R.string.nc_conversation_tags_error_limit) + else -> stringResource(R.string.nc_conversation_tags_error_generic) + } + +private fun previewTags() = + listOf( + ConversationTag(id = "favorites", name = "", type = ConversationTag.TYPE_FAVORITES), + ConversationTag(id = "1", name = "Work", sortOrder = 0), + ConversationTag(id = "2", name = "Family", sortOrder = 1), + ConversationTag(id = "3", name = "Projects", sortOrder = 2) + ) + +@Preview(showBackground = true, name = "Light") +@Preview(showBackground = true, name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ManageConversationTagsSheetPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) { + ManageConversationTagsSheetContent( + tags = previewTags(), + tagActionState = TagActionUiState.None, + callbacks = ManageConversationTagsCallbacks( + onCreateTag = {}, + onRenameTag = { _, _ -> }, + onDeleteTag = {}, + onMoveTag = { _, _ -> }, + onResetActionState = {} + ) + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index fd17ecc5c44..f926ce667be 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -226,9 +226,23 @@ class ConversationsListViewModel @Inject constructor( RESULTS } + private data class TagFilterSelection(val tagId: String? = null, val isFavorites: Boolean = false) + + private val _selectedTagFilterFlow = MutableStateFlow(null) + val selectedTagFilterFlow: StateFlow = _selectedTagFilterFlow.asStateFlow() + + // Favorites has a real, server-assigned id, so the caller tells us if it's the one selected. + private val selectedTagIsFavoritesFlow = MutableStateFlow(false) + + /** Select a tag to filter the conversation list, or null to clear the filter. */ + fun selectTagFilter(tagId: String?, isFavorites: Boolean = false) { + _selectedTagFilterFlow.value = tagId + selectedTagIsFavoritesFlow.value = tagId != null && isFavorites + } + /** * Single source of truth for the [ConversationList] LazyColumn. - * Auto-reacts to rooms, filter, search-active and search-result changes. + * Auto-reacts to rooms, filter, tag filter, search-active and search-result changes. */ val conversationListEntriesFlow: StateFlow> = combine( getRoomsStateFlow, @@ -240,12 +254,20 @@ class ConversationsListViewModel @Inject constructor( else -> SearchDisplayMode.RESULTS } }, - searchResultEntries, - hideRoomToken - ) { rooms, filterState, searchMode, searchResults, hideToken -> - buildConversationListEntries(rooms, filterState, searchMode, searchResults, hideToken) + combine(_selectedTagFilterFlow, selectedTagIsFavoritesFlow, ::TagFilterSelection), + combine(searchResultEntries, hideRoomToken, ::Pair) + ) { rooms, filterState, searchMode, tagFilter, (searchResults, hideToken) -> + buildConversationListEntries(rooms, filterState, searchMode, tagFilter, searchResults, hideToken) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + /** Clears the tag filter when the filtered-by tag no longer exists (e.g. it was deleted). */ + fun clearTagFilterIfMatches(tagId: String) { + if (_selectedTagFilterFlow.value == tagId) { + _selectedTagFilterFlow.value = null + selectedTagIsFavoritesFlow.value = false + } + } + /** Update filter state; triggers [conversationListEntriesFlow] re-emit. */ fun applyFilter(newFilterState: Map) { _filterStateFlow.value = newFilterState @@ -592,10 +614,12 @@ class ConversationsListViewModel @Inject constructor( } } + @Suppress("LongParameterList") private fun buildConversationListEntries( rooms: List, filterState: Map, searchMode: SearchDisplayMode, + tagFilter: TagFilterSelection, searchResults: List, hideToken: String? ): List { @@ -616,12 +640,20 @@ class ConversationsListViewModel @Inject constructor( filtered = when { // While search is open with an empty query, all conversations are listed, - // ignoring active filters and the default hiding of archived/future-event rooms + // ignoring active filters, the tag filter and the default hiding of archived/future-event rooms searchMode == SearchDisplayMode.ALL_CONVERSATIONS -> filtered hasFilterEnabled -> filtered.filter { filterConversationModel(it, filterState) } else -> filtered.filter { !isFutureEvent(it) && !it.hasArchived } } + if (searchMode != SearchDisplayMode.ALL_CONVERSATIONS) { + filtered = when { + tagFilter.isFavorites -> filtered.filter { it.favorite } + tagFilter.tagId != null -> filtered.filter { it.tagIds.contains(tagFilter.tagId) } + else -> filtered + } + } + val sorted = filtered.sortedWith( compareByDescending { it.favorite } .thenByDescending { it.lastActivity } diff --git a/app/src/main/java/com/nextcloud/talk/conversationtags/data/ConversationTagsRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationtags/data/ConversationTagsRepository.kt new file mode 100644 index 00000000000..f11cbe27055 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationtags/data/ConversationTagsRepository.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationtags.data + +import com.nextcloud.talk.models.json.conversations.RoomOverall +import com.nextcloud.talk.models.json.tags.ConversationTagOverall +import com.nextcloud.talk.models.json.tags.ConversationTagsOverall + +interface ConversationTagsRepository { + suspend fun getTags(credentials: String, baseUrl: String): ConversationTagsOverall + + suspend fun createTag(credentials: String, baseUrl: String, name: String): ConversationTagOverall + + suspend fun updateTag(credentials: String, baseUrl: String, tagId: String, name: String): ConversationTagOverall + + suspend fun deleteTag(credentials: String, baseUrl: String, tagId: String) + + suspend fun reorderTags(credentials: String, baseUrl: String, orderedIds: List): ConversationTagsOverall + + suspend fun assignTags(credentials: String, baseUrl: String, roomToken: String, tagIds: List): RoomOverall +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationtags/data/ConversationTagsRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/conversationtags/data/ConversationTagsRepositoryImpl.kt new file mode 100644 index 00000000000..554f196aa6a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationtags/data/ConversationTagsRepositoryImpl.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationtags.data + +import com.nextcloud.talk.api.NcApiCoroutines +import com.nextcloud.talk.models.json.conversations.RoomOverall +import com.nextcloud.talk.models.json.tags.AssignConversationTagsRequest +import com.nextcloud.talk.models.json.tags.ConversationTagOverall +import com.nextcloud.talk.models.json.tags.ConversationTagsOverall +import com.nextcloud.talk.models.json.tags.CreateConversationTagRequest +import com.nextcloud.talk.models.json.tags.ReorderConversationTagsRequest +import com.nextcloud.talk.models.json.tags.UpdateConversationTagRequest +import com.nextcloud.talk.utils.ApiUtils +import javax.inject.Inject + +class ConversationTagsRepositoryImpl @Inject constructor(private val ncApiCoroutines: NcApiCoroutines) : + ConversationTagsRepository { + + override suspend fun getTags(credentials: String, baseUrl: String): ConversationTagsOverall = + ncApiCoroutines.getConversationTags(credentials, ApiUtils.getUrlForConversationTags(baseUrl)) + + override suspend fun createTag(credentials: String, baseUrl: String, name: String): ConversationTagOverall { + val request = CreateConversationTagRequest().apply { this.name = name } + return ncApiCoroutines.createConversationTag(credentials, ApiUtils.getUrlForConversationTags(baseUrl), request) + } + + override suspend fun updateTag( + credentials: String, + baseUrl: String, + tagId: String, + name: String + ): ConversationTagOverall { + val request = UpdateConversationTagRequest().apply { this.name = name } + return ncApiCoroutines.updateConversationTag( + credentials, + ApiUtils.getUrlForConversationTag(baseUrl, tagId), + request + ) + } + + override suspend fun deleteTag(credentials: String, baseUrl: String, tagId: String) { + ncApiCoroutines.deleteConversationTag(credentials, ApiUtils.getUrlForConversationTag(baseUrl, tagId)) + } + + override suspend fun reorderTags( + credentials: String, + baseUrl: String, + orderedIds: List + ): ConversationTagsOverall { + val request = ReorderConversationTagsRequest().apply { this.orderedIds = orderedIds } + return ncApiCoroutines.reorderConversationTags( + credentials, + ApiUtils.getUrlForConversationTagsReorder(baseUrl), + request + ) + } + + override suspend fun assignTags( + credentials: String, + baseUrl: String, + roomToken: String, + tagIds: List + ): RoomOverall { + val request = AssignConversationTagsRequest().apply { this.tagIds = tagIds } + return ncApiCoroutines.assignConversationTags( + credentials, + ApiUtils.getUrlForRoomTags(baseUrl, roomToken), + request + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt new file mode 100644 index 00000000000..4d01050ee9c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt @@ -0,0 +1,214 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationtags.viewmodels + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.bluelinelabs.logansquare.LoganSquare +import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository +import com.nextcloud.talk.conversationtags.data.ConversationTagsRepository +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.tags.ConversationTag +import com.nextcloud.talk.models.json.tags.ConversationTagErrorOverall +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability +import com.nextcloud.talk.utils.SpreedFeatures +import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import retrofit2.HttpException +import javax.inject.Inject + +@Suppress("TooManyFunctions") +class ConversationTagsViewModel @Inject constructor( + private val conversationTagsRepository: ConversationTagsRepository, + private val repository: OfflineConversationsRepository, + private val currentUserProvider: CurrentUserProviderOld +) : ViewModel() { + + private val currentUser: User = currentUserProvider.currentUser.blockingGet() + private val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) ?: "" + + private val _conversationTagsFlow = MutableStateFlow>(emptyList()) + val conversationTagsFlow: StateFlow> = _conversationTagsFlow.asStateFlow() + + sealed class TagActionUiState { + data object None : TagActionUiState() + data object Success : TagActionUiState() + data class Error(val errorType: String?) : TagActionUiState() + } + + private val _tagActionState = MutableStateFlow(TagActionUiState.None) + val tagActionState: StateFlow = _tagActionState.asStateFlow() + + fun resetTagActionState() { + _tagActionState.value = TagActionUiState.None + } + + private val _conversationForTagAssignment = MutableStateFlow(null) + val conversationForTagAssignment: StateFlow = _conversationForTagAssignment.asStateFlow() + + fun setConversationForTagAssignment(model: ConversationModel?) { + _conversationForTagAssignment.value = model + } + + private val _showManageTagsSheet = MutableStateFlow(false) + val showManageTagsSheet: StateFlow = _showManageTagsSheet.asStateFlow() + + fun setShowManageTagsSheet(show: Boolean) { + _showManageTagsSheet.value = show + } + + /** Fetch conversation tags for the current user, gated behind the `conversation-tags` capability. */ + @Suppress("Detekt.TooGenericExceptionCaught") + fun loadConversationTags() { + if (!hasSpreedFeatureCapability(currentUser.capabilities?.spreedCapability, SpreedFeatures.CONVERSATION_TAGS)) { + return + } + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + conversationTagsRepository.getTags(credentials, currentUser.baseUrl!!) + } + _conversationTagsFlow.value = response.ocs?.data.toDisplayTags() + } catch (e: Exception) { + Log.e(TAG, "Failed to load conversation tags", e) + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun createConversationTag(name: String) { + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + conversationTagsRepository.createTag(credentials, currentUser.baseUrl!!, name) + } + response.ocs?.data?.let { tag -> + _conversationTagsFlow.value = (_conversationTagsFlow.value + tag).toDisplayTags() + } + _tagActionState.value = TagActionUiState.Success + } catch (e: Exception) { + _tagActionState.value = TagActionUiState.Error(extractTagErrorType(e)) + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun renameConversationTag(tagId: String, name: String) { + if (!isCustomTag(tagId)) return + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + conversationTagsRepository.updateTag(credentials, currentUser.baseUrl!!, tagId, name) + } + response.ocs?.data?.let { updated -> + _conversationTagsFlow.value = + _conversationTagsFlow.value.map { if (it.id == updated.id) updated else it } + } + _tagActionState.value = TagActionUiState.Success + } catch (e: Exception) { + _tagActionState.value = TagActionUiState.Error(extractTagErrorType(e)) + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun deleteConversationTag(tagId: String) { + if (!isCustomTag(tagId)) return + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + conversationTagsRepository.deleteTag(credentials, currentUser.baseUrl!!, tagId) + } + _conversationTagsFlow.value = _conversationTagsFlow.value.filter { it.id != tagId } + _tagActionState.value = TagActionUiState.Success + } catch (e: Exception) { + _tagActionState.value = TagActionUiState.Error(extractTagErrorType(e)) + } + } + } + + /** [orderedIds] includes the built-in Favorites tag's real id — the server tracks its sortOrder too. */ + @Suppress("Detekt.TooGenericExceptionCaught") + fun reorderConversationTags(orderedIds: List) { + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + conversationTagsRepository.reorderTags(credentials, currentUser.baseUrl!!, orderedIds) + } + response.ocs?.data?.let { tags -> + _conversationTagsFlow.value = tags.toDisplayTags() + } + _tagActionState.value = TagActionUiState.Success + } catch (e: Exception) { + _tagActionState.value = TagActionUiState.Error(extractTagErrorType(e)) + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun assignConversationTags(conversation: ConversationModel, tagIds: List) { + val original = conversation.copy() + val optimistic = conversation.copy(tagIds = tagIds) + replaceConversationForTagAssignment(conversation.token, optimistic) + viewModelScope.launch { + withContext(Dispatchers.IO) { + repository.updateConversationLocallyAndEmit(currentUser, optimistic) + } + try { + withContext(Dispatchers.IO) { + conversationTagsRepository.assignTags( + credentials, + currentUser.baseUrl!!, + conversation.token, + tagIds + ) + } + } catch (e: Exception) { + replaceConversationForTagAssignment(conversation.token, original) + withContext(Dispatchers.IO) { + repository.updateConversationLocallyAndEmit(currentUser, original) + } + Log.e(TAG, "Failed to assign conversation tags", e) + } + } + } + + /** [conversationForTagAssignment] is a snapshot, not fed from the live room list — keep it in sync. */ + private fun replaceConversationForTagAssignment(token: String, replacement: ConversationModel) { + if (_conversationForTagAssignment.value?.token == token) { + _conversationForTagAssignment.value = replacement + } + } + + private fun extractTagErrorType(exception: Exception): String? { + val httpException = exception as? HttpException ?: return null + return runCatching { + val errorBody = httpException.response()?.errorBody()?.string() + errorBody?.let { LoganSquare.parse(it, ConversationTagErrorOverall::class.java).ocs?.data?.error } + }.getOrNull() + } + + private fun isCustomTag(tagId: String): Boolean = + _conversationTagsFlow.value.firstOrNull { it.id == tagId }?.type == ConversationTag.TYPE_CUSTOM + + /** Drops the built-in "Other" tag (not surfaced in this UI) and sorts the rest by sortOrder. */ + private fun List?.toDisplayTags(): List = + this?.filter { it.type != ConversationTag.TYPE_OTHER }?.sortedBy { it.sortOrder } ?: emptyList() + + companion object { + private val TAG = ConversationTagsViewModel::class.simpleName + } +} diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt index 2b0a6919b40..e1c91d7f403 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt @@ -32,6 +32,8 @@ import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository import com.nextcloud.talk.conversationlist.data.network.ConversationsNetworkDataSource import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository import com.nextcloud.talk.conversationlist.data.network.RetrofitConversationsNetwork +import com.nextcloud.talk.conversationtags.data.ConversationTagsRepository +import com.nextcloud.talk.conversationtags.data.ConversationTagsRepositoryImpl import com.nextcloud.talk.data.database.dao.ChatBlocksDao import com.nextcloud.talk.data.database.dao.ChatMessagesDao import com.nextcloud.talk.data.database.dao.ConversationsDao @@ -71,6 +73,7 @@ import com.nextcloud.talk.utils.preferences.AppPreferences import dagger.Module import dagger.Provides import okhttp3.OkHttpClient +import javax.inject.Singleton @Suppress("TooManyFunctions") @Module @@ -153,6 +156,7 @@ class RepositoryModule { ) @Provides + @Singleton fun provideOfflineFirstConversationsRepository( dao: ConversationsDao, dataSource: ConversationsNetworkDataSource, @@ -178,6 +182,10 @@ class RepositoryModule { fun provideThreadsRepository(ncApiCoroutines: NcApiCoroutines): ThreadsRepository = ThreadsRepositoryImpl(ncApiCoroutines) + @Provides + fun provideConversationTagsRepository(ncApiCoroutines: NcApiCoroutines): ConversationTagsRepository = + ConversationTagsRepositoryImpl(ncApiCoroutines) + @Provides fun provideNetworkDataSource(okHttpClient: OkHttpClient): NetworkLoginDataSource = NetworkLoginDataSource(okHttpClient) diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index c311a9e4cd9..5f2cb32ebdc 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -20,6 +20,7 @@ import com.nextcloud.talk.conversationcreation.viewmodel.ConversationCreationVie import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditViewModel import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel +import com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel import com.nextcloud.talk.diagnosis.DiagnosisViewModel import com.nextcloud.talk.logger.ui.LogsViewModel import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel @@ -136,6 +137,11 @@ abstract class ViewModelModule { @ViewModelKey(ConversationsListViewModel::class) abstract fun conversationsListViewModel(viewModel: ConversationsListViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(ConversationTagsViewModel::class) + abstract fun conversationTagsViewModel(viewModel: ConversationTagsViewModel): ViewModel + // @Binds // @IntoMap // @ViewModelKey(ChatViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt index c4b622bd0cc..dffff5fdff8 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/mappers/ConversationMapUtils.kt @@ -64,6 +64,7 @@ fun ConversationModel.asEntity() = hasArchived = hasArchived, hasSensitive = hasSensitive, hasImportant = hasImportant, + tagIds = ArrayList(tagIds), messageDraft = messageDraft, hiddenPinnedId = hiddenPinnedId, lastPinnedId = lastPinnedId, @@ -123,6 +124,7 @@ fun ConversationEntity.toDomainModel() = hasArchived = hasArchived, hasSensitive = hasSensitive, hasImportant = hasImportant, + tagIds = tagIds ?: emptyList(), messageDraft = messageDraft, hiddenPinnedId = hiddenPinnedId, lastPinnedId = lastPinnedId, @@ -181,6 +183,7 @@ fun Conversation.asEntity(accountId: Long) = hasArchived = hasArchived, hasSensitive = hasSensitive, hasImportant = hasImportant, + tagIds = tagIds, hiddenPinnedId = hiddenPinnedId, lastPinnedId = lastPinnedId, attributes = attributes diff --git a/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt b/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt index 615975ec130..ef5a9ad5f8f 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/model/ConversationEntity.kt @@ -98,6 +98,7 @@ data class ConversationEntity( @ColumnInfo(name = "hasArchived") var hasArchived: Boolean = false, @ColumnInfo(name = "hasSensitive") var hasSensitive: Boolean = false, @ColumnInfo(name = "hasImportant") var hasImportant: Boolean = false, + @ColumnInfo(name = "tagIds") var tagIds: ArrayList? = null, @ColumnInfo(name = "hiddenPinnedId") var hiddenPinnedId: Long? = null, @ColumnInfo(name = "lastPinnedId") var lastPinnedId: Long? = null, @ColumnInfo(name = "attributes") var attributes: Int? = null, diff --git a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt index bb61d0b38f4..5190daa89bb 100644 --- a/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt +++ b/app/src/main/java/com/nextcloud/talk/data/source/local/TalkDatabase.kt @@ -48,7 +48,7 @@ import java.util.Locale ChatMessageEntity::class, ChatBlockEntity::class ], - version = 25, + version = 26, autoMigrations = [ AutoMigration(from = 9, to = 10), AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class), @@ -57,7 +57,8 @@ import java.util.Locale AutoMigration(from = 21, to = 22), AutoMigration(from = 22, to = 23), AutoMigration(from = 23, to = 24), - AutoMigration(from = 24, to = 25) + AutoMigration(from = 24, to = 25), + AutoMigration(from = 25, to = 26) ], exportSchema = true ) 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 6b1092aee8a..e0ae62452fc 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 @@ -64,6 +64,7 @@ data class ConversationModel( var hasArchived: Boolean = false, var hasSensitive: Boolean = false, var hasImportant: Boolean = false, + var tagIds: List = emptyList(), var lastPinnedId: Long? = null, var hiddenPinnedId: Long? = null, var attributes: Int? = null, @@ -141,6 +142,7 @@ data class ConversationModel( hasArchived = conversation.hasArchived, hasSensitive = conversation.hasSensitive, hasImportant = conversation.hasImportant, + tagIds = conversation.tagIds ?: emptyList(), lastPinnedId = conversation.lastPinnedId, hiddenPinnedId = conversation.hiddenPinnedId, attributes = conversation.attributes diff --git a/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt b/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt index 019fc7ce8f4..07c3270af0e 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/conversations/Conversation.kt @@ -173,6 +173,9 @@ data class Conversation( @JsonField(name = ["isImportant"]) var hasImportant: Boolean = false, + @JsonField(name = ["tagIds"]) + var tagIds: ArrayList? = null, + @JsonField(name = ["lastPinnedId"]) var lastPinnedId: Long? = null, diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/AssignConversationTagsRequest.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/AssignConversationTagsRequest.kt new file mode 100644 index 00000000000..34d00cb8d5c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/AssignConversationTagsRequest.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.tags + +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject + +@JsonObject +class AssignConversationTagsRequest { + + @JsonField(name = ["tagIds"]) + var tagIds: List? = null +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTag.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTag.kt new file mode 100644 index 00000000000..1737d943c05 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTag.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.tags + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class ConversationTag( + @JsonField(name = ["id"]) + var id: String = "", + + @JsonField(name = ["name"]) + var name: String = "", + + @JsonField(name = ["sortOrder"]) + var sortOrder: Int = 0, + + @JsonField(name = ["collapsed"]) + var collapsed: Boolean = false, + + @JsonField(name = ["type"]) + var type: String = TYPE_CUSTOM +) : Parcelable { + companion object { + const val TYPE_CUSTOM = "custom" + const val TYPE_FAVORITES = "favorites" + const val TYPE_OTHER = "other" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorOCS.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorOCS.kt new file mode 100644 index 00000000000..1071139c95c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorOCS.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.tags + +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import com.nextcloud.talk.models.json.generic.GenericMeta + +@JsonObject +data class ConversationTagErrorOCS( + @JsonField(name = ["meta"]) + var meta: GenericMeta?, + @JsonField(name = ["data"]) + var data: ConversationTagErrorResponse? = null +) { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorOverall.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorOverall.kt new file mode 100644 index 00000000000..1a6fd6d9a0c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorOverall.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.tags + +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject + +@JsonObject +data class ConversationTagErrorOverall( + @JsonField(name = ["ocs"]) + var ocs: ConversationTagErrorOCS? = null +) { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorResponse.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorResponse.kt new file mode 100644 index 00000000000..e0d4c188037 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorResponse.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.tags + +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject + +@JsonObject +class ConversationTagErrorResponse { + + @JsonField(name = ["error"]) + var error: String? = null +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagOCS.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagOCS.kt new file mode 100644 index 00000000000..39f0513f4f1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagOCS.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.tags + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import com.nextcloud.talk.models.json.generic.GenericMeta +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class ConversationTagOCS( + @JsonField(name = ["meta"]) + var meta: GenericMeta?, + @JsonField(name = ["data"]) + var data: ConversationTag? = null +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagOverall.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagOverall.kt new file mode 100644 index 00000000000..091682fb21b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagOverall.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.tags + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class ConversationTagOverall( + @JsonField(name = ["ocs"]) + var ocs: ConversationTagOCS? = null +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagsOCS.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagsOCS.kt new file mode 100644 index 00000000000..4f6bc25347e --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagsOCS.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.tags + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import com.nextcloud.talk.models.json.generic.GenericMeta +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class ConversationTagsOCS( + @JsonField(name = ["meta"]) + var meta: GenericMeta?, + @JsonField(name = ["data"]) + var data: List? = null +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagsOverall.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagsOverall.kt new file mode 100644 index 00000000000..b0a60733984 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagsOverall.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.tags + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class ConversationTagsOverall( + @JsonField(name = ["ocs"]) + var ocs: ConversationTagsOCS? = null +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/CreateConversationTagRequest.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/CreateConversationTagRequest.kt new file mode 100644 index 00000000000..ef94ab4f8ff --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/CreateConversationTagRequest.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.tags + +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject + +@JsonObject +class CreateConversationTagRequest { + + @JsonField(name = ["name"]) + var name: String? = null +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/ReorderConversationTagsRequest.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/ReorderConversationTagsRequest.kt new file mode 100644 index 00000000000..4ba9a742c32 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/ReorderConversationTagsRequest.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.tags + +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject + +@JsonObject +class ReorderConversationTagsRequest { + + @JsonField(name = ["orderedIds"]) + var orderedIds: List? = null +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/tags/UpdateConversationTagRequest.kt b/app/src/main/java/com/nextcloud/talk/models/json/tags/UpdateConversationTagRequest.kt new file mode 100644 index 00000000000..dce1664b2bd --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/tags/UpdateConversationTagRequest.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.models.json.tags + +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject + +@JsonObject +class UpdateConversationTagRequest { + + @JsonField(name = ["name"]) + var name: String? = null +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt index dfec53ec1c6..25cec08b176 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt @@ -205,6 +205,16 @@ object ApiUtils { fun getUrlForImportantConversation(baseUrl: String, roomToken: String): String = "$baseUrl$OCS_API_VERSION/apps/spreed/api/v4/room/$roomToken/important" + fun getUrlForConversationTags(baseUrl: String): String = "$baseUrl$OCS_API_VERSION/apps/spreed/api/v4/tags" + + fun getUrlForConversationTag(baseUrl: String, tagId: String): String = + "${getUrlForConversationTags(baseUrl)}/$tagId" + + fun getUrlForConversationTagsReorder(baseUrl: String): String = "${getUrlForConversationTags(baseUrl)}/reorder" + + fun getUrlForRoomTags(baseUrl: String, roomToken: String): String = + "$baseUrl$OCS_API_VERSION/apps/spreed/api/v4/room/$roomToken/tags" + @JvmStatic fun getUrlForParticipantsSelf(version: Int, baseUrl: String?, token: String?): String = getUrlForParticipants(version, baseUrl, token) + "/self" 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 e1725446804..c0400638d06 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"), + CONVERSATION_TAGS("conversation-tags") } @Suppress("TooManyFunctions") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21af10e0da6..afdfc6b8406 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -971,6 +971,22 @@ How to translate with transifex: Archived Once a conversation is archived, it will be hidden by default. Select the filter \"Archived\" to view archived conversations. Direct mentions will still be received. Once a conversation is unarchived, it will be shown by default again. + All + Tags + Manage tags + New tag + Tag name + Create + Save + Rename tag + Delete tag + Delete tag? + This tag will be removed from all conversations it is assigned to. + No tags yet + This tag name is invalid or already in use + You have reached the maximum number of tags + Something went wrong, please try again + Favorites No offline messages saved No messages yet. Be the first to say hello! Previously set