From ea1ea648861f656cf3150f5175b8f83d4bd5ae6a Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 7 Jul 2026 22:27:15 +0200 Subject: [PATCH 1/6] feat(conversationlist): add conversation tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the conversation-tags feature (server: nextcloud/spreed#17497) - gated behind the `conversation-tags` capability - Data layer: tagIds threaded through Conversation/ConversationModel/ ConversationEntity, Room migration v25→v26, new ConversationTag JSON models, Retrofit endpoints, and ConversationTagsRepository. - ConversationTagsViewModel owns tag CRUD/fetch/sheet state; tag filtering stays in ConversationsListViewModel. - UI: a horizontal FilterChip row above the conversation list, a "Tags" entry in the conversation long-press menu, an assign-tags bottom sheet, and a manage-tags bottom sheet (create/rename/delete/reorder). Only custom tags are assignable/editable, matching the web client's handling of the built-in favorites/other grouping tags. Assisted-by: Claude Code 2.1.205:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../26.json | 816 ++++++++++++++++++ .../com/nextcloud/talk/api/NcApiCoroutines.kt | 43 + .../ConversationsListActivity.kt | 9 + .../ui/AssignConversationTagsSheet.kt | 199 +++++ .../conversationlist/ui/ConversationList.kt | 329 +++++-- .../ui/ConversationOperationsSheet.kt | 44 +- .../ui/ConversationOpsAction.kt | 1 + .../ui/ConversationTagsRow.kt | 79 ++ .../ui/ConversationsListScreen.kt | 103 ++- .../ui/ManageConversationTagsCallbacks.kt | 16 + .../ui/ManageConversationTagsSheet.kt | 249 ++++++ .../viewmodels/ConversationsListViewModel.kt | 27 +- .../data/ConversationTagsRepository.kt | 26 + .../data/ConversationTagsRepositoryImpl.kt | 76 ++ .../viewmodels/ConversationTagsViewModel.kt | 210 +++++ .../talk/dagger/modules/RepositoryModule.kt | 8 + .../talk/dagger/modules/ViewModelModule.kt | 6 + .../database/mappers/ConversationMapUtils.kt | 3 + .../data/database/model/ConversationEntity.kt | 1 + .../talk/data/source/local/TalkDatabase.kt | 5 +- .../talk/models/domain/ConversationModel.kt | 2 + .../models/json/conversations/Conversation.kt | 3 + .../tags/AssignConversationTagsRequest.kt | 18 + .../talk/models/json/tags/ConversationTag.kt | 37 + .../json/tags/ConversationTagErrorOCS.kt | 22 + .../json/tags/ConversationTagErrorOverall.kt | 19 + .../json/tags/ConversationTagErrorResponse.kt | 18 + .../models/json/tags/ConversationTagOCS.kt | 25 + .../json/tags/ConversationTagOverall.kt | 22 + .../models/json/tags/ConversationTagsOCS.kt | 25 + .../json/tags/ConversationTagsOverall.kt | 22 + .../json/tags/CreateConversationTagRequest.kt | 18 + .../tags/ReorderConversationTagsRequest.kt | 18 + .../json/tags/UpdateConversationTagRequest.kt | 18 + .../java/com/nextcloud/talk/utils/ApiUtils.kt | 10 + .../nextcloud/talk/utils/CapabilitiesUtil.kt | 3 +- app/src/main/res/values/strings.xml | 15 + 37 files changed, 2471 insertions(+), 74 deletions(-) create mode 100644 app/schemas/com.nextcloud.talk.data.source.local.TalkDatabase/26.json create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/AssignConversationTagsSheet.kt create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsCallbacks.kt create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsSheet.kt create mode 100644 app/src/main/java/com/nextcloud/talk/conversationtags/data/ConversationTagsRepository.kt create mode 100644 app/src/main/java/com/nextcloud/talk/conversationtags/data/ConversationTagsRepositoryImpl.kt create mode 100644 app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/AssignConversationTagsRequest.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTag.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorOCS.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorOverall.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagErrorResponse.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagOCS.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagOverall.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagsOCS.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/ConversationTagsOverall.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/CreateConversationTagRequest.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/ReorderConversationTagsRequest.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/tags/UpdateConversationTagRequest.kt 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 0000000000..6be9b50937 --- /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 56ab616d7d..3a4b377a7e 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 8ee2b55f92..d330d84a0c 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 0000000000..3c1bd8418a --- /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 2172269312..f90f7d7c8b 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,25 @@ 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.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 +82,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,6 +101,150 @@ import kotlin.math.roundToInt private const val MSG_KEY_EXCERPT_LENGTH = 20 +/** Fraction of a raw drag delta that actually moves the tags row into view (the "resistance"). */ +private const val TAGS_REVEAL_PULL_RESISTANCE = 0.5f + +/** Fraction of the tags row that must be revealed when the gesture ends for it to snap fully open. */ +private const val TAGS_REVEAL_SNAP_THRESHOLD_FRACTION = 0.35f + +/** + * Lays out [content] at its own natural (unconstrained) height, but reports a layout height of + * only [revealPx] to its parent, bottom-clipping the rest. Reads [revealPx] during the layout + * phase (not composition), so animating it every frame during a drag never triggers recomposition + * of [content] itself — only a cheap remeasure/relayout, which is what keeps the gesture smooth. + */ +@Composable +private fun PullRevealHeader( + revealPx: () -> Float, + onNaturalHeightPxChanged: (Float) -> Unit, + content: @Composable () -> Unit +) { + Layout(content = 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) + } + } +} + +/** + * Rubber-band pull-to-reveal, Telegram/Signal style: normally [revealPx] is 0 (tags row fully + * hidden, zero layout height). Pulling down while the conversation list is scrolled to its own + * absolute top grows [revealPx] at [TAGS_REVEAL_PULL_RESISTANCE] of the raw drag distance; pulling + * up while any of it is showing collapses it 1:1 first, before the list itself scrolls. Releasing + * mid-gesture snaps to fully open or fully closed with a spring, depending on how far it was + * pulled. + * + * Deliberately wraps everything *outside* [PullToRefreshBox] (not attached to the LazyColumn + * inside it): nested-scroll pre-scroll dispatch runs outermost-connection-first, so this must be + * the outermost connection to get first claim on "pulling down while at the top" — once the + * tags row is fully revealed it stops consuming that gesture, letting the remainder flow through + * to [PullToRefreshBox]'s own overscroll detection underneath, unmodified. + * + * The interactive value itself ([revealPx]) is plain, synchronously-mutated state — not an + * [Animatable] driven via `snapTo` from a launched coroutine. Doing that instead (as an earlier + * version of this code did) queues a new coroutine on every single scroll callback during a fast + * drag, and each one competes for the Animatable's internal mutation mutex, which is what caused + * the stutter/dropped-input symptoms. A coroutine is only ever launched once per gesture, for the + * release-time settle animation. + */ +/** 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, + /** [PullToRefreshState.getDistanceFraction]: > 0 while the refresh indicator is showing/pulling. */ + val refreshDistanceFraction: () -> Float +) + +@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 same continuous gesture has already pulled the tags row to fully + * revealed. Kept true for the rest of the gesture so further pulling is absorbed + * instead of leaking into [PullToRefreshBox] — the refresh indicator may only be + * engaged by a separate, later pull, not by continuing the same one that revealed the + * tags row. Reset on every gesture end (see [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 { + // The refresh indicator engages only after the tags row is fully revealed, so it + // must also retract first: while it still has any pull progress, let the drag + // pass straight through to PullToRefreshBox's own retraction handling untouched, + // instead of collapsing the tags row underneath it. + 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 + } + + // Same gesture, already fully revealed: swallow further pulling so it can't + // chain straight into triggering the refresh indicator. + available.y > 0f && atOwnTop && reachedMaxThisGesture -> available + + else -> Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + reachedMaxThisGesture = false + // Fire-and-forget: must NOT suspend here. onPreFling is awaited by the nested-scroll + // dispatcher before the fling is handed to the LazyColumn below, so directly awaiting + // the settle animation on this coroutine would block (and visibly delay) the list's + // own fling until the spring finishes. + 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 + } + } + } + /** * The full conversation list: pull-to-refresh + LazyColumn. * Replaces RecyclerView + FlexibleAdapter + SwipeRefreshLayout. @@ -115,8 +272,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) { @@ -155,78 +333,109 @@ fun ConversationList( 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 }, + onNaturalHeightPxChanged = { tagsNaturalHeightPx = it }, + content = tagsRowContent + ) + } + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + state = pullToRefreshState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + indicator = { + // Pinned to a fixed position near the search bar (offsetting by however much the + // tags row currently pushes this Box down), instead of sliding down together with + // it and appearing to emerge from underneath the tags row. + 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) + } } } } 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 79ff9a3811..3ff60856a4 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 dff3633ac2..0cff951246 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 0000000000..1910f41521 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt @@ -0,0 +1,79 @@ +/* + * 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.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.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.models.json.tags.ConversationTag + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationTagsRow( + tags: List, + selectedTagId: String?, + onTagSelected: (String?) -> 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 { + FilterChip( + selected = selectedTagId == null, + onClick = { onTagSelected(null) }, + label = { Text(stringResource(R.string.nc_conversation_tags_filter_all)) } + ) + } + items(tags, key = { it.id }) { tag -> + val isSelected = tag.id == selectedTagId + FilterChip( + selected = isSelected, + onClick = { onTagSelected(if (isSelected) null else tag.id) }, + label = { Text(tag.name) } + ) + } + } +} + +private fun previewTags() = + listOf( + 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 = {} + ) + } +} 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 2653525028..e36c9219ba 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,6 +52,7 @@ 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 @@ -63,7 +64,9 @@ 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 +127,7 @@ data class ConversationsListScreenCallbacks( @Composable fun ConversationsListScreen( viewModel: ConversationsListViewModel, + tagsViewModel: ConversationTagsViewModel, state: ConversationsListScreenState, callbacks: ConversationsListScreenCallbacks ) { @@ -143,6 +147,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 +180,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 +213,9 @@ fun ConversationsListScreen( ) } - val lazyListState = rememberLazyListState() + // Keyed on the tag filter so switching tags (or clearing the filter) starts the list at a + // fresh, already-at-top scroll state instead of visibly jumping there from the old position. + val lazyListState = remember(selectedTagFilter) { LazyListState() } DisposableEffect(lazyListState) { callbacks.onLazyListStateAvailable(lazyListState) onDispose { callbacks.onLazyListStateAvailable(null) } @@ -311,7 +332,23 @@ fun ConversationsListScreen( listState = lazyListState, contentBottomPadding = paddingValues.calculateBottomPadding(), onSwipeConversation = callbacks.onConversationOpsAction, - isOnline = isOnline + isOnline = isOnline, + tagsRowContent = if (showConversationTagsRow) { + { + ConversationTagsRow( + tags = conversationTags, + selectedTagId = selectedTagFilter, + onTagSelected = { viewModel.selectTagFilter(it) }, + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = dimensionResource(R.dimen.standard_half_padding) + ) + ) + } + } else { + null + } ) } // Empty-state overlay (centered; handles its own visibility) @@ -372,6 +409,66 @@ fun ConversationsListScreen( ) } } + + // Assign-tags-to-conversation bottom sheet + val conversationForTags = conversationForTagAssignment + if (conversationForTags != null) { + ModalBottomSheet( + onDismissRequest = { tagsViewModel.setConversationForTagAssignment(null) }, + sheetState = tagAssignmentSheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + AssignConversationTagsSheetContent( + conversation = conversationForTags, + tags = conversationTags, + 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) + } + ) + } + } + + // Manage-tags bottom sheet + 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 0000000000..8ecd9b1cae --- /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 0000000000..f79dee3964 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsSheet.kt @@ -0,0 +1,249 @@ +/* + * 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 + ) + } + + if (tags.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 -> + 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 = 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) + } + 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 = "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 fd17ecc5c4..2f6adf4505 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,12 +226,27 @@ class ConversationsListViewModel @Inject constructor( RESULTS } + private val _selectedTagFilterFlow = MutableStateFlow(null) + val selectedTagFilterFlow: StateFlow = _selectedTagFilterFlow.asStateFlow() + + /** Select a tag to filter the conversation list, or null to clear the filter. */ + fun selectTagFilter(tagId: String?) { + _selectedTagFilterFlow.value = tagId + } + + private val tagFilteredRoomsFlow: StateFlow> = combine( + getRoomsStateFlow, + _selectedTagFilterFlow + ) { rooms, tagId -> + if (tagId == null) rooms else rooms.filter { it.tagIds.contains(tagId) } + }.stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) + /** * Single source of truth for the [ConversationList] LazyColumn. * Auto-reacts to rooms, filter, search-active and search-result changes. */ val conversationListEntriesFlow: StateFlow> = combine( - getRoomsStateFlow, + tagFilteredRoomsFlow, _filterStateFlow, combine(_isSearchActiveFlow, _currentSearchQueryFlow) { active, query -> when { @@ -246,6 +261,16 @@ class ConversationsListViewModel @Inject constructor( buildConversationListEntries(rooms, filterState, searchMode, searchResults, hideToken) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + /** + * Clears the tag filter when the filtered-by tag no longer exists (e.g. it was deleted). + * Called by [com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel]. + */ + fun clearTagFilterIfMatches(tagId: String) { + if (_selectedTagFilterFlow.value == tagId) { + _selectedTagFilterFlow.value = null + } + } + /** Update filter state; triggers [conversationListEntriesFlow] re-emit. */ fun applyFilter(newFilterState: Map) { _filterStateFlow.value = newFilterState 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 0000000000..f11cbe2705 --- /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 0000000000..554f196aa6 --- /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 0000000000..3c48f30fc6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt @@ -0,0 +1,210 @@ +/* + * 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 + ?.filter { it.type == ConversationTag.TYPE_CUSTOM } + ?: emptyList() + } 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).sortedBy { it.sortOrder } + } + _tagActionState.value = TagActionUiState.Success + } catch (e: Exception) { + _tagActionState.value = TagActionUiState.Error(extractTagErrorType(e)) + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun renameConversationTag(tagId: String, name: String) { + 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) { + 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)) + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + fun reorderConversationTags(orderedIds: List) { + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + conversationTagsRepository.reorderTags(credentials, currentUser.baseUrl!!, orderedIds) + } + response.ocs?.data?.filter { it.type == ConversationTag.TYPE_CUSTOM }?.let { + _conversationTagsFlow.value = it + } + _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) + } + } + } + + /** + * Keeps the conversation shown in the assign-tags sheet in sync with an optimistic update + * or rollback, since [conversationForTagAssignment] is a snapshot and not fed from the live + * room list. + */ + 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() + } + + 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 2b0a6919b4..e1c91d7f40 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 c311a9e4cd..5f2cb32ebd 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 c4b622bd0c..dffff5fdff 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 615975ec13..ef5a9ad5f8 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 bb61d0b38f..5190daa89b 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 6b1092aee8..e0ae62452f 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 019fc7ce8f..07c3270af0 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 0000000000..34d00cb8d5 --- /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 0000000000..1737d943c0 --- /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 0000000000..1071139c95 --- /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 0000000000..1a6fd6d9a0 --- /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 0000000000..e0d4c18803 --- /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 0000000000..39f0513f4f --- /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 0000000000..091682fb21 --- /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 0000000000..4f6bc25347 --- /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 0000000000..b0a6073398 --- /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 0000000000..ef94ab4f8f --- /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 0000000000..4ba9a742c3 --- /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 0000000000..dce1664b2b --- /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 dfec53ec1c..25cec08b17 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 e172544680..c0400638d0 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 21af10e0da..c969a7560b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -971,6 +971,21 @@ 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 No offline messages saved No messages yet. Be the first to say hello! Previously set From 40b2cb1f0b5d9b7398eb1538aefbdfb99ea024c2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 8 Jul 2026 17:43:10 +0200 Subject: [PATCH 2/6] show tag panel with animation Assisted-by: Claude Code 2.1.205:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../conversationlist/ui/ConversationList.kt | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) 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 f90f7d7c8b..142f28a191 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 @@ -60,6 +60,8 @@ 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 @@ -107,19 +109,50 @@ private const val TAGS_REVEAL_PULL_RESISTANCE = 0.5f /** Fraction of the tags row that must be revealed when the gesture ends for it to snap fully open. */ private const val TAGS_REVEAL_SNAP_THRESHOLD_FRACTION = 0.35f +/** The tags row starts at this scale and eases up to 1f as it's revealed — a subtle "pop", not a stretch. */ +private const val TAGS_REVEAL_MIN_SCALE = 0.85f + +/** Fraction of the reveal distance over which the tags row fades in; it's fully opaque well before fully open. */ +private const val TAGS_REVEAL_FADE_IN_FRACTION = 0.7f + +/** Anchors the fade/scale pop to the bottom-center edge the tags row visually emerges from. */ +@Suppress("MagicNumber") +private val tagsRevealPopOrigin = TransformOrigin(pivotFractionX = 0.5f, pivotFractionY = 1f) + /** * Lays out [content] at its own natural (unconstrained) height, but reports a layout height of - * only [revealPx] to its parent, bottom-clipping the rest. Reads [revealPx] during the layout - * phase (not composition), so animating it every frame during a drag never triggers recomposition - * of [content] itself — only a cheap remeasure/relayout, which is what keeps the gesture smooth. + * only [revealPx] to its parent, bottom-clipping the rest. Also fades and gently scales [content] + * in as it's revealed, anchored to the bottom edge it's emerging from, so the gesture feels like a + * deliberate reveal rather than a mechanical crop. Reads [revealPx]/[naturalHeightPx] during the + * layout/draw phase (not composition), so animating them every frame during a drag never triggers + * recomposition of [content] itself — only a cheap remeasure/redraw, which is what keeps the + * gesture smooth. */ @Composable private fun PullRevealHeader( revealPx: () -> Float, + naturalHeightPx: () -> Float, onNaturalHeightPxChanged: (Float) -> Unit, content: @Composable () -> Unit ) { - Layout(content = content, modifier = Modifier.clipToBounds()) { measurables, constraints -> + 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) @@ -341,6 +374,7 @@ fun ConversationList( if (tagsRowContent != null) { PullRevealHeader( revealPx = { tagsRevealPx }, + naturalHeightPx = { tagsNaturalHeightPx }, onNaturalHeightPxChanged = { tagsNaturalHeightPx = it }, content = tagsRowContent ) From 4237a96ddec38ce82cddaaeecf60d444bb3b4bf2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 9 Jul 2026 12:55:09 +0200 Subject: [PATCH 3/6] improve favorites handling for tags Assisted-by: Claude Code 2.1.205:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../ui/ConversationTagsRow.kt | 6 ++- .../ui/ConversationsListScreen.kt | 10 +++- .../ui/ManageConversationTagsSheet.kt | 47 ++++++++++++------- .../viewmodels/ConversationsListViewModel.kt | 20 ++++++-- .../viewmodels/ConversationTagsViewModel.kt | 30 +++++++++--- app/src/main/res/values/strings.xml | 1 + 6 files changed, 83 insertions(+), 31 deletions(-) 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 index 1910f41521..4b7583bbc9 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt @@ -49,10 +49,13 @@ fun ConversationTagsRow( } items(tags, key = { it.id }) { tag -> val isSelected = tag.id == selectedTagId + val isFavorites = tag.type == ConversationTag.TYPE_FAVORITES FilterChip( selected = isSelected, onClick = { onTagSelected(if (isSelected) null else tag.id) }, - label = { Text(tag.name) } + label = { + Text(if (isFavorites) stringResource(R.string.nc_conversation_tags_favorites) else tag.name) + } ) } } @@ -60,6 +63,7 @@ fun ConversationTagsRow( 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) 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 e36c9219ba..07b96fce30 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 @@ -59,6 +59,7 @@ 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 @@ -338,7 +339,12 @@ fun ConversationsListScreen( ConversationTagsRow( tags = conversationTags, selectedTagId = selectedTagFilter, - onTagSelected = { viewModel.selectTagFilter(it) }, + onTagSelected = { tagId -> + val isFavorites = conversationTags.any { + it.id == tagId && it.type == ConversationTag.TYPE_FAVORITES + } + viewModel.selectTagFilter(tagId, isFavorites) + }, modifier = Modifier .fillMaxWidth() .padding( @@ -420,7 +426,7 @@ fun ConversationsListScreen( ) { AssignConversationTagsSheetContent( conversation = conversationForTags, - tags = conversationTags, + tags = conversationTags.filter { it.type == ConversationTag.TYPE_CUSTOM }, onToggleTag = { tagId -> val newTagIds = if (conversationForTags.tagIds.contains(tagId)) { conversationForTags.tagIds - tagId 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 index f79dee3964..f14049633a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsSheet.kt @@ -91,7 +91,9 @@ fun ManageConversationTagsSheetContent( ) } - if (tags.isEmpty()) { + val customTags = tags.filter { it.type == ConversationTag.TYPE_CUSTOM } + + if (customTags.isEmpty()) { Text( text = stringResource(R.string.nc_conversation_tags_empty), modifier = Modifier @@ -103,6 +105,7 @@ fun ManageConversationTagsSheetContent( } tags.forEachIndexed { index, tag -> + val isFavorites = tag.type == ConversationTag.TYPE_FAVORITES Row( modifier = Modifier .fillMaxWidth() @@ -124,7 +127,7 @@ fun ManageConversationTagsSheetContent( } } else { Text( - text = tag.name, + 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 @@ -135,23 +138,30 @@ fun ManageConversationTagsSheetContent( IconButton(onClick = { onMoveTag(tag.id, 1) }, enabled = index < tags.lastIndex) { Icon(Icons.Outlined.KeyboardArrowDown, contentDescription = null) } - IconButton( - onClick = { - editingTagId = tag.id - editingName = tag.name - onResetActionState() + if (isFavorites) { + // Empty, disabled placeholders reserve the same width as the edit/delete + // buttons on the other rows, so the up/down arrows line up across all rows. + 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) + ) } - ) { - 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) - ) } } } @@ -221,6 +231,7 @@ private fun tagErrorMessage(errorType: String?): String = 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) 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 2f6adf4505..dca1906c59 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 @@ -229,16 +229,27 @@ class ConversationsListViewModel @Inject constructor( private val _selectedTagFilterFlow = MutableStateFlow(null) val selectedTagFilterFlow: StateFlow = _selectedTagFilterFlow.asStateFlow() + // The built-in Favorites tag has a real, server-assigned id (see ConversationTagsViewModel), + // so it can't be recognized by a fixed sentinel id — the caller tells us at selection time. + private val selectedTagIsFavoritesFlow = MutableStateFlow(false) + /** Select a tag to filter the conversation list, or null to clear the filter. */ - fun selectTagFilter(tagId: String?) { + fun selectTagFilter(tagId: String?, isFavorites: Boolean = false) { _selectedTagFilterFlow.value = tagId + selectedTagIsFavoritesFlow.value = tagId != null && isFavorites } private val tagFilteredRoomsFlow: StateFlow> = combine( getRoomsStateFlow, - _selectedTagFilterFlow - ) { rooms, tagId -> - if (tagId == null) rooms else rooms.filter { it.tagIds.contains(tagId) } + _selectedTagFilterFlow, + selectedTagIsFavoritesFlow + ) { rooms, tagId, isFavorites -> + when { + tagId == null -> rooms + // Favorites has no relation to tagIds; it mirrors the pre-existing favorite flag. + isFavorites -> rooms.filter { it.favorite } + else -> rooms.filter { it.tagIds.contains(tagId) } + } }.stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) /** @@ -268,6 +279,7 @@ class ConversationsListViewModel @Inject constructor( fun clearTagFilterIfMatches(tagId: String) { if (_selectedTagFilterFlow.value == tagId) { _selectedTagFilterFlow.value = null + selectedTagIsFavoritesFlow.value = false } } 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 index 3c48f30fc6..e57b2c25ee 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt @@ -81,9 +81,7 @@ class ConversationTagsViewModel @Inject constructor( val response = withContext(Dispatchers.IO) { conversationTagsRepository.getTags(credentials, currentUser.baseUrl!!) } - _conversationTagsFlow.value = response.ocs?.data - ?.filter { it.type == ConversationTag.TYPE_CUSTOM } - ?: emptyList() + _conversationTagsFlow.value = response.ocs?.data.toDisplayTags() } catch (e: Exception) { Log.e(TAG, "Failed to load conversation tags", e) } @@ -98,7 +96,7 @@ class ConversationTagsViewModel @Inject constructor( conversationTagsRepository.createTag(credentials, currentUser.baseUrl!!, name) } response.ocs?.data?.let { tag -> - _conversationTagsFlow.value = (_conversationTagsFlow.value + tag).sortedBy { it.sortOrder } + _conversationTagsFlow.value = (_conversationTagsFlow.value + tag).toDisplayTags() } _tagActionState.value = TagActionUiState.Success } catch (e: Exception) { @@ -109,6 +107,7 @@ class ConversationTagsViewModel @Inject constructor( @Suppress("Detekt.TooGenericExceptionCaught") fun renameConversationTag(tagId: String, name: String) { + if (!isCustomTag(tagId)) return viewModelScope.launch { try { val response = withContext(Dispatchers.IO) { @@ -127,6 +126,7 @@ class ConversationTagsViewModel @Inject constructor( @Suppress("Detekt.TooGenericExceptionCaught") fun deleteConversationTag(tagId: String) { + if (!isCustomTag(tagId)) return viewModelScope.launch { try { withContext(Dispatchers.IO) { @@ -140,6 +140,12 @@ class ConversationTagsViewModel @Inject constructor( } } + /** + * [orderedIds] is the full desired order shown in the UI, including the built-in Favorites + * tag's real id — the server tracks a real `sortOrder` for Favorites/Other just like custom + * tags (see `ConversationTagService::reorderTags` server-side), so nothing needs to be + * stripped before sending; the request's order becomes the new `sortOrder` for every id in it. + */ @Suppress("Detekt.TooGenericExceptionCaught") fun reorderConversationTags(orderedIds: List) { viewModelScope.launch { @@ -147,8 +153,8 @@ class ConversationTagsViewModel @Inject constructor( val response = withContext(Dispatchers.IO) { conversationTagsRepository.reorderTags(credentials, currentUser.baseUrl!!, orderedIds) } - response.ocs?.data?.filter { it.type == ConversationTag.TYPE_CUSTOM }?.let { - _conversationTagsFlow.value = it + response.ocs?.data?.let { tags -> + _conversationTagsFlow.value = tags.toDisplayTags() } _tagActionState.value = TagActionUiState.Success } catch (e: Exception) { @@ -204,6 +210,18 @@ class ConversationTagsViewModel @Inject constructor( }.getOrNull() } + private fun isCustomTag(tagId: String): Boolean = + _conversationTagsFlow.value.firstOrNull { it.id == tagId }?.type == ConversationTag.TYPE_CUSTOM + + /** + * The server always includes the built-in Favorites/Other tags in its response and + * auto-creates them on first access (see `ConversationTagService::fetchAndEnsureBuiltInTags`). + * Other isn't surfaced in this UI, so it's dropped here; everything else is shown sorted by + * its real `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/res/values/strings.xml b/app/src/main/res/values/strings.xml index c969a7560b..afdfc6b840 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -986,6 +986,7 @@ How to translate with transifex: 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 From 31b833bc82bb9b3f0d056a3028320516db6053cd Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 9 Jul 2026 15:13:35 +0200 Subject: [PATCH 4/6] open tag manager on longclick + improve ripple Assisted-by: Claude Code 2.1.199:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../ui/ConversationTagsRow.kt | 67 +++++++++++++++++-- .../ui/ConversationsListScreen.kt | 1 + 2 files changed, 61 insertions(+), 7 deletions(-) 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 index 4b7583bbc9..5755bac3f4 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt @@ -8,6 +8,8 @@ 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 @@ -20,12 +22,22 @@ 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 @@ -33,6 +45,7 @@ fun ConversationTagsRow( tags: List, selectedTagId: String?, onTagSelected: (String?) -> Unit, + onManageTagsClick: () -> Unit, modifier: Modifier = Modifier ) { LazyRow( @@ -41,26 +54,65 @@ fun ConversationTagsRow( horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.standard_half_padding)) ) { item { - FilterChip( + TagFilterChip( selected = selectedTagId == null, + label = stringResource(R.string.nc_conversation_tags_filter_all), onClick = { onTagSelected(null) }, - label = { Text(stringResource(R.string.nc_conversation_tags_filter_all)) } + onLongClick = onManageTagsClick ) } items(tags, key = { it.id }) { tag -> val isSelected = tag.id == selectedTagId val isFavorites = tag.type == ConversationTag.TYPE_FAVORITES - FilterChip( + TagFilterChip( selected = isSelected, + label = if (isFavorites) stringResource(R.string.nc_conversation_tags_favorites) else tag.name, onClick = { onTagSelected(if (isSelected) null else tag.id) }, - label = { - Text(if (isFavorites) stringResource(R.string.nc_conversation_tags_favorites) else tag.name) - } + onLongClick = onManageTagsClick ) } } } +/** + * [FilterChip] only exposes a plain click. Rather than layering a second clickable region on top + * (whose ripple would need to exactly replicate the chip's own shape/bounds to stay visually + * contained), long-press is detected by watching the chip's own [MutableInteractionSource] for a + * [PressInteraction.Press] that outlasts the long-press timeout — the chip's native ripple and + * click handling are left completely untouched, so the ripple is always exactly as clipped as the + * chip itself. + */ +@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), @@ -77,7 +129,8 @@ private fun ConversationTagsRowPreview() { ConversationTagsRow( tags = previewTags(), selectedTagId = "2", - onTagSelected = {} + 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 07b96fce30..87fcb53b2e 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 @@ -345,6 +345,7 @@ fun ConversationsListScreen( } viewModel.selectTagFilter(tagId, isFavorites) }, + onManageTagsClick = { tagsViewModel.setShowManageTagsSheet(true) }, modifier = Modifier .fillMaxWidth() .padding( From 00acdabb0dcac5b8db47fe02f9ffb71d7f597d39 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 9 Jul 2026 15:38:45 +0200 Subject: [PATCH 5/6] remove/shorten comments Assisted-by: Claude Code 2.1.199:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../conversationlist/ui/ConversationList.kt | 84 ++++--------------- .../ui/ConversationTagsRow.kt | 9 +- .../ui/ConversationsListScreen.kt | 5 +- .../ui/ManageConversationTagsSheet.kt | 3 +- .../viewmodels/ConversationsListViewModel.kt | 8 +- .../viewmodels/ConversationTagsViewModel.kt | 20 +---- 6 files changed, 25 insertions(+), 104 deletions(-) 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 142f28a191..f8bee4d79f 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 @@ -103,31 +103,15 @@ import kotlin.math.roundToInt private const val MSG_KEY_EXCERPT_LENGTH = 20 -/** Fraction of a raw drag delta that actually moves the tags row into view (the "resistance"). */ private const val TAGS_REVEAL_PULL_RESISTANCE = 0.5f - -/** Fraction of the tags row that must be revealed when the gesture ends for it to snap fully open. */ private const val TAGS_REVEAL_SNAP_THRESHOLD_FRACTION = 0.35f - -/** The tags row starts at this scale and eases up to 1f as it's revealed — a subtle "pop", not a stretch. */ private const val TAGS_REVEAL_MIN_SCALE = 0.85f - -/** Fraction of the reveal distance over which the tags row fades in; it's fully opaque well before fully open. */ private const val TAGS_REVEAL_FADE_IN_FRACTION = 0.7f -/** Anchors the fade/scale pop to the bottom-center edge the tags row visually emerges from. */ @Suppress("MagicNumber") private val tagsRevealPopOrigin = TransformOrigin(pivotFractionX = 0.5f, pivotFractionY = 1f) -/** - * Lays out [content] at its own natural (unconstrained) height, but reports a layout height of - * only [revealPx] to its parent, bottom-clipping the rest. Also fades and gently scales [content] - * in as it's revealed, anchored to the bottom edge it's emerging from, so the gesture feels like a - * deliberate reveal rather than a mechanical crop. Reads [revealPx]/[naturalHeightPx] during the - * layout/draw phase (not composition), so animating them every frame during a drag never triggers - * recomposition of [content] itself — only a cheap remeasure/redraw, which is what keeps the - * gesture smooth. - */ +/** Reports a layout height of only [revealPx] instead of [content]'s natural height, bottom-clipping the rest. */ @Composable private fun PullRevealHeader( revealPx: () -> Float, @@ -162,36 +146,22 @@ private fun PullRevealHeader( } } -/** - * Rubber-band pull-to-reveal, Telegram/Signal style: normally [revealPx] is 0 (tags row fully - * hidden, zero layout height). Pulling down while the conversation list is scrolled to its own - * absolute top grows [revealPx] at [TAGS_REVEAL_PULL_RESISTANCE] of the raw drag distance; pulling - * up while any of it is showing collapses it 1:1 first, before the list itself scrolls. Releasing - * mid-gesture snaps to fully open or fully closed with a spring, depending on how far it was - * pulled. - * - * Deliberately wraps everything *outside* [PullToRefreshBox] (not attached to the LazyColumn - * inside it): nested-scroll pre-scroll dispatch runs outermost-connection-first, so this must be - * the outermost connection to get first claim on "pulling down while at the top" — once the - * tags row is fully revealed it stops consuming that gesture, letting the remainder flow through - * to [PullToRefreshBox]'s own overscroll detection underneath, unmodified. - * - * The interactive value itself ([revealPx]) is plain, synchronously-mutated state — not an - * [Animatable] driven via `snapTo` from a launched coroutine. Doing that instead (as an earlier - * version of this code did) queues a new coroutine on every single scroll callback during a fast - * drag, and each one competes for the Animatable's internal mutation mutex, which is what caused - * the stutter/dropped-input symptoms. A coroutine is only ever launched once per gesture, for the - * release-time settle animation. - */ /** 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, - /** [PullToRefreshState.getDistanceFraction]: > 0 while the refresh indicator is showing/pulling. */ 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, @@ -204,13 +174,9 @@ private fun rememberPullRevealNestedScrollConnection( /** Tracks the in-flight settle animation so a fresh drag/fling can cancel a stale one. */ var settleJob: Job? = null - /** - * True once this same continuous gesture has already pulled the tags row to fully - * revealed. Kept true for the rest of the gesture so further pulling is absorbed - * instead of leaking into [PullToRefreshBox] — the refresh indicator may only be - * engaged by a separate, later pull, not by continuing the same one that revealed the - * tags row. Reset on every gesture end (see [onPreFling]). - */ + // 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 @@ -221,10 +187,8 @@ private fun rememberPullRevealNestedScrollConnection( if (!isEnabled) return Offset.Zero val atOwnTop = listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 return when { - // The refresh indicator engages only after the tags row is fully revealed, so it - // must also retract first: while it still has any pull progress, let the drag - // pass straight through to PullToRefreshBox's own retraction handling untouched, - // instead of collapsing the tags row underneath it. + // 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) @@ -244,8 +208,6 @@ private fun rememberPullRevealNestedScrollConnection( available } - // Same gesture, already fully revealed: swallow further pulling so it can't - // chain straight into triggering the refresh indicator. available.y > 0f && atOwnTop && reachedMaxThisGesture -> available else -> Offset.Zero @@ -254,10 +216,8 @@ private fun rememberPullRevealNestedScrollConnection( override suspend fun onPreFling(available: Velocity): Velocity { reachedMaxThisGesture = false - // Fire-and-forget: must NOT suspend here. onPreFling is awaited by the nested-scroll - // dispatcher before the fling is handed to the LazyColumn below, so directly awaiting - // the settle animation on this coroutine would block (and visibly delay) the list's - // own fling until the spring finishes. + // 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() @@ -278,10 +238,6 @@ private fun rememberPullRevealNestedScrollConnection( } } -/** - * The full conversation list: pull-to-refresh + LazyColumn. - * Replaces RecyclerView + FlexibleAdapter + SwipeRefreshLayout. - */ @Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -342,7 +298,6 @@ fun ConversationList( } } - // Unread-bubble: notify Activity when scrolling stops LaunchedEffect(listState) { snapshotFlow { listState.isScrollInProgress } .collect { isScrolling -> @@ -353,13 +308,11 @@ 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 @@ -387,9 +340,7 @@ fun ConversationList( .weight(1f) .fillMaxWidth(), indicator = { - // Pinned to a fixed position near the search bar (offsetting by however much the - // tags row currently pushes this Box down), instead of sliding down together with - // it and appearing to emerge from underneath the tags row. + // Offset cancels the tags row's push-down, keeping this pinned near the search bar. PullToRefreshDefaults.Indicator( state = pullToRefreshState, isRefreshing = isRefreshing, @@ -476,7 +427,6 @@ fun ConversationList( } } -/** 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/ConversationTagsRow.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt index 5755bac3f4..9b11be8b32 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationTagsRow.kt @@ -74,14 +74,7 @@ fun ConversationTagsRow( } } -/** - * [FilterChip] only exposes a plain click. Rather than layering a second clickable region on top - * (whose ripple would need to exactly replicate the chip's own shape/bounds to stay visually - * contained), long-press is detected by watching the chip's own [MutableInteractionSource] for a - * [PressInteraction.Press] that outlasts the long-press timeout — the chip's native ripple and - * click handling are left completely untouched, so the ripple is always exactly as clipped as the - * chip itself. - */ +/** 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) { 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 87fcb53b2e..899222af6f 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 @@ -214,8 +214,7 @@ fun ConversationsListScreen( ) } - // Keyed on the tag filter so switching tags (or clearing the filter) starts the list at a - // fresh, already-at-top scroll state instead of visibly jumping there from the old position. + // 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) @@ -417,7 +416,6 @@ fun ConversationsListScreen( } } - // Assign-tags-to-conversation bottom sheet val conversationForTags = conversationForTagAssignment if (conversationForTags != null) { ModalBottomSheet( @@ -444,7 +442,6 @@ fun ConversationsListScreen( } } - // Manage-tags bottom sheet if (showManageTagsSheet) { ModalBottomSheet( onDismissRequest = { tagsViewModel.setShowManageTagsSheet(false) }, 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 index f14049633a..626eebf6e7 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ManageConversationTagsSheet.kt @@ -139,8 +139,7 @@ fun ManageConversationTagsSheetContent( Icon(Icons.Outlined.KeyboardArrowDown, contentDescription = null) } if (isFavorites) { - // Empty, disabled placeholders reserve the same width as the edit/delete - // buttons on the other rows, so the up/down arrows line up across all rows. + // Reserve the edit/delete buttons' width so arrows still line up. IconButton(onClick = {}, enabled = false) {} IconButton(onClick = {}, enabled = false) {} } else { 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 dca1906c59..a49e88c647 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 @@ -229,8 +229,7 @@ class ConversationsListViewModel @Inject constructor( private val _selectedTagFilterFlow = MutableStateFlow(null) val selectedTagFilterFlow: StateFlow = _selectedTagFilterFlow.asStateFlow() - // The built-in Favorites tag has a real, server-assigned id (see ConversationTagsViewModel), - // so it can't be recognized by a fixed sentinel id — the caller tells us at selection time. + // 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. */ @@ -272,10 +271,7 @@ class ConversationsListViewModel @Inject constructor( buildConversationListEntries(rooms, filterState, searchMode, searchResults, hideToken) }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - /** - * Clears the tag filter when the filtered-by tag no longer exists (e.g. it was deleted). - * Called by [com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel]. - */ + /** 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 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 index e57b2c25ee..4d01050ee9 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationtags/viewmodels/ConversationTagsViewModel.kt @@ -140,12 +140,7 @@ class ConversationTagsViewModel @Inject constructor( } } - /** - * [orderedIds] is the full desired order shown in the UI, including the built-in Favorites - * tag's real id — the server tracks a real `sortOrder` for Favorites/Other just like custom - * tags (see `ConversationTagService::reorderTags` server-side), so nothing needs to be - * stripped before sending; the request's order becomes the new `sortOrder` for every id in it. - */ + /** [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 { @@ -191,11 +186,7 @@ class ConversationTagsViewModel @Inject constructor( } } - /** - * Keeps the conversation shown in the assign-tags sheet in sync with an optimistic update - * or rollback, since [conversationForTagAssignment] is a snapshot and not fed from the live - * room list. - */ + /** [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 @@ -213,12 +204,7 @@ class ConversationTagsViewModel @Inject constructor( private fun isCustomTag(tagId: String): Boolean = _conversationTagsFlow.value.firstOrNull { it.id == tagId }?.type == ConversationTag.TYPE_CUSTOM - /** - * The server always includes the built-in Favorites/Other tags in its response and - * auto-creates them on first access (see `ConversationTagService::fetchAndEnsureBuiltInTags`). - * Other isn't surfaced in this UI, so it's dropped here; everything else is shown sorted by - * its real `sortOrder`. - */ + /** 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() From 2a7871dcd630564c6d3742901f0b1cf2cba7fba5 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 9 Jul 2026 16:07:34 +0200 Subject: [PATCH 6/6] adapt tag behavior to new searchmode adapt behavior to https://github.com/nextcloud/talk-android/pull/6336 Assisted-by: Claude Code 2.1.199:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../viewmodels/ConversationsListViewModel.kt | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) 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 a49e88c647..f926ce667b 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,6 +226,8 @@ 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() @@ -238,25 +240,12 @@ class ConversationsListViewModel @Inject constructor( selectedTagIsFavoritesFlow.value = tagId != null && isFavorites } - private val tagFilteredRoomsFlow: StateFlow> = combine( - getRoomsStateFlow, - _selectedTagFilterFlow, - selectedTagIsFavoritesFlow - ) { rooms, tagId, isFavorites -> - when { - tagId == null -> rooms - // Favorites has no relation to tagIds; it mirrors the pre-existing favorite flag. - isFavorites -> rooms.filter { it.favorite } - else -> rooms.filter { it.tagIds.contains(tagId) } - } - }.stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) - /** * 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( - tagFilteredRoomsFlow, + getRoomsStateFlow, _filterStateFlow, combine(_isSearchActiveFlow, _currentSearchQueryFlow) { active, query -> when { @@ -265,10 +254,10 @@ 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). */ @@ -625,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 { @@ -649,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 }