mirror of
https://github.com/nextcloud/talk-android
synced 2025-06-18 19:19:33 +01:00
Merge pull request #5022 from nextcloud/bugfix/4921/avoidDupMessagesByBetterSentStatus
avoid duplicate messages
This commit is contained in:
commit
95f8b08a19
@ -0,0 +1,730 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 17,
|
||||
"identityHash": "5bc4247e179307faa995552da5d34324",
|
||||
"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, 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
|
||||
}
|
||||
],
|
||||
"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, `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, `timestamp` INTEGER NOT NULL, 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": "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": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"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, `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": "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, '5bc4247e179307faa995552da5d34324')"
|
||||
]
|
||||
}
|
||||
}
|
@ -29,6 +29,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.ChatMessageRepository
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.data.database.model.SendStatus
|
||||
import com.nextcloud.talk.data.network.NetworkMonitor
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.databinding.ItemCustomOutcomingTextMessageBinding
|
||||
@ -184,7 +185,7 @@ class OutcomingTextMessageViewHolder(itemView: View) :
|
||||
binding.checkMark.visibility = View.INVISIBLE
|
||||
binding.sendingProgress.visibility = View.GONE
|
||||
|
||||
if (message.sendingFailed) {
|
||||
if (message.sendStatus == SendStatus.FAILED) {
|
||||
updateStatus(R.drawable.baseline_error_outline_24, context.resources?.getString(R.string.nc_message_failed))
|
||||
} else if (message.isTemporary) {
|
||||
updateStatus(R.drawable.baseline_schedule_24, context.resources?.getString(R.string.nc_message_sending))
|
||||
|
@ -200,7 +200,7 @@ class MessageInputFragment : Fragment() {
|
||||
val connectionGained = (!wasOnline && isOnline)
|
||||
Log.d(TAG, "isOnline: $isOnline\nwasOnline: $wasOnline\nconnectionGained: $connectionGained")
|
||||
if (connectionGained) {
|
||||
chatActivity.messageInputViewModel.sendTempMessages(
|
||||
chatActivity.messageInputViewModel.sendUnsentMessages(
|
||||
chatActivity.conversationUser!!.getCredentials(),
|
||||
ApiUtils.getUrlForChat(
|
||||
chatActivity.chatApiVersion,
|
||||
|
@ -110,7 +110,7 @@ interface ChatMessageRepository : LifecycleAwareManager {
|
||||
|
||||
suspend fun editTempChatMessage(message: ChatMessage, editedMessageText: String): Flow<Boolean>
|
||||
|
||||
suspend fun sendTempChatMessages(credentials: String, url: String)
|
||||
suspend fun sendUnsentChatMessages(credentials: String, url: String)
|
||||
|
||||
suspend fun deleteTempMessage(chatMessage: ChatMessage)
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import android.util.Log
|
||||
import com.bluelinelabs.logansquare.annotation.JsonIgnore
|
||||
import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
|
||||
import com.nextcloud.talk.data.database.model.SendStatus
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.models.json.chat.ChatUtils.Companion.getParsedMessage
|
||||
import com.nextcloud.talk.models.json.chat.ReadStatus
|
||||
@ -119,7 +120,7 @@ data class ChatMessage(
|
||||
|
||||
var referenceId: String? = null,
|
||||
|
||||
var sendingFailed: Boolean = true,
|
||||
var sendStatus: SendStatus? = null,
|
||||
|
||||
var silent: Boolean = false
|
||||
|
||||
|
@ -19,6 +19,7 @@ import com.nextcloud.talk.data.database.mappers.asEntity
|
||||
import com.nextcloud.talk.data.database.mappers.asModel
|
||||
import com.nextcloud.talk.data.database.model.ChatBlockEntity
|
||||
import com.nextcloud.talk.data.database.model.ChatMessageEntity
|
||||
import com.nextcloud.talk.data.database.model.SendStatus
|
||||
import com.nextcloud.talk.data.network.NetworkMonitor
|
||||
import com.nextcloud.talk.data.user.model.User
|
||||
import com.nextcloud.talk.extensions.toIntOrZero
|
||||
@ -214,7 +215,8 @@ class OfflineFirstChatRepository @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
sendTempChatMessages(credentials, urlForChatting)
|
||||
// this call could be deleted when we have a worker to send messages..
|
||||
sendUnsentChatMessages(credentials, urlForChatting)
|
||||
|
||||
// delay is a dirty workaround to make sure messages are added to adapter on initial load before dealing
|
||||
// with them (otherwise there is a race condition).
|
||||
@ -365,11 +367,18 @@ class OfflineFirstChatRepository @Inject constructor(
|
||||
lookIntoFuture: Boolean,
|
||||
showUnreadMessagesMarker: Boolean
|
||||
) {
|
||||
receivedChatMessages.forEach {
|
||||
Log.d(TAG, "receivedChatMessage: " + it.message)
|
||||
}
|
||||
|
||||
// remove all temp messages from UI
|
||||
val oldTempMessages = chatDao.getTempMessagesForConversation(internalConversationId)
|
||||
.first()
|
||||
.map(ChatMessageEntity::asModel)
|
||||
oldTempMessages.forEach { _removeMessageFlow.emit(it) }
|
||||
oldTempMessages.forEach {
|
||||
Log.d(TAG, "oldTempMessage to be removed from UI: " + it.message)
|
||||
_removeMessageFlow.emit(it)
|
||||
}
|
||||
|
||||
// add new messages to UI
|
||||
val tripleChatMessages = Triple(lookIntoFuture, showUnreadMessagesMarker, receivedChatMessages)
|
||||
@ -378,6 +387,9 @@ class OfflineFirstChatRepository @Inject constructor(
|
||||
// remove temp messages from DB that are now found in the new messages
|
||||
val chatMessagesReferenceIds = receivedChatMessages.mapTo(HashSet(receivedChatMessages.size)) { it.referenceId }
|
||||
val tempChatMessagesThatCanBeReplaced = oldTempMessages.filter { it.referenceId in chatMessagesReferenceIds }
|
||||
tempChatMessagesThatCanBeReplaced.forEach {
|
||||
Log.d(TAG, "oldTempMessage that was identified in newMessages: " + it.message)
|
||||
}
|
||||
chatDao.deleteTempChatMessages(
|
||||
internalConversationId,
|
||||
tempChatMessagesThatCanBeReplaced.map { it.referenceId!! }
|
||||
@ -389,6 +401,10 @@ class OfflineFirstChatRepository @Inject constructor(
|
||||
.sortedBy { it.internalId }
|
||||
.map(ChatMessageEntity::asModel)
|
||||
|
||||
remainingTempMessages.forEach {
|
||||
Log.d(TAG, "remainingTempMessage: " + it.message)
|
||||
}
|
||||
|
||||
val triple = Triple(true, false, remainingTempMessages)
|
||||
_messageFlow.emit(triple)
|
||||
}
|
||||
@ -843,6 +859,17 @@ class OfflineFirstChatRepository @Inject constructor(
|
||||
|
||||
val chatMessageModel = response.ocs?.data?.asModel()
|
||||
|
||||
val sentMessage = chatDao.getTempMessageForConversation(
|
||||
internalConversationId,
|
||||
referenceId
|
||||
).firstOrNull()
|
||||
|
||||
sentMessage?.let {
|
||||
it.sendStatus = SendStatus.SENT_PENDING_ACK
|
||||
chatDao.updateChatMessage(it)
|
||||
}
|
||||
|
||||
Log.d(TAG, "sending chat message succeeded: " + message)
|
||||
emit(Result.success(chatMessageModel))
|
||||
}
|
||||
.catch { e ->
|
||||
@ -853,7 +880,7 @@ class OfflineFirstChatRepository @Inject constructor(
|
||||
referenceId
|
||||
).firstOrNull()
|
||||
failedMessage?.let {
|
||||
it.sendingFailed = true
|
||||
it.sendStatus = SendStatus.FAILED
|
||||
chatDao.updateChatMessage(it)
|
||||
|
||||
val failedMessageModel = it.asModel()
|
||||
@ -874,7 +901,7 @@ class OfflineFirstChatRepository @Inject constructor(
|
||||
referenceId: String
|
||||
): Flow<Result<ChatMessage?>> {
|
||||
val messageToResend = chatDao.getTempMessageForConversation(internalConversationId, referenceId).first()
|
||||
messageToResend.sendingFailed = false
|
||||
messageToResend.sendStatus = SendStatus.PENDING
|
||||
chatDao.updateChatMessage(messageToResend)
|
||||
|
||||
val messageToResendModel = messageToResend.asModel()
|
||||
@ -930,8 +957,8 @@ class OfflineFirstChatRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendTempChatMessages(credentials: String, url: String) {
|
||||
val tempMessages = chatDao.getTempMessagesForConversation(internalConversationId).first()
|
||||
override suspend fun sendUnsentChatMessages(credentials: String, url: String) {
|
||||
val tempMessages = chatDao.getTempUnsentMessagesForConversation(internalConversationId).first()
|
||||
tempMessages.sortedBy { it.internalId }.onEach {
|
||||
sendChatMessage(
|
||||
credentials,
|
||||
@ -1025,7 +1052,7 @@ class OfflineFirstChatRepository @Inject constructor(
|
||||
actorDisplayName = currentUser.displayName!!,
|
||||
referenceId = referenceId,
|
||||
isTemporary = true,
|
||||
sendingFailed = false,
|
||||
sendStatus = SendStatus.PENDING,
|
||||
silent = sendWithoutNotification
|
||||
)
|
||||
return entity
|
||||
|
@ -169,9 +169,9 @@ class MessageInputViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendTempMessages(credentials: String, url: String) {
|
||||
fun sendUnsentMessages(credentials: String, url: String) {
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendTempChatMessages(
|
||||
chatRepository.sendUnsentChatMessages(
|
||||
credentials,
|
||||
url
|
||||
)
|
||||
|
@ -50,6 +50,18 @@ interface ChatMessagesDao {
|
||||
)
|
||||
fun getTempMessagesForConversation(internalConversationId: String): Flow<List<ChatMessageEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT *
|
||||
FROM ChatMessages
|
||||
WHERE internalConversationId = :internalConversationId
|
||||
AND isTemporary = 1
|
||||
AND sendStatus != 'SENT_PENDING_ACK'
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
"""
|
||||
)
|
||||
fun getTempUnsentMessagesForConversation(internalConversationId: String): Flow<List<ChatMessageEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT *
|
||||
|
@ -68,7 +68,7 @@ fun ChatMessageEntity.asModel() =
|
||||
isDeleted = deleted,
|
||||
referenceId = referenceId,
|
||||
isTemporary = isTemporary,
|
||||
sendingFailed = sendingFailed,
|
||||
sendStatus = sendStatus,
|
||||
readStatus = ReadStatus.NONE,
|
||||
silent = silent
|
||||
)
|
||||
|
@ -64,7 +64,7 @@ data class ChatMessageEntity(
|
||||
@ColumnInfo(name = "reactions") var reactions: LinkedHashMap<String, Int>? = null,
|
||||
@ColumnInfo(name = "reactionsSelf") var reactionsSelf: ArrayList<String>? = null,
|
||||
@ColumnInfo(name = "referenceId") var referenceId: String? = null,
|
||||
@ColumnInfo(name = "sendingFailed") var sendingFailed: Boolean = false,
|
||||
@ColumnInfo(name = "sendStatus") var sendStatus: SendStatus? = null,
|
||||
@ColumnInfo(name = "silent") var silent: Boolean = false,
|
||||
@ColumnInfo(name = "systemMessage") var systemMessageType: ChatMessage.SystemMessageType,
|
||||
@ColumnInfo(name = "timestamp") var timestamp: Long = 0
|
||||
|
@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.data.database.model
|
||||
|
||||
enum class SendStatus {
|
||||
PENDING,
|
||||
SENT_PENDING_ACK,
|
||||
FAILED
|
||||
}
|
@ -1,18 +1,31 @@
|
||||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2024-2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.nextcloud.talk.data.source.local
|
||||
|
||||
import android.util.Log
|
||||
import androidx.room.DeleteColumn
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import java.sql.SQLException
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
object Migrations {
|
||||
|
||||
//region Auto migrations
|
||||
|
||||
@DeleteColumn(tableName = "ChatMessages", columnName = "sendingFailed")
|
||||
class AutoMigration16To17 : AutoMigrationSpec
|
||||
|
||||
//endregion
|
||||
|
||||
//region Manual migrations
|
||||
|
||||
val MIGRATION_6_8 = object : Migration(6, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
Log.i("Migrations", "Migrating 6 to 8")
|
||||
@ -76,6 +89,8 @@ object Migrations {
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
fun migrateToRoom(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE User_new (" +
|
||||
|
@ -1,7 +1,7 @@
|
||||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2023-2024 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2023-2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-FileCopyrightText: 2022 Andy Scherzinger <info@andy-scherzinger.de>
|
||||
* SPDX-FileCopyrightText: 2017-2020 Mario Danic <mario@lovelyhq.com>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
@ -23,12 +23,14 @@ import com.nextcloud.talk.data.database.dao.ConversationsDao
|
||||
import com.nextcloud.talk.data.database.model.ChatBlockEntity
|
||||
import com.nextcloud.talk.data.database.model.ChatMessageEntity
|
||||
import com.nextcloud.talk.data.database.model.ConversationEntity
|
||||
import com.nextcloud.talk.data.source.local.Migrations.AutoMigration16To17
|
||||
import com.nextcloud.talk.data.source.local.converters.ArrayListConverter
|
||||
import com.nextcloud.talk.data.source.local.converters.CapabilitiesConverter
|
||||
import com.nextcloud.talk.data.source.local.converters.ExternalSignalingServerConverter
|
||||
import com.nextcloud.talk.data.source.local.converters.HashMapHashMapConverter
|
||||
import com.nextcloud.talk.data.source.local.converters.LinkedHashMapConverter
|
||||
import com.nextcloud.talk.data.source.local.converters.PushConfigurationConverter
|
||||
import com.nextcloud.talk.data.source.local.converters.SendStatusConverter
|
||||
import com.nextcloud.talk.data.source.local.converters.ServerVersionConverter
|
||||
import com.nextcloud.talk.data.source.local.converters.SignalingSettingsConverter
|
||||
import com.nextcloud.talk.data.storage.ArbitraryStoragesDao
|
||||
@ -49,9 +51,10 @@ import java.util.Locale
|
||||
ChatMessageEntity::class,
|
||||
ChatBlockEntity::class
|
||||
],
|
||||
version = 16,
|
||||
version = 17,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 9, to = 10)
|
||||
AutoMigration(from = 9, to = 10),
|
||||
AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class)
|
||||
],
|
||||
exportSchema = true
|
||||
)
|
||||
@ -63,7 +66,8 @@ import java.util.Locale
|
||||
SignalingSettingsConverter::class,
|
||||
HashMapHashMapConverter::class,
|
||||
LinkedHashMapConverter::class,
|
||||
ArrayListConverter::class
|
||||
ArrayListConverter::class,
|
||||
SendStatusConverter::class
|
||||
)
|
||||
abstract class TalkDatabase : RoomDatabase() {
|
||||
|
||||
|
@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Nextcloud Talk - Android Client
|
||||
*
|
||||
* SPDX-FileCopyrightText: 2025 Marcel Hibbe <dev@mhibbe.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.nextcloud.talk.data.source.local.converters
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.nextcloud.talk.data.database.model.SendStatus
|
||||
|
||||
class SendStatusConverter {
|
||||
@TypeConverter
|
||||
fun fromStatus(value: SendStatus): String {
|
||||
return value.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toStatus(value: String): SendStatus {
|
||||
return SendStatus.valueOf(value)
|
||||
}
|
||||
}
|
@ -18,6 +18,7 @@ import com.nextcloud.talk.R
|
||||
import com.nextcloud.talk.application.NextcloudTalkApplication
|
||||
import com.nextcloud.talk.chat.ChatActivity
|
||||
import com.nextcloud.talk.chat.data.model.ChatMessage
|
||||
import com.nextcloud.talk.data.database.model.SendStatus
|
||||
import com.nextcloud.talk.data.network.NetworkMonitor
|
||||
import com.nextcloud.talk.databinding.DialogTempMessageActionsBinding
|
||||
import com.nextcloud.talk.ui.theme.ViewThemeUtils
|
||||
@ -58,9 +59,10 @@ class TempMessageActionsDialog(
|
||||
|
||||
private fun initMenuItems() {
|
||||
this.lifecycleScope.launch {
|
||||
initResendMessage(message.sendingFailed && networkMonitor.isOnline.value)
|
||||
initMenuEditMessage(message.sendingFailed || !networkMonitor.isOnline.value)
|
||||
initMenuDeleteMessage(message.sendingFailed || !networkMonitor.isOnline.value)
|
||||
val sendingFailed = message.sendStatus == SendStatus.FAILED
|
||||
initResendMessage(sendingFailed && networkMonitor.isOnline.value)
|
||||
initMenuEditMessage(sendingFailed || !networkMonitor.isOnline.value)
|
||||
initMenuDeleteMessage(sendingFailed || !networkMonitor.isOnline.value)
|
||||
initMenuItemCopy()
|
||||
}
|
||||
}
|
||||
|
@ -30,6 +30,11 @@ class DummyChatMessagesDaoImpl : ChatMessagesDao {
|
||||
override fun getTempMessagesForConversation(internalConversationId: String): Flow<List<ChatMessageEntity>> =
|
||||
flowOf()
|
||||
|
||||
override fun getTempUnsentMessagesForConversation(internalConversationId: String): Flow<List<ChatMessageEntity>> {
|
||||
// nothing to return here as long this class is only used for the Search window
|
||||
return flowOf()
|
||||
}
|
||||
|
||||
override fun getTempMessageForConversation(
|
||||
internalConversationId: String,
|
||||
referenceId: String
|
||||
|
Loading…
Reference in New Issue
Block a user