diff --git a/app/build.gradle b/app/build.gradle
index 4f63e86d..c258dced 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -56,6 +56,10 @@ android {
testInstrumentationRunner "org.pixeldroid.app.testUtility.TestRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
+
+ ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
+ }
}
sourceSets {
main.java.srcDirs += 'src/main/java'
diff --git a/app/schemas/org.pixeldroid.app.utils.db.AppDatabase/8.json b/app/schemas/org.pixeldroid.app.utils.db.AppDatabase/8.json
new file mode 100644
index 00000000..a0a57778
--- /dev/null
+++ b/app/schemas/org.pixeldroid.app.utils.db.AppDatabase/8.json
@@ -0,0 +1,985 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 8,
+ "identityHash": "37b7f1d842d148e1d117ac9caae8fb51",
+ "entities": [
+ {
+ "tableName": "instances",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uri` TEXT NOT NULL, `title` TEXT NOT NULL, `maxStatusChars` INTEGER NOT NULL, `maxPhotoSize` INTEGER NOT NULL, `maxVideoSize` INTEGER NOT NULL, `albumLimit` INTEGER NOT NULL, `videoEnabled` INTEGER NOT NULL, `pixelfed` INTEGER NOT NULL, PRIMARY KEY(`uri`))",
+ "fields": [
+ {
+ "fieldPath": "uri",
+ "columnName": "uri",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxStatusChars",
+ "columnName": "maxStatusChars",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxPhotoSize",
+ "columnName": "maxPhotoSize",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxVideoSize",
+ "columnName": "maxVideoSize",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "albumLimit",
+ "columnName": "albumLimit",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "videoEnabled",
+ "columnName": "videoEnabled",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pixelfed",
+ "columnName": "pixelfed",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uri"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "users",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `username` TEXT NOT NULL, `display_name` TEXT NOT NULL, `avatar_static` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT, `clientId` TEXT NOT NULL, `clientSecret` TEXT NOT NULL, PRIMARY KEY(`user_id`, `instance_uri`), FOREIGN KEY(`instance_uri`) REFERENCES `instances`(`uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "user_id",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "instance_uri",
+ "columnName": "instance_uri",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "display_name",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "avatar_static",
+ "columnName": "avatar_static",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isActive",
+ "columnName": "isActive",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accessToken",
+ "columnName": "accessToken",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "refreshToken",
+ "columnName": "refreshToken",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "clientId",
+ "columnName": "clientId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "clientSecret",
+ "columnName": "clientSecret",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "user_id",
+ "instance_uri"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_users_instance_uri",
+ "unique": false,
+ "columnNames": [
+ "instance_uri"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_users_instance_uri` ON `${TABLE_NAME}` (`instance_uri`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "instances",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "instance_uri"
+ ],
+ "referencedColumns": [
+ "uri"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "homePosts",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "user_id",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "instance_uri",
+ "columnName": "instance_uri",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uri",
+ "columnName": "uri",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created_at",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "account",
+ "columnName": "account",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "visibility",
+ "columnName": "visibility",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "sensitive",
+ "columnName": "sensitive",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "spoiler_text",
+ "columnName": "spoiler_text",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "media_attachments",
+ "columnName": "media_attachments",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "application",
+ "columnName": "application",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "mentions",
+ "columnName": "mentions",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "emojis",
+ "columnName": "emojis",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "reblogs_count",
+ "columnName": "reblogs_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "favourites_count",
+ "columnName": "favourites_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "replies_count",
+ "columnName": "replies_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "in_reply_to_id",
+ "columnName": "in_reply_to_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "in_reply_to_account",
+ "columnName": "in_reply_to_account",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "reblog",
+ "columnName": "reblog",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "poll",
+ "columnName": "poll",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "card",
+ "columnName": "card",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "language",
+ "columnName": "language",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "text",
+ "columnName": "text",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "favourited",
+ "columnName": "favourited",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "reblogged",
+ "columnName": "reblogged",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "muted",
+ "columnName": "muted",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bookmarked",
+ "columnName": "bookmarked",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "pinned",
+ "columnName": "pinned",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "user_id",
+ "instance_uri"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_homePosts_user_id_instance_uri",
+ "unique": false,
+ "columnNames": [
+ "user_id",
+ "instance_uri"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_homePosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "user_id",
+ "instance_uri"
+ ],
+ "referencedColumns": [
+ "user_id",
+ "instance_uri"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "publicPosts",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `id` TEXT NOT NULL, `uri` TEXT, `created_at` TEXT, `account` TEXT, `content` TEXT, `visibility` TEXT, `sensitive` INTEGER, `spoiler_text` TEXT, `media_attachments` TEXT, `application` TEXT, `mentions` TEXT, `tags` TEXT, `emojis` TEXT, `reblogs_count` INTEGER, `favourites_count` INTEGER, `replies_count` INTEGER, `url` TEXT, `in_reply_to_id` TEXT, `in_reply_to_account` TEXT, `reblog` TEXT, `poll` TEXT, `card` TEXT, `language` TEXT, `text` TEXT, `favourited` INTEGER, `reblogged` INTEGER, `muted` INTEGER, `bookmarked` INTEGER, `pinned` INTEGER, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "user_id",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "instance_uri",
+ "columnName": "instance_uri",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uri",
+ "columnName": "uri",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created_at",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "account",
+ "columnName": "account",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "visibility",
+ "columnName": "visibility",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "sensitive",
+ "columnName": "sensitive",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "spoiler_text",
+ "columnName": "spoiler_text",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "media_attachments",
+ "columnName": "media_attachments",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "application",
+ "columnName": "application",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "mentions",
+ "columnName": "mentions",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "emojis",
+ "columnName": "emojis",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "reblogs_count",
+ "columnName": "reblogs_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "favourites_count",
+ "columnName": "favourites_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "replies_count",
+ "columnName": "replies_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "in_reply_to_id",
+ "columnName": "in_reply_to_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "in_reply_to_account",
+ "columnName": "in_reply_to_account",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "reblog",
+ "columnName": "reblog",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "poll",
+ "columnName": "poll",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "card",
+ "columnName": "card",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "language",
+ "columnName": "language",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "text",
+ "columnName": "text",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "favourited",
+ "columnName": "favourited",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "reblogged",
+ "columnName": "reblogged",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "muted",
+ "columnName": "muted",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bookmarked",
+ "columnName": "bookmarked",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "pinned",
+ "columnName": "pinned",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "user_id",
+ "instance_uri"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_publicPosts_user_id_instance_uri",
+ "unique": false,
+ "columnNames": [
+ "user_id",
+ "instance_uri"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_publicPosts_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "user_id",
+ "instance_uri"
+ ],
+ "referencedColumns": [
+ "user_id",
+ "instance_uri"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "notifications",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT, `created_at` TEXT, `account` TEXT, `status` TEXT, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created_at",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "account",
+ "columnName": "account",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "user_id",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "instance_uri",
+ "columnName": "instance_uri",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "user_id",
+ "instance_uri"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_notifications_user_id_instance_uri",
+ "unique": false,
+ "columnNames": [
+ "user_id",
+ "instance_uri"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_notifications_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "user_id",
+ "instance_uri"
+ ],
+ "referencedColumns": [
+ "user_id",
+ "instance_uri"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "tabsChecked",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, `tab` TEXT NOT NULL, `checked` INTEGER NOT NULL, PRIMARY KEY(`index`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user_id",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "instance_uri",
+ "columnName": "instance_uri",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tab",
+ "columnName": "tab",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "checked",
+ "columnName": "checked",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "index",
+ "user_id",
+ "instance_uri"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_tabsChecked_user_id_instance_uri",
+ "unique": false,
+ "columnNames": [
+ "user_id",
+ "instance_uri"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_tabsChecked_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "user_id",
+ "instance_uri"
+ ],
+ "referencedColumns": [
+ "user_id",
+ "instance_uri"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "directMessages",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `unread` INTEGER, `accounts` TEXT, `last_status` TEXT, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "unread",
+ "columnName": "unread",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "accounts",
+ "columnName": "accounts",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "last_status",
+ "columnName": "last_status",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "user_id",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "instance_uri",
+ "columnName": "instance_uri",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "user_id",
+ "instance_uri"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_directMessages_user_id_instance_uri",
+ "unique": false,
+ "columnNames": [
+ "user_id",
+ "instance_uri"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_directMessages_user_id_instance_uri` ON `${TABLE_NAME}` (`user_id`, `instance_uri`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "user_id",
+ "instance_uri"
+ ],
+ "referencedColumns": [
+ "user_id",
+ "instance_uri"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "directMessagesThreads",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `hidden` INTEGER, `isAuthor` INTEGER, `type` TEXT, `text` TEXT, `media` TEXT, `carousel` TEXT, `created_at` TEXT, `timeAgo` TEXT, `reportId` TEXT, `conversationsId` TEXT NOT NULL, `user_id` TEXT NOT NULL, `instance_uri` TEXT NOT NULL, PRIMARY KEY(`id`, `conversationsId`, `user_id`, `instance_uri`), FOREIGN KEY(`user_id`, `instance_uri`) REFERENCES `users`(`user_id`, `instance_uri`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "hidden",
+ "columnName": "hidden",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isAuthor",
+ "columnName": "isAuthor",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "text",
+ "columnName": "text",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "media",
+ "columnName": "media",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "carousel",
+ "columnName": "carousel",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created_at",
+ "columnName": "created_at",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "timeAgo",
+ "columnName": "timeAgo",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "reportId",
+ "columnName": "reportId",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "conversationsId",
+ "columnName": "conversationsId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user_id",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "instance_uri",
+ "columnName": "instance_uri",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id",
+ "conversationsId",
+ "user_id",
+ "instance_uri"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_directMessagesThreads_user_id_instance_uri_conversationsId",
+ "unique": false,
+ "columnNames": [
+ "user_id",
+ "instance_uri",
+ "conversationsId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_directMessagesThreads_user_id_instance_uri_conversationsId` ON `${TABLE_NAME}` (`user_id`, `instance_uri`, `conversationsId`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "users",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "user_id",
+ "instance_uri"
+ ],
+ "referencedColumns": [
+ "user_id",
+ "instance_uri"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [],
+ "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, '37b7f1d842d148e1d117ac9caae8fb51')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a5608ec5..8e9b54b5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -141,6 +141,12 @@
+
+
+
Unit, val context: Context) : RecyclerView.EdgeEffectFactory() {
+ override fun createEdgeEffect(recyclerView: RecyclerView, direction: Int): EdgeEffect {
+
+ return object : EdgeEffect(recyclerView.context) {
+
+ // A reference to the [SpringAnimation] for this RecyclerView used to bring the item back after the over-scroll effect.
+ var translationAnim: SpringAnimation? = null
+
+ override fun onPull(deltaDistance: Float) {
+ super.onPull(deltaDistance)
+ handlePull(deltaDistance)
+ }
+
+ override fun onPull(deltaDistance: Float, displacement: Float) {
+ super.onPull(deltaDistance, displacement)
+ handlePull(deltaDistance)
+ }
+
+ private fun handlePull(deltaDistance: Float) {
+ // This is called on every touch event while the list is scrolled with a finger.
+
+ // Translate the recyclerView with the distance
+ val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
+ val translationYDelta = sign * recyclerView.width * deltaDistance * OVERSCROLL_TRANSLATION_MAGNITUDE
+ recyclerView.translationY += translationYDelta
+
+ translationAnim?.cancel()
+ }
+
+ override fun onRelease() {
+ super.onRelease()
+ // The finger is lifted. Start the animation to bring translation back to the resting state.
+ if (recyclerView.translationY != 0f) {
+ if (direction == DIRECTION_BOTTOM && recyclerView.translationY.toInt().absoluteValue.pxToDp(context) > 50) {
+ refreshCallback()
+ }
+
+ translationAnim = createAnim()?.also { it.start() }
+ }
+ }
+
+ override fun onAbsorb(velocity: Int) {
+ super.onAbsorb(velocity)
+
+ // The list has reached the edge on fling.
+ val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
+
+ val translationVelocity = sign * velocity * FLING_TRANSLATION_MAGNITUDE
+ translationAnim?.cancel()
+ translationAnim = createAnim().setStartVelocity(translationVelocity)?.also { it.start() }
+ }
+
+ override fun draw(canvas: Canvas?): Boolean {
+ // don't paint the usual edge effect
+ return false
+ }
+
+ override fun isFinished(): Boolean {
+ // Without this, will skip future calls to onAbsorb()
+ return translationAnim?.isRunning?.not() ?: true
+ }
+
+ private fun createAnim() = SpringAnimation(recyclerView, SpringAnimation.TRANSLATION_Y)
+ .setSpring(
+ SpringForce()
+ .setFinalPosition(0f)
+ .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
+ .setStiffness(SpringForce.STIFFNESS_LOW)
+ )
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/directmessages/ConversationActivity.kt b/app/src/main/java/org/pixeldroid/app/directmessages/ConversationActivity.kt
new file mode 100644
index 00000000..29f8415e
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/directmessages/ConversationActivity.kt
@@ -0,0 +1,109 @@
+package org.pixeldroid.app.directmessages
+
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.Toast
+import androidx.fragment.app.commit
+import androidx.lifecycle.lifecycleScope
+import org.pixeldroid.app.R
+import org.pixeldroid.app.databinding.ActivityConversationBinding
+import org.pixeldroid.app.directmessages.ConversationFragment.Companion.CONVERSATION_ID
+import org.pixeldroid.app.directmessages.ConversationFragment.Companion.PROFILE_ID
+import org.pixeldroid.app.utils.BaseActivity
+import org.pixeldroid.app.utils.api.PixelfedAPI
+
+class ConversationActivity : BaseActivity() {
+ lateinit var binding: ActivityConversationBinding
+
+ private lateinit var conversationFragment: ConversationFragment
+
+ companion object {
+ const val USERNAME = "ConversationActivityUsername"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityConversationBinding.inflate(layoutInflater)
+
+ conversationFragment = ConversationFragment()
+
+ setContentView(binding.root)
+ setSupportActionBar(binding.topBar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
+ val userName = intent?.getSerializableExtra(USERNAME) as? String?
+ supportActionBar?.title = getString(R.string.dm_title, userName)
+
+ val conversationId = intent?.getSerializableExtra(CONVERSATION_ID) as String
+ val pid = intent?.getSerializableExtra(PROFILE_ID) as String
+
+ activateCommenter(pid)
+
+ initConversationFragment(pid, conversationId, savedInstanceState)
+ }
+
+ private fun activateCommenter(pid: String) {
+ //Activate commenter
+ binding.submitComment.setOnClickListener {
+ val textIn = binding.editComment.text
+ //Open text input
+ if(textIn.isNullOrEmpty()) {
+ Toast.makeText(
+ binding.root.context,
+ binding.root.context.getString(R.string.empty_comment),
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ //Post the comment
+ lifecycleScope.launchWhenCreated {
+ apiHolder.api?.let { it1 -> sendMessage(it1, pid) }
+ }
+ }
+ }
+ }
+
+ private fun initConversationFragment(profileId: String, conversationId: String, savedInstanceState: Bundle?) {
+
+ val arguments = Bundle()
+ arguments.putSerializable(CONVERSATION_ID, conversationId)
+ arguments.putSerializable(PROFILE_ID, profileId)
+ conversationFragment.arguments = arguments
+
+ //TODO finish work here! commentFragment needs the swiperefreshlayout.. how??
+ //Maybe read https://archive.ph/G9VHW#selection-1324.2-1322.3 or further research
+ if (savedInstanceState == null) {
+ supportFragmentManager.commit {
+ setReorderingAllowed(true)
+ replace(R.id.conversationFragment, conversationFragment)
+ }
+ }
+ }
+
+ private suspend fun sendMessage(
+ api: PixelfedAPI,
+ pid: String,
+ ) {
+ val textIn = binding.editComment.text
+ val nonNullText = textIn.toString()
+ try {
+ binding.submitComment.isEnabled = false
+ binding.editComment.isEnabled = false
+ api.sendDirectMessage(pid, nonNullText)
+
+ //Reload to add the comment to the comment section
+ conversationFragment.adapter.refresh()
+
+ binding.editComment.isEnabled = true
+ binding.editComment.text = null
+ binding.submitComment.isEnabled = true
+ } catch (exception: Exception) {
+ Log.e("DM SEND ERROR", exception.toString())
+ Toast.makeText(
+ binding.root.context, binding.root.context.getString(R.string.comment_error),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+}
diff --git a/app/src/main/java/org/pixeldroid/app/directmessages/ConversationFragment.kt b/app/src/main/java/org/pixeldroid/app/directmessages/ConversationFragment.kt
new file mode 100644
index 00000000..415a49d6
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/directmessages/ConversationFragment.kt
@@ -0,0 +1,188 @@
+package org.pixeldroid.app.directmessages
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import androidx.lifecycle.LifecycleCoroutineScope
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.paging.ExperimentalPagingApi
+import androidx.paging.PagingDataAdapter
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import androidx.viewpager2.widget.ViewPager2
+import com.bumptech.glide.Glide
+import org.pixeldroid.app.R
+import org.pixeldroid.app.databinding.DirectMessagesConversationItemBinding
+import org.pixeldroid.app.posts.AlbumActivity
+import org.pixeldroid.app.posts.AlbumViewModel
+import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment
+import org.pixeldroid.app.posts.feeds.cachedFeeds.FeedContentRepository
+import org.pixeldroid.app.posts.feeds.cachedFeeds.FeedViewModel
+import org.pixeldroid.app.posts.feeds.cachedFeeds.ViewModelFactory
+import org.pixeldroid.app.utils.api.objects.Conversation
+import org.pixeldroid.app.utils.api.objects.Message
+import org.pixeldroid.app.utils.db.entities.DirectMessageDatabaseEntity
+import org.pixeldroid.app.utils.di.PixelfedAPIHolder
+
+
+/**
+ * Fragment for one Direct Messages conversation
+ */
+class ConversationFragment : CachedFeedFragment() {
+
+ companion object {
+ const val CONVERSATION_ID = "ConversationFragmentConversationId"
+ const val PROFILE_ID = "ConversationFragmentProfileId"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ adapter = DirectMessagesListAdapter(apiHolder)
+ }
+
+ @OptIn(ExperimentalPagingApi::class)
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val view = super.createView(inflater, container, savedInstanceState, true)
+
+ val pid = arguments?.getSerializable(PROFILE_ID) as String
+ val conversationId = arguments?.getSerializable(CONVERSATION_ID) as String
+
+ val dao = db.directMessagesConversationDao()
+ val remoteMediator = ConversationRemoteMediator(apiHolder, db, pid, conversationId)
+ // get the view model
+ @Suppress("UNCHECKED_CAST")
+ viewModel = ViewModelProvider(
+ requireActivity(),
+ ViewModelFactory(
+ db, dao, remoteMediator,
+ FeedContentRepository(db, dao, remoteMediator, conversationId)
+ )
+ )["directMessagesConversation", FeedViewModel::class.java] as FeedViewModel
+
+ launch()
+ initSearch()
+
+ return view
+ }
+
+ /**
+ * View Holder for a [Conversation] RecyclerView list item.
+ */
+ class DirectMessagesConversationViewHolder(val binding: DirectMessagesConversationItemBinding) : RecyclerView.ViewHolder(binding.root) {
+ private var message: DirectMessageDatabaseEntity? = null
+
+ init {
+ itemView.setOnClickListener {
+ message?.let {
+ if (it.type == "photo") {
+ val intent = Intent(itemView.context, AlbumActivity::class.java)
+
+ intent.putExtra(AlbumViewModel.ALBUM_IMAGES, ArrayList(it.carousel.orEmpty()))
+ intent.putExtra(AlbumViewModel.ALBUM_INDEX, 0)
+
+ itemView.context.startActivity(intent)
+ }
+ }
+ }
+ }
+
+ fun bind(
+ message: DirectMessageDatabaseEntity?,
+ api: PixelfedAPIHolder,
+ lifecycleScope: LifecycleCoroutineScope,
+ ) {
+ this.message = message
+
+ if(message?.isAuthor == true) {
+ binding.messageIncoming.visibility = GONE
+ binding.messageOutgoing.visibility = VISIBLE
+ binding.textMessageOutgoing.text = message.text
+ } else {
+ binding.messageIncoming.visibility = VISIBLE
+ binding.messageOutgoing.visibility = GONE
+ binding.textMessageIncoming.text = message?.text ?: ""
+ }
+
+ if (message?.type == "photo"){
+ binding.imageMessageIncoming.visibility = VISIBLE
+ binding.imageMessageOutgoing.visibility = VISIBLE
+ binding.textMessageOutgoing.visibility = GONE
+ binding.textMessageOutgoing.visibility = GONE
+ Glide.with(if(message.isAuthor == true) binding.imageMessageOutgoing else binding.imageMessageIncoming)
+ .load(message.media)
+ .into(if(message.isAuthor == true) binding.imageMessageOutgoing else binding.imageMessageIncoming)
+ } else {
+ binding.imageMessageIncoming.visibility = GONE
+ binding.imageMessageOutgoing.visibility = GONE
+ binding.textMessageOutgoing.visibility = VISIBLE
+ binding.textMessageIncoming.visibility = VISIBLE
+ }
+
+ message?.created_at.let {
+// if (it == null) binding.messageTime.text = ""
+// else setTextViewFromISO8601(
+// it,
+// binding.messageTime,
+// false
+// )
+ }
+ }
+
+ companion object {
+ fun create(parent: ViewGroup): DirectMessagesConversationViewHolder {
+ val itemBinding = DirectMessagesConversationItemBinding.inflate(
+ LayoutInflater.from(parent.context), parent, false
+ )
+ return DirectMessagesConversationViewHolder(itemBinding)
+ }
+ }
+ }
+
+
+ inner class DirectMessagesListAdapter(
+ private val apiHolder: PixelfedAPIHolder,
+ ) : PagingDataAdapter(
+ object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: DirectMessageDatabaseEntity,
+ newItem: DirectMessageDatabaseEntity
+ ): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(
+ oldItem: DirectMessageDatabaseEntity,
+ newItem: DirectMessageDatabaseEntity
+ ): Boolean =
+ oldItem == newItem
+ }
+ ) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ return DirectMessagesConversationViewHolder.create(parent)
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return R.layout.direct_messages_conversation_item
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ val uiModel = getItem(position)
+ uiModel?.let {
+ (holder as DirectMessagesConversationViewHolder).bind(
+ it,
+ apiHolder,
+ lifecycleScope
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/directmessages/ConversationRemoteMediator.kt b/app/src/main/java/org/pixeldroid/app/directmessages/ConversationRemoteMediator.kt
new file mode 100644
index 00000000..def077d4
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/directmessages/ConversationRemoteMediator.kt
@@ -0,0 +1,74 @@
+package org.pixeldroid.app.directmessages
+
+import android.util.Log
+import androidx.paging.*
+import androidx.room.withTransaction
+import org.pixeldroid.app.utils.db.AppDatabase
+import org.pixeldroid.app.utils.di.PixelfedAPIHolder
+import org.pixeldroid.app.utils.db.entities.DirectMessageDatabaseEntity
+import java.lang.Exception
+import java.lang.NullPointerException
+import javax.inject.Inject
+
+/**
+ * RemoteMediator for a Direct Messages conversation.
+ *
+ * A [RemoteMediator] defines a set of callbacks used to incrementally load data from a remote
+ * source into a local source wrapped by a [PagingSource], e.g., loading data from network into
+ * a local db cache.
+ */
+@OptIn(ExperimentalPagingApi::class)
+class ConversationRemoteMediator @Inject constructor(
+ private val apiHolder: PixelfedAPIHolder,
+ private val db: AppDatabase,
+ private val pid: String,
+ private val conversationId: String
+) : RemoteMediator() {
+
+ override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult {
+ try {
+ val user = db.userDao().getActiveUser()
+ ?: return MediatorResult.Error(NullPointerException("No active user exists"))
+
+ val nextPage = when (loadType) {
+ LoadType.REFRESH -> null
+ LoadType.PREPEND -> {
+ // No prepend for the moment, might be nice to add later
+ db.directMessagesConversationDao().lastMessageId(user.user_id, user.instance_uri, conversationId)
+ ?: return MediatorResult.Success(endOfPaginationReached = true)
+ }
+ LoadType.APPEND ->
+ return MediatorResult.Success(endOfPaginationReached = true)
+ }
+
+ val api = apiHolder.api ?: apiHolder.setToCurrentUser()
+ val apiResponse =
+ api.directMessagesConversation(
+ pid = pid,
+ max_id = nextPage,
+ )
+ //TODO prepend
+
+ val messages = apiResponse.messages.map {
+ DirectMessageDatabaseEntity(
+ it,
+ conversationId,
+ user
+ )
+ }
+
+ val endOfPaginationReached = messages.isEmpty()
+
+ db.withTransaction {
+ // Clear table in the database
+ if (loadType == LoadType.REFRESH) {
+ db.directMessagesConversationDao().clearFeedContent(user.user_id, user.instance_uri, conversationId)
+ }
+ db.directMessagesConversationDao().insertAll(messages)
+ }
+ return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
+ } catch (exception: Exception){
+ return MediatorResult.Error(exception)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/directmessages/DirectMessagesActivity.kt b/app/src/main/java/org/pixeldroid/app/directmessages/DirectMessagesActivity.kt
new file mode 100644
index 00000000..96d576f9
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/directmessages/DirectMessagesActivity.kt
@@ -0,0 +1,40 @@
+package org.pixeldroid.app.directmessages
+
+import android.os.Bundle
+import androidx.fragment.app.commit
+import org.pixeldroid.app.R
+import org.pixeldroid.app.databinding.ActivityConversationBinding
+import org.pixeldroid.app.databinding.ActivityFollowersBinding
+import org.pixeldroid.app.profile.FollowsActivity
+import org.pixeldroid.app.utils.BaseActivity
+
+class DirectMessagesActivity : BaseActivity() {
+ lateinit var binding: ActivityFollowersBinding
+
+ private lateinit var conversationFragment: DirectMessagesFragment
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityFollowersBinding.inflate(layoutInflater)
+
+ conversationFragment = DirectMessagesFragment()
+
+ setContentView(binding.root)
+ setSupportActionBar(binding.topBar)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ supportActionBar?.setTitle(R.string.direct_messages)
+
+ initConversationFragment(savedInstanceState)
+ }
+
+ private fun initConversationFragment(savedInstanceState: Bundle?) {
+ //TODO finish work here! commentFragment needs the swiperefreshlayout.. how??
+ //Maybe read https://archive.ph/G9VHW#selection-1324.2-1322.3 or further research
+ if (savedInstanceState == null) {
+ supportFragmentManager.commit {
+ setReorderingAllowed(true)
+ replace(R.id.conversationFragment, conversationFragment)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/pixeldroid/app/directmessages/DirectMessagesFragment.kt b/app/src/main/java/org/pixeldroid/app/directmessages/DirectMessagesFragment.kt
new file mode 100644
index 00000000..48d2ab47
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/directmessages/DirectMessagesFragment.kt
@@ -0,0 +1,171 @@
+package org.pixeldroid.app.directmessages
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.lifecycle.LifecycleCoroutineScope
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.paging.ExperimentalPagingApi
+import androidx.paging.PagingDataAdapter
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import org.pixeldroid.app.R
+import org.pixeldroid.app.databinding.DirectMessagesListItemBinding
+import org.pixeldroid.app.directmessages.ConversationActivity.Companion.USERNAME
+import org.pixeldroid.app.directmessages.ConversationFragment.Companion.CONVERSATION_ID
+import org.pixeldroid.app.directmessages.ConversationFragment.Companion.PROFILE_ID
+import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment
+import org.pixeldroid.app.posts.feeds.cachedFeeds.FeedViewModel
+import org.pixeldroid.app.posts.feeds.cachedFeeds.ViewModelFactory
+import org.pixeldroid.app.posts.parseHTMLText
+import org.pixeldroid.app.posts.setTextViewFromISO8601
+import org.pixeldroid.app.profile.ProfileActivity
+import org.pixeldroid.app.utils.api.objects.Account
+import org.pixeldroid.app.utils.api.objects.Conversation
+import org.pixeldroid.app.utils.di.PixelfedAPIHolder
+
+
+/**
+ * Fragment for the list of Direct Messages conversations.
+ */
+class DirectMessagesFragment : CachedFeedFragment() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ adapter = DirectMessagesListAdapter(apiHolder)
+ }
+
+ @OptIn(ExperimentalPagingApi::class)
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val view = super.onCreateView(inflater, container, savedInstanceState)
+
+ // get the view model
+ @Suppress("UNCHECKED_CAST")
+ viewModel = ViewModelProvider(
+ requireActivity(),
+ ViewModelFactory(db, db.directMessagesDao(), DirectMessagesRemoteMediator(apiHolder, db))
+ )["directMessagesList", FeedViewModel::class.java] as FeedViewModel
+
+ launch()
+ initSearch()
+
+ return view
+ }
+
+ /**
+ * View Holder for a [Conversation] RecyclerView list item.
+ */
+ class DirectMessagesListViewHolder(val binding: DirectMessagesListItemBinding) : RecyclerView.ViewHolder(binding.root) {
+ private var conversation: Conversation? = null
+
+ init {
+ itemView.setOnClickListener {
+ conversation?.accounts?.firstOrNull()?.let {
+ val intent = Intent(itemView.context, ConversationActivity::class.java).apply {
+ putExtra(PROFILE_ID, it.id)
+ putExtra(CONVERSATION_ID, conversation?.id)
+ putExtra(USERNAME, it.getDisplayName())
+ }
+ itemView.context.startActivity(intent)
+ }
+ }
+ binding.dmAvatar.setOnClickListener {
+ conversation?.accounts?.firstOrNull()?.let {
+ val intent = Intent(itemView.context, ProfileActivity::class.java).apply {
+ putExtra(Account.ACCOUNT_TAG, it)
+ }
+ itemView.context.startActivity(intent)
+ }
+ }
+ }
+
+ fun bind(
+ conversation: Conversation?,
+ api: PixelfedAPIHolder,
+ lifecycleScope: LifecycleCoroutineScope,
+ ) {
+
+ this.conversation = conversation
+
+ val account = conversation?.accounts?.firstOrNull()
+
+ Glide.with(itemView).load(account?.anyAvatar()).circleCrop()
+ .into(binding.dmAvatar)
+
+ binding.dmUsername.text = account?.getDisplayName()
+
+ binding.dmLastMessage.text = parseHTMLText(
+ conversation?.last_status?.content ?: "",
+ conversation?.last_status?.mentions,
+ api,
+ itemView.context,
+ lifecycleScope
+ )
+
+ conversation?.last_status?.created_at.let {
+ if (it == null) binding.messageTime.text = ""
+ else setTextViewFromISO8601(
+ it,
+ binding.messageTime,
+ false
+ )
+ }
+ }
+
+ companion object {
+ fun create(parent: ViewGroup): DirectMessagesListViewHolder {
+ val itemBinding = DirectMessagesListItemBinding.inflate(
+ LayoutInflater.from(parent.context), parent, false
+ )
+ return DirectMessagesListViewHolder(itemBinding)
+ }
+ }
+ }
+
+
+ inner class DirectMessagesListAdapter(
+ private val apiHolder: PixelfedAPIHolder,
+ ) : PagingDataAdapter(
+ object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: Conversation,
+ newItem: Conversation
+ ): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(
+ oldItem: Conversation,
+ newItem: Conversation
+ ): Boolean =
+ oldItem == newItem
+ }
+ ) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ return DirectMessagesListViewHolder.create(parent)
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return R.layout.direct_messages_list_item
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ val uiModel = getItem(position)
+ uiModel?.let {
+ (holder as DirectMessagesListViewHolder).bind(
+ it,
+ apiHolder,
+ lifecycleScope
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/directmessages/DirectMessagesRemoteMediator.kt b/app/src/main/java/org/pixeldroid/app/directmessages/DirectMessagesRemoteMediator.kt
new file mode 100644
index 00000000..dd99dc03
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/directmessages/DirectMessagesRemoteMediator.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.pixeldroid.app.directmessages
+
+import androidx.paging.*
+import androidx.paging.PagingSource.LoadResult
+import androidx.room.withTransaction
+import org.pixeldroid.app.utils.api.objects.Conversation
+import org.pixeldroid.app.utils.db.AppDatabase
+import org.pixeldroid.app.utils.di.PixelfedAPIHolder
+import org.pixeldroid.app.utils.api.objects.Notification
+import retrofit2.HttpException
+import java.lang.Exception
+import java.lang.NullPointerException
+import javax.inject.Inject
+
+/**
+ * RemoteMediator for the notifications.
+ *
+ * A [RemoteMediator] defines a set of callbacks used to incrementally load data from a remote
+ * source into a local source wrapped by a [PagingSource], e.g., loading data from network into
+ * a local db cache.
+ */
+@OptIn(ExperimentalPagingApi::class)
+class DirectMessagesRemoteMediator @Inject constructor(
+ private val apiHolder: PixelfedAPIHolder,
+ private val db: AppDatabase
+) : RemoteMediator() {
+
+ override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult {
+ try {
+ val user = db.userDao().getActiveUser()
+ ?: return MediatorResult.Error(NullPointerException("No active user exists"))
+ val api = apiHolder.api ?: apiHolder.setToCurrentUser()
+
+ val nextPage = when (loadType) {
+ LoadType.REFRESH -> null
+ LoadType.PREPEND -> {
+ // No prepend for the moment, might be nice to add later
+ return MediatorResult.Success(endOfPaginationReached = true)
+ }
+ LoadType.APPEND -> state.lastItemOrNull()?.id?.toIntOrNull()
+ ?.let { state.closestPageToPosition(it) }?.nextKey
+ ?: return MediatorResult.Success(endOfPaginationReached = true)
+ }
+
+ val apiResponse =
+ // Pixelfed uses Laravel's paging mechanism for pagination.
+ //TODO, implement also for Mastodon (see FollowersPagingSource)
+ api.directMessagesList(
+ limit = state.config.pageSize.toString(),
+ page = nextPage
+ )
+
+ apiResponse.forEach{it.user_id = user.user_id; it.instance_uri = user.instance_uri}
+
+ val endOfPaginationReached = apiResponse.isEmpty()
+
+ db.withTransaction {
+ // Clear table in the database
+ if (loadType == LoadType.REFRESH) {
+ db.directMessagesDao().clearFeedContent(user.user_id, user.instance_uri)
+ }
+ db.directMessagesDao().insertAll(apiResponse)
+ }
+ return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
+ } catch (exception: Exception){
+ return MediatorResult.Error(exception)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/main/MainActivity.kt b/app/src/main/java/org/pixeldroid/app/main/MainActivity.kt
index 9f474460..31592d64 100644
--- a/app/src/main/java/org/pixeldroid/app/main/MainActivity.kt
+++ b/app/src/main/java/org/pixeldroid/app/main/MainActivity.kt
@@ -24,24 +24,16 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.GravityCompat
import androidx.core.view.children
import androidx.core.view.isVisible
-import androidx.core.view.children
-import androidx.core.view.get
-import androidx.core.view.isVisible
-import androidx.core.view.marginEnd
-import androidx.core.view.marginTop
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.paging.ExperimentalPagingApi
-import androidx.recyclerview.widget.GridLayoutManager
-import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.bumptech.glide.Glide
-import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.color.DynamicColors
import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.navigation.NavigationView
@@ -60,9 +52,11 @@ import com.mikepenz.materialdrawer.util.DrawerImageLoader
import com.mikepenz.materialdrawer.widget.AccountHeaderView
import kotlinx.coroutines.launch
import org.ligi.tracedroid.sending.sendTraceDroidStackTracesIfExist
-import org.pixeldroid.app.login.LoginActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityMainBinding
+import org.pixeldroid.app.directmessages.DirectMessagesActivity
+import org.pixeldroid.app.directmessages.DirectMessagesFragment
+import org.pixeldroid.app.login.LoginActivity
import org.pixeldroid.app.postCreation.camera.CameraFragment
import org.pixeldroid.app.posts.NestedScrollableHost
import org.pixeldroid.app.posts.feeds.cachedFeeds.CachedFeedFragment
@@ -79,7 +73,6 @@ import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
import org.pixeldroid.app.utils.db.updateUserInfoDb
import org.pixeldroid.app.utils.hasInternet
-import org.pixeldroid.app.utils.loadDefaultMenuTabs
import org.pixeldroid.app.utils.loadDbMenuTabs
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.INSTANCE_NOTIFICATION_TAG
import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker.Companion.SHOW_NOTIFICATION_TAG
@@ -260,6 +253,10 @@ class MainActivity : BaseActivity() {
getUpdatedAccount()
binding.drawer?.itemAdapter?.add(
+ primaryDrawerItem {
+ nameRes = R.string.direct_messages
+ iconRes = R.drawable.message
+ },
primaryDrawerItem {
nameRes = R.string.menu_account
iconRes = R.drawable.person
@@ -276,9 +273,10 @@ class MainActivity : BaseActivity() {
binding.drawer?.onDrawerItemClickListener = { v, drawerItem, position ->
when (position) {
- 1 -> launchActivity(ProfileActivity())
- 2 -> launchActivity(SettingsActivity())
- 3 -> logOut()
+ 1 -> launchActivity(DirectMessagesActivity())
+ 2 -> launchActivity(ProfileActivity())
+ 3 -> launchActivity(SettingsActivity())
+ 4 -> logOut()
}
false
}
@@ -458,9 +456,7 @@ class MainActivity : BaseActivity() {
@OptIn(ExperimentalPagingApi::class)
private fun setupTabs() {
- val tabsCheckedDbEntry = with (db.userDao().getActiveUser()!!) {
- db.tabsDao().getTabsChecked(user_id, instance_uri)
- }
+ val tabsCheckedDbEntry = db.tabsDao().getTabsChecked(user!!.user_id, user!!.instance_uri)
val pageIds = listOf(R.id.page_1, R.id.page_2, R.id.page_3, R.id.page_4, R.id.page_5)
fun Tab.getFragment(): (() -> Fragment) {
@@ -480,33 +476,34 @@ class MainActivity : BaseActivity() {
arguments = Bundle().apply { putBoolean("home", false) }
}
} }
+ Tab.DIRECT_MESSAGES -> {{
+ DirectMessagesFragment()
+ }}
}
}
val tabs = if (tabsCheckedDbEntry.isEmpty()) {
- // Load default menu
- loadDefaultMenuTabs(applicationContext, binding.root)
+ // Default menu
+ Tab.defaultTabs
} else {
// Get current menu visibility and order from settings
- val tabsChecked = loadDbMenuTabs(applicationContext, tabsCheckedDbEntry).filter { it.second }.map { it.first }
+ loadDbMenuTabs(tabsCheckedDbEntry).filter { it.second }.map { it.first }
+ }
- val bottomNavigationMenu: Menu? = (binding.tabs as? NavigationBarView)?.menu?.apply {
- clear()
- }
- ?: binding.navigation?.menu?.apply {
- removeGroup(R.id.tabsId)
+ val bottomNavigationMenu: Menu? = (binding.tabs as? NavigationBarView)?.menu?.apply {
+ clear()
+ }
+ ?: binding.navigation?.menu?.apply {
+ if(tabs.contains(Tab.DIRECT_MESSAGES)) removeGroup(R.id.dmNavigationGroup)
}
- tabsChecked.zip(pageIds).forEach { (tabId, pageId) ->
- with(bottomNavigationMenu?.add(R.id.tabsId, pageId, 1, tabId.toLanguageString(baseContext))) {
- val tabIcon = tabId.getDrawable(applicationContext)
- if (tabIcon != null) {
- this?.icon = tabIcon
- }
+ tabs.zip(pageIds).forEach { (tabId, pageId) ->
+ with(bottomNavigationMenu?.add(R.id.tabsId, pageId, 1, tabId.toLanguageString(baseContext))) {
+ val tabIcon = tabId.getDrawable(applicationContext)
+ if (tabIcon != null) {
+ this?.icon = tabIcon
}
}
-
- tabsChecked
}
val tabArray: List<() -> Fragment> = tabs.map { it.getFragment() }
@@ -558,6 +555,7 @@ class MainActivity : BaseActivity() {
fun MenuItem.buttonPos() {
when(itemId){
+ R.id.dms -> launchActivity(DirectMessagesActivity())
R.id.my_profile -> launchActivity(ProfileActivity())
R.id.settings -> launchActivity(SettingsActivity())
R.id.log_out -> logOut()
diff --git a/app/src/main/java/org/pixeldroid/app/posts/feeds/CommonFeedFragmentUtils.kt b/app/src/main/java/org/pixeldroid/app/posts/feeds/CommonFeedFragmentUtils.kt
index 02c2efbc..7b652101 100644
--- a/app/src/main/java/org/pixeldroid/app/posts/feeds/CommonFeedFragmentUtils.kt
+++ b/app/src/main/java/org/pixeldroid/app/posts/feeds/CommonFeedFragmentUtils.kt
@@ -50,7 +50,7 @@ private fun showError(
* Makes the UI respond to various [LoadState]s, including errors when an error message is shown.
*/
internal fun initAdapter(
- progressBar: ProgressBar, swipeRefreshLayout: SwipeRefreshLayout,
+ progressBar: ProgressBar, swipeRefreshLayout: SwipeRefreshLayout?,
recyclerView: RecyclerView, motionLayout: MotionLayout, errorLayout: ErrorLayoutBinding,
adapter: PagingDataAdapter,
header: StoriesAdapter? = null
@@ -71,7 +71,7 @@ internal fun initAdapter(
).toTypedArray()
)
- swipeRefreshLayout.setOnRefreshListener {
+ swipeRefreshLayout?.setOnRefreshListener {
adapter.refresh()
adapter.notifyDataSetChanged()
header?.refreshStories()
@@ -79,7 +79,7 @@ internal fun initAdapter(
adapter.addLoadStateListener { loadState ->
- if(!progressBar.isVisible && swipeRefreshLayout.isRefreshing) {
+ if(!progressBar.isVisible && swipeRefreshLayout?.isRefreshing == true) {
// Stop loading spinner when loading is done
swipeRefreshLayout.isRefreshing = loadState.refresh is LoadState.Loading
}
diff --git a/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/CachedFeedFragment.kt b/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/CachedFeedFragment.kt
index 3f30c74b..1229407b 100644
--- a/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/CachedFeedFragment.kt
+++ b/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/CachedFeedFragment.kt
@@ -1,22 +1,27 @@
package org.pixeldroid.app.posts.feeds.cachedFeeds
import android.os.Bundle
+import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
-import androidx.paging.LoadState.NotLoading
import androidx.paging.PagingDataAdapter
import androidx.paging.RemoteMediator
+import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.distinctUntilChangedBy
-import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.launch
import org.pixeldroid.app.databinding.FragmentFeedBinding
+import org.pixeldroid.app.directmessages.BounceEdgeEffectFactory
import org.pixeldroid.app.posts.feeds.initAdapter
import org.pixeldroid.app.stories.StoriesAdapter
import org.pixeldroid.app.utils.BaseFragment
@@ -67,26 +72,57 @@ open class CachedFeedFragment : BaseFragment() {
// }
}
- override fun onCreateView(
- inflater: LayoutInflater, container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View? {
-
+ fun createView(inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?, reverseLayout: Boolean = false): ConstraintLayout {
super.onCreateView(inflater, container, savedInstanceState)
binding = FragmentFeedBinding.inflate(layoutInflater)
- initAdapter(binding.progressBar, binding.swipeRefreshLayout,
+ val callback: () -> Unit = {
+ binding.bottomLoadingBar.visibility = View.VISIBLE
+ GlobalScope.launch(Dispatchers.Main) {
+ delay(1000) // Wait 1 second
+ binding.bottomLoadingBar.visibility = View.GONE
+ }
+ adapter.refresh()
+ adapter.notifyDataSetChanged()
+ }
+
+ val swipeRefreshLayout = if(reverseLayout) {
+ binding.swipeRefreshLayout.isEnabled = false
+ binding.list.apply {
+ layoutManager = LinearLayoutManager(context).apply {
+ stackFromEnd = false
+ this.reverseLayout = true
+ }
+ edgeEffectFactory = BounceEdgeEffectFactory(callback, context)
+ }
+ null
+ } else binding.swipeRefreshLayout
+
+ initAdapter(binding.progressBar, swipeRefreshLayout,
binding.list, binding.motionLayout, binding.errorLayout, adapter,
headerAdapter
)
return binding.root
}
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return createView(inflater, container, savedInstanceState, false)
+ }
fun onTabReClicked() {
binding.list.limitedLengthSmoothScrollToPosition(0)
}
+
+ private fun onPullUp() {
+ // Handle the pull-up action
+ Log.e("bottom", "reached")
+ }
+
}
diff --git a/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/FeedContentRepository.kt b/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/FeedContentRepository.kt
index bf8d47f6..f63f5456 100644
--- a/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/FeedContentRepository.kt
+++ b/app/src/main/java/org/pixeldroid/app/posts/feeds/cachedFeeds/FeedContentRepository.kt
@@ -32,7 +32,8 @@ import org.pixeldroid.app.utils.db.dao.feedContent.FeedContentDao
class FeedContentRepository @ExperimentalPagingApi constructor(
private val db: AppDatabase,
private val dao: FeedContentDao,
- private val mediator: RemoteMediator
+ private val mediator: RemoteMediator,
+ private val conversationsId: String = "",
) {
/**
@@ -44,7 +45,7 @@ class FeedContentRepository @ExperimentalPagingApi const
val user = db.userDao().getActiveUser()!!
val pagingSourceFactory = {
- dao.feedContent(user.user_id, user.instance_uri)
+ dao.feedContent(user.user_id, user.instance_uri, conversationsId)
}
return Pager(
diff --git a/app/src/main/java/org/pixeldroid/app/posts/feeds/uncachedFeeds/UncachedFeedFragment.kt b/app/src/main/java/org/pixeldroid/app/posts/feeds/uncachedFeeds/UncachedFeedFragment.kt
index c4a2acbf..e8444de7 100644
--- a/app/src/main/java/org/pixeldroid/app/posts/feeds/uncachedFeeds/UncachedFeedFragment.kt
+++ b/app/src/main/java/org/pixeldroid/app/posts/feeds/uncachedFeeds/UncachedFeedFragment.kt
@@ -8,14 +8,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
-import androidx.paging.LoadState
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.distinctUntilChangedBy
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.launch
import org.pixeldroid.app.databinding.FragmentFeedBinding
import org.pixeldroid.app.posts.feeds.initAdapter
import org.pixeldroid.app.posts.feeds.launch
diff --git a/app/src/main/java/org/pixeldroid/app/posts/feeds/uncachedFeeds/hashtags/HashTagActivity.kt b/app/src/main/java/org/pixeldroid/app/posts/feeds/uncachedFeeds/hashtags/HashTagActivity.kt
index 277fccb3..09f5d029 100644
--- a/app/src/main/java/org/pixeldroid/app/posts/feeds/uncachedFeeds/hashtags/HashTagActivity.kt
+++ b/app/src/main/java/org/pixeldroid/app/posts/feeds/uncachedFeeds/hashtags/HashTagActivity.kt
@@ -1,13 +1,11 @@
package org.pixeldroid.app.posts.feeds.uncachedFeeds.hashtags
import android.os.Bundle
-import androidx.fragment.app.add
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import org.pixeldroid.app.R
import org.pixeldroid.app.databinding.ActivityFollowersBinding
import org.pixeldroid.app.posts.feeds.uncachedFeeds.UncachedPostsFragment
-import org.pixeldroid.app.posts.feeds.uncachedFeeds.accountLists.AccountListFragment
import org.pixeldroid.app.utils.BaseActivity
import org.pixeldroid.app.utils.api.objects.Tag.Companion.HASHTAG_TAG
@@ -37,7 +35,7 @@ class HashTagActivity : BaseActivity() {
supportFragmentManager.commit {
setReorderingAllowed(true)
- replace(R.id.followsFragment, args = arguments)
+ replace(R.id.conversationFragment, args = arguments)
}
}
}
diff --git a/app/src/main/java/org/pixeldroid/app/profile/FollowsActivity.kt b/app/src/main/java/org/pixeldroid/app/profile/FollowsActivity.kt
index a279ade2..9922dcc0 100644
--- a/app/src/main/java/org/pixeldroid/app/profile/FollowsActivity.kt
+++ b/app/src/main/java/org/pixeldroid/app/profile/FollowsActivity.kt
@@ -50,7 +50,7 @@ class FollowsActivity : BaseActivity() {
supportFragmentManager.commit {
setReorderingAllowed(true)
- replace(R.id.followsFragment, args = arguments)
+ replace(R.id.conversationFragment, args = arguments)
}
}
}
diff --git a/app/src/main/java/org/pixeldroid/app/settings/ArrangeTabsFragment.kt b/app/src/main/java/org/pixeldroid/app/settings/ArrangeTabsFragment.kt
index 1de40d77..059152cb 100644
--- a/app/src/main/java/org/pixeldroid/app/settings/ArrangeTabsFragment.kt
+++ b/app/src/main/java/org/pixeldroid/app/settings/ArrangeTabsFragment.kt
@@ -32,14 +32,14 @@ class ArrangeTabsFragment: DialogFragment() {
@Inject
lateinit var db: AppDatabase
- private val model: ArrangeTabsViewModel by viewModels { ArrangeTabsViewModelFactory(requireContext(), db) }
+ private val model: ArrangeTabsViewModel by viewModels()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val inflater: LayoutInflater = requireActivity().layoutInflater
val dialogView: View = inflater.inflate(R.layout.layout_tabs_arrange, null)
- model.initTabsChecked(dialogView)
+ model.initTabsChecked()
val listFeed: RecyclerView = dialogView.findViewById(R.id.tabs)
val listAdapter = ListViewAdapter(model)
diff --git a/app/src/main/java/org/pixeldroid/app/settings/ArrangeTabsViewModel.kt b/app/src/main/java/org/pixeldroid/app/settings/ArrangeTabsViewModel.kt
index 4e103a11..a4ce0f7c 100644
--- a/app/src/main/java/org/pixeldroid/app/settings/ArrangeTabsViewModel.kt
+++ b/app/src/main/java/org/pixeldroid/app/settings/ArrangeTabsViewModel.kt
@@ -1,9 +1,7 @@
package org.pixeldroid.app.settings
-import android.content.Context
-import android.view.View
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@@ -11,24 +9,10 @@ import org.pixeldroid.app.utils.Tab
import org.pixeldroid.app.utils.db.AppDatabase
import org.pixeldroid.app.utils.db.entities.TabsDatabaseEntity
import org.pixeldroid.app.utils.loadDbMenuTabs
-import org.pixeldroid.app.utils.loadDefaultMenuTabs
+import javax.inject.Inject
-
-class ArrangeTabsViewModelFactory(
- private val context: Context, private val db: AppDatabase
-) : ViewModelProvider.Factory {
-
- override fun create(modelClass: Class): T {
- if (modelClass.isAssignableFrom(ArrangeTabsViewModel::class.java)) {
- @Suppress("UNCHECKED_CAST")
- return ArrangeTabsViewModel(context, db) as T
- }
- throw IllegalArgumentException("Unknown ViewModel class")
- }
-}
-
-class ArrangeTabsViewModel(
- private val fragmentContext: Context,
+@HiltViewModel
+class ArrangeTabsViewModel @Inject constructor(
private val db: AppDatabase
): ViewModel() {
@@ -56,18 +40,17 @@ class ArrangeTabsViewModel(
}
}
- fun initTabsChecked(view: View) {
+ fun initTabsChecked() {
if (oldTabsChecked.isEmpty()) {
// Only load tabsChecked if the model has not been updated
_uiState.update { currentUiState ->
currentUiState.copy(
tabsChecked = if (_uiState.value.tabsDbEntities.isEmpty()) {
// Load default menu
- val list = loadDefaultMenuTabs(fragmentContext, view)
- list.zip(List(list.size){true}.toTypedArray()).toList()
+ Tab.defaultTabs.zip(List(Tab.defaultTabs.size){true}) + Tab.otherTabs.zip(List(Tab.otherTabs.size){false})
} else {
// Get current menu visibility and order from settings
- loadDbMenuTabs(fragmentContext, _uiState.value.tabsDbEntities).toList()
+ loadDbMenuTabs(_uiState.value.tabsDbEntities).toList()
}
)
}
diff --git a/app/src/main/java/org/pixeldroid/app/settings/SettingsActivity.kt b/app/src/main/java/org/pixeldroid/app/settings/SettingsActivity.kt
index ebcff40d..a4baff4f 100644
--- a/app/src/main/java/org/pixeldroid/app/settings/SettingsActivity.kt
+++ b/app/src/main/java/org/pixeldroid/app/settings/SettingsActivity.kt
@@ -18,7 +18,6 @@ import org.pixeldroid.app.main.MainActivity
import org.pixeldroid.app.utils.setThemeFromPreferences
import org.pixeldroid.common.ThemedActivity
-
@AndroidEntryPoint
class SettingsActivity : ThemedActivity(), SharedPreferences.OnSharedPreferenceChangeListener {
@@ -41,7 +40,11 @@ class SettingsActivity : ThemedActivity(), SharedPreferences.OnSharedPreferenceC
// Handle the back button event
// If a setting (for example language or theme) was changed, the main activity should be
// started without history so that the change is applied to the whole back stack
- if (restartMainOnExit) {
+ //TODO restore behaviour without true here, so that MainActivity is not destroyed when not necessary
+ // The true is a "temporary" (lol) fix so that tab changes are always taken into account
+ // Also, consider making the up button (arrow in action bar) also take this codepath!
+ // It recreates the activity by default
+ if (true || restartMainOnExit) {
val intent = Intent(this@SettingsActivity, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
super@SettingsActivity.startActivity(intent)
diff --git a/app/src/main/java/org/pixeldroid/app/utils/Utils.kt b/app/src/main/java/org/pixeldroid/app/utils/Utils.kt
index 94891ab3..567dd309 100644
--- a/app/src/main/java/org/pixeldroid/app/utils/Utils.kt
+++ b/app/src/main/java/org/pixeldroid/app/utils/Utils.kt
@@ -12,14 +12,12 @@ import android.net.ConnectivityManager
import android.net.Uri
import android.os.Build
import android.util.DisplayMetrics
-import android.view.View
import android.view.WindowManager
import android.webkit.MimeTypeMap
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.content.res.AppCompatResources
-import androidx.appcompat.widget.PopupMenu
import androidx.browser.customtabs.CustomTabsIntent
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
@@ -199,23 +197,14 @@ fun Fragment.bindingLifecycleAware(): ReadWriteProperty =
}
}
-fun loadDefaultMenuTabs(context: Context, anchor: View): List {
- return with(PopupMenu(context, anchor)) {
- val menu = this.menu
- menuInflater.inflate(R.menu.navigation_main, menu)
- menu.removeGroup(R.id.bottomNavigationGroup)
- (0 until menu.size()).map { Tab.fromLanguageString(context, menu.getItem(it).title.toString()) }
- }
-}
-
-fun loadDbMenuTabs(ctx: Context, tabsDbEntry: List): List> {
+fun loadDbMenuTabs(tabsDbEntry: List): List> {
return tabsDbEntry.map {
Pair(Tab.fromName(it.tab), it.checked)
}
}
enum class Tab {
- HOME_FEED, SEARCH_DISCOVER_FEED, CREATE_FEED, NOTIFICATIONS_FEED, PUBLIC_FEED;
+ HOME_FEED, SEARCH_DISCOVER_FEED, CREATE_FEED, NOTIFICATIONS_FEED, PUBLIC_FEED, DIRECT_MESSAGES;
fun toLanguageString(ctx: Context): String {
return ctx.getString(
@@ -225,6 +214,7 @@ enum class Tab {
CREATE_FEED -> R.string.create_feed
NOTIFICATIONS_FEED -> R.string.notifications_feed
PUBLIC_FEED -> R.string.public_feed
+ DIRECT_MESSAGES -> R.string.direct_messages
}
)
}
@@ -240,6 +230,7 @@ enum class Tab {
CREATE_FEED -> R.drawable.selector_camera
NOTIFICATIONS_FEED -> R.drawable.selector_notifications
PUBLIC_FEED -> R.drawable.ic_filter_black_24dp
+ DIRECT_MESSAGES -> R.drawable.selector_dm
}
return AppCompatResources.getDrawable(ctx, resId)
}
@@ -252,6 +243,7 @@ enum class Tab {
ctx.getString(R.string.create_feed) -> CREATE_FEED
ctx.getString(R.string.notifications_feed) -> NOTIFICATIONS_FEED
ctx.getString(R.string.public_feed) -> PUBLIC_FEED
+ ctx.getString(R.string.direct_messages) -> DIRECT_MESSAGES
else -> HOME_FEED
}
}
@@ -259,5 +251,18 @@ enum class Tab {
fun fromName(name: String): Tab {
return entries.filter { it.name == name }.getOrElse(0) { HOME_FEED }
}
+
+ val defaultTabs: List
+ get() = listOf(
+ HOME_FEED,
+ SEARCH_DISCOVER_FEED,
+ CREATE_FEED,
+ NOTIFICATIONS_FEED,
+ PUBLIC_FEED
+ )
+ val otherTabs: List
+ get() = listOf(
+ DIRECT_MESSAGES
+ )
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt b/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt
index c67fb1bf..08e546ab 100644
--- a/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt
+++ b/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt
@@ -429,6 +429,30 @@ interface PixelfedAPI {
@GET("/api/v1.1/discover/posts/hashtags")
suspend fun trendingHashtags() : List
+ @GET("/api/v1/conversations")
+ suspend fun directMessagesList(
+// @Query("max_id") max_id: String? = null,
+// @Query("since_id") since_id: String? = null,
+ // @Query("min_id") min_id: String? = null,
+ @Query("page") page: Int? = null,
+ @Query("limit") limit: String? = null,
+ ): List
+
+ @GET("/api/v1.1/direct/thread")
+ suspend fun directMessagesConversation(
+ @Query("pid") pid: String? = null,
+ @Query("max_id") max_id: String? = null,
+ @Query("min_id") min_id: String? = null,
+ ): DMThread
+
+ @POST("/api/v1.1/direct/thread/send")
+ suspend fun sendDirectMessage(
+ @Query("to_id") to_id: String? = null,
+ @Query("message") message: String? = null,
+ // text or emoji
+ @Query("type") min_id: String = "text",
+ ): DMThread
+
@FormUrlEncoded
@POST("/api/v1/reports")
@JvmSuppressWildcards
diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Conversation.kt b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Conversation.kt
new file mode 100644
index 00000000..370c5c45
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Conversation.kt
@@ -0,0 +1,35 @@
+package org.pixeldroid.app.utils.api.objects
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
+import java.io.Serializable
+
+/*
+Represents a conversation.
+https://docs.joinmastodon.org/entities/Conversation/
+ */
+@Entity(
+ tableName = "directMessages",
+ primaryKeys = ["id", "user_id", "instance_uri"],
+ foreignKeys = [ForeignKey(
+ entity = UserDatabaseEntity::class,
+ parentColumns = arrayOf("user_id", "instance_uri"),
+ childColumns = arrayOf("user_id", "instance_uri"),
+ onUpdate = ForeignKey.CASCADE,
+ onDelete = ForeignKey.CASCADE
+ )],
+ indices = [Index(value = ["user_id", "instance_uri"])]
+)
+data class Conversation(
+ override val id: String,
+ val unread: Boolean?,
+ val accounts: List?,
+ val last_status: Status?,
+
+ //Database values (not from API)
+ //TODO do we find this approach acceptable? Preferable to a semi-duplicate ConversationDataBaseEntity?
+ override var user_id: String,
+ override var instance_uri: String,
+): FeedContent, FeedContentDatabase, Serializable
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/objects/DMThread.kt b/app/src/main/java/org/pixeldroid/app/utils/api/objects/DMThread.kt
new file mode 100644
index 00000000..2fe3fc42
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/utils/api/objects/DMThread.kt
@@ -0,0 +1,29 @@
+package org.pixeldroid.app.utils.api.objects
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
+import java.io.Serializable
+import java.time.Instant
+
+
+/*
+Represents a conversation.
+https://docs.joinmastodon.org/entities/Conversation/
+ */
+data class DMThread(
+ override val id: String,
+ val name: String?,
+ val username: String?,
+ val avatar: String?,
+ val url: String?,
+ val muted: Boolean?,
+ val isLocal: Boolean?,
+ val domain: String?,
+ val created_at: Instant?, //ISO 8601 Datetime
+ val updated_at: Instant?,
+ val timeAgo: String?,
+ val lastMessage: String?,
+ val messages: List,
+): FeedContent, Serializable
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Message.kt b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Message.kt
new file mode 100644
index 00000000..059477c5
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Message.kt
@@ -0,0 +1,19 @@
+package org.pixeldroid.app.utils.api.objects
+
+import java.io.Serializable
+import java.time.Instant
+
+data class Message(
+ override val id: String,
+ val name: String?,
+ val hidden: Boolean?,
+ val isAuthor: Boolean?,
+ val type: String?, //TODO enum?
+ val text: String?,
+ val media: String?, //TODO,
+ val carousel: List?,
+ val created_at: Instant?, //ISO 8601 Datetime
+ val timeAgo: String?,
+ val reportId: String?,
+ //val meta: String?, //TODO
+): FeedContent, Serializable
diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt b/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt
index 7c05d2ed..eab128d9 100644
--- a/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt
+++ b/app/src/main/java/org/pixeldroid/app/utils/db/AppDatabase.kt
@@ -6,15 +6,21 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
-import org.pixeldroid.app.utils.db.dao.*
+import org.pixeldroid.app.utils.api.objects.Conversation
+import org.pixeldroid.app.utils.api.objects.Notification
+import org.pixeldroid.app.utils.db.dao.InstanceDao
+import org.pixeldroid.app.utils.db.dao.TabsDao
+import org.pixeldroid.app.utils.db.dao.UserDao
+import org.pixeldroid.app.utils.db.dao.feedContent.DirectMessagesConversationDao
+import org.pixeldroid.app.utils.db.dao.feedContent.DirectMessagesDao
import org.pixeldroid.app.utils.db.dao.feedContent.NotificationDao
import org.pixeldroid.app.utils.db.dao.feedContent.posts.HomePostDao
import org.pixeldroid.app.utils.db.dao.feedContent.posts.PublicPostDao
+import org.pixeldroid.app.utils.db.entities.DirectMessageDatabaseEntity
import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
import org.pixeldroid.app.utils.db.entities.UserDatabaseEntity
-import org.pixeldroid.app.utils.api.objects.Notification
import org.pixeldroid.app.utils.db.entities.TabsDatabaseEntity
@Database(entities = [
@@ -23,13 +29,17 @@ import org.pixeldroid.app.utils.db.entities.TabsDatabaseEntity
HomeStatusDatabaseEntity::class,
PublicFeedStatusDatabaseEntity::class,
Notification::class,
- TabsDatabaseEntity::class
+ TabsDatabaseEntity::class,
+ Conversation::class,
+ DirectMessageDatabaseEntity::class,
],
- version = 7,
autoMigrations = [
- AutoMigration(from = 6, to = 7)
+ AutoMigration(from = 6, to = 7),
+ AutoMigration(from = 7, to = 8)
],
+ version = 8
)
+
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun instanceDao(): InstanceDao
@@ -38,6 +48,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun publicPostDao(): PublicPostDao
abstract fun notificationDao(): NotificationDao
abstract fun tabsDao(): TabsDao
+ abstract fun directMessagesDao(): DirectMessagesDao
+ abstract fun directMessagesConversationDao(): DirectMessagesConversationDao
}
val MIGRATION_3_4 = object : Migration(3, 4) {
@@ -56,4 +68,4 @@ val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE instances ADD COLUMN pixelfed INTEGER NOT NULL DEFAULT 1")
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/Converters.kt b/app/src/main/java/org/pixeldroid/app/utils/db/Converters.kt
index 71d9d32e..64aa3e19 100644
--- a/app/src/main/java/org/pixeldroid/app/utils/db/Converters.kt
+++ b/app/src/main/java/org/pixeldroid/app/utils/db/Converters.kt
@@ -137,6 +137,15 @@ class Converters {
Status.Visibility::class.java
)
+ @TypeConverter
+ fun accountListToJson(type: List?): String {
+ val listType = object : TypeToken?>() {}.type
+ return gson.toJson(type, listType)
+ }
-
+ @TypeConverter
+ fun jsonToAccountList(json: String): List? {
+ val listType = object : TypeToken?>() {}.type
+ return gson.fromJson(json, listType)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/DirectMessagesConversationDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/DirectMessagesConversationDao.kt
new file mode 100644
index 00000000..4abeadc3
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/DirectMessagesConversationDao.kt
@@ -0,0 +1,25 @@
+package org.pixeldroid.app.utils.db.dao.feedContent
+
+import androidx.paging.PagingSource
+import androidx.room.Dao
+import androidx.room.Query
+import org.pixeldroid.app.utils.api.objects.Conversation
+import org.pixeldroid.app.utils.api.objects.Notification
+import org.pixeldroid.app.utils.db.entities.DirectMessageDatabaseEntity
+
+@Dao
+interface DirectMessagesConversationDao: FeedContentDao {
+
+ @Query("DELETE FROM directMessagesThreads WHERE user_id=:userId AND instance_uri=:instanceUri AND conversationsId=:conversationsId")
+ override suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
+
+ //TODO think about ordering
+ @Query("SELECT * FROM directMessagesThreads WHERE user_id=:userId AND instance_uri=:instanceUri AND conversationsId=:conversationsId ORDER BY datetime(created_at) DESC")
+ override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource
+
+ @Query("DELETE FROM directMessagesThreads WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id AND conversationsId=:conversationsId")
+ override suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
+
+ @Query("SELECT id FROM directMessagesThreads WHERE user_id=:userId AND instance_uri=:instanceUri AND conversationsId=:conversationsId ORDER BY datetime(created_at) ASC LIMIT 1")
+ suspend fun lastMessageId(userId: String, instanceUri: String, conversationsId: String): String?
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/DirectMessagesDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/DirectMessagesDao.kt
new file mode 100644
index 00000000..0c759a15
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/DirectMessagesDao.kt
@@ -0,0 +1,20 @@
+package org.pixeldroid.app.utils.db.dao.feedContent
+
+import androidx.paging.PagingSource
+import androidx.room.Dao
+import androidx.room.Query
+import org.pixeldroid.app.utils.api.objects.Conversation
+
+@Dao
+interface DirectMessagesDao: FeedContentDao {
+
+ @Query("DELETE FROM directMessages WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=''")
+ override suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
+
+ //TODO think about ordering
+ @Query("""SELECT * FROM directMessages WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=""""")
+ override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource
+
+ @Query("DELETE FROM directMessages WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id AND :conversationsId=''")
+ override suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/FeedContentDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/FeedContentDao.kt
index 31ef8e7d..42124812 100644
--- a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/FeedContentDao.kt
+++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/FeedContentDao.kt
@@ -7,13 +7,14 @@ import org.pixeldroid.app.utils.api.objects.FeedContentDatabase
interface FeedContentDao{
- fun feedContent(userId: String, instanceUri: String): PagingSource
+ fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource
- suspend fun clearFeedContent(userId: String, instanceUri: String)
+ suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
+ suspend fun clearFeedContent(userId: String, instanceUri: String) = clearFeedContent(userId, instanceUri, "")
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(feedContent: List)
- suspend fun delete(id: String, userId: String, instanceUri: String)
-
+ suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
+ suspend fun delete(id: String, userId: String, instanceUri: String) = delete(id, userId, instanceUri, "")
}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt
index 0c498190..6c0d7bba 100644
--- a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt
+++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/NotificationDao.kt
@@ -8,17 +8,17 @@ import org.pixeldroid.app.utils.api.objects.Notification
@Dao
interface NotificationDao: FeedContentDao {
- @Query("DELETE FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri")
- override suspend fun clearFeedContent(userId: String, instanceUri: String)
+ @Query("DELETE FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=''")
+ override suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
- @Query("""SELECT * FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri
+ @Query("""SELECT * FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=""
ORDER BY datetime(created_at) DESC""")
- override fun feedContent(userId: String, instanceUri: String): PagingSource
+ override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource
@Query("""SELECT * FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri
ORDER BY datetime(created_at) DESC LIMIT 1""")
fun latestNotification(userId: String, instanceUri: String): Notification?
- @Query("DELETE FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id")
- override suspend fun delete(id: String, userId: String, instanceUri: String)
+ @Query("DELETE FROM notifications WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id AND :conversationsId=''")
+ override suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
}
\ No newline at end of file
diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt
index 07efde38..ad62e1b0 100644
--- a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt
+++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/HomePostDao.kt
@@ -8,15 +8,15 @@ import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
@Dao
interface HomePostDao: FeedContentDao {
- @Query("""SELECT * FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri
+ @Query("""SELECT * FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=""
ORDER BY datetime(created_at) DESC""")
- override fun feedContent(userId: String, instanceUri: String): PagingSource
+ override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource
- @Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri")
- override suspend fun clearFeedContent(userId: String, instanceUri: String)
+ @Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=''")
+ override suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
- @Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id")
- override suspend fun delete(id: String, userId: String, instanceUri: String)
+ @Query("DELETE FROM homePosts WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id AND :conversationsId=''")
+ override suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
@Query("UPDATE homePosts SET bookmarked=:bookmarked WHERE user_id=:id AND instance_uri=:instanceUri AND id=:statusId")
fun bookmarkStatus(id: String, instanceUri: String, statusId: String, bookmarked: Boolean)
diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/PublicPostDao.kt b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/PublicPostDao.kt
index 918c96e2..a425233f 100644
--- a/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/PublicPostDao.kt
+++ b/app/src/main/java/org/pixeldroid/app/utils/db/dao/feedContent/posts/PublicPostDao.kt
@@ -8,15 +8,15 @@ import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
@Dao
interface PublicPostDao: FeedContentDao {
- @Query("""SELECT * FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri
+ @Query("""SELECT * FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=""
ORDER BY datetime(created_at) DESC""")
- override fun feedContent(userId: String, instanceUri: String): PagingSource
+ override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource
- @Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri")
- override suspend fun clearFeedContent(userId: String, instanceUri: String)
+ @Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri AND :conversationsId=''")
+ override suspend fun clearFeedContent(userId: String, instanceUri: String, conversationsId: String)
- @Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id")
- override suspend fun delete(id: String, userId: String, instanceUri: String)
+ @Query("DELETE FROM publicPosts WHERE user_id=:userId AND instance_uri=:instanceUri AND id=:id AND :conversationsId=''")
+ override suspend fun delete(id: String, userId: String, instanceUri: String, conversationsId: String)
@Query("UPDATE homePosts SET bookmarked=:bookmarked WHERE user_id=:id AND instance_uri=:instanceUri AND id=:statusId")
fun bookmarkStatus(id: String, instanceUri: String, statusId: String, bookmarked: Boolean)
diff --git a/app/src/main/java/org/pixeldroid/app/utils/db/entities/DirectMessageDatabaseEntity.kt b/app/src/main/java/org/pixeldroid/app/utils/db/entities/DirectMessageDatabaseEntity.kt
new file mode 100644
index 00000000..1dc38a42
--- /dev/null
+++ b/app/src/main/java/org/pixeldroid/app/utils/db/entities/DirectMessageDatabaseEntity.kt
@@ -0,0 +1,62 @@
+package org.pixeldroid.app.utils.db.entities
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import org.pixeldroid.app.utils.api.objects.Attachment
+import org.pixeldroid.app.utils.api.objects.FeedContent
+import org.pixeldroid.app.utils.api.objects.FeedContentDatabase
+import org.pixeldroid.app.utils.api.objects.Message
+import java.io.Serializable
+import java.time.Instant
+
+@Entity(
+ tableName = "directMessagesThreads",
+ primaryKeys = ["id", "conversationsId", "user_id", "instance_uri"],
+ foreignKeys = [ForeignKey(
+ entity = UserDatabaseEntity::class,
+ parentColumns = arrayOf("user_id", "instance_uri"),
+ childColumns = arrayOf("user_id", "instance_uri"),
+ onUpdate = ForeignKey.CASCADE,
+ onDelete = ForeignKey.CASCADE
+ )],
+ indices = [Index(value = ["user_id", "instance_uri", "conversationsId"])]
+)
+data class DirectMessageDatabaseEntity(
+ override val id: String,
+ val name: String?,
+ val hidden: Boolean?,
+ val isAuthor: Boolean?,
+ val type: String?, //TODO enum?
+ val text: String?,
+ val media: String?, //TODO,
+ val carousel: List?,
+ val created_at: Instant?, //ISO 8601 Datetime
+ val timeAgo: String?,
+ val reportId: String?,
+ //val meta: String?, //TODO
+
+ // Database values (not from API)
+ val conversationsId: String,
+ override var user_id: String,
+ override var instance_uri: String,
+): FeedContent, FeedContentDatabase, Serializable {
+ constructor(message: Message, conversationsId: String, user: UserDatabaseEntity) : this(
+ message.id,
+ message.name,
+ message.hidden,
+ message.isAuthor,
+ message.type,
+ message.text,
+ message.media,
+ message.carousel,
+ message.created_at,
+ message.timeAgo,
+ message.reportId,
+ //message.meta,
+
+ conversationsId,
+ user.user_id,
+ user.instance_uri
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/message.xml b/app/src/main/res/drawable/message.xml
new file mode 100644
index 00000000..5175b3f5
--- /dev/null
+++ b/app/src/main/res/drawable/message.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/message_bubble_incoming.xml b/app/src/main/res/drawable/message_bubble_incoming.xml
new file mode 100644
index 00000000..ddd6486f
--- /dev/null
+++ b/app/src/main/res/drawable/message_bubble_incoming.xml
@@ -0,0 +1,107 @@
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/message_bubble_outgoing.xml b/app/src/main/res/drawable/message_bubble_outgoing.xml
new file mode 100644
index 00000000..dcbbea2e
--- /dev/null
+++ b/app/src/main/res/drawable/message_bubble_outgoing.xml
@@ -0,0 +1,108 @@
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/outline_message.xml b/app/src/main/res/drawable/outline_message.xml
new file mode 100644
index 00000000..20444443
--- /dev/null
+++ b/app/src/main/res/drawable/outline_message.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/selector_dm.xml b/app/src/main/res/drawable/selector_dm.xml
new file mode 100644
index 00000000..93f51e90
--- /dev/null
+++ b/app/src/main/res/drawable/selector_dm.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout-land/activity_main.xml b/app/src/main/res/layout-land/activity_main.xml
index 8affa9d7..6bc04a7e 100644
--- a/app/src/main/res/layout-land/activity_main.xml
+++ b/app/src/main/res/layout-land/activity_main.xml
@@ -35,7 +35,7 @@
android:id="@+id/tabs"
android:layout_width="wrap_content"
android:layout_height="match_parent"
- app:menu="@menu/navigation_main" />
+ tools:menu="@menu/navigation_main" />
+ tools:menu="@menu/navigation_main" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_conversation.xml b/app/src/main/res/layout/activity_conversation.xml
new file mode 100644
index 00000000..c78bf936
--- /dev/null
+++ b/app/src/main/res/layout/activity_conversation.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_followers.xml b/app/src/main/res/layout/activity_followers.xml
index 3bd91979..7714375d 100644
--- a/app/src/main/res/layout/activity_followers.xml
+++ b/app/src/main/res/layout/activity_followers.xml
@@ -19,7 +19,7 @@
+ tools:menu="@menu/navigation_main" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/direct_messages_list_item.xml b/app/src/main/res/layout/direct_messages_list_item.xml
new file mode 100644
index 00000000..87ace435
--- /dev/null
+++ b/app/src/main/res/layout/direct_messages_list_item.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml
index 4f0944a7..195346a3 100644
--- a/app/src/main/res/layout/fragment_feed.xml
+++ b/app/src/main/res/layout/fragment_feed.xml
@@ -2,7 +2,6 @@
@@ -57,4 +56,16 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu-sw600dp-land/navigation_main.xml b/app/src/main/res/menu-sw600dp-land/navigation_main.xml
index 03d08ab5..1d6b05de 100644
--- a/app/src/main/res/menu-sw600dp-land/navigation_main.xml
+++ b/app/src/main/res/menu-sw600dp-land/navigation_main.xml
@@ -1,33 +1,18 @@