Merge branch 'view_model_fixes' into 'master'

Direct Messages

Closes #389

See merge request pixeldroid/PixelDroid!596
This commit is contained in:
Matthieu 2024-08-24 15:12:21 +00:00
commit 100ae532c3
52 changed files with 2723 additions and 182 deletions

View File

@ -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'

View File

@ -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')"
]
}
}

View File

@ -141,6 +141,12 @@
<activity android:name=".searchDiscover.TrendingActivity"
android:theme="@style/BaseAppTheme" />
<activity android:name=".directmessages.ConversationActivity"
android:theme="@style/BaseAppTheme"
android:windowSoftInputMode="adjustPan" />
<activity android:name="org.pixeldroid.app.directmessages.DirectMessagesActivity"
android:theme="@style/BaseAppTheme" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View File

@ -0,0 +1,95 @@
package org.pixeldroid.app.directmessages
import android.content.Context
import android.graphics.Canvas
import android.widget.EdgeEffect
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import androidx.recyclerview.widget.RecyclerView
import org.pixeldroid.common.pxToDp
import kotlin.math.absoluteValue
/** The magnitude of translation distance while the list is over-scrolled. */
private const val OVERSCROLL_TRANSLATION_MAGNITUDE = 0.4f
/** The magnitude of translation distance when the list reaches the edge on fling. */
private const val FLING_TRANSLATION_MAGNITUDE = 0.5f
/**
* Replace edge effect by a bounce
*/
class BounceEdgeEffectFactory(val refreshCallback: () -> 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)
)
}
}
}

View File

@ -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()
}
}
}

View File

@ -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<DirectMessageDatabaseEntity>() {
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<DirectMessageDatabaseEntity>
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<DirectMessageDatabaseEntity, RecyclerView.ViewHolder>(
object : DiffUtil.ItemCallback<DirectMessageDatabaseEntity>() {
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
)
}
}
}
}

View File

@ -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<Int, DirectMessageDatabaseEntity>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, DirectMessageDatabaseEntity>): 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)
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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<Conversation>() {
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<Conversation>
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<Conversation, RecyclerView.ViewHolder>(
object : DiffUtil.ItemCallback<Conversation>() {
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
)
}
}
}
}

View File

@ -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<Int, Conversation>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, Conversation>): 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)
}
}
}

View File

@ -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()

View File

@ -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 <T: Any> initAdapter(
progressBar: ProgressBar, swipeRefreshLayout: SwipeRefreshLayout,
progressBar: ProgressBar, swipeRefreshLayout: SwipeRefreshLayout?,
recyclerView: RecyclerView, motionLayout: MotionLayout, errorLayout: ErrorLayoutBinding,
adapter: PagingDataAdapter<T, RecyclerView.ViewHolder>,
header: StoriesAdapter? = null
@ -71,7 +71,7 @@ internal fun <T: Any> initAdapter(
).toTypedArray()
)
swipeRefreshLayout.setOnRefreshListener {
swipeRefreshLayout?.setOnRefreshListener {
adapter.refresh()
adapter.notifyDataSetChanged()
header?.refreshStories()
@ -79,7 +79,7 @@ internal fun <T: Any> 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
}

View File

@ -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<T: FeedContentDatabase> : 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")
}
}

View File

@ -32,7 +32,8 @@ import org.pixeldroid.app.utils.db.dao.feedContent.FeedContentDao
class FeedContentRepository<T: FeedContentDatabase> @ExperimentalPagingApi constructor(
private val db: AppDatabase,
private val dao: FeedContentDao<T>,
private val mediator: RemoteMediator<Int, T>
private val mediator: RemoteMediator<Int, T>,
private val conversationsId: String = "",
) {
/**
@ -44,7 +45,7 @@ class FeedContentRepository<T: FeedContentDatabase> @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(

View File

@ -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

View File

@ -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<UncachedPostsFragment>(R.id.followsFragment, args = arguments)
replace<UncachedPostsFragment>(R.id.conversationFragment, args = arguments)
}
}
}

View File

@ -50,7 +50,7 @@ class FollowsActivity : BaseActivity() {
supportFragmentManager.commit {
setReorderingAllowed(true)
replace<AccountListFragment>(R.id.followsFragment, args = arguments)
replace<AccountListFragment>(R.id.conversationFragment, args = arguments)
}
}
}

View File

@ -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)

View File

@ -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 <T : ViewModel> create(modelClass: Class<T>): 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()
}
)
}

View File

@ -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)

View File

@ -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 <T> Fragment.bindingLifecycleAware(): ReadWriteProperty<Fragment, T> =
}
}
fun loadDefaultMenuTabs(context: Context, anchor: View): List<Tab> {
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<TabsDatabaseEntity>): List<Pair<Tab, Boolean>> {
fun loadDbMenuTabs(tabsDbEntry: List<TabsDatabaseEntity>): List<Pair<Tab, Boolean>> {
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<Tab>
get() = listOf(
HOME_FEED,
SEARCH_DISCOVER_FEED,
CREATE_FEED,
NOTIFICATIONS_FEED,
PUBLIC_FEED
)
val otherTabs: List<Tab>
get() = listOf(
DIRECT_MESSAGES
)
}
}

View File

@ -429,6 +429,30 @@ interface PixelfedAPI {
@GET("/api/v1.1/discover/posts/hashtags")
suspend fun trendingHashtags() : List<Tag>
@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<Conversation>
@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

View File

@ -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<Account>?,
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

View File

@ -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<Message>,
): FeedContent, Serializable

View File

@ -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<Attachment>?,
val created_at: Instant?, //ISO 8601 Datetime
val timeAgo: String?,
val reportId: String?,
//val meta: String?, //TODO
): FeedContent, Serializable

View File

@ -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")
}
}
}

View File

@ -137,6 +137,15 @@ class Converters {
Status.Visibility::class.java
)
@TypeConverter
fun accountListToJson(type: List<Account>?): String {
val listType = object : TypeToken<List<Account?>?>() {}.type
return gson.toJson(type, listType)
}
@TypeConverter
fun jsonToAccountList(json: String): List<Account>? {
val listType = object : TypeToken<List<Account?>?>() {}.type
return gson.fromJson(json, listType)
}
}

View File

@ -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<DirectMessageDatabaseEntity> {
@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<Int, DirectMessageDatabaseEntity>
@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?
}

View File

@ -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<Conversation> {
@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<Int, Conversation>
@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)
}

View File

@ -7,13 +7,14 @@ import org.pixeldroid.app.utils.api.objects.FeedContentDatabase
interface FeedContentDao<T: FeedContentDatabase>{
fun feedContent(userId: String, instanceUri: String): PagingSource<Int, T>
fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource<Int, T>
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<T>)
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, "")
}

View File

@ -8,17 +8,17 @@ import org.pixeldroid.app.utils.api.objects.Notification
@Dao
interface NotificationDao: FeedContentDao<Notification> {
@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<Int, Notification>
override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource<Int, Notification>
@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)
}

View File

@ -8,15 +8,15 @@ import org.pixeldroid.app.utils.db.entities.HomeStatusDatabaseEntity
@Dao
interface HomePostDao: FeedContentDao<HomeStatusDatabaseEntity> {
@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<Int, HomeStatusDatabaseEntity>
override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource<Int, HomeStatusDatabaseEntity>
@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)

View File

@ -8,15 +8,15 @@ import org.pixeldroid.app.utils.db.entities.PublicFeedStatusDatabaseEntity
@Dao
interface PublicPostDao: FeedContentDao<PublicFeedStatusDatabaseEntity> {
@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<Int, PublicFeedStatusDatabaseEntity>
override fun feedContent(userId: String, instanceUri: String, conversationsId: String): PagingSource<Int, PublicFeedStatusDatabaseEntity>
@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)

View File

@ -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<Attachment>?,
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
)
}

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM18,14L6,14v-2h12v2zM18,11L6,11L6,9h12v2zM18,8L6,8L6,6h12v2z"/>
</vector>

View File

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!--Shadow Layers-->
<item>
<rotate
android:fromDegrees="-35"
android:pivotX="0%"
android:pivotY="0%"
android:toDegrees="0">
<shape android:shape="rectangle">
<corners android:radius="4dp"/>
<padding
android:bottom="1px"
android:left="1px"
android:right="1px"/>
<solid android:color="#01000000" />
</shape>
</rotate>
</item>
<item android:left="8dp">
<shape android:shape="rectangle">
<padding
android:bottom="1px"
android:left="1px"
android:right="1px"/>
<solid android:color="#01000000" />
<corners android:radius="8dp" />
</shape>
</item>
<!--===============-->
<item>
<rotate
android:fromDegrees="-35"
android:pivotX="0%"
android:pivotY="0%"
android:toDegrees="0">
<shape android:shape="rectangle">
<corners android:radius="4dp"/>
<padding
android:bottom="1px" />
<solid android:color="#09000000" />
</shape>
</rotate>
</item>
<item android:left="8dp">
<shape android:shape="rectangle">
<padding
android:bottom="1px" />
<solid android:color="#09000000" />
<corners android:radius="8dp" />
</shape>
</item>
<!--===============-->
<item>
<rotate
android:fromDegrees="-35"
android:pivotX="0%"
android:pivotY="0%"
android:toDegrees="0">
<shape android:shape="rectangle">
<corners android:radius="4dp"/>
<padding
android:bottom="1px"
android:left="1px"
android:right="1px"/>
<solid android:color="#10000000" />
</shape>
</rotate>
</item>
<item android:left="8dp">
<shape android:shape="rectangle">
<padding
android:bottom="1px"
android:left="1px"
android:right="1px"/>
<solid android:color="#10000000" />
<corners android:radius="8dp" />
</shape>
</item>
<!--ForeGround-->
<item>
<rotate
android:fromDegrees="-35"
android:pivotX="0%"
android:pivotY="0%"
android:toDegrees="0">
<shape android:shape="rectangle">
<corners android:radius="4dp"/>
<solid android:color="@color/white" />
</shape>
</rotate>
</item>
<item android:left="8dp">
<shape android:shape="rectangle">
<solid android:color="@color/white" />
<corners android:radius="8dp" />
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!--Shadow Layer-->
<item>
<rotate
android:fromDegrees="40"
android:pivotX="100%"
android:pivotY="0%"
android:toDegrees="0">
<shape android:shape="rectangle">
<corners android:radius="4dp" />
<padding
android:bottom="1px"
android:left="1px"
android:right="1px" />
<solid android:color="#01000000" />
</shape>
</rotate>
</item>
<item android:right="10dp">
<shape android:shape="rectangle">
<corners android:radius="4dp" />
<padding
android:bottom="1px"
android:left="1px"
android:right="1px" />
<solid android:color="#01000000" />
</shape>
</item>
<!--===============-->
<item>
<rotate
android:fromDegrees="40"
android:pivotX="100%"
android:pivotY="0%"
android:toDegrees="0">
<shape android:shape="rectangle">
<corners android:radius="4dp" />
<padding android:bottom="1px" />
<solid android:color="#09000000" />
</shape>
</rotate>
</item>
<item android:right="10dp">
<shape android:shape="rectangle">
<corners android:radius="4dp" />
<padding android:bottom="1px" />
<solid android:color="#09000000" />
</shape>
</item>
<!--===============-->
<item>
<rotate
android:fromDegrees="40"
android:pivotX="100%"
android:pivotY="0%"
android:toDegrees="0">
<shape android:shape="rectangle">
<corners android:radius="4dp" />
<padding
android:bottom="1px"
android:left="1px"
android:right="1px" />
<solid android:color="#10000000" />
</shape>
</rotate>
</item>
<item android:right="10dp">
<shape android:shape="rectangle">
<corners android:radius="4dp" />
<padding
android:bottom="1px"
android:left="1px"
android:right="1px" />
<solid android:color="#10000000" />
</shape>
</item>
<!--===============-->
<!--ForeGround-->
<item>
<rotate
android:fromDegrees="40"
android:pivotX="100%"
android:pivotY="0%"
android:toDegrees="0">
<shape android:shape="rectangle">
<solid android:color="#CBEBFC" />
</shape>
</rotate>
</item>
<item android:right="10dp">
<shape android:shape="rectangle">
<solid android:color="#CBEBFC" />
<corners android:radius="4dp" />
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M4,4h16v12L5.17,16L4,17.17L4,4m0,-2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2L4,2zM6,12h8v2L6,14v-2zM6,9h12v2L6,11L6,9zM6,6h12v2L6,8L6,6z"/>
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/message" android:state_checked="true"/>
<item android:drawable="@drawable/outline_message" android:state_checked="false"/>
</selector>

View File

@ -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" />
</LinearLayout>
<androidx.viewpager2.widget.ViewPager2

View File

@ -38,7 +38,7 @@
app:elevation="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:menu="@menu/navigation_main" />
tools:menu="@menu/navigation_main" />
</LinearLayout>
<androidx.viewpager2.widget.ViewPager2

View File

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/message_incoming"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/message_bubble_incoming"
android:paddingStart="20dp"
android:paddingTop="4dp"
android:paddingEnd="10dp"
android:paddingBottom="10dp"
app:layout_constraintHeight_max="wrap"
app:layout_constraintHeight_percent="0.8"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="90dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_max="500dp"
>
<TextView
android:id="@+id/text_message_incoming"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lineSpacingExtra="2dp"
android:textColor="@color/black"
android:textSize="13.5sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Hi, How are you?\nqsdfqsdf" />
<ImageView
android:id="@+id/image_message_incoming"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/message_outgoing"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/message_bubble_outgoing"
android:paddingStart="20dp"
android:paddingTop="4dp"
android:paddingEnd="30dp"
android:paddingBottom="10dp"
app:layout_constraintHeight_max="wrap"
app:layout_constraintHeight_percent="0.8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_max="500dp">
<TextView
android:id="@+id/text_message_outgoing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lineSpacingExtra="2dp"
android:textColor="@color/black"
android:textSize="13.5sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Fire, and you? 🔥" />
<ImageView
android:id="@+id/image_message_outgoing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/scrollview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSecondaryContainer"
android:fitsSystemWindows="true"
tools:context=".posts.PostActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSecondaryContainer"
android:fitsSystemWindows="true"
android:theme="@style/ThemeOverlay.AppCompat.ActionBar">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/top_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintPost"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/conversationFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:layout_constraintBottom_toTopOf="@id/commentIn"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/commentIn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/conversationFragment"
app:layout_constraintBottom_toBottomOf="parent"
tools:layout_editor_absoluteX="10dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/textInputLayout2"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/submitComment"
app:layout_constraintStart_toStartOf="parent">
<EditText
android:id="@+id/editComment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/comment_noun"
android:importantForAutofill="no"
android:inputType="text|textCapSentences|textMultiLine" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/submitComment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:contentDescription="@string/submit_comment"
android:text="@string/comment_verb"
app:layout_constraintBottom_toBottomOf="@+id/textInputLayout2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/textInputLayout2"
app:layout_constraintTop_toTopOf="@+id/textInputLayout2" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -19,7 +19,7 @@
</com.google.android.material.appbar.AppBarLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/followsFragment"
android:id="@+id/conversationFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"

View File

@ -37,7 +37,7 @@
app:elevation="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:menu="@menu/navigation_main" />
tools:menu="@menu/navigation_main" />
</LinearLayout>
<androidx.viewpager2.widget.ViewPager2

View File

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/message_incoming"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/message_bubble_incoming"
android:paddingStart="20dp"
android:paddingTop="4dp"
android:paddingEnd="10dp"
android:paddingBottom="10dp"
app:layout_constraintHeight_max="wrap"
app:layout_constraintHeight_percent="0.8"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_max="500dp">
<TextView
android:id="@+id/text_message_incoming"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lineSpacingExtra="2dp"
android:textColor="@color/black"
android:textSize="13.5sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Hi, How are you?\nqsdfqsdf" />
<ImageView
android:id="@+id/image_message_incoming"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/message_outgoing"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@drawable/message_bubble_outgoing"
android:paddingStart="20dp"
android:paddingTop="4dp"
android:paddingEnd="30dp"
android:paddingBottom="10dp"
app:layout_constraintHeight_max="wrap"
app:layout_constraintHeight_percent="0.8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_max="500dp">
<TextView
android:id="@+id/text_message_outgoing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lineSpacingExtra="2dp"
android:textColor="@color/black"
android:textSize="13.5sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Fire, and you? 🔥" />
<ImageView
android:id="@+id/image_message_outgoing"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:layout_margin="5dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp">
<TextView
android:id="@+id/message_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="@+id/dm_username"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="July 23" />
<TextView
android:id="@+id/dm_username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:drawablePadding="6dp"
android:paddingStart="38dp"
android:textColor="?android:textColorTertiary"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@+id/message_time"
app:layout_constraintStart_toStartOf="@+id/dm_avatar"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlSymmetry"
tools:text="fdsqfdsfsqdfdsfqdsfsdfsfddsfqsdsdfsqdf liked your post" />
<ImageView
android:id="@+id/dm_avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="14dp"
android:layout_marginTop="14dp"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/dm_username"
tools:src="@drawable/ic_default_user"
android:contentDescription="@string/profile_picture" />
<TextView
android:id="@+id/dm_last_message"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.164"
app:layout_constraintStart_toEndOf="@+id/dm_avatar"
app:layout_constraintTop_toBottomOf="@+id/dm_username"
app:layout_constraintVertical_bias="0.408"
tools:text="Post description" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

View File

@ -2,7 +2,6 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/coordinatorLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -57,4 +56,16 @@
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:id="@+id/bottomLoadingBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="gone"
android:indeterminate="true"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,33 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group android:id="@+id/tabsId">
<item
android:id="@+id/page_1"
android:enabled="true"
android:icon="@drawable/selector_home_feed"
android:title="@string/home_feed"/>
<item
android:id="@+id/page_2"
android:enabled="true"
android:icon="@drawable/ic_search_white_24dp"
android:title="@string/search_discover_feed"/>
<item
android:id="@+id/page_3"
android:enabled="true"
android:icon="@drawable/selector_camera"
android:title="@string/create_feed"/>
<item
android:id="@+id/page_4"
android:enabled="true"
android:icon="@drawable/selector_notifications"
android:title="@string/notifications_feed"/>
<item
android:id="@+id/page_5"
android:enabled="true"
android:icon="@drawable/ic_filter_black_24dp"
android:title="@string/public_feed"/>
</group>
<group
<group
android:id="@+id/tabsId">
</group>
<group
android:id="@+id/dmNavigationGroup"
android:orderInCategory="2">
<item
android:id="@+id/dms"
android:enabled="true"
android:icon="@drawable/selector_dm"
android:title="@string/direct_messages" />
</group>
<group
android:id="@+id/bottomNavigationGroup"
android:orderInCategory="3">
<item

View File

@ -1,28 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/page_1"
android:enabled="true"
android:icon="@drawable/selector_home_feed"
android:title="@string/home_feed"/>
<item
android:id="@+id/page_2"
android:enabled="true"
android:icon="@drawable/ic_search_white_24dp"
android:title="@string/search_discover_feed"/>
<item
android:id="@+id/page_3"
android:enabled="true"
android:icon="@drawable/selector_camera"
android:title="@string/create_feed"/>
<item
android:id="@+id/page_4"
android:enabled="true"
android:icon="@drawable/selector_notifications"
android:title="@string/notifications_feed"/>
<item
android:id="@+id/page_5"
android:enabled="true"
android:icon="@drawable/ic_filter_black_24dp"
android:title="@string/public_feed"/>
<group
android:id="@+id/tabsId">
</group>
</menu>

View File

@ -1,4 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item type="id" name="comment" />
<item type="id" name="page_1" />
<item type="id" name="page_2" />
<item type="id" name="page_3" />
<item type="id" name="page_4" />
<item type="id" name="page_5" />
</resources>

View File

@ -351,4 +351,7 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<string name="arrange_tabs_summary">Arrange tabs</string>
<string name="arrange_tabs_description">Change visibility and order of tabs</string>
<string name="content_header">Content</string>
<string name="dm_title">DM to %1$s</string>
<string name="direct_messages">Direct Messages</string>
</resources>