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.roomVersion = '2.4.2'
ext.retrofitVersion = '2.9.0'
@ -112,8 +112,6 @@ repositories {
// if libraries are changed here, they should also be changed in LicenseActivity
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion"
@ -150,6 +148,7 @@ dependencies {
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$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:logging-interceptor:$okhttpVersion"
@ -189,8 +188,8 @@ dependencies {
testImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "org.robolectric:robolectric:4.4"
testImplementation "org.mockito:mockito-inline:3.6.28"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
testImplementation "org.mockito:mockito-inline:4.4.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
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: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
android:name=".components.login.LoginActivity"
android:windowSoftInputMode="adjustResize">
@ -30,13 +46,7 @@
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
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>
<intent-filter>
<action android:name="android.intent.action.SEND" />
@ -84,9 +94,6 @@
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/share_shortcuts" />
</activity>
<activity

View File

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

View File

@ -41,6 +41,7 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding;
import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Notification;
import com.keylesspalace.tusky.entity.Status;
@ -229,7 +230,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case VIEW_TYPE_FOLLOW: {
if (payloadForHolder == null) {
FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(concreteNotificaton.getAccount());
holder.setMessage(concreteNotificaton.getAccount(), concreteNotificaton.getType() == Notification.Type.SIGN_UP);
holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId());
}
break;
@ -287,7 +288,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case REBLOG: {
return VIEW_TYPE_STATUS_NOTIFICATION;
}
case FOLLOW: {
case FOLLOW:
case SIGN_UP: {
return VIEW_TYPE_FOLLOW;
}
case FOLLOW_REQUEST: {
@ -339,10 +341,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
this.statusDisplayOptions = statusDisplayOptions;
}
void setMessage(TimelineAccount account) {
void setMessage(TimelineAccount account, Boolean isSignUp) {
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 wholeMessage = String.format(format, wrappedDisplayName);
CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
@ -599,13 +601,14 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
avatarRadius24dp, statusDisplayOptions.animateAvatars());
}
private void setQuoteContainer(Status status, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) {
if (status != null) {
private void setQuoteContainer(StatusViewData.Concrete quote, final LinkListener listener, StatusDisplayOptions statusDisplayOptions) {
if (quote != null) {
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),
statusDisplayOptions)
.setupQuoteContainer();
.setupQuoteContainer(quote);
} else {
quoteContainer.setVisibility(View.GONE);
}
@ -678,7 +681,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
}
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.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.databinding.ViewQuoteInlineBinding;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData;
@ -480,10 +481,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
favouriteButton.setChecked(favourited);
}
private void setQuoteContainer(Status status, final StatusActionListener listener, StatusDisplayOptions statusDisplayOptions) {
if (status != null) {
private void setQuoteContainer(StatusViewData.Concrete quote, final StatusActionListener listener, StatusDisplayOptions statusDisplayOptions) {
if (quote != null) {
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 {
quoteContainer.setVisibility(View.GONE);
}
@ -857,7 +859,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
actionable.getAccount().getBot(), statusDisplayOptions);
setReblogged(actionable.getReblogged());
setFavourited(actionable.getFavourited());
setQuoteContainer(actionable.getQuote(), listener, statusDisplayOptions);
setQuoteContainer(status.getQuoteViewData(), listener, statusDisplayOptions);
setBookmarked(actionable.getBookmarked());
List<Attachment> attachments = actionable.getAttachments();
boolean sensitive = actionable.getSensitive();
@ -1152,9 +1154,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener
) {
final Card card = status.getActionable().getCard();
final Status actionable = status.getActionable();
final Card card = actionable.getCard();
if (cardViewMode != CardViewMode.NONE &&
status.getActionable().getAttachments().size() == 0 &&
actionable.getAttachments().size() == 0 &&
actionable.getPoll() == null &&
card != null &&
!TextUtils.isEmpty(card.getUrl()) &&
(!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,
// so let's blur the preview in that case
// 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 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.hide
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.show
import com.keylesspalace.tusky.util.viewBinding
@ -380,12 +380,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
}
}
viewModel.accountFieldData.observe(
this,
{
accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged()
}
)
this
) {
accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged()
}
viewModel.noteSaved.observe(this) {
binding.saveNoteInfo.visible(it, View.INVISIBLE)
}
@ -400,11 +399,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
adapter.refreshContent()
}
viewModel.isRefreshing.observe(
this,
{ isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
}
)
this
) { isRefreshing ->
binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true
}
binding.swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
@ -415,7 +413,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
binding.accountUsernameTextView.text = usernameFormatted
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)
// 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.createClickableText
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.setClickableText
class AccountFieldAdapter(
@ -65,7 +66,7 @@ class AccountFieldAdapter(
val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis)
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)
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.Success
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.rx3.rxSingle
import javax.inject.Inject
class AnnouncementsViewModel @Inject constructor(
@ -56,8 +57,9 @@ class AnnouncementsViewModel @Inject constructor(
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext {
mastodonApi.getInstance()
.map { Either.Right(it) }
rxSingle {
mastodonApi.getInstance().getOrThrow()
}.map { Either.Right(it) }
}
) { emojis, either ->
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.disposables.Disposable
import kotlinx.coroutines.launch
import java.util.*
import kotlinx.coroutines.rx3.rxSingle
import java.util.Locale
import javax.inject.Inject
class ComposeViewModel @Inject constructor(
@ -110,7 +111,7 @@ class ComposeViewModel @Inject constructor(
fun loadInstanceDataFromNetwork(loadActually: Boolean) {
when (loadActually) {
true -> Single.zip(
api.getCustomEmojis(), api.getInstance()
api.getCustomEmojis(), rxSingle { api.getInstance().getOrThrow() }
) { emojis, instance ->
InstanceEntity(
instance = accountManager.activeAccount?.domain!!,
@ -298,7 +299,7 @@ class ComposeViewModel @Inject constructor(
): LiveData<Unit> {
val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { }
rxSingle { api.deleteScheduledStatus(scheduledTootId.toString()) }.toObservable().map { }
} else {
Observable.just(Unit)
}.toLiveData()

View File

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

View File

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.conversation
import android.text.Spanned
import androidx.room.Embedded
import androidx.room.Entity
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.Status
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.shouldTrimStatus
import com.keylesspalace.tusky.viewdata.StatusViewData
import java.util.Date
@Entity(primaryKeys = ["id", "accountId"])
@ -38,7 +37,16 @@ data class ConversationEntity(
val accounts: List<ConversationAccountEntity>,
val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
)
) {
fun toViewData(): ConversationViewData {
return ConversationViewData(
id = id,
accounts = accounts,
unread = unread,
lastStatus = lastStatus.toViewData()
)
}
}
data class ConversationAccountEntity(
val id: String,
@ -67,7 +75,7 @@ data class ConversationStatusEntity(
val inReplyToId: String?,
val inReplyToAccountId: String?,
val account: ConversationAccountEntity,
val content: Spanned,
val content: String,
val createdAt: Date,
val emojis: List<Emoji>,
val favouritesCount: Int,
@ -80,96 +88,44 @@ data class ConversationStatusEntity(
val tags: List<HashTag>?,
val showingHiddenContent: Boolean,
val expanded: Boolean,
val collapsible: Boolean,
val collapsed: Boolean,
val muted: Boolean,
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
if (id != other.id) return false
if (url != other.url) return false
if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false
if (account != other.account) return false
if (content.toString() != other.content.toString()) return false
if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false
if (favouritesCount != other.favouritesCount) return false
if (favourited != other.favourited) return false
if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false
if (attachments != other.attachments) return false
if (mentions != other.mentions) return false
if (tags != other.tags) return false
if (showingHiddenContent != other.showingHiddenContent) return false
if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false
if (collapsed != other.collapsed) return false
if (muted != other.muted) return false
if (poll != other.poll) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + account.hashCode()
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,
fun toViewData(): StatusViewData.Concrete {
return StatusViewData.Concrete(
status = 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,
),
isExpanded = expanded,
isShowingContent = showingHiddenContent,
isCollapsed = collapsed
)
}
}
@ -203,7 +159,6 @@ fun Status.toEntity() =
tags = tags,
showingHiddenContent = false,
expanded = false,
collapsible = shouldTrimStatus(content),
collapsed = true,
muted = muted ?: false,
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.adapter.StatusBaseViewHolder;
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.util.ImageLoadingHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.util.List;
@ -69,11 +72,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
}
void setupWithConversation(ConversationEntity conversation) {
ConversationStatusEntity status = conversation.getLastStatus();
ConversationAccountEntity account = status.getAccount();
void setupWithConversation(ConversationViewData conversation) {
StatusViewData.Concrete statusViewData = conversation.getLastStatus();
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);
setUsername(account.getUsername());
@ -84,7 +88,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(),
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) {
@ -95,7 +99,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
mediaLabel.setVisibility(View.GONE);
}
} else {
setMediaLabel(attachments, sensitive, listener, status.getShowingHiddenContent());
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views.
mediaPreviews[0].setVisibility(View.GONE);
mediaPreviews[1].setVisibility(View.GONE);
@ -104,10 +108,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
hideSensitiveMediaWarning();
}
setupButtons(listener, account.getId(), status.getContent().toString(),
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
false, statusDisplayOptions);
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(),
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);

View File

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

View File

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

View File

@ -33,7 +33,6 @@ import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityLoginBinding
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AppCredentials
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.viewBinding
@ -159,32 +158,33 @@ class LoginActivity : BaseActivity(), Injectable {
setLoading(true)
lifecycleScope.launch {
val credentials: AppCredentials = try {
mastodonApi.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
)
} catch (e: Exception) {
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error =
getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(e))
return@launch
}
mastodonApi.authenticateApp(
domain, getString(R.string.app_name), oauthRedirectUri,
OAUTH_SCOPES, getString(R.string.tusky_website)
).fold(
{ credentials ->
// Before we open browser page we save the data.
// 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.
// Alternatively we could pass it all as part of the intent and receive it back
// but it is a bit of a workaround.
preferences.edit()
.putString(DOMAIN, domain)
.putString(CLIENT_ID, credentials.clientId)
.putString(CLIENT_SECRET, credentials.clientSecret)
.apply()
// Before we open browser page we save the data.
// 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.
// Alternatively we could pass it all as part of the intent and receive it back
// but it is a bit of a workaround.
preferences.edit()
.putString(DOMAIN, domain)
.putString(CLIENT_ID, credentials.clientId)
.putString(CLIENT_SECRET, credentials.clientSecret)
.apply()
redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
redirectUserToAuthorizeAndLogin(domain, credentials.clientId)
},
{ e ->
binding.loginButton.isEnabled = true
binding.domainTextInputLayout.error =
getString(R.string.error_failed_app_registration)
setLoading(false)
Log.e(TAG, Log.getStackTraceString(e))
return@launch
}
)
}
}
@ -217,29 +217,28 @@ class LoginActivity : BaseActivity(), Injectable {
setLoading(true)
val accessToken = try {
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, oauthRedirectUri, code,
"authorization_code"
)
} 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
}
mastodonApi.fetchOAuthToken(
domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
).fold(
{ accessToken ->
accountManager.addAccount(accessToken.accessToken, domain)
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
startActivity(intent)
finish()
overridePendingTransition(R.anim.explode, R.anim.explode)
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
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) {

View File

@ -16,6 +16,7 @@ import android.webkit.WebStorage
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.net.toUri
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.databinding.LoginWebviewBinding
@ -103,8 +104,8 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
webView.webViewClient = object : WebViewClient() {
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
view: WebView,
request: WebResourceRequest,
error: WebResourceError
) {
Log.d("LoginWeb", "Failed to load ${data.url}: $error")
@ -115,7 +116,17 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
view: WebView,
request: WebResourceRequest
): 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) {
val error = url.getQueryParameter("error")
if (error != null) {
@ -130,6 +141,7 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
}
}
}
webView.setBackgroundColor(Color.TRANSPARENT)
if (savedInstanceState == null) {

View File

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

View File

@ -13,8 +13,8 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding
import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding
@ -216,7 +216,7 @@ class EmojiPreference(
.setPositiveButton(R.string.restart) { _, _ ->
// Restart the app
// 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(
context,
0x1f973, // This is the codepoint of the party face emoji :D

View File

@ -122,6 +122,17 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable {
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 ->

View File

@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.map
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub
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.RxAwareViewModel
import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.toViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -74,6 +77,11 @@ class ReportViewModel @Inject constructor(
pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) }
).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)
private val selectedIds = HashSet<String>()
@ -155,7 +163,7 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ relationship ->
val muting = relationship?.muting == true
val muting = relationship.muting
muteStateMutable.value = Success(muting)
if (muting) {
eventHub.dispatch(MuteEvent(accountId))
@ -180,7 +188,7 @@ class ReportViewModel @Inject constructor(
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ relationship ->
val blocking = relationship?.blocking == true
val blocking = relationship.blocking
blockStateMutable.value = Success(blocking)
if (blocking) {
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.shouldTrimStatus
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.StatusViewData
import com.keylesspalace.tusky.viewdata.toViewData
import java.util.Date
@ -45,20 +46,21 @@ class StatusViewHolder(
private val statusDisplayOptions: StatusDisplayOptions,
private val viewState: StatusViewState,
private val adapterHandler: AdapterHandler,
private val getStatusForPosition: (Int) -> Status?
private val getStatusForPosition: (Int) -> StatusViewData.Concrete?
) : RecyclerView.ViewHolder(binding.root) {
private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height)
private val statusViewHelper = StatusViewHelper(itemView)
private val previewListener = object : StatusViewHelper.MediaPreviewListener {
override fun onViewMedia(v: View?, idx: Int) {
status()?.let { status ->
adapterHandler.showMedia(v, status, idx)
viewdata()?.let { viewdata ->
adapterHandler.showMedia(v, viewdata.status, idx)
}
}
override fun onContentHiddenChange(isShowing: Boolean) {
status()?.id?.let { id ->
viewdata()?.id?.let { id ->
viewState.setMediaShow(id, isShowing)
}
}
@ -66,57 +68,57 @@ class StatusViewHolder(
init {
binding.statusSelection.setOnCheckedChangeListener { _, isChecked ->
status()?.let { status ->
adapterHandler.setStatusChecked(status, isChecked)
viewdata()?.let { viewdata ->
adapterHandler.setStatusChecked(viewdata.status, isChecked)
}
}
binding.statusMediaPreviewContainer.clipToOutline = true
}
fun bind(status: Status) {
binding.statusSelection.isChecked = adapterHandler.isStatusChecked(status.id)
fun bind(viewData: StatusViewData.Concrete) {
binding.statusSelection.isChecked = adapterHandler.isStatusChecked(viewData.id)
updateTextView()
val sensitive = status.sensitive
val sensitive = viewData.status.sensitive
statusViewHelper.setMediasPreview(
statusDisplayOptions, status.attachments,
sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive),
statusDisplayOptions, viewData.status.attachments,
sensitive, previewListener, viewState.isMediaShow(viewData.id, viewData.status.sensitive),
mediaViewHeight
)
statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions)
setCreatedAt(status.createdAt)
statusViewHelper.setupPollReadonly(viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions)
setCreatedAt(viewData.status.createdAt)
}
private fun updateTextView() {
status()?.let { status ->
viewdata()?.let { viewdata ->
setupCollapsedState(
shouldTrimStatus(status.content), viewState.isCollapsed(status.id, true),
viewState.isContentShow(status.id, status.sensitive), status.spoilerText
shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true),
viewState.isContentShow(viewdata.id, viewdata.status.sensitive), viewdata.spoilerText
)
if (status.spoilerText.isBlank()) {
setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler, status.quote != null)
if (viewdata.spoilerText.isBlank()) {
setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler, viewdata.status.quote != null)
binding.statusContentWarningButton.hide()
binding.statusContentWarningDescription.hide()
} 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.show()
binding.statusContentWarningButton.show()
setContentWarningButtonText(viewState.isContentShow(status.id, true))
setContentWarningButtonText(viewState.isContentShow(viewdata.id, true))
binding.statusContentWarningButton.setOnClickListener {
status()?.let { status ->
val contentShown = viewState.isContentShow(status.id, true)
viewdata()?.let { viewdata ->
val contentShown = viewState.isContentShow(viewdata.id, true)
binding.statusContentWarningDescription.invalidate()
viewState.setContentShow(status.id, !contentShown)
setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler, status.quote != null)
viewState.setContentShow(viewdata.id, !contentShown)
setTextVisible(!contentShown, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler, viewdata.status.quote != null)
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 */
if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
binding.buttonToggleContent.setOnClickListener {
status()?.let { status ->
viewState.setCollapsed(status.id, !collapsed)
viewdata()?.let { viewdata ->
viewState.setCollapsed(viewdata.id, !collapsed)
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 com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.databinding.ItemReportStatusBinding
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.viewdata.StatusViewData
class StatusesAdapter(
private val statusDisplayOptions: StatusDisplayOptions,
private val statusViewState: StatusViewState,
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
}
@ -50,11 +50,11 @@ class StatusesAdapter(
}
companion object {
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<Status>() {
override fun areContentsTheSame(oldItem: Status, newItem: Status): Boolean =
val STATUS_COMPARATOR = object : DiffUtil.ItemCallback<StatusViewData.Concrete>() {
override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
oldItem == newItem
override fun areItemsTheSame(oldItem: Status, newItem: Status): Boolean =
override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean =
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.network.MastodonApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import javax.inject.Inject
class ScheduledStatusViewModel @Inject constructor(
@ -43,12 +42,14 @@ class ScheduledStatusViewModel @Inject constructor(
fun deleteScheduledStatus(status: ScheduledStatus) {
viewModelScope.launch {
try {
mastodonApi.deleteScheduledStatus(status.id).await()
pagingSourceFactory.remove(status)
} catch (throwable: Throwable) {
Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable)
}
mastodonApi.deleteScheduledStatus(status.id).fold(
{
pagingSourceFactory.remove(status)
},
{ throwable ->
Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable)
}
)
}
}
}

View File

@ -15,9 +15,6 @@
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.reflect.TypeToken
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.Status
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 java.util.Date
@ -119,7 +114,7 @@ fun Status.toEntity(
authorServerId = actionableStatus.account.id,
inReplyToId = actionableStatus.inReplyToId,
inReplyToAccountId = actionableStatus.inReplyToAccountId,
content = actionableStatus.content.toHtml(),
content = actionableStatus.content,
createdAt = actionableStatus.createdAt.time,
emojis = actionableStatus.emojis.let(gson::toJson),
reblogsCount = actionableStatus.reblogsCount,
@ -165,8 +160,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
?: SpannedString(""),
content = status.content.orEmpty(),
createdAt = Date(status.createdAt),
emojis = emojis,
reblogsCount = status.reblogsCount,
@ -196,7 +190,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = null,
inReplyToAccountId = null,
reblog = reblog,
content = SpannedString(""),
content = "",
createdAt = Date(status.createdAt), // lie but whatever?
emojis = listOf(),
reblogsCount = 0,
@ -225,8 +219,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content?.parseAsHtml()?.trimTrailingWhitespace()
?: SpannedString(""),
content = status.content.orEmpty(),
createdAt = Date(status.createdAt),
emojis = emojis,
reblogsCount = status.reblogsCount,
@ -252,7 +245,6 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
status = status,
isExpanded = this.status.expanded,
isShowingContent = this.status.contentShowing,
isCollapsible = shouldTrimStatus(status.content),
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.TimelineCases
import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
@ -82,15 +85,13 @@ class CachedTimelineViewModel @Inject constructor(
}
).flow
.map { pagingData ->
pagingData.map { timelineStatus ->
pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus ->
timelineStatus.toViewData(gson)
}
}
.map { pagingData ->
pagingData.filter { statusViewData ->
}.filter(Dispatchers.Default.asExecutor()) { statusViewData ->
!shouldFilterStatus(statusViewData)
}
}
.flowOn(Dispatchers.Default)
.cachedIn(viewModelScope)
init {

View File

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

View File

@ -50,6 +50,7 @@ data class AccountEntity(
var notificationsFavorited: Boolean = true,
var notificationsPolls: Boolean = true,
var notificationsSubscriptions: Boolean = true,
var notificationsSignUps: Boolean = true,
var notificationSound: Boolean = true,
var notificationVibration: 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,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 31)
}, version = 33)
public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao();
@ -483,4 +483,48 @@ public abstract class AppDatabase extends RoomDatabase {
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.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@ -31,8 +30,8 @@ interface ConversationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(conversation: ConversationEntity): Long
@Delete
suspend fun delete(conversation: ConversationEntity): Int
@Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
suspend fun delete(id: String, accountId: Long): Int
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>

View File

@ -15,9 +15,6 @@
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.TypeConverter
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.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.ArrayList
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
@ -144,22 +139,6 @@ class Converters @Inject constructor (
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
fun pollToJson(poll: Poll?): String? {
return gson.toJson(poll)

View File

@ -23,6 +23,7 @@ import com.keylesspalace.tusky.FiltersActivity
import com.keylesspalace.tusky.LicenseActivity
import com.keylesspalace.tusky.ListsActivity
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.ViewMediaActivity
@ -118,6 +119,9 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesDraftActivity(): DraftsActivity
@ContributesAndroidInjector
abstract fun contributesSplashActivity(): SplashActivity
@ContributesAndroidInjector
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.Migration25_26(appContext.getExternalFilesDir("Tusky")),
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()
}

View File

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

View File

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName
import java.util.Date
@ -24,7 +23,7 @@ data class Account(
@SerializedName("username") val localUsername: 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
val note: Spanned,
val note: String,
val url: String,
val avatar: String,
val header: String,
@ -54,56 +53,6 @@ data class Account(
get() = displayName.orEmpty()
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(
@ -115,7 +64,7 @@ data class AccountSource(
data class Field(
val name: String,
val value: Spanned,
val value: String,
@SerializedName("verified_at") val verifiedAt: Date?
)

View File

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

View File

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

View File

@ -37,7 +37,9 @@ data class Notification(
FOLLOW("follow"),
FOLLOW_REQUEST("follow_request"),
POLL("poll"),
STATUS("status");
STATUS("status"),
SIGN_UP("admin.sign_up"),
;
companion object {
@ -49,7 +51,7 @@ data class Notification(
}
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 {

View File

@ -16,10 +16,9 @@
package com.keylesspalace.tusky.entity
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.URLSpan
import com.google.gson.annotations.SerializedName
import java.util.ArrayList
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import java.util.Date
data class Status(
@ -29,7 +28,7 @@ data class Status(
@SerializedName("in_reply_to_id") var inReplyToId: String?,
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?,
val reblog: Status?,
val content: Spanned,
val content: String,
@SerializedName("created_at", alternate = ["published"]) val createdAt: Date,
val emojis: List<Emoji>,
@SerializedName("reblogs_count") val reblogsCount: Int,
@ -143,8 +142,9 @@ data class Status(
}
private fun getEditableText(): String {
val builder = SpannableStringBuilder(content)
for (span in content.getSpans(0, content.length, URLSpan::class.java)) {
val contentSpanned = content.parseAsMastodonHtml()
val builder = SpannableStringBuilder(content.parseAsMastodonHtml())
for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) {
val url = span.url
for ((_, url1, username) in mentions) {
if (url == url1) {
@ -158,71 +158,6 @@ data class Status(
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(
val id: String,
val url: String,

View File

@ -15,6 +15,10 @@
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.content.Context;
import android.content.DialogInterface;
@ -114,10 +118,6 @@ import kotlin.Unit;
import kotlin.collections.CollectionsKt;
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
SwipeRefreshLayout.OnRefreshListener,
StatusActionListener,
@ -716,6 +716,8 @@ public class NotificationsFragment extends SFragment implements
return getString(R.string.notification_poll_name);
case STATUS:
return getString(R.string.notification_subscription_name);
case SIGN_UP:
return getString(R.string.notification_sign_up_name);
default:
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 {
companion object {
const val ENDPOINT_AUTHORIZE = "/oauth/authorize"
const val ENDPOINT_AUTHORIZE = "oauth/authorize"
const val DOMAIN_HEADER = "domain"
const val PLACEHOLDER_DOMAIN = "dummy.placeholder"
}
@ -80,7 +80,7 @@ interface MastodonApi {
fun getCustomEmojis(): Single<List<Emoji>>
@GET("api/v1/instance")
fun getInstance(): Single<Instance>
suspend fun getInstance(): Result<Instance>
@GET("api/v1/filters")
fun getFilters(): Single<List<Filter>>
@ -249,12 +249,12 @@ interface MastodonApi {
): Single<List<ScheduledStatus>>
@DELETE("api/v1/scheduled_statuses/{id}")
fun deleteScheduledStatus(
suspend fun deleteScheduledStatus(
@Path("id") scheduledStatusId: String
): Single<ResponseBody>
): Result<ResponseBody>
@GET("api/v1/accounts/verify_credentials")
fun accountVerifyCredentials(): Single<Account>
suspend fun accountVerifyCredentials(): Result<Account>
@FormUrlEncoded
@PATCH("api/v1/accounts/update_credentials")
@ -265,7 +265,7 @@ interface MastodonApi {
@Multipart
@PATCH("api/v1/accounts/update_credentials")
fun accountUpdateCredentials(
suspend fun accountUpdateCredentials(
@Part(value = "display_name") displayName: RequestBody?,
@Part(value = "note") note: RequestBody?,
@Part(value = "locked") locked: RequestBody?,
@ -279,7 +279,7 @@ interface MastodonApi {
@Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?,
@Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?,
@Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody?
): Call<Account>
): Result<Account>
@GET("api/v1/accounts/search")
fun searchAccounts(
@ -447,7 +447,7 @@ interface MastodonApi {
@Field("redirect_uris") redirectUris: String,
@Field("scopes") scopes: String,
@Field("website") website: String
): AppCredentials
): Result<AppCredentials>
@FormUrlEncoded
@POST("oauth/token")
@ -458,7 +458,7 @@ interface MastodonApi {
@Field("redirect_uri") redirectUri: String,
@Field("code") code: String,
@Field("grant_type") grantType: String
): AccessToken
): Result<AccessToken>
@FormUrlEncoded
@POST("api/v1/lists")

View File

@ -69,6 +69,7 @@ object PrefKeys {
const val NOTIFICATION_FILTER_FOLLOW_REQUESTS = "notificationFilterFollowRequests"
const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows"
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_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,
isCollapsed: Boolean
): StatusViewData.Concrete {
val visibleStatus = this.reblog ?: this
return StatusViewData.Concrete(
status = this,
isShowingContent = isShowingContent,
isCollapsible = shouldTrimStatus(visibleStatus.content),
isCollapsed = isCollapsed,
isExpanded = isExpanded,
)

View File

@ -15,9 +15,11 @@
package com.keylesspalace.tusky.viewdata
import android.os.Build
import android.text.SpannableStringBuilder
import android.text.Spanned
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.
@ -32,13 +34,6 @@ sealed class StatusViewData {
val status: Status,
val isExpanded: 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
* 500 characters or not.
@ -51,6 +46,14 @@ sealed class StatusViewData {
override val id: String
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 spoilerText: String
val username: String
@ -71,48 +74,23 @@ sealed class StatusViewData {
val rebloggingStatus: Status?
get() = if (status.reblog != null) status else null
val quoteViewData =
status.quote?.let { Concrete(it, isExpanded, isShowingContent, isCollapsed) }
init {
if (Build.VERSION.SDK_INT == 23) {
// https://github.com/tuskyapp/Tusky/issues/563
this.content = replaceCrashingCharacters(status.actionableStatus.content)
this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml())
this.spoilerText =
replaceCrashingCharacters(status.actionableStatus.spoilerText).toString()
this.username =
replaceCrashingCharacters(status.actionableStatus.account.username).toString()
} else {
this.content = status.actionableStatus.content
this.content = status.actionableStatus.content.parseAsMastodonHtml()
this.spoilerText = status.actionableStatus.spoilerText
this.username = status.actionableStatus.account.username
}
}
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
}
this.isCollapsible = shouldTrimStatus(this.content)
}
/** Helper for Java */

View File

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

View File

@ -4,7 +4,6 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import autodispose2.SingleSubscribeProxy
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemDrawerFooterBinding
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()
fun setSubscribeProxy(subscribeProxy: SingleSubscribeProxy<Instance>) {
subscribeProxy.subscribe(
{ instance ->
binding.instanceData.text = String.format("%s\n%s\n%s", instance.title, instance.uri, instance.version)
},
{
binding.instanceData.text = binding.root.context.getString(R.string.instance_data_failed)
}
)
fun setInstance(instance: Result<Instance>) {
instance
.onSuccess {
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)
}
}
}

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">
<item quantity="one">Mìnich e dhan fheadhainn air a bheil cion-lèirsinn
\n(%d caractar(an) air a char as fhaide)</item>
<item quantity="two"/>
<item quantity="few"/>
<item quantity="other"/>
</plurals>
<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>

View File

@ -54,7 +54,7 @@
<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">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_more">Máis</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_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_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_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>
@ -411,8 +411,8 @@
<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">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_delete_post_warning">Eliminar este toot\?</string>
<string name="dialog_redraft_post_warning">Eliminar e reescribir esta publicación\?</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_message_cancel_follow_request">Revogar a solicitude de seguimento\?</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_denied">Heimild var hafnað.</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_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_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_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_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_notifications">Tilkynningar</string>
<string name="title_public_local">Staðvært</string>
<string name="title_public_federated">Sameiginlegt</string>
<string name="title_direct_messages">Bein skilaboð</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_with_replies">Með svörum</string>
<string name="title_posts_pinned">Fest</string>
@ -62,8 +62,8 @@
<string name="post_content_show_less">Fella saman</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="notification_reblog_format">%s endurbirti tístið þitt</string>
<string name="notification_favourite_format">%s setti tíst frá þér í eftirlæti</string>
<string name="notification_reblog_format">%s endurbirti færsluna þína</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="report_username_format">Kæra @%s</string>
<string name="report_comment_hint">Aðrar athugasemdir\?</string>
@ -117,7 +117,7 @@
<string name="action_reject">Hafna</string>
<string name="action_access_drafts">Drög</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_emoji_keyboard">Lyklaborð með tjáningartáknum</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_description">Tilkynningar um nýja fylgjendur</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_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_description">Tilkynningar um kannanir sem er lokið</string>
<string name="notification_mention_format">%s minntist á þig</string>
@ -273,7 +273,7 @@
<string name="abbreviated_seconds_ago">%ds</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_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="replying_to">Svar til @%s</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_apply_filter">Sía</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="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>
@ -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 á:
\n
\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
\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="review_notifications">Yfirfara tilkynningar</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_name"> tíst</string>
<string name="pref_title_notification_filter_subscriptions">einhver sem ég er áskrifandi að birti 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">jar færslur</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="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>
@ -518,4 +518,5 @@
<string name="duration_180_days">180 dagar</string>
<string name="duration_365_days">365 dagar</string>
<string name="duration_14_days">14 dagar</string>
<string name="tusky_compose_post_quicksetting_label">Semja færslu</string>
</resources>

View File

@ -506,10 +506,10 @@
<string name="limit_notifications">Ogranicz liczbę powiadomień o zmianach na osi czasu</string>
<string name="label_duration">Czas trwania</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 - powiadomienia o ulubionych/podbiciach/obserwowaniu
\n - liczba polubień/podbić toota
\n - liczba polubień/podbić wpisu
\n - statystyki obserwujących/postów na profilach
\n
\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="action_unbookmark">Usuń z zakładek</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>

View File

@ -16,7 +16,7 @@
<string name="title_notifications">Сповіщення</string>
<string name="title_home">Головна</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_upload_permission">Потрібен дозвіл на читання медіа.</string>
<string name="error_media_upload_opening">Не вдається відкрити цей файл.</string>
@ -24,7 +24,7 @@
<string name="error_audio_upload_size">Аудіофайли повинні бути менше 40 МБ.</string>
<string name="error_video_upload_size">Відео повинне бути менше 40 МБ.</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_empty">Не може бути порожнім.</string>
<string name="error_network">Сталася помилка мережі! Перевірте інтернет-з\'єднання та спробуйте знову!</string>

View File

@ -2,7 +2,7 @@
<resources>
<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_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_title">Đang đăng…</string>
<plurals name="notification_title_summary">
@ -192,7 +192,7 @@
<string name="title_posts_pinned">Ghim</string>
<string name="title_posts_with_replies">Trả lời</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_direct_messages">Tin nhắn</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_reblogged_by">Những người đăng lại tút này</string>
<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 name="favs">
<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_follow_format">%s followed 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="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_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_sign_ups">somebody signed up</string>
<string name="pref_title_appearance_settings">Appearance</string>
<string name="pref_title_app_theme">App Theme</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_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_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_summary_large">%1$s, %2$s, %3$s and %4$d others</string>

View File

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

View File

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

View File

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

View File

@ -1,10 +1,8 @@
package com.keylesspalace.tusky
import android.text.Spanned
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.json.SpannedTypeAdapter
import com.keylesspalace.tusky.viewdata.StatusViewData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
@ -39,9 +37,7 @@ class StatusComparisonTest {
assertEquals(createStatus(note = "Test"), createStatus(note = "Test 123456"))
}
private val gson = GsonBuilder().registerTypeAdapter(
Spanned::class.java, SpannedTypeAdapter()
).create()
private val gson = Gson()
@Test
fun `two equal status view data - should be equal`() {
@ -49,14 +45,12 @@ class StatusComparisonTest {
status = createStatus(),
isExpanded = false,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
val viewdata2 = StatusViewData.Concrete(
status = createStatus(),
isExpanded = false,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
assertEquals(viewdata1, viewdata2)
@ -68,14 +62,12 @@ class StatusComparisonTest {
status = createStatus(),
isExpanded = true,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
val viewdata2 = StatusViewData.Concrete(
status = createStatus(),
isExpanded = false,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
assertNotEquals(viewdata1, viewdata2)
@ -87,14 +79,12 @@ class StatusComparisonTest {
status = createStatus(content = "whatever"),
isExpanded = true,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
val viewdata2 = StatusViewData.Concrete(
status = createStatus(),
isExpanded = false,
isShowingContent = false,
isCollapsible = false,
isCollapsed = false
)
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.Converters
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
@ -31,6 +28,9 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
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.annotation.Config
import retrofit2.HttpException

View File

@ -1,14 +1,19 @@
package com.keylesspalace.tusky.components.timeline
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.NetworkTimelineViewModel
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
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 {
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.AccountManager
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 okhttp3.Headers
import okhttp3.ResponseBody.Companion.toResponseBody
@ -24,6 +19,11 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
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 retrofit2.HttpException
import retrofit2.Response
@ -331,7 +331,6 @@ class NetworkTimelineRemoteMediatorTest {
mockStatusViewData("2"),
mockStatusViewData("1"),
)
verify(timelineViewModel).nextKey = "0"
assertTrue(result is RemoteMediator.MediatorResult.Success)
assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)

View File

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

View File

@ -1,5 +1,4 @@
buildscript {
ext.kotlin_version = '1.6.10'
repositories {
google()
mavenCentral()
@ -7,12 +6,12 @@ buildscript {
}
dependencies {
classpath "com.android.tools.build:gradle:7.1.2"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jlleitschuh.gradle:ktlint-gradle:10.1.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.20"
classpath "org.jlleitschuh.gradle:ktlint-gradle:10.2.1"
}
}
plugins {
id "org.jlleitschuh.gradle.ktlint" version "10.1.0"
id "org.jlleitschuh.gradle.ktlint" version "10.2.1"
}
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
- і багато інших дрібних виправлень і вдосконалень