Merge remote-tracking branch 'tuskyapp/develop'

This commit is contained in:
kyori19 2022-04-24 16:42:54 +09:00
commit 6c630e08dd
72 changed files with 2579 additions and 948 deletions

View File

@ -95,7 +95,7 @@ android {
} }
} }
ext.coroutinesVersion = "1.6.0" ext.coroutinesVersion = "1.6.1"
ext.lifecycleVersion = "2.4.1" ext.lifecycleVersion = "2.4.1"
ext.roomVersion = '2.4.2' ext.roomVersion = '2.4.2'
ext.retrofitVersion = '2.9.0' ext.retrofitVersion = '2.9.0'
@ -112,8 +112,6 @@ repositories {
// if libraries are changed here, they should also be changed in LicenseActivity // if libraries are changed here, they should also be changed in LicenseActivity
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion"
@ -150,6 +148,7 @@ dependencies {
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion" implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion"
implementation "at.connyduck:kotlin-result-calladapter:1.0.1"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion"
@ -189,8 +188,8 @@ dependencies {
testImplementation "androidx.test.ext:junit:1.1.3" testImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "org.robolectric:robolectric:4.4" testImplementation "org.robolectric:robolectric:4.4"
testImplementation "org.mockito:mockito-inline:3.6.28" testImplementation "org.mockito:mockito-inline:4.4.0"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
androidTestImplementation "androidx.room:room-testing:$roomVersion" androidTestImplementation "androidx.room:room-testing:$roomVersion"

View File

@ -0,0 +1,815 @@
{
"formatVersion": 1,
"database": {
"version": 32,
"identityHash": "c92343960c9d46d9cfd49f1873cce47d",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSubscriptions",
"columnName": "notificationsSubscriptions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSignUps",
"columnName": "notificationsSignUps",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "minPollDuration",
"columnName": "minPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollDuration",
"columnName": "maxPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "charactersReservedPerUrl",
"columnName": "charactersReservedPerUrl",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.muted",
"columnName": "s_muted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"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, 'c92343960c9d46d9cfd49f1873cce47d')"
]
}
}

View File

@ -0,0 +1,809 @@
{
"formatVersion": 1,
"database": {
"version": 33,
"identityHash": "920a0e0c9a600bd236f6bf959b469c18",
"entities": [
{
"tableName": "DraftEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "failedToSend",
"columnName": "failedToSend",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowRequested",
"columnName": "notificationsFollowRequested",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsPolls",
"columnName": "notificationsPolls",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSubscriptions",
"columnName": "notificationsSubscriptions",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsSignUps",
"columnName": "notificationsSignUps",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysOpenSpoiler",
"columnName": "alwaysOpenSpoiler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsFilter",
"columnName": "notificationsFilter",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptions",
"columnName": "maxPollOptions",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollOptionLength",
"columnName": "maxPollOptionLength",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "minPollDuration",
"columnName": "minPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "maxPollDuration",
"columnName": "maxPollDuration",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "charactersReservedPerUrl",
"columnName": "charactersReservedPerUrl",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bookmarked",
"columnName": "bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "muted",
"columnName": "muted",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expanded",
"columnName": "expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentCollapsed",
"columnName": "contentCollapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentShowing",
"columnName": "contentShowing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinned",
"columnName": "pinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bot",
"columnName": "bot",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.bookmarked",
"columnName": "s_bookmarked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.tags",
"columnName": "s_tags",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.muted",
"columnName": "s_muted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.poll",
"columnName": "s_poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"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, '920a0e0c9a600bd236f6bf959b469c18')"
]
}
}

View File

@ -22,6 +22,22 @@
android:theme="@style/TuskyTheme" android:theme="@style/TuskyTheme"
android:usesCleartextTraffic="false"> android:usesCleartextTraffic="false">
<activity
android:name=".SplashActivity"
android:theme="@style/SplashTheme"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_shortcuts" />
</activity>
<activity <activity
android:name=".components.login.LoginActivity" android:name=".components.login.LoginActivity"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
@ -30,13 +46,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
android:theme="@style/SplashTheme"
android:exported="true"> android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
@ -84,9 +94,6 @@
<meta-data <meta-data
android:name="android.service.chooser.chooser_target_service" android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" /> android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_shortcuts" />
</activity> </activity>
<activity <activity

View File

@ -44,7 +44,6 @@ import androidx.appcompat.widget.PopupMenu
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat
import androidx.emoji.text.EmojiCompat.InitCallback import androidx.emoji.text.EmojiCompat.InitCallback
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -134,7 +133,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.accelf.yuito.CustomUncaughtExceptionHandler
import net.accelf.yuito.FooterDrawerItem import net.accelf.yuito.FooterDrawerItem
import net.accelf.yuito.QuickTootViewModel import net.accelf.yuito.QuickTootViewModel
import net.accelf.yuito.streaming.StreamingManager import net.accelf.yuito.streaming.StreamingManager
@ -188,14 +186,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler(CustomUncaughtExceptionHandler(applicationContext))
installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// delete old notification channels
NotificationHelper.deleteLegacyNotificationChannels(this, accountManager)
val activeAccount = accountManager.activeAccount val activeAccount = accountManager.activeAccount
?: return // will be redirected to LoginActivity by BaseActivity ?: return // will be redirected to LoginActivity by BaseActivity
@ -573,15 +565,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
onClick = ::logout onClick = ::logout
} }
) )
addStickyDrawerItems( val footer = FooterDrawerItem()
FooterDrawerItem().apply { addStickyDrawerItems(footer)
setSubscribeProxy( lifecycleScope.launch {
mastodonApi.getInstance() footer.setInstance(mastodonApi.getInstance())
.observeOn(AndroidSchedulers.mainThread()) }
.autoDispose(this@MainActivity, Lifecycle.Event.ON_DESTROY)
)
}
)
if (addSearchButton) { if (addSearchButton) {
binding.mainDrawer.addItemsAtPosition( binding.mainDrawer.addItemsAtPosition(
@ -877,18 +865,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
} }
private fun fetchUserInfo() { private fun fetchUserInfo() = lifecycleScope.launch {
mastodonApi.accountVerifyCredentials() mastodonApi.accountVerifyCredentials().fold(
.observeOn(AndroidSchedulers.mainThread()) { userInfo ->
.autoDispose(this, Lifecycle.Event.ON_DESTROY) onFetchUserInfoSuccess(userInfo)
.subscribe( },
{ userInfo -> { throwable ->
onFetchUserInfoSuccess(userInfo) Log.e(TAG, "Failed to fetch user info. " + throwable.message)
}, }
{ throwable -> )
Log.e(TAG, "Failed to fetch user info. " + throwable.message)
}
)
} }
private fun onFetchUserInfoSuccess(me: Account) { private fun onFetchUserInfoSuccess(me: Account) {

View File

@ -0,0 +1,54 @@
/* Copyright 2018 Conny Duck
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import net.accelf.yuito.CustomUncaughtExceptionHandler
import javax.inject.Inject
@SuppressLint("CustomSplashScreen")
class SplashActivity : AppCompatActivity(), Injectable {
@Inject
lateinit var accountManager: AccountManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Thread.setDefaultUncaughtExceptionHandler(CustomUncaughtExceptionHandler(applicationContext))
/** delete old notification channels */
NotificationHelper.deleteLegacyNotificationChannels(this, accountManager)
/** Determine whether the user is currently logged in, and if so go ahead and load the
* timeline. Otherwise, start the activity_login screen. */
val intent = if (accountManager.activeAccount != null) {
Intent(this, MainActivity::class.java)
} else {
LoginActivity.getIntent(this, false)
}
startActivity(intent)
finish()
}
}

View File

@ -283,7 +283,6 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
} }
return@fromCallable false return@fromCallable false
} }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnDispose { .doOnDispose {

View File

@ -41,6 +41,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
@ -229,7 +230,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case VIEW_TYPE_FOLLOW: { case VIEW_TYPE_FOLLOW: {
if (payloadForHolder == null) { if (payloadForHolder == null) {
FollowViewHolder holder = (FollowViewHolder) viewHolder; FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(concreteNotificaton.getAccount()); holder.setMessage(concreteNotificaton.getAccount(), concreteNotificaton.getType() == Notification.Type.SIGN_UP);
holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId()); holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId());
} }
break; break;
@ -287,7 +288,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case REBLOG: { case REBLOG: {
return VIEW_TYPE_STATUS_NOTIFICATION; return VIEW_TYPE_STATUS_NOTIFICATION;
} }
case FOLLOW: { case FOLLOW:
case SIGN_UP: {
return VIEW_TYPE_FOLLOW; return VIEW_TYPE_FOLLOW;
} }
case FOLLOW_REQUEST: { case FOLLOW_REQUEST: {
@ -339,10 +341,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
this.statusDisplayOptions = statusDisplayOptions; this.statusDisplayOptions = statusDisplayOptions;
} }
void setMessage(TimelineAccount account) { void setMessage(TimelineAccount account, Boolean isSignUp) {
Context context = message.getContext(); Context context = message.getContext();
String format = context.getString(R.string.notification_follow_format); String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format);
String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
String wholeMessage = String.format(format, wrappedDisplayName); String wholeMessage = String.format(format, wrappedDisplayName);
CharSequence emojifiedMessage = CustomEmojiHelper.emojify( CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
@ -599,13 +601,14 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
avatarRadius24dp, statusDisplayOptions.animateAvatars()); avatarRadius24dp, statusDisplayOptions.animateAvatars());
} }
private void setQuoteContainer(Status status, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) { private void setQuoteContainer(StatusViewData.Concrete quote, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) {
if (status != null) { if (quote != null) {
quoteContainer.setVisibility(View.VISIBLE); quoteContainer.setVisibility(View.VISIBLE);
new QuoteInlineHelper(status, quoteContainer, listener, ViewQuoteInlineBinding binding = ViewQuoteInlineBinding.bind(quoteContainer);
new QuoteInlineHelper(binding, listener,
quoteContainer.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp), quoteContainer.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp),
statusDisplayOptions) statusDisplayOptions)
.setupQuoteContainer(); .setupQuoteContainer(quote);
} else { } else {
quoteContainer.setVisibility(View.GONE); quoteContainer.setVisibility(View.GONE);
} }
@ -678,7 +681,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
} }
contentWarningDescriptionTextView.setText(emojifiedContentWarning); contentWarningDescriptionTextView.setText(emojifiedContentWarning);
setQuoteContainer(statusViewData.getStatus().getQuote(), listener, statusDisplayOptions); setQuoteContainer(statusViewData.getQuoteViewData(), listener, statusDisplayOptions);
} }
} }

View File

@ -33,6 +33,7 @@ import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners;
import com.google.android.material.button.MaterialButton; import com.google.android.material.button.MaterialButton;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding;
import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Attachment.Focus; import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData; import com.keylesspalace.tusky.entity.Attachment.MetaData;
@ -480,10 +481,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
favouriteButton.setChecked(favourited); favouriteButton.setChecked(favourited);
} }
private void setQuoteContainer(Status status, final StatusActionListener listener, StatusDisplayOptions statusDisplayOptions) { private void setQuoteContainer(StatusViewData.Concrete quote, final StatusActionListener listener, StatusDisplayOptions statusDisplayOptions) {
if (status != null) { if (quote != null) {
quoteContainer.setVisibility(View.VISIBLE); quoteContainer.setVisibility(View.VISIBLE);
new QuoteInlineHelper(status, quoteContainer, listener, avatarRadius24dp, statusDisplayOptions).setupQuoteContainer(); ViewQuoteInlineBinding binding = ViewQuoteInlineBinding.bind(quoteContainer);
new QuoteInlineHelper(binding, listener, avatarRadius24dp, statusDisplayOptions).setupQuoteContainer(quote);
} else { } else {
quoteContainer.setVisibility(View.GONE); quoteContainer.setVisibility(View.GONE);
} }
@ -857,7 +859,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
actionable.getAccount().getBot(), statusDisplayOptions); actionable.getAccount().getBot(), statusDisplayOptions);
setReblogged(actionable.getReblogged()); setReblogged(actionable.getReblogged());
setFavourited(actionable.getFavourited()); setFavourited(actionable.getFavourited());
setQuoteContainer(actionable.getQuote(), listener, statusDisplayOptions); setQuoteContainer(status.getQuoteViewData(), listener, statusDisplayOptions);
setBookmarked(actionable.getBookmarked()); setBookmarked(actionable.getBookmarked());
List<Attachment> attachments = actionable.getAttachments(); List<Attachment> attachments = actionable.getAttachments();
boolean sensitive = actionable.getSensitive(); boolean sensitive = actionable.getSensitive();
@ -1152,9 +1154,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener final StatusActionListener listener
) { ) {
final Card card = status.getActionable().getCard(); final Status actionable = status.getActionable();
final Card card = actionable.getCard();
if (cardViewMode != CardViewMode.NONE && if (cardViewMode != CardViewMode.NONE &&
status.getActionable().getAttachments().size() == 0 && actionable.getAttachments().size() == 0 &&
actionable.getPoll() == null &&
card != null && card != null &&
!TextUtils.isEmpty(card.getUrl()) && !TextUtils.isEmpty(card.getUrl()) &&
(!status.isCollapsible() || !status.isCollapsed())) { (!status.isCollapsible() || !status.isCollapsed())) {
@ -1176,7 +1180,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
// Statuses from other activitypub sources can be marked sensitive even if there's no media, // Statuses from other activitypub sources can be marked sensitive even if there's no media,
// so let's blur the preview in that case // so let's blur the preview in that case
// If media previews are disabled, show placeholder for cards as well // If media previews are disabled, show placeholder for cards as well
if (statusDisplayOptions.mediaPreviewEnabled() && !status.getActionable().getSensitive() && !TextUtils.isEmpty(card.getImage())) { if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) {
int topLeftRadius = 0; int topLeftRadius = 0;
int topRightRadius = 0; int topRightRadius = 0;

View File

@ -78,7 +78,7 @@ import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
@ -380,12 +380,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} }
} }
viewModel.accountFieldData.observe( viewModel.accountFieldData.observe(
this, this
{ ) {
accountFieldAdapter.fields = it accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged() accountFieldAdapter.notifyDataSetChanged()
} }
)
viewModel.noteSaved.observe(this) { viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE) binding.saveNoteInfo.visible(it, View.INVISIBLE)
} }
@ -400,11 +399,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
adapter.refreshContent() adapter.refreshContent()
} }
viewModel.isRefreshing.observe( viewModel.isRefreshing.observe(
this, this
{ isRefreshing -> ) { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
} }
)
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue) binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
} }
@ -415,7 +413,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountUsernameTextView.text = usernameFormatted binding.accountUsernameTextView.text = usernameFormatted
binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis) binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis)
val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis) val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis)
setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this)
// accountFieldAdapter.fields = account.fields ?: emptyList() // accountFieldAdapter.fields = account.fields ?: emptyList()

View File

@ -29,6 +29,7 @@ import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.createClickableText import com.keylesspalace.tusky.util.createClickableText
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
class AccountFieldAdapter( class AccountFieldAdapter(
@ -65,7 +66,7 @@ class AccountFieldAdapter(
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis) val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
nameTextView.text = emojifiedName nameTextView.text = emojifiedName
val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis) val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis)
setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener) setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener)
if (field.verifiedAt != null) { if (field.verifiedAt != null) {

View File

@ -35,6 +35,7 @@ import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.rx3.rxSingle
import javax.inject.Inject import javax.inject.Inject
class AnnouncementsViewModel @Inject constructor( class AnnouncementsViewModel @Inject constructor(
@ -56,8 +57,9 @@ class AnnouncementsViewModel @Inject constructor(
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) } .map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext { .onErrorResumeNext {
mastodonApi.getInstance() rxSingle {
.map { Either.Right(it) } mastodonApi.getInstance().getOrThrow()
}.map { Either.Right(it) }
} }
) { emojis, either -> ) { emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis) either.asLeftOrNull()?.copy(emojiList = emojis)

View File

@ -48,7 +48,8 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import kotlinx.coroutines.rx3.rxSingle
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
class ComposeViewModel @Inject constructor( class ComposeViewModel @Inject constructor(
@ -110,7 +111,7 @@ class ComposeViewModel @Inject constructor(
fun loadInstanceDataFromNetwork(loadActually: Boolean) { fun loadInstanceDataFromNetwork(loadActually: Boolean) {
when (loadActually) { when (loadActually) {
true -> Single.zip( true -> Single.zip(
api.getCustomEmojis(), api.getInstance() api.getCustomEmojis(), rxSingle { api.getInstance().getOrThrow() }
) { emojis, instance -> ) { emojis, instance ->
InstanceEntity( InstanceEntity(
instance = accountManager.activeAccount?.domain!!, instance = accountManager.activeAccount?.domain!!,
@ -298,7 +299,7 @@ class ComposeViewModel @Inject constructor(
): LiveData<Unit> { ): LiveData<Unit> {
val deletionObservable = if (isEditingScheduledToot) { val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { } rxSingle { api.deleteScheduledStatus(scheduledTootId.toString()) }.toObservable().map { }
} else { } else {
Observable.just(Unit) Observable.just(Unit)
}.toLiveData() }.toLiveData()

View File

@ -26,7 +26,7 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
class ConversationAdapter( class ConversationAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener private val listener: StatusActionListener
) : PagingDataAdapter<ConversationEntity, ConversationViewHolder>(CONVERSATION_COMPARATOR) { ) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false) val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
@ -37,17 +37,13 @@ class ConversationAdapter(
holder.setupWithConversation(getItem(position)) holder.setupWithConversation(getItem(position))
} }
fun item(position: Int): ConversationEntity? {
return getItem(position)
}
companion object { companion object {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() { val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationViewData>() {
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { override fun areItemsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
return oldItem.id == newItem.id return oldItem.id == newItem.id
} }
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean { override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
return oldItem == newItem return oldItem == newItem
} }
} }

View File

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import android.text.Spanned
import androidx.room.Embedded import androidx.room.Embedded
import androidx.room.Entity import androidx.room.Entity
import androidx.room.TypeConverters import androidx.room.TypeConverters
@ -27,7 +26,7 @@ import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date import java.util.Date
@Entity(primaryKeys = ["id", "accountId"]) @Entity(primaryKeys = ["id", "accountId"])
@ -38,7 +37,16 @@ data class ConversationEntity(
val accounts: List<ConversationAccountEntity>, val accounts: List<ConversationAccountEntity>,
val unread: Boolean, val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
) ) {
fun toViewData(): ConversationViewData {
return ConversationViewData(
id = id,
accounts = accounts,
unread = unread,
lastStatus = lastStatus.toViewData()
)
}
}
data class ConversationAccountEntity( data class ConversationAccountEntity(
val id: String, val id: String,
@ -67,7 +75,7 @@ data class ConversationStatusEntity(
val inReplyToId: String?, val inReplyToId: String?,
val inReplyToAccountId: String?, val inReplyToAccountId: String?,
val account: ConversationAccountEntity, val account: ConversationAccountEntity,
val content: Spanned, val content: String,
val createdAt: Date, val createdAt: Date,
val emojis: List<Emoji>, val emojis: List<Emoji>,
val favouritesCount: Int, val favouritesCount: Int,
@ -80,96 +88,44 @@ data class ConversationStatusEntity(
val tags: List<HashTag>?, val tags: List<HashTag>?,
val showingHiddenContent: Boolean, val showingHiddenContent: Boolean,
val expanded: Boolean, val expanded: Boolean,
val collapsible: Boolean,
val collapsed: Boolean, val collapsed: Boolean,
val muted: Boolean, val muted: Boolean,
val poll: Poll? val poll: Poll?
) { ) {
/** its necessary to override this because Spanned.equals does not work as expected */
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ConversationStatusEntity fun toViewData(): StatusViewData.Concrete {
return StatusViewData.Concrete(
if (id != other.id) return false status = Status(
if (url != other.url) return false id = id,
if (inReplyToId != other.inReplyToId) return false url = url,
if (inReplyToAccountId != other.inReplyToAccountId) return false account = account.toAccount(),
if (account != other.account) return false inReplyToId = inReplyToId,
if (content.toString() != other.content.toString()) return false inReplyToAccountId = inReplyToAccountId,
if (createdAt != other.createdAt) return false content = content,
if (emojis != other.emojis) return false reblog = null,
if (favouritesCount != other.favouritesCount) return false createdAt = createdAt,
if (favourited != other.favourited) return false emojis = emojis,
if (sensitive != other.sensitive) return false reblogsCount = 0,
if (spoilerText != other.spoilerText) return false favouritesCount = favouritesCount,
if (attachments != other.attachments) return false reblogged = false,
if (mentions != other.mentions) return false favourited = favourited,
if (tags != other.tags) return false bookmarked = bookmarked,
if (showingHiddenContent != other.showingHiddenContent) return false sensitive = sensitive,
if (expanded != other.expanded) return false spoilerText = spoilerText,
if (collapsible != other.collapsible) return false visibility = Status.Visibility.DIRECT,
if (collapsed != other.collapsed) return false attachments = attachments,
if (muted != other.muted) return false mentions = mentions,
if (poll != other.poll) return false tags = tags,
application = null,
return true pinned = false,
} muted = muted,
poll = poll,
override fun hashCode(): Int { card = null,
var result = id.hashCode() quote = null,
result = 31 * result + (url?.hashCode() ?: 0) ),
result = 31 * result + (inReplyToId?.hashCode() ?: 0) isExpanded = expanded,
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) isShowingContent = showingHiddenContent,
result = 31 * result + account.hashCode() isCollapsed = collapsed
result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode()
result = 31 * result + favouritesCount
result = 31 * result + favourited.hashCode()
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + tags.hashCode()
result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode()
result = 31 * result + muted.hashCode()
result = 31 * result + poll.hashCode()
return result
}
fun toStatus(): Status {
return Status(
id = id,
url = url,
account = account.toAccount(),
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
content = content,
reblog = null,
createdAt = createdAt,
emojis = emojis,
reblogsCount = 0,
favouritesCount = favouritesCount,
reblogged = false,
favourited = favourited,
bookmarked = bookmarked,
sensitive = sensitive,
spoilerText = spoilerText,
visibility = Status.Visibility.DIRECT,
attachments = attachments,
mentions = mentions,
tags = tags,
application = null,
pinned = false,
muted = muted,
poll = poll,
card = null,
quote = null,
) )
} }
} }
@ -203,7 +159,6 @@ fun Status.toEntity() =
tags = tags, tags = tags,
showingHiddenContent = false, showingHiddenContent = false,
expanded = false, expanded = false,
collapsible = shouldTrimStatus(content),
collapsed = true, collapsed = true,
muted = muted ?: false, muted = muted ?: false,
poll = poll poll = poll

View File

@ -0,0 +1,87 @@
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.viewdata.StatusViewData
data class ConversationViewData(
val id: String,
val accounts: List<ConversationAccountEntity>,
val unread: Boolean,
val lastStatus: StatusViewData.Concrete
) {
fun toEntity(
accountId: Long,
favourited: Boolean = lastStatus.status.favourited,
bookmarked: Boolean = lastStatus.status.bookmarked,
muted: Boolean = lastStatus.status.muted ?: false,
poll: Poll? = lastStatus.status.poll,
expanded: Boolean = lastStatus.isExpanded,
collapsed: Boolean = lastStatus.isCollapsed,
showingHiddenContent: Boolean = lastStatus.isShowingContent
): ConversationEntity {
return ConversationEntity(
accountId = accountId,
id = id,
accounts = accounts,
unread = unread,
lastStatus = lastStatus.toConversationStatusEntity(
favourited = favourited,
bookmarked = bookmarked,
muted = muted,
poll = poll,
expanded = expanded,
collapsed = collapsed,
showingHiddenContent = showingHiddenContent
)
)
}
}
fun StatusViewData.Concrete.toConversationStatusEntity(
favourited: Boolean = status.favourited,
bookmarked: Boolean = status.bookmarked,
muted: Boolean = status.muted ?: false,
poll: Poll? = status.poll,
expanded: Boolean = isExpanded,
collapsed: Boolean = isCollapsed,
showingHiddenContent: Boolean = isShowingContent
): ConversationStatusEntity {
return ConversationStatusEntity(
id = id,
url = status.url,
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
account = status.account.toEntity(),
content = status.content,
createdAt = status.createdAt,
emojis = status.emojis,
favouritesCount = status.favouritesCount,
favourited = favourited,
bookmarked = bookmarked,
sensitive = status.sensitive,
spoilerText = status.spoilerText,
attachments = status.attachments,
mentions = status.mentions,
tags = status.tags,
showingHiddenContent = showingHiddenContent,
expanded = expanded,
collapsed = collapsed,
muted = muted,
poll = poll
)
}

View File

@ -28,11 +28,14 @@ import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; import com.keylesspalace.tusky.adapter.StatusBaseViewHolder;
import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.List; import java.util.List;
@ -69,11 +72,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
} }
void setupWithConversation(ConversationEntity conversation) { void setupWithConversation(ConversationViewData conversation) {
ConversationStatusEntity status = conversation.getLastStatus(); StatusViewData.Concrete statusViewData = conversation.getLastStatus();
ConversationAccountEntity account = status.getAccount(); Status status = statusViewData.getStatus();
TimelineAccount account = status.getAccount();
setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener); setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername()); setUsername(account.getUsername());
@ -84,7 +88,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
List<Attachment> attachments = status.getAttachments(); List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive(); boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(), setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash()); statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) { if (attachments.size() == 0) {
@ -95,7 +99,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
mediaLabel.setVisibility(View.GONE); mediaLabel.setVisibility(View.GONE);
} }
} else { } else {
setMediaLabel(attachments, sensitive, listener, status.getShowingHiddenContent()); setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views. // Hide all unused views.
mediaPreviews[0].setVisibility(View.GONE); mediaPreviews[0].setVisibility(View.GONE);
mediaPreviews[1].setVisibility(View.GONE); mediaPreviews[1].setVisibility(View.GONE);
@ -104,10 +108,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
hideSensitiveMediaWarning(); hideSensitiveMediaWarning();
} }
setupButtons(listener, account.getId(), status.getContent().toString(), setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
false, statusDisplayOptions); false, statusDisplayOptions);
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
status.getMentions(), status.getTags(), status.getEmojis(), status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);

View File

@ -104,7 +104,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
initSwipeToRefresh() initSwipeToRefresh()
lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewModel.conversationFlow.collectLatest { pagingData -> viewModel.conversationFlow.collectLatest { pagingData ->
adapter.submitData(pagingData) adapter.submitData(pagingData)
} }
@ -155,7 +155,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onFavourite(favourite: Boolean, position: Int) { override fun onFavourite(favourite: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.favourite(favourite, conversation) viewModel.favourite(favourite, conversation)
} }
} }
@ -165,18 +165,18 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onBookmark(favourite: Boolean, position: Int) { override fun onBookmark(favourite: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.bookmark(favourite, conversation) viewModel.bookmark(favourite, conversation)
} }
} }
override fun onMore(view: View, position: Int) { override fun onMore(view: View, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
val popup = PopupMenu(requireContext(), view) val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.conversation_more) popup.inflate(R.menu.conversation_more)
if (conversation.lastStatus.muted) { if (conversation.lastStatus.status.muted == true) {
popup.menu.removeItem(R.id.status_mute_conversation) popup.menu.removeItem(R.id.status_mute_conversation)
} else { } else {
popup.menu.removeItem(R.id.status_unmute_conversation) popup.menu.removeItem(R.id.status_unmute_conversation)
@ -195,14 +195,14 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.toStatus()), view) viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view)
} }
} }
override fun onViewThread(position: Int) { override fun onViewThread(position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewThread(conversation.lastStatus.id, conversation.lastStatus.url) viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url)
} }
} }
@ -211,13 +211,13 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onExpandedChange(expanded: Boolean, position: Int) { override fun onExpandedChange(expanded: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.expandHiddenStatus(expanded, conversation) viewModel.expandHiddenStatus(expanded, conversation)
} }
} }
override fun onContentHiddenChange(isShowing: Boolean, position: Int) { override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.showContent(isShowing, conversation) viewModel.showContent(isShowing, conversation)
} }
} }
@ -227,7 +227,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.collapseLongStatus(isCollapsed, conversation) viewModel.collapseLongStatus(isCollapsed, conversation)
} }
} }
@ -247,12 +247,12 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onReply(position: Int) { override fun onReply(position: Int) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
reply(conversation.lastStatus.toStatus()) reply(conversation.lastStatus.status)
} }
} }
private fun deleteConversation(conversation: ConversationEntity) { private fun deleteConversation(conversation: ConversationViewData) {
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning) .setMessage(R.string.dialog_delete_conversation_warning)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
@ -274,7 +274,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) { override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
adapter.item(position)?.let { conversation -> adapter.peek(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation) viewModel.voteInPoll(choices, conversation)
} }
} }

View File

@ -16,16 +16,18 @@
package com.keylesspalace.tusky.components.conversation package com.keylesspalace.tusky.components.conversation
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.RxAwareViewModel import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
import javax.inject.Inject import javax.inject.Inject
@ -35,7 +37,7 @@ class ConversationsViewModel @Inject constructor(
private val database: AppDatabase, private val database: AppDatabase,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val api: MastodonApi private val api: MastodonApi
) : RxAwareViewModel() { ) : ViewModel() {
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
val conversationFlow = Pager( val conversationFlow = Pager(
@ -44,104 +46,117 @@ class ConversationsViewModel @Inject constructor(
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
) )
.flow .flow
.map { pagingData ->
pagingData.map { conversation -> conversation.toViewData() }
}
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
fun favourite(favourite: Boolean, conversation: ConversationEntity) { fun favourite(favourite: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
timelineCases.favourite(conversation.lastStatus.id, favourite).await() timelineCases.favourite(conversation.lastStatus.id, favourite).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(favourited = favourite) accountId = accountManager.activeAccount!!.id,
favourited = favourite
) )
database.conversationDao().insert(newConversation) saveConversationToDb(newConversation)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to favourite status", e) Log.w(TAG, "failed to favourite status", e)
} }
} }
} }
fun bookmark(bookmark: Boolean, conversation: ConversationEntity) { fun bookmark(bookmark: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).await() timelineCases.bookmark(conversation.lastStatus.id, bookmark).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(bookmarked = bookmark) accountId = accountManager.activeAccount!!.id,
bookmarked = bookmark
) )
database.conversationDao().insert(newConversation) saveConversationToDb(newConversation)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to bookmark status", e) Log.w(TAG, "failed to bookmark status", e)
} }
} }
} }
fun voteInPoll(choices: List<Int>, conversation: ConversationEntity) { fun voteInPoll(choices: List<Int>, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.poll?.id!!, choices).await() val poll = timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(poll = poll) accountId = accountManager.activeAccount!!.id,
poll = poll
) )
database.conversationDao().insert(newConversation) saveConversationToDb(newConversation)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to vote in poll", e) Log.w(TAG, "failed to vote in poll", e)
} }
} }
} }
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationEntity) { fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(expanded = expanded) accountId = accountManager.activeAccount!!.id,
expanded = expanded
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationEntity) { fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(collapsed = collapsed) accountId = accountManager.activeAccount!!.id,
collapsed = collapsed
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun showContent(showing: Boolean, conversation: ConversationEntity) { fun showContent(showing: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing) accountId = accountManager.activeAccount!!.id,
showingHiddenContent = showing
) )
saveConversationToDb(newConversation) saveConversationToDb(newConversation)
} }
} }
fun remove(conversation: ConversationEntity) { fun remove(conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
api.deleteConversation(conversationId = conversation.id) api.deleteConversation(conversationId = conversation.id)
database.conversationDao().delete(conversation) database.conversationDao().delete(
id = conversation.id,
accountId = accountManager.activeAccount!!.id
)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "failed to delete conversation", e) Log.w(TAG, "failed to delete conversation", e)
} }
} }
} }
fun muteConversation(conversation: ConversationEntity) { fun muteConversation(conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val newStatus = timelineCases.muteConversation( timelineCases.muteConversation(
conversation.lastStatus.id, conversation.lastStatus.id,
!conversation.lastStatus.muted !(conversation.lastStatus.status.muted ?: false)
).await() ).await()
val newConversation = conversation.copy( val newConversation = conversation.toEntity(
lastStatus = newStatus.toEntity() accountId = accountManager.activeAccount!!.id,
muted = !(conversation.lastStatus.status.muted ?: false)
) )
database.conversationDao().insert(newConversation) database.conversationDao().insert(newConversation)
@ -151,7 +166,7 @@ class ConversationsViewModel @Inject constructor(
} }
} }
suspend fun saveConversationToDb(conversation: ConversationEntity) { private suspend fun saveConversationToDb(conversation: ConversationEntity) {
database.conversationDao().insert(conversation) database.conversationDao().insert(conversation)
} }

View File

@ -33,7 +33,6 @@ import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityLoginBinding import com.keylesspalace.tusky.databinding.ActivityLoginBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
@ -159,32 +158,33 @@ class LoginActivity : BaseActivity(), Injectable {
setLoading(true) setLoading(true)
lifecycleScope.launch { lifecycleScope.launch {
val credentials: AppCredentials = try { mastodonApi.authenticateApp(
mastodonApi.authenticateApp( domain, getString(R.string.app_name), oauthRedirectUri,
domain, getString(R.string.app_name), oauthRedirectUri, OAUTH_SCOPES, getString(R.string.tusky_website)
OAUTH_SCOPES, getString(R.string.tusky_website) ).fold(
) { credentials ->
} catch (e: Exception) { // Before we open browser page we save the data.
binding.loginButton.isEnabled = true // Even if we don't open other apps user may go to password manager or somewhere else
binding.domainTextInputLayout.error = // and we will need to pick up the process where we left off.
getString(R.string.error_failed_app_registration) // Alternatively we could pass it all as part of the intent and receive it back
setLoading(false) // but it is a bit of a workaround.
Log.e(TAG, Log.getStackTraceString(e)) preferences.edit()
return@launch .putString(DOMAIN, domain)
} .putString(CLIENT_ID, credentials.clientId)
.putString(CLIENT_SECRET, credentials.clientSecret)
.apply()
// Before we open browser page we save the data. redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
// Even if we don't open other apps user may go to password manager or somewhere else },
// and we will need to pick up the process where we left off. { e ->
// Alternatively we could pass it all as part of the intent and receive it back binding.loginButton.isEnabled = true
// but it is a bit of a workaround. binding.domainTextInputLayout.error =
preferences.edit() getString(R.string.error_failed_app_registration)
.putString(DOMAIN, domain) setLoading(false)
.putString(CLIENT_ID, credentials.clientId) Log.e(TAG, Log.getStackTraceString(e))
.putString(CLIENT_SECRET, credentials.clientSecret) return@launch
.apply() }
)
redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
} }
} }
@ -217,29 +217,28 @@ class LoginActivity : BaseActivity(), Injectable {
setLoading(true) setLoading(true)
val accessToken = try { mastodonApi.fetchOAuthToken(
mastodonApi.fetchOAuthToken( domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
domain, clientId, clientSecret, oauthRedirectUri, code, ).fold(
"authorization_code" { accessToken ->
) accountManager.addAccount(accessToken.accessToken, domain)
} catch (e: Exception) {
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(
TAG,
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
)
return
}
accountManager.addAccount(accessToken.accessToken, domain) val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
val intent = Intent(this, MainActivity::class.java) startActivity(intent)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK finish()
startActivity(intent) overridePendingTransition(R.anim.explode, R.anim.explode)
finish() },
overridePendingTransition(R.anim.explode, R.anim.explode) { e ->
setLoading(false)
binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token)
Log.e(
TAG,
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
)
}
)
} }
private fun setLoading(loadingState: Boolean) { private fun setLoading(loadingState: Boolean) {

View File

@ -16,6 +16,7 @@ import android.webkit.WebStorage
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.core.net.toUri
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.databinding.LoginWebviewBinding import com.keylesspalace.tusky.databinding.LoginWebviewBinding
@ -103,8 +104,8 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
webView.webViewClient = object : WebViewClient() { webView.webViewClient = object : WebViewClient() {
override fun onReceivedError( override fun onReceivedError(
view: WebView?, view: WebView,
request: WebResourceRequest?, request: WebResourceRequest,
error: WebResourceError error: WebResourceError
) { ) {
Log.d("LoginWeb", "Failed to load ${data.url}: $error") Log.d("LoginWeb", "Failed to load ${data.url}: $error")
@ -115,7 +116,17 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
view: WebView, view: WebView,
request: WebResourceRequest request: WebResourceRequest
): Boolean { ): Boolean {
val url = request.url return shouldOverrideUrlLoading(request.url)
}
/* overriding this deprecated method is necessary for it to work on api levels < 24 */
@Suppress("OVERRIDE_DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView?, urlString: String?): Boolean {
val url = urlString?.toUri() ?: return false
return shouldOverrideUrlLoading(url)
}
fun shouldOverrideUrlLoading(url: Uri): Boolean {
return if (url.scheme == oauthUrl.scheme && url.host == oauthUrl.host) { return if (url.scheme == oauthUrl.scheme && url.host == oauthUrl.host) {
val error = url.getQueryParameter("error") val error = url.getQueryParameter("error")
if (error != null) { if (error != null) {
@ -130,6 +141,7 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
} }
} }
} }
webView.setBackgroundColor(Color.TRANSPARENT) webView.setBackgroundColor(Color.TRANSPARENT)
if (savedInstanceState == null) { if (savedInstanceState == null) {

View File

@ -16,6 +16,9 @@
package com.keylesspalace.tusky.components.notifications; package com.keylesspalace.tusky.components.notifications;
import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml;
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
import android.app.NotificationChannel; import android.app.NotificationChannel;
import android.app.NotificationChannelGroup; import android.app.NotificationChannelGroup;
import android.app.NotificationManager; import android.app.NotificationManager;
@ -73,8 +76,6 @@ import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
public class NotificationHelper { public class NotificationHelper {
private static int notificationId = 0; private static int notificationId = 0;
@ -116,6 +117,7 @@ public class NotificationHelper {
public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE"; public static final String CHANNEL_FAVOURITE = "CHANNEL_FAVOURITE";
public static final String CHANNEL_POLL = "CHANNEL_POLL"; public static final String CHANNEL_POLL = "CHANNEL_POLL";
public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS"; public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS";
public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP";
/** /**
* WorkManager Tag * WorkManager Tag
@ -340,7 +342,7 @@ public class NotificationHelper {
Status status = body.getStatus(); Status status = body.getStatus();
String citedLocalAuthor = status.getAccount().getLocalUsername(); String citedLocalAuthor = status.getAccount().getLocalUsername();
String citedText = status.getContent().toString(); String citedText = parseAsMastodonHtml(status.getContent()).toString();
String inReplyToId = status.getId(); String inReplyToId = status.getId();
Status actionableStatus = status.getActionableStatus(); Status actionableStatus = status.getActionableStatus();
Status.Visibility replyVisibility = actionableStatus.getVisibility(); Status.Visibility replyVisibility = actionableStatus.getVisibility();
@ -392,6 +394,7 @@ public class NotificationHelper {
CHANNEL_FAVOURITE + account.getIdentifier(), CHANNEL_FAVOURITE + account.getIdentifier(),
CHANNEL_POLL + account.getIdentifier(), CHANNEL_POLL + account.getIdentifier(),
CHANNEL_SUBSCRIPTIONS + account.getIdentifier(), CHANNEL_SUBSCRIPTIONS + account.getIdentifier(),
CHANNEL_SIGN_UP + account.getIdentifier(),
}; };
int[] channelNames = { int[] channelNames = {
R.string.notification_mention_name, R.string.notification_mention_name,
@ -401,6 +404,7 @@ public class NotificationHelper {
R.string.notification_favourite_name, R.string.notification_favourite_name,
R.string.notification_poll_name, R.string.notification_poll_name,
R.string.notification_subscription_name, R.string.notification_subscription_name,
R.string.notification_sign_up_name,
}; };
int[] channelDescriptions = { int[] channelDescriptions = {
R.string.notification_mention_descriptions, R.string.notification_mention_descriptions,
@ -410,6 +414,7 @@ public class NotificationHelper {
R.string.notification_favourite_description, R.string.notification_favourite_description,
R.string.notification_poll_description, R.string.notification_poll_description,
R.string.notification_subscription_description, R.string.notification_subscription_description,
R.string.notification_sign_up_description,
}; };
List<NotificationChannel> channels = new ArrayList<>(6); List<NotificationChannel> channels = new ArrayList<>(6);
@ -560,6 +565,8 @@ public class NotificationHelper {
return account.getNotificationsFavorited(); return account.getNotificationsFavorited();
case POLL: case POLL:
return account.getNotificationsPolls(); return account.getNotificationsPolls();
case SIGN_UP:
return account.getNotificationsSignUps();
default: default:
return false; return false;
} }
@ -582,6 +589,8 @@ public class NotificationHelper {
return CHANNEL_FAVOURITE + account.getIdentifier(); return CHANNEL_FAVOURITE + account.getIdentifier();
case POLL: case POLL:
return CHANNEL_POLL + account.getIdentifier(); return CHANNEL_POLL + account.getIdentifier();
case SIGN_UP:
return CHANNEL_SIGN_UP + account.getIdentifier();
default: default:
return null; return null;
} }
@ -663,6 +672,8 @@ public class NotificationHelper {
} else { } else {
return context.getString(R.string.poll_ended_voted); return context.getString(R.string.poll_ended_voted);
} }
case SIGN_UP:
return String.format(context.getString(R.string.notification_sign_up_format), accountName);
} }
return null; return null;
} }
@ -671,6 +682,7 @@ public class NotificationHelper {
switch (notification.getType()) { switch (notification.getType()) {
case FOLLOW: case FOLLOW:
case FOLLOW_REQUEST: case FOLLOW_REQUEST:
case SIGN_UP:
return "@" + notification.getAccount().getUsername(); return "@" + notification.getAccount().getUsername();
case MENTION: case MENTION:
case FAVOURITE: case FAVOURITE:
@ -679,13 +691,13 @@ public class NotificationHelper {
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) {
return notification.getStatus().getSpoilerText(); return notification.getStatus().getSpoilerText();
} else { } else {
return notification.getStatus().getContent().toString(); return parseAsMastodonHtml(notification.getStatus().getContent()).toString();
} }
case POLL: case POLL:
if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) { if (!TextUtils.isEmpty(notification.getStatus().getSpoilerText())) {
return notification.getStatus().getSpoilerText(); return notification.getStatus().getSpoilerText();
} else { } else {
StringBuilder builder = new StringBuilder(notification.getStatus().getContent()); StringBuilder builder = new StringBuilder(parseAsMastodonHtml(notification.getStatus().getContent()));
builder.append('\n'); builder.append('\n');
Poll poll = notification.getStatus().getPoll(); Poll poll = notification.getStatus().getPoll();
List<PollOption> options = poll.getOptions(); List<PollOption> options = poll.getOptions();

View File

@ -13,8 +13,8 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding
import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding
@ -216,7 +216,7 @@ class EmojiPreference(
.setPositiveButton(R.string.restart) { _, _ -> .setPositiveButton(R.string.restart) { _, _ ->
// Restart the app // Restart the app
// From https://stackoverflow.com/a/17166729/5070653 // From https://stackoverflow.com/a/17166729/5070653
val launchIntent = Intent(context, MainActivity::class.java) val launchIntent = Intent(context, SplashActivity::class.java)
val mPendingIntent = PendingIntent.getActivity( val mPendingIntent = PendingIntent.getActivity(
context, context,
0x1f973, // This is the codepoint of the party face emoji :D 0x1f973, // This is the codepoint of the party face emoji :D

View File

@ -122,6 +122,17 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
true true
} }
} }
switchPreference {
setTitle(R.string.pref_title_notification_filter_sign_ups)
key = PrefKeys.NOTIFICATION_FILTER_SIGN_UPS
isIconSpaceReserved = false
isChecked = activeAccount.notificationsSignUps
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsSignUps = newValue as Boolean }
true
}
}
} }
preferenceCategory(R.string.pref_title_notification_alerts) { category -> preferenceCategory(R.string.pref_title_notification_alerts) { category ->

View File

@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map
import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.MuteEvent
@ -34,11 +35,13 @@ import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.toViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -74,6 +77,11 @@ class ReportViewModel @Inject constructor(
pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) } pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) }
).flow ).flow
} }
.map { pagingData ->
/* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData.Concrete
instead of StatusViewState */
pagingData.map { status -> status.toViewData(false, false, false) }
}
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
private val selectedIds = HashSet<String>() private val selectedIds = HashSet<String>()
@ -155,7 +163,7 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ relationship -> { relationship ->
val muting = relationship?.muting == true val muting = relationship.muting
muteStateMutable.value = Success(muting) muteStateMutable.value = Success(muting)
if (muting) { if (muting) {
eventHub.dispatch(MuteEvent(accountId)) eventHub.dispatch(MuteEvent(accountId))
@ -180,7 +188,7 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ relationship -> { relationship ->
val blocking = relationship?.blocking == true val blocking = relationship.blocking
blockStateMutable.value = Success(blocking) blockStateMutable.value = Success(blocking)
if (blocking) { if (blocking) {
eventHub.dispatch(BlockEvent(accountId)) eventHub.dispatch(BlockEvent(accountId))

View File

@ -37,6 +37,7 @@ import com.keylesspalace.tusky.util.setClickableMentions
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.toViewData import com.keylesspalace.tusky.viewdata.toViewData
import java.util.Date import java.util.Date
@ -45,20 +46,21 @@ class StatusViewHolder(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val viewState: StatusViewState, private val viewState: StatusViewState,
private val adapterHandler: AdapterHandler, private val adapterHandler: AdapterHandler,
private val getStatusForPosition: (Int) -> Status? private val getStatusForPosition: (Int) -> StatusViewData.Concrete?
) : RecyclerView.ViewHolder(binding.root) { ) : RecyclerView.ViewHolder(binding.root) {
private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height)
private val statusViewHelper = StatusViewHelper(itemView) private val statusViewHelper = StatusViewHelper(itemView)
private val previewListener = object : StatusViewHelper.MediaPreviewListener { private val previewListener = object : StatusViewHelper.MediaPreviewListener {
override fun onViewMedia(v: View?, idx: Int) { override fun onViewMedia(v: View?, idx: Int) {
status()?.let { status -> viewdata()?.let { viewdata ->
adapterHandler.showMedia(v, status, idx) adapterHandler.showMedia(v, viewdata.status, idx)
} }
} }
override fun onContentHiddenChange(isShowing: Boolean) { override fun onContentHiddenChange(isShowing: Boolean) {
status()?.id?.let { id -> viewdata()?.id?.let { id ->
viewState.setMediaShow(id, isShowing) viewState.setMediaShow(id, isShowing)
} }
} }
@ -66,57 +68,57 @@ class StatusViewHolder(
init { init {
binding.statusSelection.setOnCheckedChangeListener { _, isChecked -> binding.statusSelection.setOnCheckedChangeListener { _, isChecked ->
status()?.let { status -> viewdata()?.let { viewdata ->
adapterHandler.setStatusChecked(status, isChecked) adapterHandler.setStatusChecked(viewdata.status, isChecked)
} }
} }
binding.statusMediaPreviewContainer.clipToOutline = true binding.statusMediaPreviewContainer.clipToOutline = true
} }
fun bind(status: Status) { fun bind(viewData: StatusViewData.Concrete) {
binding.statusSelection.isChecked = adapterHandler.isStatusChecked(status.id) binding.statusSelection.isChecked = adapterHandler.isStatusChecked(viewData.id)
updateTextView() updateTextView()
val sensitive = status.sensitive val sensitive = viewData.status.sensitive
statusViewHelper.setMediasPreview( statusViewHelper.setMediasPreview(
statusDisplayOptions, status.attachments, statusDisplayOptions, viewData.status.attachments,
sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive), sensitive, previewListener, viewState.isMediaShow(viewData.id, viewData.status.sensitive),
mediaViewHeight mediaViewHeight
) )
statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions) statusViewHelper.setupPollReadonly(viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions)
setCreatedAt(status.createdAt) setCreatedAt(viewData.status.createdAt)
} }
private fun updateTextView() { private fun updateTextView() {
status()?.let { status -> viewdata()?.let { viewdata ->
setupCollapsedState( setupCollapsedState(
shouldTrimStatus(status.content), viewState.isCollapsed(status.id, true), shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true),
viewState.isContentShow(status.id, status.sensitive), status.spoilerText viewState.isContentShow(viewdata.id, viewdata.status.sensitive), viewdata.spoilerText
) )
if (status.spoilerText.isBlank()) { if (viewdata.spoilerText.isBlank()) {
setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler, status.quote != null) setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler, viewdata.status.quote != null)
binding.statusContentWarningButton.hide() binding.statusContentWarningButton.hide()
binding.statusContentWarningDescription.hide() binding.statusContentWarningDescription.hide()
} else { } else {
val emojiSpoiler = status.spoilerText.emojify(status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis) val emojiSpoiler = viewdata.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis)
binding.statusContentWarningDescription.text = emojiSpoiler binding.statusContentWarningDescription.text = emojiSpoiler
binding.statusContentWarningDescription.show() binding.statusContentWarningDescription.show()
binding.statusContentWarningButton.show() binding.statusContentWarningButton.show()
setContentWarningButtonText(viewState.isContentShow(status.id, true)) setContentWarningButtonText(viewState.isContentShow(viewdata.id, true))
binding.statusContentWarningButton.setOnClickListener { binding.statusContentWarningButton.setOnClickListener {
status()?.let { status -> viewdata()?.let { viewdata ->
val contentShown = viewState.isContentShow(status.id, true) val contentShown = viewState.isContentShow(viewdata.id, true)
binding.statusContentWarningDescription.invalidate() binding.statusContentWarningDescription.invalidate()
viewState.setContentShow(status.id, !contentShown) viewState.setContentShow(viewdata.id, !contentShown)
setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler, status.quote != null) setTextVisible(!contentShown, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler, viewdata.status.quote != null)
setContentWarningButtonText(!contentShown) setContentWarningButtonText(!contentShown)
} }
} }
setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.tags, status.emojis, adapterHandler, status.quote != null) setTextVisible(viewState.isContentShow(viewdata.id, true), viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler, viewdata.status.quote != null)
} }
} }
} }
@ -170,8 +172,8 @@ class StatusViewHolder(
/* input filter for TextViews have to be set before text */ /* input filter for TextViews have to be set before text */
if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
binding.buttonToggleContent.setOnClickListener { binding.buttonToggleContent.setOnClickListener {
status()?.let { status -> viewdata()?.let { viewdata ->
viewState.setCollapsed(status.id, !collapsed) viewState.setCollapsed(viewdata.id, !collapsed)
updateTextView() updateTextView()
} }
} }
@ -190,5 +192,5 @@ class StatusViewHolder(
} }
} }
private fun status() = getStatusForPosition(bindingAdapterPosition) private fun viewdata() = getStatusForPosition(bindingAdapterPosition)
} }

View File

@ -22,16 +22,16 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.components.report.model.StatusViewState import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.databinding.ItemReportStatusBinding import com.keylesspalace.tusky.databinding.ItemReportStatusBinding
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
class StatusesAdapter( class StatusesAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private val statusDisplayOptions: StatusDisplayOptions,
private val statusViewState: StatusViewState, private val statusViewState: StatusViewState,
private val adapterHandler: AdapterHandler private val adapterHandler: AdapterHandler
) : PagingDataAdapter<Status, StatusViewHolder>(STATUS_COMPARATOR) { ) : PagingDataAdapter<StatusViewData.Concrete, StatusViewHolder>(STATUS_COMPARATOR) {
private val statusForPosition: (Int) -> Status? = { position: Int -> private val statusForPosition: (Int) -> StatusViewData.Concrete? = { position: Int ->
if (position != RecyclerView.NO_POSITION) getItem(position) else null if (position != RecyclerView.NO_POSITION) getItem(position) else null
} }
@ -50,11 +50,11 @@ class StatusesAdapter(
} }
companion object { companion object {
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Status>() { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean = override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem == newItem oldItem == newItem
override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean = override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem.id == newItem.id oldItem.id == newItem.id
} }
} }

View File

@ -25,7 +25,6 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import javax.inject.Inject import javax.inject.Inject
class ScheduledStatusViewModel @Inject constructor( class ScheduledStatusViewModel @Inject constructor(
@ -43,12 +42,14 @@ class ScheduledStatusViewModel @Inject constructor(
fun deleteScheduledStatus(status: ScheduledStatus) { fun deleteScheduledStatus(status: ScheduledStatus) {
viewModelScope.launch { viewModelScope.launch {
try { mastodonApi.deleteScheduledStatus(status.id).fold(
mastodonApi.deleteScheduledStatus(status.id).await() {
pagingSourceFactory.remove(status) pagingSourceFactory.remove(status)
} catch (throwable: Throwable) { },
Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable) { throwable ->
} Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable)
}
)
} }
} }
} }

View File

@ -15,9 +15,6 @@
package com.keylesspalace.tusky.components.timeline package com.keylesspalace.tusky.components.timeline
import android.text.SpannedString
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.db.TimelineAccountEntity import com.keylesspalace.tusky.db.TimelineAccountEntity
@ -29,8 +26,6 @@ import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date import java.util.Date
@ -119,7 +114,7 @@ fun Status.toEntity(
authorServerId = actionableStatus.account.id, authorServerId = actionableStatus.account.id,
inReplyToId = actionableStatus.inReplyToId, inReplyToId = actionableStatus.inReplyToId,
inReplyToAccountId = actionableStatus.inReplyToAccountId, inReplyToAccountId = actionableStatus.inReplyToAccountId,
content = actionableStatus.content.toHtml(), content = actionableStatus.content,
createdAt = actionableStatus.createdAt.time, createdAt = actionableStatus.createdAt.time,
emojis = actionableStatus.emojis.let(gson::toJson), emojis = actionableStatus.emojis.let(gson::toJson),
reblogsCount = actionableStatus.reblogsCount, reblogsCount = actionableStatus.reblogsCount,
@ -165,8 +160,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId, inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId, inReplyToAccountId = status.inReplyToAccountId,
reblog = null, reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() content = status.content.orEmpty(),
?: SpannedString(""),
createdAt = Date(status.createdAt), createdAt = Date(status.createdAt),
emojis = emojis, emojis = emojis,
reblogsCount = status.reblogsCount, reblogsCount = status.reblogsCount,
@ -196,7 +190,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
reblog = reblog, reblog = reblog,
content = SpannedString(""), content = "",
createdAt = Date(status.createdAt), // lie but whatever? createdAt = Date(status.createdAt), // lie but whatever?
emojis = listOf(), emojis = listOf(),
reblogsCount = 0, reblogsCount = 0,
@ -225,8 +219,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId, inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId, inReplyToAccountId = status.inReplyToAccountId,
reblog = null, reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace() content = status.content.orEmpty(),
?: SpannedString(""),
createdAt = Date(status.createdAt), createdAt = Date(status.createdAt),
emojis = emojis, emojis = emojis,
reblogsCount = status.reblogsCount, reblogsCount = status.reblogsCount,
@ -252,7 +245,6 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
status = status, status = status,
isExpanded = this.status.expanded, isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing, isShowingContent = this.status.contentShowing,
isCollapsible = shouldTrimStatus(status.content),
isCollapsed = this.status.contentCollapsed isCollapsed = this.status.contentCollapsed
) )
} }

View File

@ -43,7 +43,10 @@ import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
@ -82,15 +85,13 @@ class CachedTimelineViewModel @Inject constructor(
} }
).flow ).flow
.map { pagingData -> .map { pagingData ->
pagingData.map { timelineStatus -> pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
timelineStatus.toViewData(gson) timelineStatus.toViewData(gson)
} }.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
}
.map { pagingData ->
pagingData.filter { statusViewData ->
!shouldFilterStatus(statusViewData) !shouldFilterStatus(statusViewData)
} }
} }
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
init { init {

View File

@ -40,6 +40,9 @@ import com.keylesspalace.tusky.util.isLessThan
import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.isLessThanOrEqual
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
@ -81,10 +84,11 @@ class NetworkTimelineViewModel @Inject constructor(
remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) remoteMediator = NetworkTimelineRemoteMediator(accountManager, this)
).flow ).flow
.map { pagingData -> .map { pagingData ->
pagingData.filter { statusViewData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
!shouldFilterStatus(statusViewData) !shouldFilterStatus(statusViewData)
} }
} }
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) {

View File

@ -50,6 +50,7 @@ data class AccountEntity(
var notificationsFavorited: Boolean = true, var notificationsFavorited: Boolean = true,
var notificationsPolls: Boolean = true, var notificationsPolls: Boolean = true,
var notificationsSubscriptions: Boolean = true, var notificationsSubscriptions: Boolean = true,
var notificationsSignUps: Boolean = true,
var notificationSound: Boolean = true, var notificationSound: Boolean = true,
var notificationVibration: Boolean = true, var notificationVibration: Boolean = true,
var notificationLight: Boolean = true, var notificationLight: Boolean = true,

View File

@ -31,7 +31,7 @@ import java.io.File;
*/ */
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 31) }, version = 33)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); public abstract AccountDao accountDao();
@ -483,4 +483,48 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("DELETE FROM `TimelineStatusEntity`"); database.execSQL("DELETE FROM `TimelineStatusEntity`");
} }
}; };
public static final Migration MIGRATION_31_32 = new Migration(31, 32) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSignUps` INTEGER NOT NULL DEFAULT 1");
}
};
public static final Migration MIGRATION_32_33 = new Migration(32, 33) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
// ConversationEntity lost the s_collapsible column
// since SQLite does not support removing columns and it is just a cache table, we recreate the whole table.
database.execSQL("DROP TABLE `ConversationEntity`");
database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" +
"`accountId` INTEGER NOT NULL," +
"`id` TEXT NOT NULL," +
"`accounts` TEXT NOT NULL," +
"`unread` INTEGER NOT NULL," +
"`s_id` TEXT NOT NULL," +
"`s_url` TEXT," +
"`s_inReplyToId` TEXT," +
"`s_inReplyToAccountId` TEXT," +
"`s_account` TEXT NOT NULL," +
"`s_content` TEXT NOT NULL," +
"`s_createdAt` INTEGER NOT NULL," +
"`s_emojis` TEXT NOT NULL," +
"`s_favouritesCount` INTEGER NOT NULL," +
"`s_favourited` INTEGER NOT NULL," +
"`s_bookmarked` INTEGER NOT NULL," +
"`s_sensitive` INTEGER NOT NULL," +
"`s_spoilerText` TEXT NOT NULL," +
"`s_attachments` TEXT NOT NULL," +
"`s_mentions` TEXT NOT NULL," +
"`s_tags` TEXT," +
"`s_showingHiddenContent` INTEGER NOT NULL," +
"`s_expanded` INTEGER NOT NULL," +
"`s_collapsed` INTEGER NOT NULL," +
"`s_muted` INTEGER NOT NULL," +
"`s_poll` TEXT," +
"PRIMARY KEY(`id`, `accountId`))");
}
};
} }

View File

@ -17,7 +17,6 @@ package com.keylesspalace.tusky.db
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
@ -31,8 +30,8 @@ interface ConversationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(conversation: ConversationEntity): Long suspend fun insert(conversation: ConversationEntity): Long
@Delete @Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
suspend fun delete(conversation: ConversationEntity): Int suspend fun delete(id: String, accountId: Long): Int
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity> fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>

View File

@ -15,9 +15,6 @@
package com.keylesspalace.tusky.db package com.keylesspalace.tusky.db
import android.text.Spanned
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import androidx.room.ProvidedTypeConverter import androidx.room.ProvidedTypeConverter
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.google.gson.Gson import com.google.gson.Gson
@ -32,10 +29,8 @@ import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
import java.util.ArrayList
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -144,22 +139,6 @@ class Converters @Inject constructor (
return Date(date) return Date(date)
} }
@TypeConverter
fun spannedToString(spanned: Spanned?): String? {
if (spanned == null) {
return null
}
return spanned.toHtml()
}
@TypeConverter
fun stringToSpanned(spannedString: String?): Spanned? {
if (spannedString == null) {
return null
}
return spannedString.parseAsHtml().trimTrailingWhitespace()
}
@TypeConverter @TypeConverter
fun pollToJson(poll: Poll?): String? { fun pollToJson(poll: Poll?): String? {
return gson.toJson(poll) return gson.toJson(poll)

View File

@ -23,6 +23,7 @@ import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.LicenseActivity import com.keylesspalace.tusky.LicenseActivity
import com.keylesspalace.tusky.ListsActivity import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
@ -118,6 +119,9 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesDraftActivity(): DraftsActivity abstract fun contributesDraftActivity(): DraftsActivity
@ContributesAndroidInjector
abstract fun contributesSplashActivity(): SplashActivity
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesAccessTokenLoginActivity(): AccessTokenLoginActivity abstract fun contributesAccessTokenLoginActivity(): AccessTokenLoginActivity
} }

View File

@ -68,7 +68,8 @@ class AppModule {
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25,
AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")),
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31 AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
AppDatabase.MIGRATION_32_33
) )
.build() .build()
} }

View File

@ -18,12 +18,10 @@ package com.keylesspalace.tusky.di
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build import android.os.Build
import android.text.Spanned import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.NotestockApi import com.keylesspalace.tusky.network.NotestockApi
@ -53,11 +51,7 @@ class NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun providesGson(): Gson { fun providesGson() = Gson()
return GsonBuilder()
.registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter())
.create()
}
@Provides @Provides
@Singleton @Singleton
@ -114,6 +108,7 @@ class NetworkModule {
.client(httpClient) .client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson)) .addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create()) .addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.addCallAdapterFactory(KotlinResultCallAdapterFactory.create())
.build() .build()
} }

View File

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.Date import java.util.Date
@ -24,7 +23,7 @@ data class Account(
@SerializedName("username") val localUsername: String, @SerializedName("username") val localUsername: String,
@SerializedName("acct", alternate = ["subject"]) val username: String, @SerializedName("acct", alternate = ["subject"]) val username: String,
@SerializedName("display_name") private val displayName: String?, // should never be null per Api definition, but some servers break the contract @SerializedName("display_name") private val displayName: String?, // should never be null per Api definition, but some servers break the contract
val note: Spanned, val note: String,
val url: String, val url: String,
val avatar: String, val avatar: String,
val header: String, val header: String,
@ -54,56 +53,6 @@ data class Account(
get() = displayName.orEmpty() get() = displayName.orEmpty()
fun isRemote(): Boolean = this.username != this.localUsername fun isRemote(): Boolean = this.username != this.localUsername
/**
* overriding equals & hashcode because Spanned does not always compare correctly otherwise
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Account
if (id != other.id) return false
if (localUsername != other.localUsername) return false
if (username != other.username) return false
if (displayName != other.displayName) return false
if (note.toString() != other.note.toString()) return false
if (url != other.url) return false
if (avatar != other.avatar) return false
if (header != other.header) return false
if (locked != other.locked) return false
if (followersCount != other.followersCount) return false
if (followingCount != other.followingCount) return false
if (statusesCount != other.statusesCount) return false
if (source != other.source) return false
if (bot != other.bot) return false
if (emojis != other.emojis) return false
if (fields != other.fields) return false
if (moved != other.moved) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + localUsername.hashCode()
result = 31 * result + username.hashCode()
result = 31 * result + (displayName?.hashCode() ?: 0)
result = 31 * result + note.toString().hashCode()
result = 31 * result + url.hashCode()
result = 31 * result + avatar.hashCode()
result = 31 * result + header.hashCode()
result = 31 * result + locked.hashCode()
result = 31 * result + followersCount
result = 31 * result + followingCount
result = 31 * result + statusesCount
result = 31 * result + (source?.hashCode() ?: 0)
result = 31 * result + bot.hashCode()
result = 31 * result + (emojis?.hashCode() ?: 0)
result = 31 * result + (fields?.hashCode() ?: 0)
result = 31 * result + (moved?.hashCode() ?: 0)
return result
}
} }
data class AccountSource( data class AccountSource(
@ -115,7 +64,7 @@ data class AccountSource(
data class Field( data class Field(
val name: String, val name: String,
val value: Spanned, val value: String,
@SerializedName("verified_at") val verifiedAt: Date? @SerializedName("verified_at") val verifiedAt: Date?
) )

View File

@ -15,13 +15,12 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.Date import java.util.Date
data class Announcement( data class Announcement(
val id: String, val id: String,
val content: Spanned, val content: String,
@SerializedName("starts_at") val startsAt: Date?, @SerializedName("starts_at") val startsAt: Date?,
@SerializedName("ends_at") val endsAt: Date?, @SerializedName("ends_at") val endsAt: Date?,
@SerializedName("all_day") val allDay: Boolean, @SerializedName("all_day") val allDay: Boolean,

View File

@ -15,13 +15,12 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
data class Card( data class Card(
val url: String, val url: String,
val title: Spanned, val title: String,
val description: Spanned, val description: String,
@SerializedName("author_name") val authorName: String, @SerializedName("author_name") val authorName: String,
val image: String, val image: String,
val type: String, val type: String,
@ -31,9 +30,7 @@ data class Card(
val embed_url: String? val embed_url: String?
) { ) {
override fun hashCode(): Int { override fun hashCode() = url.hashCode()
return url.hashCode()
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (other !is Card) { if (other !is Card) {

View File

@ -37,7 +37,9 @@ data class Notification(
FOLLOW("follow"), FOLLOW("follow"),
FOLLOW_REQUEST("follow_request"), FOLLOW_REQUEST("follow_request"),
POLL("poll"), POLL("poll"),
STATUS("status"); STATUS("status"),
SIGN_UP("admin.sign_up"),
;
companion object { companion object {
@ -49,7 +51,7 @@ data class Notification(
} }
return UNKNOWN return UNKNOWN
} }
val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS) val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP)
} }
override fun toString(): String { override fun toString(): String {

View File

@ -16,10 +16,9 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.URLSpan import android.text.style.URLSpan
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.ArrayList import com.keylesspalace.tusky.util.parseAsMastodonHtml
import java.util.Date import java.util.Date
data class Status( data class Status(
@ -29,7 +28,7 @@ data class Status(
@SerializedName("in_reply_to_id") var inReplyToId: String?, @SerializedName("in_reply_to_id") var inReplyToId: String?,
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
val reblog: Status?, val reblog: Status?,
val content: Spanned, val content: String,
@SerializedName("created_at", alternate = ["published"]) val createdAt: Date, @SerializedName("created_at", alternate = ["published"]) val createdAt: Date,
val emojis: List<Emoji>, val emojis: List<Emoji>,
@SerializedName("reblogs_count") val reblogsCount: Int, @SerializedName("reblogs_count") val reblogsCount: Int,
@ -143,8 +142,9 @@ data class Status(
} }
private fun getEditableText(): String { private fun getEditableText(): String {
val builder = SpannableStringBuilder(content) val contentSpanned = content.parseAsMastodonHtml()
for (span in content.getSpans(0, content.length, URLSpan::class.java)) { val builder = SpannableStringBuilder(content.parseAsMastodonHtml())
for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) {
val url = span.url val url = span.url
for ((_, url1, username) in mentions) { for ((_, url1, username) in mentions) {
if (url == url1) { if (url == url1) {
@ -158,71 +158,6 @@ data class Status(
return builder.toString() return builder.toString()
} }
/**
* overriding equals & hashcode because Spanned does not always compare correctly otherwise
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Status
if (id != other.id) return false
if (url != other.url) return false
if (account != other.account) return false
if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false
if (reblog != other.reblog) return false
if (content.toString() != other.content.toString()) return false
if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false
if (reblogsCount != other.reblogsCount) return false
if (favouritesCount != other.favouritesCount) return false
if (reblogged != other.reblogged) return false
if (favourited != other.favourited) return false
if (bookmarked != other.bookmarked) return false
if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false
if (visibility != other.visibility) return false
if (attachments != other.attachments) return false
if (mentions != other.mentions) return false
if (tags != other.tags) return false
if (application != other.application) return false
if (pinned != other.pinned) return false
if (muted != other.muted) return false
if (poll != other.poll) return false
if (card != other.card) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + account.hashCode()
result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + (reblog?.hashCode() ?: 0)
result = 31 * result + content.toString().hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode()
result = 31 * result + reblogsCount
result = 31 * result + favouritesCount
result = 31 * result + reblogged.hashCode()
result = 31 * result + favourited.hashCode()
result = 31 * result + bookmarked.hashCode()
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + visibility.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.hashCode()
result = 31 * result + (tags?.hashCode() ?: 0)
result = 31 * result + (application?.hashCode() ?: 0)
result = 31 * result + (pinned?.hashCode() ?: 0)
result = 31 * result + (muted?.hashCode() ?: 0)
result = 31 * result + (poll?.hashCode() ?: 0)
result = 31 * result + (card?.hashCode() ?: 0)
return result
}
data class Mention( data class Mention(
val id: String, val id: String,
val url: String, val url: String,

View File

@ -15,6 +15,10 @@
package com.keylesspalace.tusky.fragment; package com.keylesspalace.tusky.fragment;
import static com.keylesspalace.tusky.util.StringUtils.isLessThan;
import static autodispose2.AutoDispose.autoDisposable;
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
import android.app.Activity; import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
@ -114,10 +118,6 @@ import kotlin.Unit;
import kotlin.collections.CollectionsKt; import kotlin.collections.CollectionsKt;
import kotlin.jvm.functions.Function1; import kotlin.jvm.functions.Function1;
import static autodispose2.AutoDispose.autoDisposable;
import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from;
import static com.keylesspalace.tusky.util.StringUtils.isLessThan;
public class NotificationsFragment extends SFragment implements public class NotificationsFragment extends SFragment implements
SwipeRefreshLayout.OnRefreshListener, SwipeRefreshLayout.OnRefreshListener,
StatusActionListener, StatusActionListener,
@ -716,6 +716,8 @@ public class NotificationsFragment extends SFragment implements
return getString(R.string.notification_poll_name); return getString(R.string.notification_poll_name);
case STATUS: case STATUS:
return getString(R.string.notification_subscription_name); return getString(R.string.notification_subscription_name);
case SIGN_UP:
return getString(R.string.notification_sign_up_name);
default: default:
return "Unknown"; return "Unknown";
} }

View File

@ -1,62 +0,0 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.json
import android.text.Spanned
import android.text.SpannedString
import androidx.core.text.HtmlCompat
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import org.jsoup.Jsoup
import java.lang.reflect.Type
class SpannedTypeAdapter : JsonDeserializer<Spanned>, JsonSerializer<Spanned?> {
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Spanned {
return json.asString
/* Mastodon uses 'white-space: pre-wrap;' so spaces are displayed as returned by the Api.
* We can't use CSS so we replace spaces with non-breaking-spaces to emulate the behavior.
*/
?.replace("<br> ", "<br>&nbsp;")
?.replace("<br /> ", "<br />&nbsp;")
?.replace("<br/> ", "<br/>&nbsp;")
?.replace(" ", "&nbsp;&nbsp;")
?.let { html ->
Jsoup.parse(html)
.apply {
select(".quote-inline").forEach { it.remove() }
}
.html()
}
?.parseAsHtml()
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
* most status contents do, so it should be trimmed. */
?.trimTrailingWhitespace()
?: SpannedString("")
}
override fun serialize(src: Spanned?, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
return JsonPrimitive(src!!.toHtml(HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL))
}
}

View File

@ -68,7 +68,7 @@ import retrofit2.http.Query
interface MastodonApi { interface MastodonApi {
companion object { companion object {
const val ENDPOINT_AUTHORIZE = "/oauth/authorize" const val ENDPOINT_AUTHORIZE = "oauth/authorize"
const val DOMAIN_HEADER = "domain" const val DOMAIN_HEADER = "domain"
const val PLACEHOLDER_DOMAIN = "dummy.placeholder" const val PLACEHOLDER_DOMAIN = "dummy.placeholder"
} }
@ -80,7 +80,7 @@ interface MastodonApi {
fun getCustomEmojis(): Single<List<Emoji>> fun getCustomEmojis(): Single<List<Emoji>>
@GET("api/v1/instance") @GET("api/v1/instance")
fun getInstance(): Single<Instance> suspend fun getInstance(): Result<Instance>
@GET("api/v1/filters") @GET("api/v1/filters")
fun getFilters(): Single<List<Filter>> fun getFilters(): Single<List<Filter>>
@ -249,12 +249,12 @@ interface MastodonApi {
): Single<List<ScheduledStatus>> ): Single<List<ScheduledStatus>>
@DELETE("api/v1/scheduled_statuses/{id}") @DELETE("api/v1/scheduled_statuses/{id}")
fun deleteScheduledStatus( suspend fun deleteScheduledStatus(
@Path("id") scheduledStatusId: String @Path("id") scheduledStatusId: String
): Single<ResponseBody> ): Result<ResponseBody>
@GET("api/v1/accounts/verify_credentials") @GET("api/v1/accounts/verify_credentials")
fun accountVerifyCredentials(): Single<Account> suspend fun accountVerifyCredentials(): Result<Account>
@FormUrlEncoded @FormUrlEncoded
@PATCH("api/v1/accounts/update_credentials") @PATCH("api/v1/accounts/update_credentials")
@ -265,7 +265,7 @@ interface MastodonApi {
@Multipart @Multipart
@PATCH("api/v1/accounts/update_credentials") @PATCH("api/v1/accounts/update_credentials")
fun accountUpdateCredentials( suspend fun accountUpdateCredentials(
@Part(value = "display_name") displayName: RequestBody?, @Part(value = "display_name") displayName: RequestBody?,
@Part(value = "note") note: RequestBody?, @Part(value = "note") note: RequestBody?,
@Part(value = "locked") locked: RequestBody?, @Part(value = "locked") locked: RequestBody?,
@ -279,7 +279,7 @@ interface MastodonApi {
@Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?, @Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?,
@Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?, @Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?,
@Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody? @Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody?
): Call<Account> ): Result<Account>
@GET("api/v1/accounts/search") @GET("api/v1/accounts/search")
fun searchAccounts( fun searchAccounts(
@ -447,7 +447,7 @@ interface MastodonApi {
@Field("redirect_uris") redirectUris: String, @Field("redirect_uris") redirectUris: String,
@Field("scopes") scopes: String, @Field("scopes") scopes: String,
@Field("website") website: String @Field("website") website: String
): AppCredentials ): Result<AppCredentials>
@FormUrlEncoded @FormUrlEncoded
@POST("oauth/token") @POST("oauth/token")
@ -458,7 +458,7 @@ interface MastodonApi {
@Field("redirect_uri") redirectUri: String, @Field("redirect_uri") redirectUri: String,
@Field("code") code: String, @Field("code") code: String,
@Field("grant_type") grantType: String @Field("grant_type") grantType: String
): AccessToken ): Result<AccessToken>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/lists") @POST("api/v1/lists")

View File

@ -69,6 +69,7 @@ object PrefKeys {
const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests" const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests"
const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows" const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows"
const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions" const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions"
const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps"
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies" const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies"
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"

View File

@ -0,0 +1,70 @@
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
@file:JvmName("StatusParsingHelper")
package com.keylesspalace.tusky.util
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.core.text.parseAsHtml
import org.jsoup.Jsoup.parse
/**
* parse a String containing html from the Mastodon api to Spanned
*/
fun String.parseAsMastodonHtml(): Spanned {
return this.replace("<br> ", "<br>&nbsp;")
.replace("<br /> ", "<br />&nbsp;")
.replace("<br/> ", "<br/>&nbsp;")
.replace(" ", "&nbsp;&nbsp;")
.let { parse(it) }
.apply {
select(".quote-inline").forEach { it.remove() }
}
.html()
.parseAsHtml()
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
* most status contents do, so it should be trimmed. */
.trimTrailingWhitespace()
}
fun replaceCrashingCharacters(content: Spanned): Spanned {
return replaceCrashingCharacters(content as CharSequence) as Spanned
}
fun replaceCrashingCharacters(content: CharSequence): CharSequence? {
var replacing = false
var builder: SpannableStringBuilder? = null
val length = content.length
for (index in 0 until length) {
val character = content[index]
// If there are more than one or two, switch to a map
if (character == SOFT_HYPHEN) {
if (!replacing) {
replacing = true
builder = SpannableStringBuilder(content, 0, index)
}
builder!!.append(ASCII_HYPHEN)
} else if (replacing) {
builder!!.append(character)
}
}
return if (replacing) builder else content
}
private const val SOFT_HYPHEN = '\u00ad'
private const val ASCII_HYPHEN = '-'

View File

@ -27,12 +27,9 @@ fun Status.toViewData(
isExpanded: Boolean, isExpanded: Boolean,
isCollapsed: Boolean isCollapsed: Boolean
): StatusViewData.Concrete { ): StatusViewData.Concrete {
val visibleStatus = this.reblog ?: this
return StatusViewData.Concrete( return StatusViewData.Concrete(
status = this, status = this,
isShowingContent = isShowingContent, isShowingContent = isShowingContent,
isCollapsible = shouldTrimStatus(visibleStatus.content),
isCollapsed = isCollapsed, isCollapsed = isCollapsed,
isExpanded = isExpanded, isExpanded = isExpanded,
) )

View File

@ -15,9 +15,11 @@
package com.keylesspalace.tusky.viewdata package com.keylesspalace.tusky.viewdata
import android.os.Build import android.os.Build
import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.replaceCrashingCharacters
import com.keylesspalace.tusky.util.shouldTrimStatus
/** /**
* Created by charlag on 11/07/2017. * Created by charlag on 11/07/2017.
@ -32,13 +34,6 @@ sealed class StatusViewData {
val status: Status, val status: Status,
val isExpanded: Boolean, val isExpanded: Boolean,
val isShowingContent: Boolean, val isShowingContent: Boolean,
/**
* Specifies whether the content of this post is allowed to be collapsed or if it should show
* all content regardless.
*
* @return Whether the post is collapsible or never collapsed.
*/
val isCollapsible: Boolean,
/** /**
* Specifies whether the content of this post is currently limited in visibility to the first * Specifies whether the content of this post is currently limited in visibility to the first
* 500 characters or not. * 500 characters or not.
@ -51,6 +46,14 @@ sealed class StatusViewData {
override val id: String override val id: String
get() = status.id get() = status.id
/**
* Specifies whether the content of this post is allowed to be collapsed or if it should show
* all content regardless.
*
* @return Whether the post is collapsible or never collapsed.
*/
val isCollapsible: Boolean
val content: Spanned val content: Spanned
val spoilerText: String val spoilerText: String
val username: String val username: String
@ -71,48 +74,23 @@ sealed class StatusViewData {
val rebloggingStatus: Status? val rebloggingStatus: Status?
get() = if (status.reblog != null) status else null get() = if (status.reblog != null) status else null
val quoteViewData =
status.quote?.let { Concrete(it, isExpanded, isShowingContent, isCollapsed) }
init { init {
if (Build.VERSION.SDK_INT == 23) { if (Build.VERSION.SDK_INT == 23) {
// https://github.com/tuskyapp/Tusky/issues/563 // https://github.com/tuskyapp/Tusky/issues/563
this.content = replaceCrashingCharacters(status.actionableStatus.content) this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml())
this.spoilerText = this.spoilerText =
replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() replaceCrashingCharacters(status.actionableStatus.spoilerText).toString()
this.username = this.username =
replaceCrashingCharacters(status.actionableStatus.account.username).toString() replaceCrashingCharacters(status.actionableStatus.account.username).toString()
} else { } else {
this.content = status.actionableStatus.content this.content = status.actionableStatus.content.parseAsMastodonHtml()
this.spoilerText = status.actionableStatus.spoilerText this.spoilerText = status.actionableStatus.spoilerText
this.username = status.actionableStatus.account.username this.username = status.actionableStatus.account.username
} }
} this.isCollapsible = shouldTrimStatus(this.content)
companion object {
private const val SOFT_HYPHEN = '\u00ad'
private const val ASCII_HYPHEN = '-'
fun replaceCrashingCharacters(content: Spanned): Spanned {
return replaceCrashingCharacters(content as CharSequence) as Spanned
}
fun replaceCrashingCharacters(content: CharSequence): CharSequence? {
var replacing = false
var builder: SpannableStringBuilder? = null
val length = content.length
for (index in 0 until length) {
val character = content[index]
// If there are more than one or two, switch to a map
if (character == SOFT_HYPHEN) {
if (!replacing) {
replacing = true
builder = SpannableStringBuilder(content, 0, index)
}
builder!!.append(ASCII_HYPHEN)
} else if (replacing) {
builder!!.append(character)
}
}
return if (replacing) builder else content
}
} }
/** Helper for Java */ /** Helper for Java */

View File

@ -20,6 +20,7 @@ import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
@ -31,8 +32,7 @@ import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import io.reactivex.rxjava3.disposables.CompositeDisposable import kotlinx.coroutines.launch
import io.reactivex.rxjava3.kotlin.addTo
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
@ -40,9 +40,7 @@ import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import retrofit2.Call import retrofit2.HttpException
import retrofit2.Callback
import retrofit2.Response
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -63,24 +61,20 @@ class EditProfileViewModel @Inject constructor(
private var oldProfileData: Account? = null private var oldProfileData: Account? = null
private val disposables = CompositeDisposable() fun obtainProfile() = viewModelScope.launch {
fun obtainProfile() {
if (profileData.value == null || profileData.value is Error) { if (profileData.value == null || profileData.value is Error) {
profileData.postValue(Loading()) profileData.postValue(Loading())
mastodonApi.accountVerifyCredentials() mastodonApi.accountVerifyCredentials().fold(
.subscribe( { profile ->
{ profile -> oldProfileData = profile
oldProfileData = profile profileData.postValue(Success(profile))
profileData.postValue(Success(profile)) },
}, {
{ profileData.postValue(Error())
profileData.postValue(Error()) }
} )
)
.addTo(disposables)
} }
} }
@ -151,34 +145,34 @@ class EditProfileViewModel @Inject constructor(
return return
} }
mastodonApi.accountUpdateCredentials( viewModelScope.launch {
displayName, note, locked, avatar, header, mastodonApi.accountUpdateCredentials(
field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second displayName, note, locked, avatar, header,
).enqueue(object : Callback<Account> { field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second
override fun onResponse(call: Call<Account>, response: Response<Account>) { ).fold(
val newProfileData = response.body() { newProfileData ->
if (!response.isSuccessful || newProfileData == null) { saveData.postValue(Success())
val errorResponse = response.errorBody()?.string() eventHub.dispatch(ProfileEditedEvent(newProfileData))
val errorMsg = if (!errorResponse.isNullOrBlank()) { },
try { { throwable ->
JSONObject(errorResponse).optString("error", null) if (throwable is HttpException) {
} catch (e: JSONException) { val errorResponse = throwable.response()?.errorBody()?.string()
val errorMsg = if (!errorResponse.isNullOrBlank()) {
try {
JSONObject(errorResponse).optString("error", "")
} catch (e: JSONException) {
null
}
} else {
null null
} }
saveData.postValue(Error(errorMessage = errorMsg))
} else { } else {
null saveData.postValue(Error())
} }
saveData.postValue(Error(errorMessage = errorMsg))
return
} }
saveData.postValue(Success()) )
eventHub.dispatch(ProfileEditedEvent(newProfileData)) }
}
override fun onFailure(call: Call<Account>, t: Throwable) {
saveData.postValue(Error())
}
})
} }
// cache activity state for rotation change // cache activity state for rotation change
@ -208,15 +202,11 @@ class EditProfileViewModel @Inject constructor(
return File(application.cacheDir, filename) return File(application.cacheDir, filename)
} }
override fun onCleared() { fun obtainInstance() = viewModelScope.launch {
disposables.dispose()
}
fun obtainInstance() {
if (instanceData.value == null || instanceData.value is Error) { if (instanceData.value == null || instanceData.value is Error) {
instanceData.postValue(Loading()) instanceData.postValue(Loading())
mastodonApi.getInstance().subscribe( mastodonApi.getInstance().fold(
{ instance -> { instance ->
instanceData.postValue(Success(instance)) instanceData.postValue(Success(instance))
}, },
@ -224,7 +214,6 @@ class EditProfileViewModel @Inject constructor(
instanceData.postValue(Error()) instanceData.postValue(Error())
} }
) )
.addTo(disposables)
} }
} }
} }

View File

@ -4,7 +4,6 @@ import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import autodispose2.SingleSubscribeProxy
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemDrawerFooterBinding import com.keylesspalace.tusky.databinding.ItemDrawerFooterBinding
import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Instance
@ -35,14 +34,13 @@ class FooterDrawerItem : AbstractDrawerItem<FooterDrawerItem, BindingHolder<Item
override fun getViewHolder(v: View): BindingHolder<ItemDrawerFooterBinding> = throw UnsupportedOperationException() override fun getViewHolder(v: View): BindingHolder<ItemDrawerFooterBinding> = throw UnsupportedOperationException()
fun setSubscribeProxy(subscribeProxy: SingleSubscribeProxy<Instance>) { fun setInstance(instance: Result<Instance>) {
subscribeProxy.subscribe( instance
{ instance -> .onSuccess {
binding.instanceData.text = String.format("%s\n%s\n%s", instance.title, instance.uri, instance.version) binding.instanceData.text = String.format("%s\n%s\n%s", it.title, it.uri, it.version)
}, }
{ .onFailure {
binding.instanceData.text = binding.root.context.getString(R.string.instance_data_failed) binding.instanceData.text = binding.root.context.getString(R.string.instance_data_failed)
} }
)
} }
} }

View File

@ -1,150 +0,0 @@
package net.accelf.yuito;
import android.content.Context;
import android.text.Spanned;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Px;
import com.google.android.material.button.MaterialButton;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import java.util.List;
public class QuoteInlineHelper {
private final Status quoteStatus;
private final View quoteContainer;
private final ImageView quoteAvatar;
private final TextView quoteDisplayName;
private final TextView quoteUsername;
private final TextView quoteContentWarningDescription;
private final MaterialButton quoteContentWarningButton;
private final TextView quoteContent;
private final TextView quoteMedia;
private final LinkListener listener;
@Px
private final int avatarRadius24dp;
private final StatusDisplayOptions statusDisplayOptions;
public QuoteInlineHelper(Status status, View container, LinkListener listener,
@Px int avatarRadius24dp, StatusDisplayOptions statusDisplayOptions) {
quoteStatus = status;
quoteContainer = container;
quoteAvatar = container.findViewById(R.id.status_quote_inline_avatar);
quoteDisplayName = container.findViewById(R.id.status_quote_inline_display_name);
quoteUsername = container.findViewById(R.id.status_quote_inline_username);
quoteContentWarningDescription = container.findViewById(R.id.status_quote_inline_content_warning_description);
quoteContentWarningButton = container.findViewById(R.id.status_quote_inline_content_warning_button);
quoteContent = container.findViewById(R.id.status_quote_inline_content);
quoteMedia = container.findViewById(R.id.status_quote_inline_media);
this.listener = listener;
this.avatarRadius24dp = avatarRadius24dp;
this.statusDisplayOptions = statusDisplayOptions;
}
private void setDisplayName(String name, List<Emoji> customEmojis) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(name, customEmojis, quoteDisplayName, statusDisplayOptions.animateEmojis());
quoteDisplayName.setText(emojifiedName);
}
private void setUsername(String name) {
Context context = quoteUsername.getContext();
String format = context.getString(R.string.post_username_format);
String usernameText = String.format(format, name);
quoteUsername.setText(usernameText);
}
private void setContent(
Spanned content,
List<Status.Mention> mentions,
List<HashTag> tags,
List<Emoji> emojis,
LinkListener listener
) {
Spanned singleLineText = SpannedTextHelper.replaceSpanned(content);
CharSequence emojifiedText = CustomEmojiHelper.emojify(singleLineText, emojis, quoteContent, statusDisplayOptions.animateEmojis());
LinkHelper.setClickableText(quoteContent, emojifiedText, mentions, tags, listener);
}
private void setAvatar(String url, @Px int avatarRadius24dp, StatusDisplayOptions statusDisplayOptions) {
ImageLoadingHelper.loadAvatar(url, quoteAvatar, avatarRadius24dp, statusDisplayOptions.animateAvatars());
}
private void setSpoilerText(String spoilerText, List<Emoji> emojis) {
CharSequence emojiSpoiler =
CustomEmojiHelper.emojify(spoilerText, emojis, quoteContentWarningDescription, statusDisplayOptions.animateEmojis());
quoteContentWarningDescription.setText(emojiSpoiler);
quoteContentWarningDescription.setVisibility(View.VISIBLE);
quoteContentWarningButton.setVisibility(View.VISIBLE);
quoteContentWarningButton.setOnClickListener(v
-> setContentVisibility(!(quoteContent.getVisibility() == View.VISIBLE)));
setContentVisibility(false);
}
private void setContentVisibility(boolean show) {
if (show) {
quoteContent.setVisibility(View.VISIBLE);
quoteContentWarningButton.setText(R.string.post_content_warning_show_less);
} else {
quoteContent.setVisibility(View.GONE);
quoteContentWarningButton.setText(R.string.post_content_warning_show_more);
}
}
private void hideSpoilerText() {
quoteContentWarningDescription.setVisibility(View.GONE);
quoteContentWarningButton.setVisibility(View.GONE);
quoteContent.setVisibility(View.VISIBLE);
}
private void setOnClickListener(String accountId, String statusUrl) {
quoteAvatar.setOnClickListener(view -> listener.onViewAccount(accountId));
quoteDisplayName.setOnClickListener(view -> listener.onViewAccount(accountId));
quoteUsername.setOnClickListener(view -> listener.onViewAccount(accountId));
quoteContent.setOnClickListener(view -> listener.onViewUrl(statusUrl, statusUrl));
quoteMedia.setOnClickListener(view -> listener.onViewUrl(statusUrl, statusUrl));
quoteContainer.setOnClickListener(view -> listener.onViewUrl(statusUrl, statusUrl));
}
public void setupQuoteContainer() {
TimelineAccount account = quoteStatus.getAccount();
setDisplayName(account.getName(), account.getEmojis());
setUsername(account.getUsername());
setContent(
quoteStatus.getContent(),
quoteStatus.getMentions(),
quoteStatus.getTags(),
quoteStatus.getEmojis(),
listener
);
setAvatar(account.getAvatar(), avatarRadius24dp, statusDisplayOptions);
setOnClickListener(account.getId(), quoteStatus.getUrl());
if (quoteStatus.getSpoilerText().isEmpty()) {
hideSpoilerText();
} else {
setSpoilerText(quoteStatus.getSpoilerText(), quoteStatus.getEmojis());
}
if (quoteStatus.getAttachments().size() == 0) {
quoteMedia.setVisibility(View.GONE);
} else {
quoteMedia.setVisibility(View.VISIBLE);
quoteMedia.setText(quoteContainer.getContext().getString(R.string.status_quote_media,
quoteStatus.getAttachments().size()));
}
}
}

View File

@ -0,0 +1,120 @@
package net.accelf.yuito
import android.text.Spanned
import android.view.View
import androidx.annotation.Px
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Status.Mention
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.setClickableText
import com.keylesspalace.tusky.viewdata.StatusViewData
class QuoteInlineHelper(
private val binding: ViewQuoteInlineBinding,
private val listener: LinkListener,
@Px private val avatarRadius24dp: Int,
private val statusDisplayOptions: StatusDisplayOptions,
) {
private fun setDisplayName(name: String, customEmojis: List<Emoji>?) {
val viewDisplayName = binding.statusQuoteInlineDisplayName
val emojifiedName = name.emojify(customEmojis, viewDisplayName, statusDisplayOptions.animateEmojis)
viewDisplayName.text = emojifiedName
}
private fun setUsername(name: String) {
val viewUserName = binding.statusQuoteInlineUsername
val context = viewUserName.context
val format = context.getString(R.string.post_username_format)
val usernameText = String.format(format, name)
viewUserName.text = usernameText
}
private fun setContent(
content: Spanned,
mentions: List<Mention>,
tags: List<HashTag>?,
emojis: List<Emoji>,
) {
val viewContent = binding.statusQuoteInlineContent
val singleLineText = SpannedTextHelper.replaceSpanned(content)
val emojifiedText = singleLineText.emojify(emojis, viewContent, statusDisplayOptions.animateEmojis)
setClickableText(viewContent, emojifiedText, mentions, tags, listener)
}
private fun setAvatar(url: String, @Px avatarRadius24dp: Int, statusDisplayOptions: StatusDisplayOptions) {
loadAvatar(url, binding.statusQuoteInlineAvatar, avatarRadius24dp, statusDisplayOptions.animateAvatars)
}
private fun setSpoilerText(spoilerText: String, emojis: List<Emoji>) {
val viewDescription = binding.statusQuoteInlineContentWarningDescription
val viewButton = binding.statusQuoteInlineContentWarningButton
val emojiSpoiler = spoilerText.emojify(emojis, viewDescription, statusDisplayOptions.animateEmojis)
viewDescription.text = emojiSpoiler
viewDescription.visibility = View.VISIBLE
viewButton.visibility = View.VISIBLE
viewButton.setOnClickListener {
setContentVisibility(binding.statusQuoteInlineContent.visibility != View.VISIBLE)
}
setContentVisibility(false)
}
private fun setContentVisibility(show: Boolean) {
binding.statusQuoteInlineContent.visibility = when (show) {
true -> View.VISIBLE
false -> View.GONE
}
binding.statusQuoteInlineContentWarningButton.setText(when (show) {
true -> R.string.post_content_warning_show_less
false -> R.string.post_content_warning_show_more
})
}
private fun hideSpoilerText() {
binding.statusQuoteInlineContentWarningDescription.visibility = View.GONE
binding.statusQuoteInlineContentWarningButton.visibility = View.GONE
binding.statusQuoteInlineContent.visibility = View.VISIBLE
}
private fun setOnClickListener(accountId: String, statusUrl: String?) {
binding.statusQuoteInlineAvatar.setOnClickListener { listener.onViewAccount(accountId) }
binding.statusQuoteInlineDisplayName.setOnClickListener { listener.onViewAccount(accountId) }
binding.statusQuoteInlineUsername.setOnClickListener { listener.onViewAccount(accountId) }
binding.statusQuoteInlineContent.setOnClickListener { listener.onViewUrl(statusUrl!!, statusUrl) }
binding.statusQuoteInlineMedia.setOnClickListener { listener.onViewUrl(statusUrl!!, statusUrl) }
binding.root.setOnClickListener { listener.onViewUrl(statusUrl!!, statusUrl) }
}
fun setupQuoteContainer(quote: StatusViewData.Concrete) {
val actionable = quote.actionable
val account = actionable.account
setDisplayName(account.name, account.emojis)
setUsername(account.username)
setContent(
quote.content,
actionable.mentions,
actionable.tags,
actionable.emojis,
)
setAvatar(account.avatar, avatarRadius24dp, statusDisplayOptions)
setOnClickListener(account.id, actionable.url)
if (quote.spoilerText.isEmpty()) {
hideSpoilerText()
} else {
setSpoilerText(quote.spoilerText, actionable.emojis)
}
val viewMedia = binding.statusQuoteInlineMedia
if (actionable.attachments.size == 0) {
viewMedia.visibility = View.GONE
} else {
viewMedia.visibility = View.VISIBLE
viewMedia.text = viewMedia.context.getString(R.string.status_quote_media, actionable.attachments.size)
}
}
}

View File

@ -253,9 +253,6 @@
<plurals name="hint_describe_for_visually_impaired"> <plurals name="hint_describe_for_visually_impaired">
<item quantity="one">Mìnich e dhan fheadhainn air a bheil cion-lèirsinn <item quantity="one">Mìnich e dhan fheadhainn air a bheil cion-lèirsinn
\n(%d caractar(an) air a char as fhaide)</item> \n(%d caractar(an) air a char as fhaide)</item>
<item quantity="two"/>
<item quantity="few"/>
<item quantity="other"/>
</plurals> </plurals>
<string name="error_failed_set_caption">Cha deach leinn am fo-thiotal a shuidheachadh</string> <string name="error_failed_set_caption">Cha deach leinn am fo-thiotal a shuidheachadh</string>
<string name="compose_active_account_description">A postadh leis a chunntas %1$s</string> <string name="compose_active_account_description">A postadh leis a chunntas %1$s</string>

View File

@ -54,7 +54,7 @@
<string name="action_follow">Seguir</string> <string name="action_follow">Seguir</string>
<string name="action_logout_confirm">Tes a certeza de que queres desconectar a conta %1$s\?</string> <string name="action_logout_confirm">Tes a certeza de que queres desconectar a conta %1$s\?</string>
<string name="action_logout">Desconectar</string> <string name="action_logout">Desconectar</string>
<string name="action_login">Conecta con Mastodon</string> <string name="action_login">Accede con Mastodon</string>
<string name="action_compose">Redactar</string> <string name="action_compose">Redactar</string>
<string name="action_more">Máis</string> <string name="action_more">Máis</string>
<string name="action_unfavourite">Eliminar favorito</string> <string name="action_unfavourite">Eliminar favorito</string>
@ -116,7 +116,7 @@
<string name="error_video_upload_size">Os ficheiros de vídeo teñen que ser menores de 40MB.</string> <string name="error_video_upload_size">Os ficheiros de vídeo teñen que ser menores de 40MB.</string>
<string name="error_image_upload_size">O ficheiro debe ser menor de 8MB.</string> <string name="error_image_upload_size">O ficheiro debe ser menor de 8MB.</string>
<string name="error_compose_character_limit">A publicación é demasiado longa!</string> <string name="error_compose_character_limit">A publicación é demasiado longa!</string>
<string name="error_retrieving_oauth_token">Fallou a obtención do token de conexión.</string> <string name="error_retrieving_oauth_token">Fallou a obtención do token de acceso.</string>
<string name="error_authorization_denied">A autorización foi rexeitada.</string> <string name="error_authorization_denied">A autorización foi rexeitada.</string>
<string name="error_authorization_unknown">Aconteceu un erro non identificado de autorización.</string> <string name="error_authorization_unknown">Aconteceu un erro non identificado de autorización.</string>
<string name="error_no_web_browser_found">Non se atopou un navegador para utilizar.</string> <string name="error_no_web_browser_found">Non se atopou un navegador para utilizar.</string>
@ -411,8 +411,8 @@
<string name="dialog_block_warning">Bloquear @%s\?</string> <string name="dialog_block_warning">Bloquear @%s\?</string>
<string name="mute_domain_warning_dialog_ok">Agochar todo o dominio</string> <string name="mute_domain_warning_dialog_ok">Agochar todo o dominio</string>
<string name="mute_domain_warning">Tes a certeza de querer bloquear a todo %s\? Non verás o contido dese dominio en ningunha cronoloxía pública ou nas notificacións. As túas seguidoras nese dominio serán eliminadas.</string> <string name="mute_domain_warning">Tes a certeza de querer bloquear a todo %s\? Non verás o contido dese dominio en ningunha cronoloxía pública ou nas notificacións. As túas seguidoras nese dominio serán eliminadas.</string>
<string name="dialog_redraft_post_warning">Eliminar e reescribir este toot\?</string> <string name="dialog_redraft_post_warning">Eliminar e reescribir esta publicación\?</string>
<string name="dialog_delete_post_warning">Eliminar este toot\?</string> <string name="dialog_delete_post_warning">Eliminar esta publicación\?</string>
<string name="dialog_unfollow_warning">Deixar de seguir esta conta\?</string> <string name="dialog_unfollow_warning">Deixar de seguir esta conta\?</string>
<string name="dialog_message_cancel_follow_request">Revogar a solicitude de seguimento\?</string> <string name="dialog_message_cancel_follow_request">Revogar a solicitude de seguimento\?</string>
<string name="dialog_download_image">Descargar</string> <string name="dialog_download_image">Descargar</string>

View File

@ -21,23 +21,23 @@
<string name="error_authorization_unknown">Óskilgreind auðkenningarvilla kom upp.</string> <string name="error_authorization_unknown">Óskilgreind auðkenningarvilla kom upp.</string>
<string name="error_authorization_denied">Heimild var hafnað.</string> <string name="error_authorization_denied">Heimild var hafnað.</string>
<string name="error_retrieving_oauth_token">Mistókst að fá innskráningarteikn.</string> <string name="error_retrieving_oauth_token">Mistókst að fá innskráningarteikn.</string>
<string name="error_compose_character_limit">Stöðufærslan er of löng!</string> <string name="error_compose_character_limit">Færslan er of löng!</string>
<string name="error_image_upload_size">Skráin verður að vera minni en 8MB.</string> <string name="error_image_upload_size">Skráin verður að vera minni en 8MB.</string>
<string name="error_video_upload_size">Myndskeiðaskrár verða að vera minni en 40MB.</string> <string name="error_video_upload_size">Myndskeiðaskrár verða að vera minni en 40MB.</string>
<string name="error_media_upload_type">Þessa tegund skrár er ekki hægt að senda inn.</string> <string name="error_media_upload_type">Þessa tegund skrár er ekki hægt að senda inn.</string>
<string name="error_media_upload_opening">Ekki var hægt að opna skrána.</string> <string name="error_media_upload_opening">Ekki var hægt að opna skrána.</string>
<string name="error_media_upload_permission">Krafist er heimilda til að lesa gögn.</string> <string name="error_media_upload_permission">Krafist er heimilda til að lesa gögn.</string>
<string name="error_media_download_permission">Krafist er heimilda til að geyma gögn.</string> <string name="error_media_download_permission">Krafist er heimilda til að geyma gögn.</string>
<string name="error_media_upload_image_or_video">Ekki er hægt að hengja bæði myndir og myndskeið við sömu stöðufærslu.</string> <string name="error_media_upload_image_or_video">Ekki er hægt að hengja bæði myndir og myndskeið við sömu færslu.</string>
<string name="error_media_upload_sending">Sendingin mistókst.</string> <string name="error_media_upload_sending">Sendingin mistókst.</string>
<string name="error_sender_account_gone">Villa við að senda tíst.</string> <string name="error_sender_account_gone">Villa við að senda færslu.</string>
<string name="title_home">Heim</string> <string name="title_home">Heim</string>
<string name="title_notifications">Tilkynningar</string> <string name="title_notifications">Tilkynningar</string>
<string name="title_public_local">Staðvært</string> <string name="title_public_local">Staðvært</string>
<string name="title_public_federated">Sameiginlegt</string> <string name="title_public_federated">Sameiginlegt</string>
<string name="title_direct_messages">Bein skilaboð</string> <string name="title_direct_messages">Bein skilaboð</string>
<string name="title_tab_preferences">Flipar</string> <string name="title_tab_preferences">Flipar</string>
<string name="title_view_thread">Tíst</string> <string name="title_view_thread">Þráður</string>
<string name="title_posts">Færslur</string> <string name="title_posts">Færslur</string>
<string name="title_posts_with_replies">Með svörum</string> <string name="title_posts_with_replies">Með svörum</string>
<string name="title_posts_pinned">Fest</string> <string name="title_posts_pinned">Fest</string>
@ -62,8 +62,8 @@
<string name="post_content_show_less">Fella saman</string> <string name="post_content_show_less">Fella saman</string>
<string name="message_empty">Ekkert hér.</string> <string name="message_empty">Ekkert hér.</string>
<string name="footer_empty">Ekkert hér. Togaðu niður til að endurhlaða!</string> <string name="footer_empty">Ekkert hér. Togaðu niður til að endurhlaða!</string>
<string name="notification_reblog_format">%s endurbirti tístið þitt</string> <string name="notification_reblog_format">%s endurbirti færsluna þína</string>
<string name="notification_favourite_format">%s setti tíst frá þér í eftirlæti</string> <string name="notification_favourite_format">%s setti færslu frá þér í eftirlæti</string>
<string name="notification_follow_format">%s fylgist núna með þér</string> <string name="notification_follow_format">%s fylgist núna með þér</string>
<string name="report_username_format">Kæra @%s</string> <string name="report_username_format">Kæra @%s</string>
<string name="report_comment_hint">Aðrar athugasemdir\?</string> <string name="report_comment_hint">Aðrar athugasemdir\?</string>
@ -117,7 +117,7 @@
<string name="action_reject">Hafna</string> <string name="action_reject">Hafna</string>
<string name="action_access_drafts">Drög</string> <string name="action_access_drafts">Drög</string>
<string name="action_access_scheduled_posts">Áætluð tíst</string> <string name="action_access_scheduled_posts">Áætluð tíst</string>
<string name="action_toggle_visibility">Sýnileiki tísts</string> <string name="action_toggle_visibility">Sýnileiki færslu</string>
<string name="action_content_warning">Aðvörun vegna efnis</string> <string name="action_content_warning">Aðvörun vegna efnis</string>
<string name="action_emoji_keyboard">Lyklaborð með tjáningartáknum</string> <string name="action_emoji_keyboard">Lyklaborð með tjáningartáknum</string>
<string name="action_schedule_post">Tímasetja tíst</string> <string name="action_schedule_post">Tímasetja tíst</string>
@ -234,9 +234,9 @@
<string name="notification_follow_name">Nýir fylgjendur</string> <string name="notification_follow_name">Nýir fylgjendur</string>
<string name="notification_follow_description">Tilkynningar um nýja fylgjendur</string> <string name="notification_follow_description">Tilkynningar um nýja fylgjendur</string>
<string name="notification_boost_name">Endurbirtingar</string> <string name="notification_boost_name">Endurbirtingar</string>
<string name="notification_boost_description">Tilkynningar þegar tístin þín eru endurbirt</string> <string name="notification_boost_description">Tilkynningar þegar færslurnar þínar eru endurbirtar</string>
<string name="notification_favourite_name">Eftirlæti</string> <string name="notification_favourite_name">Eftirlæti</string>
<string name="notification_favourite_description">Tilkynningar þegar tístin þín eru sett í eftirlæti</string> <string name="notification_favourite_description">Tilkynningar þegar færslurnar þínar eru settar í eftirlæti</string>
<string name="notification_poll_name">Kannanir</string> <string name="notification_poll_name">Kannanir</string>
<string name="notification_poll_description">Tilkynningar um kannanir sem er lokið</string> <string name="notification_poll_description">Tilkynningar um kannanir sem er lokið</string>
<string name="notification_mention_format">%s minntist á þig</string> <string name="notification_mention_format">%s minntist á þig</string>
@ -273,7 +273,7 @@
<string name="abbreviated_seconds_ago">%ds</string> <string name="abbreviated_seconds_ago">%ds</string>
<string name="follows_you">Fylgir þér</string> <string name="follows_you">Fylgir þér</string>
<string name="pref_title_alway_show_sensitive_media">Alltaf birta myndefni sem merkt er viðkvæmt</string> <string name="pref_title_alway_show_sensitive_media">Alltaf birta myndefni sem merkt er viðkvæmt</string>
<string name="pref_title_alway_open_spoiler">Alltaf fletta út tístum sem eru með aðvörun vegna efnis</string> <string name="pref_title_alway_open_spoiler">Alltaf fletta út færslum sem eru með aðvörun vegna efnis</string>
<string name="title_media">Gagnaskrár</string> <string name="title_media">Gagnaskrár</string>
<string name="replying_to">Svar til @%s</string> <string name="replying_to">Svar til @%s</string>
<string name="load_more_placeholder_text">hlaða inn fleiru</string> <string name="load_more_placeholder_text">hlaða inn fleiru</string>
@ -376,7 +376,7 @@
<string name="notifications_clear">Hreinsa</string> <string name="notifications_clear">Hreinsa</string>
<string name="notifications_apply_filter">Sía</string> <string name="notifications_apply_filter">Sía</string>
<string name="filter_apply">Virkja</string> <string name="filter_apply">Virkja</string>
<string name="compose_shortcut_long_label">Semja tíst</string> <string name="compose_shortcut_long_label">Semja færslu</string>
<string name="compose_shortcut_short_label">Semja skilaboð</string> <string name="compose_shortcut_short_label">Semja skilaboð</string>
<string name="notification_clear_text">Ertu viss um að þú viljir endanlega eyða öllum tilkynningunum þínum\?</string> <string name="notification_clear_text">Ertu viss um að þú viljir endanlega eyða öllum tilkynningunum þínum\?</string>
<string name="compose_preview_image_description">Aðgerðir fyrir mynd %s</string> <string name="compose_preview_image_description">Aðgerðir fyrir mynd %s</string>
@ -478,7 +478,7 @@
<string name="wellbeing_mode_notice">Sumar upplýsingar sem gætu haft áhrif á andlega vellíðan þína verða faldar. Þetta hefur áhrif á: <string name="wellbeing_mode_notice">Sumar upplýsingar sem gætu haft áhrif á andlega vellíðan þína verða faldar. Þetta hefur áhrif á:
\n \n
\n - Eftirlæti/Endurbirtingar/Tilkynningar um fylgjendabeiðnir \n - Eftirlæti/Endurbirtingar/Tilkynningar um fylgjendabeiðnir
\n - Eftirlæti/Talningu á endurbirtingum tísta \n - Eftirlæti/Talningu á endurbirtingum færslna
\n - Fylgjendur/Tölfræði færslna í notendasniðum \n - Fylgjendur/Tölfræði færslna í notendasniðum
\n \n
\n Þetta hefur ekki áhrif á ýti-tilkynningar, en þú getur yfirfarið handvirkt kjörstillingar þínar varðandi tilkynningar.</string> \n Þetta hefur ekki áhrif á ýti-tilkynningar, en þú getur yfirfarið handvirkt kjörstillingar þínar varðandi tilkynningar.</string>
@ -503,9 +503,9 @@
<string name="limit_notifications">Takmarka tilkynningar á tímalínu</string> <string name="limit_notifications">Takmarka tilkynningar á tímalínu</string>
<string name="review_notifications">Yfirfara tilkynningar</string> <string name="review_notifications">Yfirfara tilkynningar</string>
<string name="pref_title_wellbeing_mode">Vellíðan</string> <string name="pref_title_wellbeing_mode">Vellíðan</string>
<string name="notification_subscription_description">Tilkynningar þegar einhver sem þú ert áskrifandi að hefur birt nýtt tíst</string> <string name="notification_subscription_description">Tilkynningar þegar einhver sem þú ert áskrifandi að hefur birt nýja færslu</string>
<string name="notification_subscription_name"> tíst</string> <string name="notification_subscription_name">jar færslur</string>
<string name="pref_title_notification_filter_subscriptions">einhver sem ég er áskrifandi að birti nýtt tíst</string> <string name="pref_title_notification_filter_subscriptions">einhver sem ég er áskrifandi að birti nýja færslu</string>
<string name="notification_subscription_format">%s sendi inn rétt í þessu</string> <string name="notification_subscription_format">%s sendi inn rétt í þessu</string>
<string name="follow_requests_info">Jafnvel þótt aðgangurinn þinn sé ekki læstur, fannst starfsfólki %1$s að þú gætir viljað yfirfara handvirkt fylgjendabeiðnir frá þessum aðgöngum.</string> <string name="follow_requests_info">Jafnvel þótt aðgangurinn þinn sé ekki læstur, fannst starfsfólki %1$s að þú gætir viljað yfirfara handvirkt fylgjendabeiðnir frá þessum aðgöngum.</string>
<string name="action_unbookmark">Fjarlægja bókamerki</string> <string name="action_unbookmark">Fjarlægja bókamerki</string>
@ -518,4 +518,5 @@
<string name="duration_180_days">180 dagar</string> <string name="duration_180_days">180 dagar</string>
<string name="duration_365_days">365 dagar</string> <string name="duration_365_days">365 dagar</string>
<string name="duration_14_days">14 dagar</string> <string name="duration_14_days">14 dagar</string>
<string name="tusky_compose_post_quicksetting_label">Semja færslu</string>
</resources> </resources>

View File

@ -506,10 +506,10 @@
<string name="limit_notifications">Ogranicz liczbę powiadomień o zmianach na osi czasu</string> <string name="limit_notifications">Ogranicz liczbę powiadomień o zmianach na osi czasu</string>
<string name="label_duration">Czas trwania</string> <string name="label_duration">Czas trwania</string>
<string name="notification_subscription_name">Nowe wpisy</string> <string name="notification_subscription_name">Nowe wpisy</string>
<string name="wellbeing_mode_notice">Niektóre informacje, które mogą wpływać na Twoj dobrostan psychiczny zostaną ukryte. W ich skład wchodzą: <string name="wellbeing_mode_notice">Niektóre informacje, które mogą wpływać na Twój dobrostan psychiczny zostaną ukryte. W ich skład wchodzą:
\n \n
\n - powiadomienia o ulubionych/podbiciach/obserwowaniu \n - powiadomienia o ulubionych/podbiciach/obserwowaniu
\n - liczba polubień/podbić toota \n - liczba polubień/podbić wpisu
\n - statystyki obserwujących/postów na profilach \n - statystyki obserwujących/postów na profilach
\n \n
\nNie będzie to miało wpływu na powiadomienia typu push, ale możesz zmienić ustawienia powiadomień ręcznie.</string> \nNie będzie to miało wpływu na powiadomienia typu push, ale możesz zmienić ustawienia powiadomień ręcznie.</string>
@ -542,4 +542,11 @@
<string name="notification_follow_request_format">%s poprosił(a) o możliwość śledzenia Cię</string> <string name="notification_follow_request_format">%s poprosił(a) o możliwość śledzenia Cię</string>
<string name="action_unbookmark">Usuń z zakładek</string> <string name="action_unbookmark">Usuń z zakładek</string>
<string name="pref_title_confirm_favourites">Pytaj o potwierdzenie przed dodaniem do ulubionych</string> <string name="pref_title_confirm_favourites">Pytaj o potwierdzenie przed dodaniem do ulubionych</string>
<string name="duration_14_days">14 dni</string>
<string name="duration_30_days">30 dni</string>
<string name="duration_60_days">60 dni</string>
<string name="duration_90_days">90 dni</string>
<string name="duration_180_days">180 dni</string>
<string name="duration_365_days">365 dni</string>
<string name="tusky_compose_post_quicksetting_label">Utwórz wpis</string>
</resources> </resources>

View File

@ -16,7 +16,7 @@
<string name="title_notifications">Сповіщення</string> <string name="title_notifications">Сповіщення</string>
<string name="title_home">Головна</string> <string name="title_home">Головна</string>
<string name="error_sender_account_gone">Помилка надсилання допису.</string> <string name="error_sender_account_gone">Помилка надсилання допису.</string>
<string name="error_media_upload_image_or_video">Зображення та відео не можуть бути прикріплені до статусу одночасно.</string> <string name="error_media_upload_image_or_video">Зображення та відео не можуть бути прикріплені до допису одночасно.</string>
<string name="error_media_download_permission">Потрібен дозвіл на зберігання медіа.</string> <string name="error_media_download_permission">Потрібен дозвіл на зберігання медіа.</string>
<string name="error_media_upload_permission">Потрібен дозвіл на читання медіа.</string> <string name="error_media_upload_permission">Потрібен дозвіл на читання медіа.</string>
<string name="error_media_upload_opening">Не вдається відкрити цей файл.</string> <string name="error_media_upload_opening">Не вдається відкрити цей файл.</string>
@ -24,7 +24,7 @@
<string name="error_audio_upload_size">Аудіофайли повинні бути менше 40 МБ.</string> <string name="error_audio_upload_size">Аудіофайли повинні бути менше 40 МБ.</string>
<string name="error_video_upload_size">Відео повинне бути менше 40 МБ.</string> <string name="error_video_upload_size">Відео повинне бути менше 40 МБ.</string>
<string name="error_image_upload_size">Файл повинен бути менше 8 МБ.</string> <string name="error_image_upload_size">Файл повинен бути менше 8 МБ.</string>
<string name="error_compose_character_limit">Статус надто довгий!</string> <string name="error_compose_character_limit">Допис задовгий!</string>
<string name="error_no_web_browser_found">Не вдалося знайти браузер, який можна використати.</string> <string name="error_no_web_browser_found">Не вдалося знайти браузер, який можна використати.</string>
<string name="error_empty">Не може бути порожнім.</string> <string name="error_empty">Не може бути порожнім.</string>
<string name="error_network">Сталася помилка мережі! Перевірте інтернет-з\'єднання та спробуйте знову!</string> <string name="error_network">Сталася помилка мережі! Перевірте інтернет-з\'єднання та спробуйте знову!</string>

View File

@ -2,7 +2,7 @@
<resources> <resources>
<string name="notification_clear_text">Bạn có muốn xóa toàn bộ thông báo\?</string> <string name="notification_clear_text">Bạn có muốn xóa toàn bộ thông báo\?</string>
<string name="send_post_notification_saved_content">Đã lưu tút vào nháp</string> <string name="send_post_notification_saved_content">Đã lưu tút vào nháp</string>
<string name="send_post_notification_cancel_title">Đã hủy đăng</string> <string name="send_post_notification_cancel_title">Hủy đăng</string>
<string name="send_post_notification_channel_name">Đăng Tút</string> <string name="send_post_notification_channel_name">Đăng Tút</string>
<string name="send_post_notification_title">Đang đăng…</string> <string name="send_post_notification_title">Đang đăng…</string>
<plurals name="notification_title_summary"> <plurals name="notification_title_summary">
@ -192,7 +192,7 @@
<string name="title_posts_pinned">Ghim</string> <string name="title_posts_pinned">Ghim</string>
<string name="title_posts_with_replies">Trả lời</string> <string name="title_posts_with_replies">Trả lời</string>
<string name="title_posts">Tút</string> <string name="title_posts">Tút</string>
<string name="title_view_thread">Chuỗi tút</string> <string name="title_view_thread">Nội dung tút</string>
<string name="title_tab_preferences">Xếp tab</string> <string name="title_tab_preferences">Xếp tab</string>
<string name="title_direct_messages">Tin nhắn</string> <string name="title_direct_messages">Tin nhắn</string>
<string name="title_public_federated">Thế giới</string> <string name="title_public_federated">Thế giới</string>
@ -394,7 +394,7 @@
<string name="title_favourited_by">Những người thích tút này</string> <string name="title_favourited_by">Những người thích tút này</string>
<string name="title_reblogged_by">Những người đăng lại tút này</string> <string name="title_reblogged_by">Những người đăng lại tút này</string>
<plurals name="reblogs"> <plurals name="reblogs">
<item quantity="other"><b>%s</b> lượt đăng lại</item> <item quantity="other"><b>%s</b> Đăng lại</item>
</plurals> </plurals>
<plurals name="favs"> <plurals name="favs">
<item quantity="other"><b>%1$s</b> Thích</item> <item quantity="other"><b>%1$s</b> Thích</item>

View File

@ -67,6 +67,7 @@
<string name="notification_favourite_format">%s favorited your post</string> <string name="notification_favourite_format">%s favorited your post</string>
<string name="notification_follow_format">%s followed you</string> <string name="notification_follow_format">%s followed you</string>
<string name="notification_follow_request_format">%s requested to follow you</string> <string name="notification_follow_request_format">%s requested to follow you</string>
<string name="notification_sign_up_format">%s signed up</string>
<string name="notification_subscription_format">%s just posted</string> <string name="notification_subscription_format">%s just posted</string>
<string name="report_username_format">Report @%s</string> <string name="report_username_format">Report @%s</string>
@ -245,6 +246,7 @@
<string name="pref_title_notification_filter_favourites">my posts are favorited</string> <string name="pref_title_notification_filter_favourites">my posts are favorited</string>
<string name="pref_title_notification_filter_poll">polls have ended</string> <string name="pref_title_notification_filter_poll">polls have ended</string>
<string name="pref_title_notification_filter_subscriptions">somebody I\'m subscribed to published a new post</string> <string name="pref_title_notification_filter_subscriptions">somebody I\'m subscribed to published a new post</string>
<string name="pref_title_notification_filter_sign_ups">somebody signed up</string>
<string name="pref_title_appearance_settings">Appearance</string> <string name="pref_title_appearance_settings">Appearance</string>
<string name="pref_title_app_theme">App Theme</string> <string name="pref_title_app_theme">App Theme</string>
<string name="pref_title_timelines">Timelines</string> <string name="pref_title_timelines">Timelines</string>
@ -321,6 +323,8 @@
<string name="notification_poll_description">Notifications about polls that have ended</string> <string name="notification_poll_description">Notifications about polls that have ended</string>
<string name="notification_subscription_name">New posts</string> <string name="notification_subscription_name">New posts</string>
<string name="notification_subscription_description">Notifications when somebody you\'re subscribed to published a new post</string> <string name="notification_subscription_description">Notifications when somebody you\'re subscribed to published a new post</string>
<string name="notification_sign_up_name">Sign ups</string>
<string name="notification_sign_up_description">Notifications about new users</string>
<string name="notification_mention_format">%s mentioned you</string> <string name="notification_mention_format">%s mentioned you</string>
<string name="notification_summary_large">%1$s, %2$s, %3$s and %4$d others</string> <string name="notification_summary_large">%1$s, %2$s, %3$s and %4$d others</string>

View File

@ -15,16 +15,11 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.text.SpannedString
import android.widget.LinearLayout
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.SearchResult
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.plugins.RxJavaPlugins import io.reactivex.rxjava3.plugins.RxJavaPlugins
@ -39,8 +34,8 @@ import org.junit.runner.RunWith
import org.junit.runners.Parameterized import org.junit.runners.Parameterized
import org.mockito.ArgumentMatchers.anyBoolean import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.Mockito.eq import org.mockito.Mockito.eq
import org.mockito.Mockito.mock import org.mockito.kotlin.doReturn
import java.util.ArrayList import org.mockito.kotlin.mock
import java.util.Date import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -74,7 +69,7 @@ class BottomSheetActivityTest {
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
reblog = null, reblog = null,
content = SpannedString("omgwat"), content = "omgwat",
createdAt = Date(), createdAt = Date(),
emojis = emptyList(), emojis = emptyList(),
reblogsCount = 0, reblogsCount = 0,
@ -307,7 +302,7 @@ class BottomSheetActivityTest {
init { init {
mastodonApi = api mastodonApi = api
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
bottomSheet = mock(BottomSheetBehavior::class.java) as BottomSheetBehavior<LinearLayout> bottomSheet = mock()
} }
override fun openLink(url: String) { override fun openLink(url: String) {

View File

@ -17,15 +17,12 @@ package com.keylesspalace.tusky
import android.content.Intent import android.content.Intent
import android.os.Looper.getMainLooper import android.os.Looper.getMainLooper
import android.text.SpannedString
import android.widget.EditText import android.widget.EditText
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT
import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH
import com.keylesspalace.tusky.components.compose.MediaUploader
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
@ -37,18 +34,16 @@ import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.entity.InstanceConfiguration import com.keylesspalace.tusky.entity.InstanceConfiguration
import com.keylesspalace.tusky.entity.StatusConfiguration import com.keylesspalace.tusky.entity.StatusConfiguration
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.service.ServiceClient
import com.nhaarman.mockitokotlin2.any
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.core.SingleObserver
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.Mockito.`when` import org.mockito.kotlin.any
import org.mockito.Mockito.mock import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.Robolectric import org.robolectric.Robolectric
import org.robolectric.Shadows.shadowOf import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@ -94,44 +89,47 @@ class ComposeActivityTest {
val controller = Robolectric.buildActivity(ComposeActivity::class.java) val controller = Robolectric.buildActivity(ComposeActivity::class.java)
activity = controller.get() activity = controller.get()
accountManagerMock = mock(AccountManager::class.java) accountManagerMock = mock {
`when`(accountManagerMock.activeAccount).thenReturn(account) on { activeAccount } doReturn account
}
apiMock = mock(MastodonApi::class.java) apiMock = mock {
`when`(apiMock.getCustomEmojis()).thenReturn(Single.just(emptyList())) on { getCustomEmojis() } doReturn Single.just(emptyList())
`when`(apiMock.getInstance()).thenReturn(object : Single<Instance>() { onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance ->
override fun subscribeActual(observer: SingleObserver<in Instance>) {
val instance = instanceResponseCallback?.invoke()
if (instance == null) { if (instance == null) {
observer.onError(Throwable()) Result.failure(Throwable())
} else { } else {
observer.onSuccess(instance) Result.success(instance)
} }
} }
}) }
val instanceDaoMock = mock(InstanceDao::class.java) val instanceDaoMock: InstanceDao = mock {
`when`(instanceDaoMock.loadMetadataForInstance(any())).thenReturn( on { loadMetadataForInstance(any()) } doReturn
Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null)) Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null))
) on { loadMetadataForInstance(any()) } doReturn
Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null))
}
val dbMock = mock(AppDatabase::class.java) val dbMock: AppDatabase = mock {
`when`(dbMock.instanceDao()).thenReturn(instanceDaoMock) on { instanceDao() } doReturn instanceDaoMock
}
val viewModel = ComposeViewModel( val viewModel = ComposeViewModel(
apiMock, apiMock,
accountManagerMock, accountManagerMock,
mock(MediaUploader::class.java), mock(),
mock(ServiceClient::class.java), mock(),
mock(DraftHelper::class.java), mock(),
dbMock dbMock
) )
activity.intent = Intent(activity, ComposeActivity::class.java).apply { activity.intent = Intent(activity, ComposeActivity::class.java).apply {
putExtra(ComposeActivity.COMPOSE_OPTIONS_EXTRA, composeOptions) putExtra(ComposeActivity.COMPOSE_OPTIONS_EXTRA, composeOptions)
} }
val viewModelFactoryMock = mock(ViewModelFactory::class.java) val viewModelFactoryMock: ViewModelFactory = mock {
`when`(viewModelFactoryMock.create(ComposeViewModel::class.java)).thenReturn(viewModel) on { create(ComposeViewModel::class.java) } doReturn viewModel
}
activity.accountManager = accountManagerMock activity.accountManager = accountManagerMock
activity.viewModelFactory = viewModelFactoryMock activity.viewModelFactory = viewModelFactoryMock
@ -470,7 +468,7 @@ class ComposeActivityTest {
"admin", "admin",
"admin", "admin",
"admin", "admin",
SpannedString(""), "",
"https://example.token", "https://example.token",
"", "",
"", "",
@ -490,7 +488,7 @@ class ComposeActivityTest {
) )
} }
fun getCustomInstanceConfiguration(maximumStatusCharacters: Int? = null, charactersReservedPerUrl: Int? = null): InstanceConfiguration { private fun getCustomInstanceConfiguration(maximumStatusCharacters: Int? = null, charactersReservedPerUrl: Int? = null): InstanceConfiguration {
return InstanceConfiguration( return InstanceConfiguration(
statuses = StatusConfiguration( statuses = StatusConfiguration(
maxCharacters = maximumStatusCharacters, maxCharacters = maximumStatusCharacters,

View File

@ -1,6 +1,5 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.text.SpannedString
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
@ -8,12 +7,12 @@ import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.PollOption import com.keylesspalace.tusky.entity.PollOption
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.nhaarman.mockitokotlin2.mock
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.util.ArrayList import java.util.ArrayList
import java.util.Date import java.util.Date
@ -22,7 +21,7 @@ import java.util.Date
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class FilterTest { class FilterTest {
lateinit var filterModel: FilterModel private lateinit var filterModel: FilterModel
@Before @Before
fun setup() { fun setup() {
@ -162,7 +161,7 @@ class FilterTest {
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
reblog = null, reblog = null,
content = SpannedString(content), content = content,
createdAt = Date(), createdAt = Date(),
emojis = emptyList(), emojis = emptyList(),
reblogsCount = 0, reblogsCount = 0,

View File

@ -1,10 +1,8 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.text.Spanned
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.gson.GsonBuilder import com.google.gson.Gson
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
@ -39,9 +37,7 @@ class StatusComparisonTest {
assertEquals(createStatus(note = "Test"), createStatus(note = "Test 123456")) assertEquals(createStatus(note = "Test"), createStatus(note = "Test 123456"))
} }
private val gson = GsonBuilder().registerTypeAdapter( private val gson = Gson()
Spanned::class.java, SpannedTypeAdapter()
).create()
@Test @Test
fun `two equal status view data - should be equal`() { fun `two equal status view data - should be equal`() {
@ -49,14 +45,12 @@ class StatusComparisonTest {
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
val viewdata2 = StatusViewData.Concrete( val viewdata2 = StatusViewData.Concrete(
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
assertEquals(viewdata1, viewdata2) assertEquals(viewdata1, viewdata2)
@ -68,14 +62,12 @@ class StatusComparisonTest {
status = createStatus(), status = createStatus(),
isExpanded = true, isExpanded = true,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
val viewdata2 = StatusViewData.Concrete( val viewdata2 = StatusViewData.Concrete(
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
assertNotEquals(viewdata1, viewdata2) assertNotEquals(viewdata1, viewdata2)
@ -87,14 +79,12 @@ class StatusComparisonTest {
status = createStatus(content = "whatever"), status = createStatus(content = "whatever"),
isExpanded = true, isExpanded = true,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
val viewdata2 = StatusViewData.Concrete( val viewdata2 = StatusViewData.Concrete(
status = createStatus(), status = createStatus(),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = false isCollapsed = false
) )
assertNotEquals(viewdata1, viewdata2) assertNotEquals(viewdata1, viewdata2)

View File

@ -17,9 +17,6 @@ import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.nhaarman.mockitokotlin2.anyOrNull
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -31,6 +28,9 @@ import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.Shadows.shadowOf import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import retrofit2.HttpException import retrofit2.HttpException

View File

@ -1,14 +1,19 @@
package com.keylesspalace.tusky.components.timeline package com.keylesspalace.tusky.components.timeline
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.robolectric.annotation.Config
@Config(sdk = [28])
@RunWith(AndroidJUnit4::class)
class NetworkTimelinePagingSourceTest { class NetworkTimelinePagingSourceTest {
private val status = mockStatusViewData() private val status = mockStatusViewData()

View File

@ -12,11 +12,6 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineView
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import com.nhaarman.mockitokotlin2.anyOrNull
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.doThrow
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.verify
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.Headers import okhttp3.Headers
import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.ResponseBody.Companion.toResponseBody
@ -24,6 +19,11 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Response import retrofit2.Response
@ -331,7 +331,6 @@ class NetworkTimelineRemoteMediatorTest {
mockStatusViewData("2"), mockStatusViewData("2"),
mockStatusViewData("1"), mockStatusViewData("1"),
) )
verify(timelineViewModel).nextKey = "0" verify(timelineViewModel).nextKey = "0"
assertTrue(result is RemoteMediator.MediatorResult.Success) assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)

View File

@ -1,6 +1,5 @@
package com.keylesspalace.tusky.components.timeline package com.keylesspalace.tusky.components.timeline
import android.text.SpannedString
import com.google.gson.Gson import com.google.gson.Gson
import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
@ -25,7 +24,7 @@ fun mockStatus(id: String = "100") = Status(
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
reblog = null, reblog = null,
content = SpannedString("Test"), content = "Test",
createdAt = fixedDate, createdAt = fixedDate,
emojis = emptyList(), emojis = emptyList(),
reblogsCount = 1, reblogsCount = 1,
@ -51,7 +50,6 @@ fun mockStatusViewData(id: String = "100") = StatusViewData.Concrete(
status = mockStatus(id), status = mockStatus(id),
isExpanded = false, isExpanded = false,
isShowingContent = false, isShowingContent = false,
isCollapsible = false,
isCollapsed = true, isCollapsed = true,
) )

View File

@ -1,5 +1,4 @@
buildscript { buildscript {
ext.kotlin_version = '1.6.10'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
@ -7,12 +6,12 @@ buildscript {
} }
dependencies { dependencies {
classpath "com.android.tools.build:gradle:7.1.2" classpath "com.android.tools.build:gradle:7.1.2"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.20"
classpath "org.jlleitschuh.gradle:ktlint-gradle:10.1.0" classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1"
} }
} }
plugins { plugins {
id "org.jlleitschuh.gradle.ktlint" version "10.1.0" id "org.jlleitschuh.gradle.ktlint" version "10.2.1"
} }
allprojects { allprojects {

View File

@ -0,0 +1,8 @@
Tusky v16.0
- Logika ładowania osi czasu została przepisana w celu przyspieszenia jej i naprawienia błędów.
- Tusky wspiera teraz animowane emotikony w formatach APNG i Animated WebP.
- Mnóstwo poprawek
- Wsparcie dla Androida 11
- Nowe tłumaczenia: Gaelicki szkocki, galicyjski, ukraiński
- Ulepszone tłumaczenia

View File

@ -0,0 +1,7 @@
Tusky v17.0
- «Відкрити як...» тепер також доступно в меню профілів облікових записів за користування кількома обліковими записами
- Тепер вхід обробляється у WebView у застосунку
- Підтримка Android 12
- підтримка нового API конфігурації сервера Mastodon
- і багато інших дрібних виправлень і вдосконалень