Merge remote-tracking branch 'tuskyapp/develop'

# Conflicts:
#	app/build.gradle
#	app/src/main/java/com/keylesspalace/tusky/SplashActivity.kt
#	app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java
#	app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt
#	app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt
#	app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java
#	app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt
#	app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt
#	app/src/main/res/layout/activity_main.xml
#	app/src/main/res/values-cs/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-fa/strings.xml
#	app/src/main/res/values-fr/strings.xml
#	app/src/main/res/values-hu/strings.xml
#	app/src/main/res/values-it/strings.xml
#	app/src/main/res/values-no-rNB/strings.xml
#	app/src/main/res/values-zh-rCN/strings.xml
This commit is contained in:
kyori19 2022-07-16 02:10:58 +09:00
commit 57aab71b0e
No known key found for this signature in database
GPG Key ID: CB37D0651E7F52AA
113 changed files with 4584 additions and 840 deletions

View File

@ -108,7 +108,7 @@ ext.glideVersion = '4.13.1'
ext.daggerVersion = '2.42' ext.daggerVersion = '2.42'
ext.materialdrawerVersion = '8.4.5' ext.materialdrawerVersion = '8.4.5'
ext.emoji2_version = '1.1.0' ext.emoji2_version = '1.1.0'
ext.filemojicompat_version = '3.2.1' ext.filemojicompat_version = '3.2.2'
repositories { repositories {
maven { maven {
@ -154,7 +154,7 @@ dependencies {
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion" implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion"
implementation "at.connyduck:kotlin-result-calladapter:1.0.1" implementation "at.connyduck:networkresult-calladapter:1.0.0"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion"
@ -165,7 +165,7 @@ dependencies {
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion" implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion"
kapt "com.github.bumptech.glide:compiler:$glideVersion" kapt "com.github.bumptech.glide:compiler:$glideVersion"
implementation "com.github.penfeizhou.android.animation:glide-plugin:2.20.0" implementation "com.github.penfeizhou.android.animation:glide-plugin:2.22.0"
implementation "io.reactivex.rxjava3:rxjava:3.1.3" implementation "io.reactivex.rxjava3:rxjava:3.1.3"
implementation "io.reactivex.rxjava3:rxandroid:3.0.0" implementation "io.reactivex.rxjava3:rxandroid:3.0.0"
@ -188,7 +188,7 @@ dependencies {
implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion" implementation "com.mikepenz:materialdrawer-iconics:$materialdrawerVersion"
implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar' implementation 'com.mikepenz:google-material-typeface:4.0.0.2-kotlin@aar'
implementation "com.github.CanHub:Android-Image-Cropper:4.1.0" implementation "com.github.CanHub:Android-Image-Cropper:4.2.1"
implementation "de.c1710:filemojicompat-ui:$filemojicompat_version" implementation "de.c1710:filemojicompat-ui:$filemojicompat_version"
implementation "de.c1710:filemojicompat:$filemojicompat_version" implementation "de.c1710:filemojicompat:$filemojicompat_version"
@ -202,6 +202,8 @@ dependencies {
testImplementation "org.mockito:mockito-inline:4.4.0" testImplementation "org.mockito:mockito-inline:4.4.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0" testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0"
testImplementation "com.squareup.okhttp3:mockwebserver:$okhttpVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0" androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
androidTestImplementation "androidx.room:room-testing:$roomVersion" androidTestImplementation "androidx.room:room-testing:$roomVersion"
androidTestImplementation "androidx.test.ext:junit:1.1.3" androidTestImplementation "androidx.test.ext:junit:1.1.3"

View File

@ -0,0 +1,869 @@
{
"formatVersion": 1,
"database": {
"version": 37,
"identityHash": "11033751d382aa8a1c6fc68833097d35",
"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, `notificationsUpdates` 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, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` 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": "notificationsUpdates",
"columnName": "notificationsUpdates",
"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
},
{
"fieldPath": "oauthScopes",
"columnName": "oauthScopes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unifiedPushUrl",
"columnName": "unifiedPushUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushPubKey",
"columnName": "pushPubKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushPrivKey",
"columnName": "pushPrivKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushAuth",
"columnName": "pushAuth",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushServerKey",
"columnName": "pushServerKey",
"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, `repliesCount` 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, `card` TEXT, 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": "repliesCount",
"columnName": "repliesCount",
"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
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
}
],
"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_repliesCount` 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.repliesCount",
"columnName": "s_repliesCount",
"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, '11033751d382aa8a1c6fc68833097d35')"
]
}
}

View File

@ -0,0 +1,875 @@
{
"formatVersion": 1,
"database": {
"version": 38,
"identityHash": "798fc8d34064eb671c079689d4650ea5",
"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, `notificationsUpdates` 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, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` 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": "notificationsUpdates",
"columnName": "notificationsUpdates",
"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
},
{
"fieldPath": "oauthScopes",
"columnName": "oauthScopes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unifiedPushUrl",
"columnName": "unifiedPushUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushPubKey",
"columnName": "pushPubKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushPrivKey",
"columnName": "pushPrivKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushAuth",
"columnName": "pushAuth",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushServerKey",
"columnName": "pushServerKey",
"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, `repliesCount` 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, `card` TEXT, 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": "repliesCount",
"columnName": "repliesCount",
"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
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
}
],
"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, `order` INTEGER 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_repliesCount` 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": "order",
"columnName": "order",
"affinity": "INTEGER",
"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.repliesCount",
"columnName": "s_repliesCount",
"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, '798fc8d34064eb671c079689d4650ea5')"
]
}
}

View File

@ -0,0 +1,893 @@
{
"formatVersion": 1,
"database": {
"version": 39,
"identityHash": "bf4c9d8417b71e549170a568522d513d",
"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, `clientId` TEXT, `clientSecret` TEXT, `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, `notificationsUpdates` 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, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` 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": "clientId",
"columnName": "clientId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "clientSecret",
"columnName": "clientSecret",
"affinity": "TEXT",
"notNull": false
},
{
"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": "notificationsUpdates",
"columnName": "notificationsUpdates",
"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
},
{
"fieldPath": "oauthScopes",
"columnName": "oauthScopes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unifiedPushUrl",
"columnName": "unifiedPushUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushPubKey",
"columnName": "pushPubKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushPrivKey",
"columnName": "pushPrivKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushAuth",
"columnName": "pushAuth",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pushServerKey",
"columnName": "pushServerKey",
"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, `repliesCount` 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, `card` TEXT, `quote` TEXT, 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": "repliesCount",
"columnName": "repliesCount",
"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
},
{
"fieldPath": "card",
"columnName": "card",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "quote",
"columnName": "quote",
"affinity": "TEXT",
"notNull": false
}
],
"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, `order` INTEGER 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_repliesCount` 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": "order",
"columnName": "order",
"affinity": "INTEGER",
"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.repliesCount",
"columnName": "s_repliesCount",
"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, 'bf4c9d8417b71e549170a568522d513d')"
]
}
}

View File

@ -49,6 +49,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
import at.connyduck.calladapter.networkresult.fold
import autodispose2.androidx.lifecycle.autoDispose import autodispose2.androidx.lifecycle.autoDispose
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager import com.bumptech.glide.RequestManager
@ -70,13 +71,10 @@ import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
import com.keylesspalace.tusky.components.conversation.ConversationsRepository
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.disableAllNotifications import com.keylesspalace.tusky.components.notifications.disableAllNotifications
import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount
import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback import com.keylesspalace.tusky.components.notifications.enablePushNotificationsWithFallback
import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary import com.keylesspalace.tusky.components.notifications.showMigrationNoticeIfNecessary
import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity
@ -94,11 +92,12 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.ResettableFragment import com.keylesspalace.tusky.interfaces.ResettableFragment
import com.keylesspalace.tusky.pager.MainPagerAdapter import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.LogoutUsecase
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.deleteStaleCachedMedia import com.keylesspalace.tusky.util.deleteStaleCachedMedia
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.removeShortcut import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.updateShortcut import com.keylesspalace.tusky.util.updateShortcut
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.visible
@ -153,10 +152,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
lateinit var cacheUpdater: CacheUpdater lateinit var cacheUpdater: CacheUpdater
@Inject @Inject
lateinit var conversationRepository: ConversationsRepository lateinit var logoutUsecase: LogoutUsecase
@Inject
lateinit var draftHelper: DraftHelper
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@ -834,28 +830,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
.setTitle(R.string.action_logout) .setTitle(R.string.action_logout)
.setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName))
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
binding.appBar.hide()
binding.viewPager.hide()
binding.progressBar.show()
binding.bottomNav.hide()
binding.composeButton.hide()
lifecycleScope.launch { lifecycleScope.launch {
// Only disable UnifiedPush for this account -- do not call disableNotifications(), val otherAccountAvailable = logoutUsecase.logout()
// which unnecessarily disables it for all accounts and then re-enables it again at val intent = if (otherAccountAvailable) {
// the next launch
disableUnifiedPushNotificationsForAccount(this@MainActivity, activeAccount)
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this@MainActivity)
cacheUpdater.clearForUser(activeAccount.id)
conversationRepository.deleteCacheForAccount(activeAccount.id)
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
removeShortcut(this@MainActivity, activeAccount)
val newAccount = accountManager.logActiveAccountOut()
if (!NotificationHelper.areNotificationsEnabled(
this@MainActivity,
accountManager
)
) {
NotificationHelper.disablePullNotifications(this@MainActivity)
}
val intent = if (newAccount == null) {
LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT)
} else {
Intent(this@MainActivity, MainActivity::class.java) Intent(this@MainActivity, MainActivity::class.java)
} else {
LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT)
} }
startActivity(intent) startActivity(intent)
finishWithoutSlideOutAnimation() finishWithoutSlideOutAnimation()
@ -888,7 +874,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this) NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
// Setup push notifications // Setup push notifications
showMigrationNoticeIfNecessary(this, binding.root, accountManager) showMigrationNoticeIfNecessary(this, binding.mainCoordinatorLayout, binding.composeButton, accountManager)
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
lifecycleScope.launch { lifecycleScope.launch {
enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager) enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)

View File

@ -20,7 +20,6 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import net.accelf.yuito.CustomUncaughtExceptionHandler import net.accelf.yuito.CustomUncaughtExceptionHandler
@ -37,12 +36,8 @@ class SplashActivity : AppCompatActivity(), Injectable {
Thread.setDefaultUncaughtExceptionHandler(CustomUncaughtExceptionHandler(applicationContext)) 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 /** Determine whether the user is currently logged in, and if so go ahead and load the
* timeline. Otherwise, start the activity_login screen. */ * timeline. Otherwise, start the activity_login screen. */
val intent = if (accountManager.activeAccount != null) { val intent = if (accountManager.activeAccount != null) {
Intent(this, MainActivity::class.java) Intent(this, MainActivity::class.java)
} else { } else {

View File

@ -31,6 +31,7 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.sparkbutton.helpers.Utils import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from
import autodispose2.autoDispose import autodispose2.autoDispose

View File

@ -44,6 +44,7 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(co
binding.username.text = account.fullName binding.username.text = account.fullName
binding.displayName.text = account.displayName.emojify(account.emojis, binding.displayName, animateEmojis) binding.displayName.text = account.displayName.emojify(account.emojis, binding.displayName, animateEmojis)
binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp) val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
val animateAvatar = pm.getBoolean("animateGifAvatars", false) val animateAvatar = pm.getBoolean("animateGifAvatars", false)

View File

@ -1,42 +0,0 @@
/* Copyright 2019 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.adapter
import androidx.paging.LoadState
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.util.visible
class NetworkStateViewHolder(
private val binding: ItemNetworkStateBinding,
private val retryCallback: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun setUpWithNetworkState(state: LoadState) {
binding.progressBar.visible(state == LoadState.Loading)
binding.retryButton.visible(state is LoadState.Error)
val msg = if (state is LoadState.Error) {
state.error.message
} else {
null
}
binding.errorMsg.visible(msg != null)
binding.errorMsg.text = msg
binding.retryButton.setOnClickListener {
retryCallback()
}
}
}

View File

@ -74,10 +74,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
public static class Key { public static class Key {
public static final String KEY_CREATED = "created"; public static final String KEY_CREATED = "created";
} }
private TextView displayName; private TextView displayName;
private TextView username; private TextView username;
private ImageButton replyButton; private ImageButton replyButton;
private TextView replyCountLabel;
private SparkButton reblogButton; private SparkButton reblogButton;
private SparkButton favouriteButton; private SparkButton favouriteButton;
private ImageButton quoteButton; private ImageButton quoteButton;
@ -129,6 +129,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
content = itemView.findViewById(R.id.status_content); content = itemView.findViewById(R.id.status_content);
avatar = itemView.findViewById(R.id.status_avatar); avatar = itemView.findViewById(R.id.status_avatar);
replyButton = itemView.findViewById(R.id.status_reply); replyButton = itemView.findViewById(R.id.status_reply);
replyCountLabel = itemView.findViewById(R.id.status_replies);
reblogButton = itemView.findViewById(R.id.status_inset); reblogButton = itemView.findViewById(R.id.status_inset);
favouriteButton = itemView.findViewById(R.id.status_favourite); favouriteButton = itemView.findViewById(R.id.status_favourite);
quoteButton = itemView.findViewById(R.id.status_quote); quoteButton = itemView.findViewById(R.id.status_quote);
@ -418,6 +419,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
private void setReplyCount(int repliesCount) {
// This label only exists in the non-detailed view (to match the web ui)
if (replyCountLabel != null) {
replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount)));
}
}
private void setReblogged(boolean reblogged) { private void setReblogged(boolean reblogged) {
reblogButton.setChecked(reblogged); reblogButton.setChecked(reblogged);
} }
@ -841,6 +849,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions); setCreatedAt(actionable.getCreatedAt(), statusDisplayOptions);
setStatusVisibility(actionable.getVisibility()); setStatusVisibility(actionable.getVisibility());
setIsReply(actionable.getInReplyToId() != null); setIsReply(actionable.getInReplyToId() != null);
setReplyCount(actionable.getRepliesCount());
setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(),
actionable.getAccount().getBot(), statusDisplayOptions); actionable.getAccount().getBot(), statusDisplayOptions);
setReblogged(actionable.getReblogged()); setReblogged(actionable.getReblogged());
@ -1147,6 +1156,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
actionable.getPoll() == null && actionable.getPoll() == null &&
card != null && card != null &&
!TextUtils.isEmpty(card.getUrl()) && !TextUtils.isEmpty(card.getUrl()) &&
(!actionable.getSensitive() || status.isExpanded()) &&
(!status.isCollapsible() || !status.isCollapsed())) { (!status.isCollapsible() || !status.isCollapsed())) {
cardView.setVisibility(View.VISIBLE); cardView.setVisibility(View.VISIBLE);
cardTitle.setText(card.getTitle()); cardTitle.setText(card.getTitle());

View File

@ -107,18 +107,24 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
@NonNull final StatusActionListener listener, @NonNull final StatusActionListener listener,
@NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads) {
super.setupWithStatus(status, listener, statusDisplayOptions, payloads); // We never collapse statuses in the detail view
setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ?
status.copyWithCollapsed(false) :
status;
super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads);
setupCard(uncollapsedStatus, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status
if (payloads == null) { if (payloads == null) {
Status actionable = uncollapsedStatus.getActionable();
if (!statusDisplayOptions.hideStats()) { if (!statusDisplayOptions.hideStats()) {
setReblogAndFavCount(status.getActionable().getReblogsCount(), setReblogAndFavCount(actionable.getReblogsCount(),
status.getActionable().getFavouritesCount(), listener); actionable.getFavouritesCount(), listener);
} else { } else {
hideQuantitativeStats(); hideQuantitativeStats();
} }
setApplication(status.getActionable().getApplication()); setApplication(actionable.getApplication());
} }
} }

View File

@ -9,7 +9,7 @@ import javax.inject.Inject
class CacheUpdater @Inject constructor( class CacheUpdater @Inject constructor(
eventHub: EventHub, eventHub: EventHub,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val appDatabase: AppDatabase, appDatabase: AppDatabase,
gson: Gson gson: Gson
) { ) {
@ -44,8 +44,4 @@ class CacheUpdater @Inject constructor(
fun stop() { fun stop() {
this.disposable.dispose() this.disposable.dispose()
} }
suspend fun clearForUser(accountId: Long) {
appDatabase.timelineDao().removeAll(accountId)
}
} }

View File

@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository

View File

@ -23,6 +23,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.PorterDuff import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter import android.graphics.PorterDuffColorFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
@ -58,6 +59,9 @@ import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageContract
import com.canhub.cropper.options
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
@ -87,6 +91,7 @@ import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.afterTextChanged import com.keylesspalace.tusky.util.afterTextChanged
import com.keylesspalace.tusky.util.combineLiveData import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.combineOptionalLiveData import com.keylesspalace.tusky.util.combineOptionalLiveData
import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.highlightSpans
import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.loadAvatar
@ -157,6 +162,32 @@ class ComposeActivity :
} }
} }
// Contract kicked off by editImageInQueue; expects viewModel.cropImageItemOld set
private val cropImage = registerForActivityResult(CropImageContract()) { result ->
val uriNew = result.uriContent
if (result.isSuccessful && uriNew != null) {
viewModel.cropImageItemOld?.let { itemOld ->
val size = getMediaSize(contentResolver, uriNew)
lifecycleScope.launch {
viewModel.addMediaToQueue(
itemOld.type,
uriNew,
size,
itemOld.description,
itemOld
)
}
}
} else if (result == CropImage.CancelledResult) {
Log.w("ComposeActivity", "Edit image cancelled by user")
} else {
Log.w("ComposeActivity", "Edit image failed: " + result.error)
displayTransientError(R.string.error_image_edit_failed)
}
viewModel.cropImageItemOld = null
}
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -191,6 +222,7 @@ class ComposeActivity :
viewModel.updateDescription(item.localId, newDescription) viewModel.updateDescription(item.localId, newDescription)
} }
}, },
onEditImage = this::editImageInQueue,
onRemove = this::removeMediaFromQueue onRemove = this::removeMediaFromQueue
) )
binding.composeMediaPreviewBar.layoutManager = binding.composeMediaPreviewBar.layoutManager =
@ -429,8 +461,13 @@ class ComposeActivity :
enableButton(binding.composeAddMediaButton, active, active) enableButton(binding.composeAddMediaButton, active, active)
enablePollButton(media.isNullOrEmpty()) enablePollButton(media.isNullOrEmpty())
}.subscribe() }.subscribe()
viewModel.uploadError.observe { viewModel.uploadError.observe { throwable ->
displayTransientError(R.string.error_media_upload_sending) Log.w(TAG, "media upload failed", throwable)
if (throwable is UploadServerError) {
displayTransientError(throwable.errorMessage)
} else {
displayTransientError(R.string.error_media_upload_sending)
}
} }
viewModel.setupComplete.observe { viewModel.setupComplete.observe {
// Focus may have changed during view model setup, ensure initial focus is on the edit field // Focus may have changed during view model setup, ensure initial focus is on the edit field
@ -594,19 +631,23 @@ class ComposeActivity :
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
private fun displayTransientError(@StringRes stringId: Int) { private fun displayTransientError(errorMessage: String) {
val bar = Snackbar.make(binding.activityCompose, stringId, Snackbar.LENGTH_LONG) val bar = Snackbar.make(binding.activityCompose, errorMessage, Snackbar.LENGTH_LONG)
// necessary so snackbar is shown over everything // necessary so snackbar is shown over everything
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
bar.setAnchorView(R.id.composeBottomBar)
bar.show() bar.show()
} }
private fun displayTransientError(@StringRes stringId: Int) {
displayTransientError(getString(stringId))
}
private fun toggleHideMedia() { private fun toggleHideMedia() {
this.viewModel.toggleMarkSensitive() this.viewModel.toggleMarkSensitive()
} }
private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) { private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) {
if (viewModel.media.value.isNullOrEmpty()) { if (viewModel.media.value.isEmpty()) {
binding.composeHideMediaButton.hide() binding.composeHideMediaButton.hide()
} else { } else {
binding.composeHideMediaButton.show() binding.composeHideMediaButton.show()
@ -948,6 +989,26 @@ class ComposeActivity :
binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) binding.addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
} }
private fun editImageInQueue(item: QueuedMedia) {
// If input image is lossless, output image should be lossless.
// Currently the only supported lossless format is png.
val mimeType: String? = contentResolver.getType(item.uri)
val isPng: Boolean = mimeType != null && mimeType.endsWith("/png")
val tempFile = createNewImageFile(this, if (isPng) ".png" else ".jpg")
// "Authority" must be the same as the android:authorities string in AndroidManifest.xml
val uriNew = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", tempFile)
viewModel.cropImageItemOld = item
cropImage.launch(
options(uri = item.uri) {
setOutputUri(uriNew)
setOutputCompressFormat(if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG)
}
)
}
private fun removeMediaFromQueue(item: QueuedMedia) { private fun removeMediaFromQueue(item: QueuedMedia) {
viewModel.removeMediaFromQueue(item) viewModel.removeMediaFromQueue(item)
} }

View File

@ -23,6 +23,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.drafts.DraftHelper
@ -39,7 +40,6 @@ import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.combineLiveData import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import com.keylesspalace.tusky.util.result
import com.keylesspalace.tusky.util.toLiveData import com.keylesspalace.tusky.util.toLiveData
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -100,6 +100,9 @@ class ComposeViewModel @Inject constructor(
private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
// Used in ComposeActivity to pass state to result function when cropImage contract inflight
var cropImageItemOld: QueuedMedia? = null
fun loadInstanceDataFromNetwork(loadActually: Boolean) { fun loadInstanceDataFromNetwork(loadActually: Boolean) {
viewModelScope.launch { viewModelScope.launch {
emoji.postValue(when (loadActually) { emoji.postValue(when (loadActually) {
@ -133,13 +136,16 @@ class ComposeViewModel @Inject constructor(
} }
} }
private suspend fun addMediaToQueue( suspend fun addMediaToQueue(
type: QueuedMedia.Type, type: QueuedMedia.Type,
uri: Uri, uri: Uri,
mediaSize: Long, mediaSize: Long,
description: String? = null description: String? = null,
replaceItem: QueuedMedia? = null
): QueuedMedia { ): QueuedMedia {
val mediaItem = media.updateAndGet { mediaValue -> var stashMediaItem: QueuedMedia? = null
media.updateAndGet { mediaValue ->
val mediaItem = QueuedMedia( val mediaItem = QueuedMedia(
localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1, localId = (mediaValue.maxOfOrNull { it.localId } ?: 0) + 1,
uri = uri, uri = uri,
@ -147,8 +153,19 @@ class ComposeViewModel @Inject constructor(
mediaSize = mediaSize, mediaSize = mediaSize,
description = description description = description
) )
mediaValue + mediaItem stashMediaItem = mediaItem
}.last()
if (replaceItem != null) {
mediaToJob[replaceItem.localId]?.cancel()
mediaValue.map {
if (it.localId == replaceItem.localId) mediaItem else it
}
} else { // Append
mediaValue + mediaItem
}
}
val mediaItem = stashMediaItem!! // stashMediaItem is always non-null and uncaptured at this point, but Kotlin doesn't know that
mediaToJob[mediaItem.localId] = viewModelScope.launch { mediaToJob[mediaItem.localId] = viewModelScope.launch {
mediaUploader mediaUploader
.uploadMedia(mediaItem) .uploadMedia(mediaItem)
@ -213,7 +230,7 @@ class ComposeViewModel @Inject constructor(
val contentWarningChanged = showContentWarning.value!! && val contentWarningChanged = showContentWarning.value!! &&
!contentWarning.isNullOrEmpty() && !contentWarning.isNullOrEmpty() &&
!startingContentWarning.startsWith(contentWarning.toString()) !startingContentWarning.startsWith(contentWarning.toString())
val mediaChanged = !media.value.isNullOrEmpty() val mediaChanged = media.value.isNotEmpty()
val pollChanged = poll.value != null val pollChanged = poll.value != null
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged
@ -346,8 +363,7 @@ class ComposeViewModel @Inject constructor(
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> { fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
when (token[0]) { when (token[0]) {
'@' -> { '@' -> {
return api.searchAccountsCall(query = token.substring(1), limit = 10) return api.searchAccountsSync(query = token.substring(1), limit = 10)
.result()
.fold({ accounts -> .fold({ accounts ->
accounts.map { AutocompleteResult.AccountResult(it) } accounts.map { AutocompleteResult.AccountResult(it) }
}, { e -> }, { e ->
@ -356,8 +372,7 @@ class ComposeViewModel @Inject constructor(
}) })
} }
'#' -> { '#' -> {
return api.searchCall(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.result()
.fold({ searchResult -> .fold({ searchResult ->
searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) } searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
}, { e -> }, { e ->

View File

@ -32,6 +32,7 @@ import com.keylesspalace.tusky.components.compose.view.ProgressImageView
class MediaPreviewAdapter( class MediaPreviewAdapter(
context: Context, context: Context,
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit, private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
private val onEditImage: (ComposeActivity.QueuedMedia) -> Unit,
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() { ) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
@ -43,12 +44,16 @@ class MediaPreviewAdapter(
val item = differ.currentList[position] val item = differ.currentList[position]
val popup = PopupMenu(view.context, view) val popup = PopupMenu(view.context, view)
val addCaptionId = 1 val addCaptionId = 1
val removeId = 2 val editImageId = 2
val removeId = 3
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
if (item.type == ComposeActivity.QueuedMedia.Type.IMAGE)
popup.menu.add(0, editImageId, 0, R.string.action_edit_image)
popup.menu.add(0, removeId, 0, R.string.action_remove) popup.menu.add(0, removeId, 0, R.string.action_remove)
popup.setOnMenuItemClickListener { menuItem -> popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) { when (menuItem.itemId) {
addCaptionId -> onAddCaption(item) addCaptionId -> onAddCaption(item)
editImageId -> onEditImage(item)
removeId -> onRemove(item) removeId -> onRemove(item)
} }
true true

View File

@ -23,6 +23,7 @@ import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
@ -31,6 +32,7 @@ import com.keylesspalace.tusky.network.ProgressRequestBody
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
import com.keylesspalace.tusky.util.getImageSquarePixels import com.keylesspalace.tusky.util.getImageSquarePixels
import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.getServerErrorMessage
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -54,14 +56,14 @@ sealed class UploadEvent {
data class FinishedEvent(val mediaId: String) : UploadEvent() data class FinishedEvent(val mediaId: String) : UploadEvent()
} }
fun createNewImageFile(context: Context): File { fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
// Create an image file name // Create an image file name
val randomId = randomAlphanumericString(12) val randomId = randomAlphanumericString(12)
val imageFileName = "Tusky_${randomId}_" val imageFileName = "Tusky_${randomId}_"
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
return File.createTempFile( return File.createTempFile(
imageFileName, /* prefix */ imageFileName, /* prefix */
".jpg", /* suffix */ suffix, /* suffix */
storageDir /* directory */ storageDir /* directory */
) )
} }
@ -72,6 +74,7 @@ class AudioSizeException : Exception()
class VideoSizeException : Exception() class VideoSizeException : Exception()
class MediaTypeException : Exception() class MediaTypeException : Exception()
class CouldNotOpenFileException : Exception() class CouldNotOpenFileException : Exception()
class UploadServerError(val errorMessage: String) : Exception()
class MediaUploader @Inject constructor( class MediaUploader @Inject constructor(
private val context: Context, private val context: Context,
@ -222,13 +225,21 @@ class MediaUploader @Inject constructor(
null null
} }
val result = mediaUploadApi.uploadMedia(body, description).getOrThrow() mediaUploadApi.uploadMedia(body, description).fold({ result ->
if (media.uri.scheme == "file") { if (media.uri.scheme == "file") {
media.uri.path?.let { media.uri.path?.let {
File(it).delete() File(it).delete()
}
} }
} send(UploadEvent.FinishedEvent(result.id))
send(UploadEvent.FinishedEvent(result.id)) }, { throwable ->
val errorMessage = throwable.getServerErrorMessage()
if (errorMessage == null) {
throw throwable
} else {
throw UploadServerError(errorMessage)
}
})
awaitClose() awaitClose()
} }
} }
@ -245,7 +256,7 @@ class MediaUploader @Inject constructor(
} }
private companion object { private companion object {
private const val TAG = "MediaUploaderImpl" private const val TAG = "MediaUploader"
private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB
private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB

View File

@ -20,21 +20,40 @@ import android.view.ViewGroup
import androidx.paging.PagingDataAdapter import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
class ConversationAdapter( class ConversationAdapter(
private val statusDisplayOptions: StatusDisplayOptions, private var statusDisplayOptions: StatusDisplayOptions,
private val listener: StatusActionListener private val listener: StatusActionListener
) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) { ) : PagingDataAdapter<ConversationViewData, ConversationViewHolder>(CONVERSATION_COMPARATOR) {
var mediaPreviewEnabled: Boolean
get() = statusDisplayOptions.mediaPreviewEnabled
set(mediaPreviewEnabled) {
statusDisplayOptions = statusDisplayOptions.copy(
mediaPreviewEnabled = mediaPreviewEnabled
)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false) val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false)
return ConversationViewHolder(view, statusDisplayOptions, listener) return ConversationViewHolder(view, statusDisplayOptions, listener)
} }
override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) { override fun onBindViewHolder(holder: ConversationViewHolder, position: Int) {
holder.setupWithConversation(getItem(position)) onBindViewHolder(holder, position, emptyList())
}
override fun onBindViewHolder(
holder: ConversationViewHolder,
position: Int,
payloads: List<Any>
) {
getItem(position)?.let { conversationViewData ->
holder.setupWithConversation(conversationViewData, payloads.firstOrNull())
}
} }
companion object { companion object {
@ -44,7 +63,17 @@ class ConversationAdapter(
} }
override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean { override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean {
return oldItem == newItem return false // Items are different always. It allows to refresh timestamp on every view holder update
}
override fun getChangePayload(oldItem: ConversationViewData, newItem: ConversationViewData): Any? {
return if (oldItem == newItem) {
// If items are equal - update timestamp only
listOf(StatusBaseViewHolder.Key.KEY_CREATED)
} else {
// If items are different - update the whole view holder
null
}
} }
} }
} }

View File

@ -34,6 +34,7 @@ import java.util.Date
data class ConversationEntity( data class ConversationEntity(
val accountId: Long, val accountId: Long,
val id: String, val id: String,
val order: Int,
val accounts: List<ConversationAccountEntity>, val accounts: List<ConversationAccountEntity>,
val unread: Boolean, val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
@ -41,6 +42,7 @@ data class ConversationEntity(
fun toViewData(): ConversationViewData { fun toViewData(): ConversationViewData {
return ConversationViewData( return ConversationViewData(
id = id, id = id,
order = order,
accounts = accounts, accounts = accounts,
unread = unread, unread = unread,
lastStatus = lastStatus.toViewData() lastStatus = lastStatus.toViewData()
@ -50,6 +52,7 @@ data class ConversationEntity(
data class ConversationAccountEntity( data class ConversationAccountEntity(
val id: String, val id: String,
val localUsername: String,
val username: String, val username: String,
val displayName: String, val displayName: String,
val avatar: String, val avatar: String,
@ -58,12 +61,12 @@ data class ConversationAccountEntity(
fun toAccount(): TimelineAccount { fun toAccount(): TimelineAccount {
return TimelineAccount( return TimelineAccount(
id = id, id = id,
localUsername = localUsername,
username = username, username = username,
displayName = displayName, displayName = displayName,
url = "", url = "",
avatar = avatar, avatar = avatar,
emojis = emojis, emojis = emojis,
localUsername = "",
) )
} }
} }
@ -79,6 +82,7 @@ data class ConversationStatusEntity(
val createdAt: Date, val createdAt: Date,
val emojis: List<Emoji>, val emojis: List<Emoji>,
val favouritesCount: Int, val favouritesCount: Int,
val repliesCount: Int,
val favourited: Boolean, val favourited: Boolean,
val bookmarked: Boolean, val bookmarked: Boolean,
val sensitive: Boolean, val sensitive: Boolean,
@ -107,6 +111,7 @@ data class ConversationStatusEntity(
emojis = emojis, emojis = emojis,
reblogsCount = 0, reblogsCount = 0,
favouritesCount = favouritesCount, favouritesCount = favouritesCount,
repliesCount = repliesCount,
reblogged = false, reblogged = false,
favourited = favourited, favourited = favourited,
bookmarked = bookmarked, bookmarked = bookmarked,
@ -133,6 +138,7 @@ data class ConversationStatusEntity(
fun TimelineAccount.toEntity() = fun TimelineAccount.toEntity() =
ConversationAccountEntity( ConversationAccountEntity(
id = id, id = id,
localUsername = localUsername,
username = username, username = username,
displayName = name, displayName = name,
avatar = avatar, avatar = avatar,
@ -150,6 +156,7 @@ fun Status.toEntity() =
createdAt = createdAt, createdAt = createdAt,
emojis = emojis, emojis = emojis,
favouritesCount = favouritesCount, favouritesCount = favouritesCount,
repliesCount = repliesCount,
favourited = favourited, favourited = favourited,
bookmarked = bookmarked, bookmarked = bookmarked,
sensitive = sensitive, sensitive = sensitive,
@ -164,10 +171,11 @@ fun Status.toEntity() =
poll = poll poll = poll
) )
fun Conversation.toEntity(accountId: Long) = fun Conversation.toEntity(accountId: Long, order: Int) =
ConversationEntity( ConversationEntity(
accountId = accountId, accountId = accountId,
id = id, id = id,
order = order,
accounts = accounts.map { it.toEntity() }, accounts = accounts.map { it.toEntity() },
unread = unread, unread = unread,
lastStatus = lastStatus!!.toEntity() lastStatus = lastStatus!!.toEntity()

View File

@ -19,22 +19,35 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.LoadStateAdapter import androidx.paging.LoadStateAdapter
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding
import com.keylesspalace.tusky.util.BindingHolder
import com.keylesspalace.tusky.util.visible
class ConversationLoadStateAdapter( class ConversationLoadStateAdapter(
private val retryCallback: () -> Unit private val retryCallback: () -> Unit
) : LoadStateAdapter<NetworkStateViewHolder>() { ) : LoadStateAdapter<BindingHolder<ItemNetworkStateBinding>>() {
override fun onBindViewHolder(holder: NetworkStateViewHolder, loadState: LoadState) { override fun onBindViewHolder(holder: BindingHolder<ItemNetworkStateBinding>, loadState: LoadState) {
holder.setUpWithNetworkState(loadState) val binding = holder.binding
binding.progressBar.visible(loadState == LoadState.Loading)
binding.retryButton.visible(loadState is LoadState.Error)
val msg = if (loadState is LoadState.Error) {
loadState.error.message
} else {
null
}
binding.errorMsg.visible(msg != null)
binding.errorMsg.text = msg
binding.retryButton.setOnClickListener {
retryCallback()
}
} }
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
loadState: LoadState loadState: LoadState
): NetworkStateViewHolder { ): BindingHolder<ItemNetworkStateBinding> {
val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false) val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return NetworkStateViewHolder(binding, retryCallback) return BindingHolder(binding)
} }
} }

View File

@ -20,6 +20,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData
data class ConversationViewData( data class ConversationViewData(
val id: String, val id: String,
val order: Int,
val accounts: List<ConversationAccountEntity>, val accounts: List<ConversationAccountEntity>,
val unread: Boolean, val unread: Boolean,
val lastStatus: StatusViewData.Concrete val lastStatus: StatusViewData.Concrete
@ -37,6 +38,7 @@ data class ConversationViewData(
return ConversationEntity( return ConversationEntity(
accountId = accountId, accountId = accountId,
id = id, id = id,
order = order,
accounts = accounts, accounts = accounts,
unread = unread, unread = unread,
lastStatus = lastStatus.toConversationStatusEntity( lastStatus = lastStatus.toConversationStatusEntity(
@ -71,6 +73,7 @@ fun StatusViewData.Concrete.toConversationStatusEntity(
createdAt = status.createdAt, createdAt = status.createdAt,
emojis = status.emojis, emojis = status.emojis,
favouritesCount = status.favouritesCount, favouritesCount = status.favouritesCount,
repliesCount = status.repliesCount,
favourited = favourited, favourited = favourited,
bookmarked = bookmarked, bookmarked = bookmarked,
sensitive = status.sensitive, sensitive = status.sensitive,

View File

@ -23,6 +23,8 @@ import android.widget.Button;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
@ -43,12 +45,12 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
private TextView conversationNameTextView; private final TextView conversationNameTextView;
private Button contentCollapseButton; private final Button contentCollapseButton;
private ImageView[] avatars; private final ImageView[] avatars;
private StatusDisplayOptions statusDisplayOptions; private final StatusDisplayOptions statusDisplayOptions;
private StatusActionListener listener; private final StatusActionListener listener;
ConversationViewHolder(View itemView, ConversationViewHolder(View itemView,
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
@ -64,7 +66,6 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
this.statusDisplayOptions = statusDisplayOptions; this.statusDisplayOptions = statusDisplayOptions;
this.listener = listener; this.listener = listener;
} }
@Override @Override
@ -72,52 +73,67 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height); return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
} }
void setupWithConversation(ConversationViewData conversation) { void setupWithConversation(
@NonNull ConversationViewData conversation,
@Nullable Object payloads
) {
StatusViewData.Concrete statusViewData = conversation.getLastStatus(); StatusViewData.Concrete statusViewData = conversation.getLastStatus();
Status status = statusViewData.getStatus(); Status status = statusViewData.getStatus();
TimelineAccount account = status.getAccount();
setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener); if (payloads == null) {
TimelineAccount account = status.getAccount();
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener);
setUsername(account.getUsername());
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
setIsReply(status.getInReplyToId() != null);
setFavourited(status.getFavourited());
setBookmarked(status.getBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) { setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername());
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
setIsReply(status.getInReplyToId() != null);
setFavourited(status.getFavourited());
setBookmarked(status.getBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(),
statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) {
hideSensitiveMediaWarning();
}
// Hide the unused label.
for (TextView mediaLabel : mediaLabels) {
mediaLabel.setVisibility(View.GONE);
}
} else {
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent());
// Hide all unused views.
mediaPreviews[0].setVisibility(View.GONE);
mediaPreviews[1].setVisibility(View.GONE);
mediaPreviews[2].setVisibility(View.GONE);
mediaPreviews[3].setVisibility(View.GONE);
hideSensitiveMediaWarning(); hideSensitiveMediaWarning();
} }
// Hide the unused label.
for (TextView mediaLabel : mediaLabels) { setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
mediaLabel.setVisibility(View.GONE); false, statusDisplayOptions);
}
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
setConversationName(conversation.getAccounts());
setAvatars(conversation.getAccounts());
} else { } else {
setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); if (payloads instanceof List) {
// Hide all unused views. for (Object item : (List<?>) payloads) {
mediaPreviews[0].setVisibility(View.GONE); if (Key.KEY_CREATED.equals(item)) {
mediaPreviews[1].setVisibility(View.GONE); setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
mediaPreviews[2].setVisibility(View.GONE); }
mediaPreviews[3].setVisibility(View.GONE); }
hideSensitiveMediaWarning(); }
} }
setupButtons(listener, account.getId(), statusViewData.getContent().toString(),
false, statusDisplayOptions);
setSpoilerAndContent(statusViewData.isExpanded(), statusViewData.getContent(), status.getSpoilerText(),
status.getMentions(), status.getTags(), status.getEmojis(),
PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener);
setConversationName(conversation.getAccounts());
setAvatars(conversation.getAccounts());
} }
private void setConversationName(List<ConversationAccountEntity> accounts) { private void setConversationName(List<ConversationAccountEntity> accounts) {

View File

@ -22,21 +22,28 @@ import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.sparkbutton.helpers.Utils
import autodispose2.androidx.lifecycle.autoDispose
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID
import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.databinding.FragmentTimelineBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
@ -45,29 +52,31 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@OptIn(ExperimentalPagingApi::class)
class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment { class ConversationsFragment : SFragment(), StatusActionListener, Injectable, ReselectableFragment {
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var eventHub: EventHub
private val viewModel: ConversationsViewModel by viewModels { viewModelFactory } private val viewModel: ConversationsViewModel by viewModels { viewModelFactory }
private val binding by viewBinding(FragmentTimelineBinding::bind) private val binding by viewBinding(FragmentTimelineBinding::bind)
private lateinit var adapter: ConversationAdapter private lateinit var adapter: ConversationAdapter
private lateinit var loadStateAdapter: ConversationLoadStateAdapter
private var layoutManager: LinearLayoutManager? = null private var hideFab = false
private var initialRefreshDone: Boolean = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false) return inflater.inflate(R.layout.fragment_timeline, container, false)
@ -91,56 +100,106 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
) )
adapter = ConversationAdapter(statusDisplayOptions, this) adapter = ConversationAdapter(statusDisplayOptions, this)
loadStateAdapter = ConversationLoadStateAdapter(adapter::retry)
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) setupRecyclerView()
layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager
binding.recyclerView.adapter = adapter.withLoadStateFooter(loadStateAdapter)
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.progressBar.hide()
binding.statusView.hide()
initSwipeToRefresh() initSwipeToRefresh()
adapter.addLoadStateListener { loadState ->
if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
binding.statusView.hide()
binding.progressBar.hide()
if (adapter.itemCount == 0) {
when (loadState.refresh) {
is LoadState.NotLoading -> {
if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) {
binding.statusView.show()
binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null)
}
}
is LoadState.Error -> {
binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null)
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null)
}
}
is LoadState.Loading -> {
binding.progressBar.show()
}
}
}
}
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0 && adapter.itemCount != itemCount) {
binding.recyclerView.post {
if (getView() != null) {
binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30))
}
}
}
}
})
hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false)
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val composeButton = (activity as ActionButtonActivity).actionButton
if (composeButton != null) {
if (hideFab) {
if (dy > 0 && composeButton.isShown) {
composeButton.hide() // hides the button if we're scrolling down
} else if (dy < 0 && !composeButton.isShown) {
composeButton.show() // shows it if we are scrolling up
}
} else if (!composeButton.isShown) {
composeButton.show()
}
}
}
})
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
viewModel.conversationFlow.collectLatest { pagingData -> viewModel.conversationFlow.collectLatest { pagingData ->
adapter.submitData(pagingData) adapter.submitData(pagingData)
} }
} }
adapter.addLoadStateListener { loadStates -> lifecycleScope.launchWhenResumed {
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
loadStates.refresh.let { refreshState -> while (!useAbsoluteTime) {
if (refreshState is LoadState.Error) { adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED))
binding.statusView.show() delay(1.toDuration(DurationUnit.MINUTES))
if (refreshState.error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
adapter.refresh()
}
} else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
adapter.refresh()
}
}
} else {
binding.statusView.hide()
}
binding.progressBar.visible(refreshState == LoadState.Loading && adapter.itemCount == 0)
if (refreshState is LoadState.NotLoading && !initialRefreshDone) {
// jump to top after the initial refresh finished
binding.recyclerView.scrollToPosition(0)
initialRefreshDone = true
}
if (refreshState != LoadState.Loading) {
binding.swipeRefreshLayout.isRefreshing = false
}
} }
} }
eventHub.events
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { event ->
if (event is PreferenceChangedEvent) {
onPreferenceChanged(event.preferenceKey)
}
}
}
private fun setupRecyclerView() {
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(context)
binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry))
} }
private fun initSwipeToRefresh() { private fun initSwipeToRefresh() {
@ -207,7 +266,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
override fun onOpenReblog(position: Int) { override fun onOpenReblog(position: Int) {
// there are no reblogs in search results // there are no reblogs in conversations
} }
override fun onExpandedChange(expanded: Boolean, position: Int) { override fun onExpandedChange(expanded: Boolean, position: Int) {
@ -252,6 +311,19 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
} }
} }
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) {
adapter.peek(position)?.let { conversation ->
viewModel.voteInPoll(choices, conversation)
}
}
override fun onReselect() {
if (isAdded) {
binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
}
}
private fun deleteConversation(conversation: ConversationViewData) { private fun deleteConversation(conversation: ConversationViewData) {
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setMessage(R.string.dialog_delete_conversation_warning) .setMessage(R.string.dialog_delete_conversation_warning)
@ -262,20 +334,20 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
.show() .show()
} }
private fun jumpToTop() { private fun onPreferenceChanged(key: String) {
if (isAdded) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
layoutManager?.scrollToPosition(0) when (key) {
binding.recyclerView.stopScroll() PrefKeys.FAB_HIDE -> {
} hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false)
} }
PrefKeys.MEDIA_PREVIEW_ENABLED -> {
override fun onReselect() { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled
jumpToTop() val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled
} if (enabled != oldMediaPreviewEnabled) {
adapter.mediaPreviewEnabled = enabled
override fun onVoteInPoll(position: Int, choices: MutableList<Int>) { adapter.notifyItemRangeChanged(0, adapter.itemCount)
adapter.peek(position)?.let { conversation -> }
viewModel.voteInPoll(choices, conversation) }
} }
} }

View File

@ -4,8 +4,11 @@ import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType import androidx.paging.LoadType
import androidx.paging.PagingState import androidx.paging.PagingState
import androidx.paging.RemoteMediator import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
class ConversationsRemoteMediator( class ConversationsRemoteMediator(
@ -14,38 +17,53 @@ class ConversationsRemoteMediator(
private val db: AppDatabase private val db: AppDatabase
) : RemoteMediator<Int, ConversationEntity>() { ) : RemoteMediator<Int, ConversationEntity>() {
private var nextKey: String? = null
private var order: Int = 0
override suspend fun load( override suspend fun load(
loadType: LoadType, loadType: LoadType,
state: PagingState<Int, ConversationEntity> state: PagingState<Int, ConversationEntity>
): MediatorResult { ): MediatorResult {
if (loadType == LoadType.PREPEND) {
return MediatorResult.Success(endOfPaginationReached = true)
}
if (loadType == LoadType.REFRESH) {
nextKey = null
order = 0
}
try { try {
val conversationsResult = when (loadType) { val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize)
LoadType.REFRESH -> {
api.getConversations(limit = state.config.initialLoadSize) val conversations = conversationsResponse.body()
} if (!conversationsResponse.isSuccessful || conversations == null) {
LoadType.PREPEND -> { return MediatorResult.Error(HttpException(conversationsResponse))
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.lastStatus?.id
api.getConversations(maxId = maxId, limit = state.config.pageSize)
}
} }
if (loadType == LoadType.REFRESH) { db.withTransaction {
db.conversationDao().deleteForAccount(accountId)
if (loadType == LoadType.REFRESH) {
db.conversationDao().deleteForAccount(accountId)
}
val linkHeader = conversationsResponse.headers()["Link"]
val links = HttpHeaderLink.parse(linkHeader)
nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
db.conversationDao().insert(
conversations
.filterNot { it.lastStatus == null }
.map {
it.toEntity(accountId, order++)
}
)
} }
db.conversationDao().insert( return MediatorResult.Success(endOfPaginationReached = nextKey == null)
conversationsResult
.filterNot { it.lastStatus == null }
.map { it.toEntity(accountId) }
)
return MediatorResult.Success(endOfPaginationReached = conversationsResult.isEmpty())
} catch (e: Exception) { } catch (e: Exception) {
return MediatorResult.Error(e) return MediatorResult.Error(e)
} }
} }
override suspend fun initialize() = InitializeAction.LAUNCH_INITIAL_REFRESH
} }

View File

@ -1,37 +0,0 @@
/* Copyright 2021 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.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConversationsRepository @Inject constructor(
val mastodonApi: MastodonApi,
val db: AppDatabase
) {
fun deleteCacheForAccount(accountId: Long) {
Single.fromCallable {
db.conversationDao().deleteForAccount(accountId)
}.subscribeOn(Schedulers.io())
.subscribe()
}
}

View File

@ -26,7 +26,7 @@ import androidx.paging.map
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.usecase.TimelineCases
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await import kotlinx.coroutines.rx3.await
@ -41,7 +41,7 @@ class ConversationsViewModel @Inject constructor(
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
val conversationFlow = Pager( val conversationFlow = Pager(
config = PagingConfig(pageSize = 10, enablePlaceholders = false, initialLoadSize = 20), config = PagingConfig(pageSize = 30),
remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database), remoteMediator = ConversationsRemoteMediator(accountManager.activeAccount!!.id, api, database),
pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) } pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountManager.activeAccount!!.id) }
) )

View File

@ -16,6 +16,9 @@
package com.keylesspalace.tusky.components.instanceinfo package com.keylesspalace.tusky.components.instanceinfo
import android.util.Log import android.util.Log
import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse
import at.connyduck.calladapter.networkresult.onSuccess
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.EmojisEntity import com.keylesspalace.tusky.db.EmojisEntity

View File

@ -26,6 +26,7 @@ import android.widget.TextView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import at.connyduck.calladapter.networkresult.fold
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
@ -33,6 +34,7 @@ import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityLoginBinding import com.keylesspalace.tusky.databinding.ActivityLoginBinding
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.AccessToken
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.getNonNullString
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
@ -228,26 +230,50 @@ class LoginActivity : BaseActivity(), Injectable {
domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code" domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code"
).fold( ).fold(
{ accessToken -> { accessToken ->
accountManager.addAccount(accessToken.accessToken, domain, OAUTH_SCOPES) fetchAccountDetails(accessToken, domain, clientId, clientSecret)
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 -> { e ->
setLoading(false) setLoading(false)
binding.domainTextInputLayout.error = binding.domainTextInputLayout.error =
getString(R.string.error_retrieving_oauth_token) getString(R.string.error_retrieving_oauth_token)
Log.e( Log.e(TAG, getString(R.string.error_retrieving_oauth_token), e)
TAG,
"%s %s".format(getString(R.string.error_retrieving_oauth_token), e.message),
)
} }
) )
} }
private suspend fun fetchAccountDetails(
accessToken: AccessToken,
domain: String,
clientId: String,
clientSecret: String
) {
mastodonApi.accountVerifyCredentials(
domain = domain,
auth = "Bearer ${accessToken.accessToken}"
).fold({ newAccount ->
accountManager.addAccount(
accessToken = accessToken.accessToken,
domain = domain,
clientId = clientId,
clientSecret = clientSecret,
oauthScopes = OAUTH_SCOPES,
newAccount = newAccount
)
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_loading_account_details)
Log.e(TAG, getString(R.string.error_loading_account_details), e)
})
}
private fun setLoading(loadingState: Boolean) { private fun setLoading(loadingState: Boolean) {
if (loadingState) { if (loadingState) {
binding.loginLoadingLayout.visibility = View.VISIBLE binding.loginLoadingLayout.visibility = View.VISIBLE

View File

@ -83,6 +83,10 @@ class LoginWebViewActivity : BaseActivity(), Injectable {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true)
}
val data = OauthLogin.parseData(intent) val data = OauthLogin.parseData(intent)
setContentView(binding.root) setContentView(binding.root)

View File

@ -458,24 +458,6 @@ public class NotificationHelper {
} }
} }
public static void deleteLegacyNotificationChannels(@NonNull Context context, @NonNull AccountManager accountManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
// used until Tusky 1.4
notificationManager.deleteNotificationChannel(CHANNEL_MENTION);
notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE);
notificationManager.deleteNotificationChannel(CHANNEL_BOOST);
notificationManager.deleteNotificationChannel(CHANNEL_FOLLOW);
// used until Tusky 1.7
for(AccountEntity account: accountManager.getAllAccountsOrderedByActive()) {
notificationManager.deleteNotificationChannel(CHANNEL_FAVOURITE+" "+account.getIdentifier());
}
}
}
public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull AccountManager accountManager) { public static boolean areNotificationsEnabled(@NonNull Context context, @NonNull AccountManager accountManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@ -23,6 +23,8 @@ import android.util.Log
import android.view.View import android.view.View
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.login.LoginActivity
@ -49,7 +51,12 @@ private fun accountNeedsMigration(account: AccountEntity): Boolean =
fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean = fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean =
accountManager.activeAccount?.let(::accountNeedsMigration) ?: false accountManager.activeAccount?.let(::accountNeedsMigration) ?: false
fun showMigrationNoticeIfNecessary(context: Context, parent: View, accountManager: AccountManager) { fun showMigrationNoticeIfNecessary(
context: Context,
parent: View,
anchorView: View?,
accountManager: AccountManager
) {
// No point showing anything if we cannot enable it // No point showing anything if we cannot enable it
if (!isUnifiedPushAvailable(context)) return if (!isUnifiedPushAvailable(context)) return
if (!anyAccountNeedsMigration(accountManager)) return if (!anyAccountNeedsMigration(accountManager)) return
@ -57,10 +64,10 @@ fun showMigrationNoticeIfNecessary(context: Context, parent: View, accountManage
val pm = PreferenceManager.getDefaultSharedPreferences(context) val pm = PreferenceManager.getDefaultSharedPreferences(context)
if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return
Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE).apply { Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE)
setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) } .setAnchorView(anchorView)
show() .setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) }
} .show()
} }
private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) { private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) {
@ -87,7 +94,7 @@ private suspend fun enableUnifiedPushNotificationsForAccount(context: Context, a
// Already registered, update the subscription to match notification settings // Already registered, update the subscription to match notification settings
updateUnifiedPushSubscription(context, api, accountManager, account) updateUnifiedPushSubscription(context, api, accountManager, account)
} else { } else {
UnifiedPush.registerAppWithDialog(context, account.id.toString()) UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE))
} }
} }
@ -150,7 +157,14 @@ private fun buildSubscriptionData(context: Context, account: AccountEntity): Map
} }
// Called by UnifiedPush callback // Called by UnifiedPush callback
suspend fun registerUnifiedPushEndpoint(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity, endpoint: String) { suspend fun registerUnifiedPushEndpoint(
context: Context,
api: MastodonApi,
accountManager: AccountManager,
account: AccountEntity,
endpoint: String
) = withContext(Dispatchers.IO) {
// Generate a prime256v1 key pair for WebPush // Generate a prime256v1 key pair for WebPush
// Decryption is unimplemented for now, since Mastodon uses an old WebPush // Decryption is unimplemented for now, since Mastodon uses an old WebPush
// standard which does not send needed information for decryption in the payload // standard which does not send needed information for decryption in the payload
@ -159,27 +173,22 @@ suspend fun registerUnifiedPushEndpoint(context: Context, api: MastodonApi, acco
val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1) val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1)
val auth = CryptoUtil.secureRandomBytesEncoded(16) val auth = CryptoUtil.secureRandomBytesEncoded(16)
withContext(Dispatchers.IO) { api.subscribePushNotifications(
api.subscribePushNotifications( "Bearer ${account.accessToken}", account.domain,
"Bearer ${account.accessToken}", account.domain, endpoint, keyPair.pubkey, auth,
endpoint, keyPair.pubkey, auth, buildSubscriptionData(context, account)
buildSubscriptionData(context, account) ).onFailure { throwable ->
).onFailure { Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable)
Log.d(TAG, "Error setting push endpoint for account ${account.id}") disableUnifiedPushNotificationsForAccount(context, account)
Log.d(TAG, Log.getStackTraceString(it)) }.onSuccess {
Log.d(TAG, (it as HttpException).response().toString()) Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
disableUnifiedPushNotificationsForAccount(context, account) account.pushPubKey = keyPair.pubkey
}.onSuccess { account.pushPrivKey = keyPair.privKey
Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}") account.pushAuth = auth
account.pushServerKey = it.serverKey
account.pushPubKey = keyPair.pubkey account.unifiedPushUrl = endpoint
account.pushPrivKey = keyPair.privKey accountManager.saveAccount(account)
account.pushAuth = auth
account.pushServerKey = it.serverKey
account.unifiedPushUrl = endpoint
accountManager.saveAccount(account)
}
} }
} }

View File

@ -39,7 +39,7 @@ class TabFilterPreferencesFragment : PreferenceFragmentCompat() {
checkBoxPreference { checkBoxPreference {
setTitle(R.string.pref_title_show_replies) setTitle(R.string.pref_title_show_replies)
key = PrefKeys.TAB_FILTER_HOME_REPLIES key = PrefKeys.TAB_FILTER_HOME_REPLIES
setDefaultValue(false) setDefaultValue(true)
isIconSpaceReserved = false isIconSpaceReserved = false
} }
} }

View File

@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.ScheduledStatus
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi

View File

@ -30,7 +30,7 @@ import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.NotestockApi import com.keylesspalace.tusky.network.NotestockApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.RxAwareViewModel
import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData

View File

@ -107,9 +107,6 @@ class TimelineFragment :
private lateinit var adapter: TimelinePagingAdapter private lateinit var adapter: TimelinePagingAdapter
private var isSwipeToRefreshEnabled = true private var isSwipeToRefreshEnabled = true
private var layoutManager: LinearLayoutManager? = null
private var scrollListener: RecyclerView.OnScrollListener? = null
private var hideFab = false private var hideFab = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -244,7 +241,7 @@ class TimelineFragment :
if (actionButtonPresent()) { if (actionButtonPresent()) {
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
hideFab = preferences.getBoolean("fabHide", false) hideFab = preferences.getBoolean("fabHide", false)
scrollListener = object : RecyclerView.OnScrollListener() { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) {
val composeButton = (activity as ActionButtonActivity).actionButton val composeButton = (activity as ActionButtonActivity).actionButton
if (composeButton != null) { if (composeButton != null) {
@ -259,9 +256,7 @@ class TimelineFragment :
} }
} }
} }
}.also { })
binding.recyclerView.addOnScrollListener(it)
}
} }
eventHub.events eventHub.events
@ -297,8 +292,7 @@ class TimelineFragment :
} }
) )
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
layoutManager = LinearLayoutManager(context) binding.recyclerView.layoutManager = LinearLayoutManager(context)
binding.recyclerView.layoutManager = layoutManager
val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) val divider = DividerItemDecoration(context, RecyclerView.VERTICAL)
binding.recyclerView.addItemDecoration(divider) binding.recyclerView.addItemDecoration(divider)
@ -509,7 +503,7 @@ class TimelineFragment :
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false)
if (!useAbsoluteTime) { if (!useAbsoluteTime) {
Observable.interval(1, TimeUnit.MINUTES) Observable.interval(0, 1, TimeUnit.MINUTES)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_PAUSE) .autoDispose(this, Lifecycle.Event.ON_PAUSE)
.subscribe { .subscribe {
@ -520,7 +514,7 @@ class TimelineFragment :
override fun onReselect() { override fun onReselect() {
if (isAdded) { if (isAdded) {
layoutManager!!.scrollToPosition(0) binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll() binding.recyclerView.stopScroll()
} }
} }

View File

@ -99,6 +99,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity {
contentShowing = false, contentShowing = false,
pinned = false, pinned = false,
card = null, card = null,
repliesCount = 0,
quote = null, quote = null,
) )
} }
@ -141,6 +142,7 @@ fun Status.toEntity(
contentCollapsed = contentCollapsed, contentCollapsed = contentCollapsed,
pinned = actionableStatus.pinned == true, pinned = actionableStatus.pinned == true,
card = actionableStatus.card?.let(gson::toJson), card = actionableStatus.card?.let(gson::toJson),
repliesCount = actionableStatus.repliesCount,
quote = actionableStatus.quote?.let(gson::toJson), quote = actionableStatus.quote?.let(gson::toJson),
) )
} }
@ -186,6 +188,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
muted = status.muted, muted = status.muted,
poll = poll, poll = poll,
card = card, card = card,
repliesCount = status.repliesCount,
quote = quote, quote = quote,
) )
} }
@ -216,6 +219,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
muted = status.muted, muted = status.muted,
poll = null, poll = null,
card = null, card = null,
repliesCount = status.repliesCount,
quote = null, quote = null,
) )
} else { } else {
@ -245,6 +249,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData {
muted = status.muted, muted = status.muted,
poll = poll, poll = poll,
card = card, card = card,
repliesCount = status.repliesCount,
quote = quote, quote = quote,
) )
} }

View File

@ -51,6 +51,10 @@ class CachedTimelineRemoteMediator(
state: PagingState<Int, TimelineStatusWithAccount> state: PagingState<Int, TimelineStatusWithAccount>
): MediatorResult { ): MediatorResult {
if (!activeAccount.isLoggedIn()) {
return MediatorResult.Success(endOfPaginationReached = true)
}
try { try {
var dbEmpty = false var dbEmpty = false

View File

@ -21,6 +21,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.filter import androidx.paging.filter
import androidx.paging.map import androidx.paging.map
@ -37,11 +38,12 @@ import com.keylesspalace.tusky.components.timeline.toViewData
import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.TimelineStatusWithAccount
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor import kotlinx.coroutines.asExecutor
@ -69,7 +71,17 @@ class CachedTimelineViewModel @Inject constructor(
private val db: AppDatabase, private val db: AppDatabase,
private val gson: Gson, private val gson: Gson,
streamingManager: StreamingManager, streamingManager: StreamingManager,
) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel, streamingManager) { ) : TimelineViewModel(
timelineCases,
api,
eventHub,
accountManager,
sharedPreferences,
filterModel,
streamingManager,
) {
private var currentPagingSource: PagingSource<Int, TimelineStatusWithAccount>? = null
@OptIn(ExperimentalPagingApi::class) @OptIn(ExperimentalPagingApi::class)
override val statuses = Pager( override val statuses = Pager(
@ -81,6 +93,8 @@ class CachedTimelineViewModel @Inject constructor(
EmptyTimelinePagingSource() EmptyTimelinePagingSource()
} else { } else {
db.timelineDao().getStatuses(activeAccount.id) db.timelineDao().getStatuses(activeAccount.id)
}.also { newPagingSource ->
this.currentPagingSource = newPagingSource
} }
} }
).flow ).flow
@ -116,13 +130,15 @@ class CachedTimelineViewModel @Inject constructor(
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch { viewModelScope.launch {
db.timelineDao().setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) db.timelineDao()
.setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing)
} }
} }
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch { viewModelScope.launch {
db.timelineDao().setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) db.timelineDao()
.setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed)
} }
} }
@ -149,12 +165,21 @@ class CachedTimelineViewModel @Inject constructor(
val activeAccount = accountManager.activeAccount!! val activeAccount = accountManager.activeAccount!!
timelineDao.insertStatus(Placeholder(placeholderId, loading = true).toEntity(activeAccount.id)) timelineDao.insertStatus(
Placeholder(placeholderId, loading = true).toEntity(
activeAccount.id
)
)
val response = db.withTransaction { val response = db.withTransaction {
val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId) val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId)
val nextPlaceholderId = timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId) val nextPlaceholderId =
api.homeTimeline(maxId = idAbovePlaceholder, sinceId = nextPlaceholderId, limit = LOAD_AT_ONCE) timelineDao.getNextPlaceholderIdAfter(activeAccount.id, placeholderId)
api.homeTimeline(
maxId = idAbovePlaceholder,
sinceId = nextPlaceholderId,
limit = LOAD_AT_ONCE
)
}.await() }.await()
val statuses = response.body() val statuses = response.body()
@ -168,16 +193,21 @@ class CachedTimelineViewModel @Inject constructor(
timelineDao.delete(activeAccount.id, placeholderId) timelineDao.delete(activeAccount.id, placeholderId)
val overlappedStatuses = if (statuses.isNotEmpty()) { val overlappedStatuses = if (statuses.isNotEmpty()) {
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id) timelineDao.deleteRange(
activeAccount.id,
statuses.last().id,
statuses.first().id
)
} else { } else {
0 0
} }
for (status in statuses) { for (status in statuses) {
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson))
status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount -> status.reblog?.account?.toEntity(activeAccount.id, gson)
timelineDao.insertAccount(rebloggedAccount) ?.let { rebloggedAccount ->
} timelineDao.insertAccount(rebloggedAccount)
}
timelineDao.insertStatus( timelineDao.insertStatus(
status.toEntity( status.toEntity(
timelineUserId = activeAccount.id, timelineUserId = activeAccount.id,
@ -196,7 +226,10 @@ class CachedTimelineViewModel @Inject constructor(
to guarantee the placeholder has an id that exists on the server as not all to guarantee the placeholder has an id that exists on the server as not all
servers handle client generated ids as expected */ servers handle client generated ids as expected */
timelineDao.insertStatus( timelineDao.insertStatus(
Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id) Placeholder(
statuses.last().id,
loading = false
).toEntity(activeAccount.id)
) )
} }
} }
@ -211,7 +244,8 @@ class CachedTimelineViewModel @Inject constructor(
private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { private suspend fun loadMoreFailed(placeholderId: String, e: Exception) {
Log.w("CachedTimelineVM", "failed loading statuses", e) Log.w("CachedTimelineVM", "failed loading statuses", e)
val activeAccount = accountManager.activeAccount!! val activeAccount = accountManager.activeAccount!!
db.timelineDao().insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) db.timelineDao()
.insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
} }
override fun handleReblogEvent(reblogEvent: ReblogEvent) { override fun handleReblogEvent(reblogEvent: ReblogEvent) {
@ -266,6 +300,13 @@ class CachedTimelineViewModel @Inject constructor(
} }
} }
override suspend fun invalidate() {
// invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load
if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) {
currentPagingSource?.invalidate()
}
}
companion object { companion object {
private const val MAX_STATUSES_IN_CACHE = 1000 private const val MAX_STATUSES_IN_CACHE = 1000
} }

View File

@ -34,7 +34,7 @@ import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.getDomain
import com.keylesspalace.tusky.util.isLessThan import com.keylesspalace.tusky.util.isLessThan
import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.isLessThanOrEqual
@ -270,6 +270,10 @@ class NetworkTimelineViewModel @Inject constructor(
currentSource?.invalidate() currentSource?.invalidate()
} }
override suspend fun invalidate() {
currentSource?.invalidate()
}
@Throws(IOException::class, HttpException::class) @Throws(IOException::class, HttpException::class)
suspend fun fetchStatusesForKind( suspend fun fetchStatusesForKind(
fromId: String?, fromId: String?,

View File

@ -41,8 +41,8 @@ import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -140,6 +140,7 @@ abstract class TimelineViewModel(
this.isStreamingEnabled = isStreamingEnabled this.isStreamingEnabled = isStreamingEnabled
if (kind == Kind.HOME) { if (kind == Kind.HOME) {
// Note the variable is "true if filter" but the underlying preference/settings text is "true if show"
filterRemoveReplies = filterRemoveReplies =
!sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true)
filterRemoveReblogs = filterRemoveReblogs =
@ -233,6 +234,9 @@ abstract class TimelineViewModel(
abstract fun fullReload() abstract fun fullReload()
/** Triggered when currently displayed data must be reloaded. */
protected abstract suspend fun invalidate()
protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean { protected fun shouldFilterStatus(statusViewData: StatusViewData): Boolean {
val status = statusViewData.asStatusOrNull()?.status ?: return false val status = statusViewData.asStatusOrNull()?.status ?: return false
return status.inReplyToId != null && filterRemoveReplies || return status.inReplyToId != null && filterRemoveReplies ||
@ -353,6 +357,9 @@ abstract class TimelineViewModel(
filterContextMatchesKind(kind, it.context) filterContextMatchesKind(kind, it.context)
} }
) )
// After the filters are loaded we need to reload displayed content to apply them.
// It can happen during the usage or at startup, when we get statuses before filters.
invalidate()
} }
} }

View File

@ -37,6 +37,8 @@ data class AccountEntity(
@field:PrimaryKey(autoGenerate = true) var id: Long, @field:PrimaryKey(autoGenerate = true) var id: Long,
val domain: String, val domain: String,
var accessToken: String, var accessToken: String,
var clientId: String?, // nullable for backward compatibility
var clientSecret: String?, // nullable for backward compatibility
var isActive: Boolean, var isActive: Boolean,
var accountId: String = "", var accountId: String = "",
var username: String = "", var username: String = "",
@ -81,6 +83,15 @@ data class AccountEntity(
val fullName: String val fullName: String
get() = "@$username@$domain" get() = "@$username@$domain"
fun logout() {
// deleting credentials so they cannot be used again
accessToken = ""
clientId = null
clientSecret = null
}
fun isLoggedIn() = accessToken.isNotEmpty()
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false

View File

@ -48,13 +48,22 @@ class AccountManager @Inject constructor(db: AppDatabase) {
} }
/** /**
* Adds a new empty account and makes it the active account. * Adds a new account and makes it the active account.
* More account information has to be added later with [updateActiveAccount]
* or the account wont be saved to the database.
* @param accessToken the access token for the new account * @param accessToken the access token for the new account
* @param domain the domain of the accounts Mastodon instance * @param domain the domain of the accounts Mastodon instance
* @param clientId the oauth client id used to sign in the account
* @param clientSecret the oauth client secret used to sign in the account
* @param oauthScopes the oauth scopes granted to the account
* @param newAccount the [Account] as returned by the Mastodon Api
*/ */
fun addAccount(accessToken: String, domain: String, oauthScopes: String) { fun addAccount(
accessToken: String,
domain: String,
clientId: String,
clientSecret: String,
oauthScopes: String,
newAccount: Account
) {
activeAccount?.let { activeAccount?.let {
it.isActive = false it.isActive = false
@ -62,13 +71,35 @@ class AccountManager @Inject constructor(db: AppDatabase) {
accountDao.insertOrReplace(it) accountDao.insertOrReplace(it)
} }
// check if this is a relogin with an existing account, if yes update it, otherwise create a new one
val existingAccountIndex = accounts.indexOfFirst { account ->
domain == account.domain && newAccount.id == account.accountId
}
val newAccountEntity = if (existingAccountIndex != -1) {
accounts[existingAccountIndex].copy(
accessToken = accessToken,
clientId = clientId,
clientSecret = clientSecret,
oauthScopes = oauthScopes,
isActive = true
).also { accounts[existingAccountIndex] = it }
} else {
val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0
val newAccountId = maxAccountId + 1
AccountEntity(
id = newAccountId,
domain = domain.lowercase(Locale.ROOT),
accessToken = accessToken,
clientId = clientId,
clientSecret = clientSecret,
oauthScopes = oauthScopes,
isActive = true,
accountId = newAccount.id
).also { accounts.add(it) }
}
val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0 activeAccount = newAccountEntity
val newAccountId = maxAccountId + 1 updateActiveAccount(newAccount)
activeAccount = AccountEntity(
id = newAccountId, domain = domain.lowercase(Locale.ROOT),
accessToken = accessToken, oauthScopes = oauthScopes, isActive = true
)
} }
/** /**
@ -89,11 +120,12 @@ class AccountManager @Inject constructor(db: AppDatabase) {
*/ */
fun logActiveAccountOut(): AccountEntity? { fun logActiveAccountOut(): AccountEntity? {
if (activeAccount == null) { return activeAccount?.let { account ->
return null
} else { account.logout()
accounts.remove(activeAccount!!)
accountDao.delete(activeAccount!!) accounts.remove(account)
accountDao.delete(account)
if (accounts.size > 0) { if (accounts.size > 0) {
accounts[0].isActive = true accounts[0].isActive = true
@ -103,7 +135,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
} else { } else {
activeAccount = null activeAccount = null
} }
return activeAccount activeAccount
} }
} }
@ -123,17 +155,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
it.emojis = account.emojis ?: emptyList() it.emojis = account.emojis ?: emptyList()
Log.d(TAG, "updateActiveAccount: saving account with id " + it.id) Log.d(TAG, "updateActiveAccount: saving account with id " + it.id)
it.id = accountDao.insertOrReplace(it) accountDao.insertOrReplace(it)
val accountIndex = accounts.indexOf(it)
if (accountIndex != -1) {
// in case the user was already logged in with this account, remove the old information
accounts.removeAt(accountIndex)
accounts.add(accountIndex, it)
} else {
accounts.add(it)
}
} }
} }

View File

@ -31,7 +31,7 @@ import java.io.File;
*/ */
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 36) }, version = 39)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract AccountDao accountDao(); public abstract AccountDao accountDao();
@ -554,4 +554,32 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''");
} }
}; };
public static final Migration MIGRATION_36_37 = new Migration(36, 37) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `repliesCount` INTEGER NOT NULL DEFAULT 0");
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_repliesCount` INTEGER NOT NULL DEFAULT 0");
}
};
public static final Migration MIGRATION_37_38 = new Migration(37, 38) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
// database needs to be cleaned because the ConversationAccountEntity got a new attribute
database.execSQL("DELETE FROM `ConversationEntity`");
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `order` INTEGER NOT NULL DEFAULT 0");
// timestamps are now serialized differently so all cache tables that contain them need to be cleaned
database.execSQL("DELETE FROM `TimelineStatusEntity`");
}
};
public static final Migration MIGRATION_38_39 = new Migration(38, 39) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientId` TEXT");
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` TEXT");
}
};
} }

View File

@ -28,14 +28,14 @@ interface ConversationsDao {
suspend fun insert(conversations: List<ConversationEntity>) suspend fun insert(conversations: List<ConversationEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(conversation: ConversationEntity): Long suspend fun insert(conversation: ConversationEntity)
@Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId") @Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId")
suspend fun delete(id: String, accountId: Long): Int suspend fun delete(id: String, accountId: Long)
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC") @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY `order` ASC")
fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity> fun conversationsForAccount(accountId: Long): PagingSource<Int, ConversationEntity>
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") @Query("DELETE FROM ConversationEntity WHERE accountId = :accountId")
fun deleteForAccount(accountId: Long) suspend fun deleteForAccount(accountId: Long)
} }

View File

@ -19,6 +19,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
@Dao @Dao
interface InstanceDao { interface InstanceDao {
@ -29,9 +30,11 @@ interface InstanceDao {
@Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class) @Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class)
suspend fun insertOrReplace(emojis: EmojisEntity) suspend fun insertOrReplace(emojis: EmojisEntity)
@RewriteQueriesToDropUnusedColumns
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
suspend fun getInstanceInfo(instance: String): InstanceInfoEntity? suspend fun getInstanceInfo(instance: String): InstanceInfoEntity?
@RewriteQueriesToDropUnusedColumns
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
suspend fun getEmojiInfo(instance: String): EmojisEntity? suspend fun getEmojiInfo(instance: String): EmojisEntity?
} }

View File

@ -34,7 +34,7 @@ abstract class TimelineDao {
""" """
SELECT s.serverId, s.url, s.timelineUserId, SELECT s.serverId, s.url, s.timelineUserId,
s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt,
s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive,
s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId,
s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned,
s.quote, s.quote,
@ -198,4 +198,7 @@ AND timelineUserId = :accountId
*/ */
@Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1")
abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String? abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String?
@Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId")
abstract suspend fun getStatusCount(accountId: Long): Int
} }

View File

@ -61,6 +61,7 @@ data class TimelineStatusEntity(
val emojis: String?, val emojis: String?,
val reblogsCount: Int, val reblogsCount: Int,
val favouritesCount: Int, val favouritesCount: Int,
val repliesCount: Int,
val reblogged: Boolean, val reblogged: Boolean,
val bookmarked: Boolean, val bookmarked: Boolean,
val favourited: Boolean, val favourited: Boolean,

View File

@ -70,7 +70,8 @@ class AppModule {
AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29,
AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32, AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32,
AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35, AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35,
AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38,
AppDatabase.MIGRATION_38_39
) )
.build() .build()
} }

View File

@ -18,10 +18,12 @@ package com.keylesspalace.tusky.di
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build import android.os.Build
import at.connyduck.calladapter.kotlinresult.KotlinResultCallAdapterFactory import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.json.Rfc3339DateJsonAdapter
import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.MediaUploadApi import com.keylesspalace.tusky.network.MediaUploadApi
@ -40,6 +42,7 @@ import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create import retrofit2.create
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Proxy import java.net.Proxy
import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
@ -52,7 +55,9 @@ class NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun providesGson() = Gson() fun providesGson(): Gson = GsonBuilder()
.registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter())
.create()
@Provides @Provides
@Singleton @Singleton
@ -109,7 +114,7 @@ class NetworkModule {
.client(httpClient) .client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson)) .addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava3CallAdapterFactory.create()) .addCallAdapterFactory(RxJava3CallAdapterFactory.create())
.addCallAdapterFactory(KotlinResultCallAdapterFactory.create()) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build() .build()
} }

View File

@ -33,6 +33,7 @@ data class Status(
val emojis: List<Emoji>, val emojis: List<Emoji>,
@SerializedName("reblogs_count") val reblogsCount: Int, @SerializedName("reblogs_count") val reblogsCount: Int,
@SerializedName("favourites_count") val favouritesCount: Int, @SerializedName("favourites_count") val favouritesCount: Int,
@SerializedName("replies_count") val repliesCount: Int,
var reblogged: Boolean, var reblogged: Boolean,
var favourited: Boolean, var favourited: Boolean,
var bookmarked: Boolean, var bookmarked: Boolean,

View File

@ -547,7 +547,7 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void onContentCollapsedChange(boolean isCollapsed, int position) { public void onContentCollapsedChange(boolean isCollapsed, int position) {
updateViewDataAt(position, (vd) -> vd.copyWIthCollapsed(isCollapsed)); updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed));
; ;
} }
@ -972,10 +972,10 @@ public class NotificationsFragment extends SFragment implements
if (notifications.size() == 0 && adapter.getItemCount() == 0) { if (notifications.size() == 0 && adapter.getItemCount() == 0) {
this.statusView.setVisibility(View.VISIBLE); this.statusView.setVisibility(View.VISIBLE);
this.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); this.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null);
} else {
swipeRefreshLayout.setEnabled(true);
} }
updateFilterVisibility(); updateFilterVisibility();
swipeRefreshLayout.setEnabled(true);
swipeRefreshLayout.setRefreshing(false); swipeRefreshLayout.setRefreshing(false);
progressBar.setVisibility(View.GONE); progressBar.setVisibility(View.GONE);
} }
@ -1240,7 +1240,7 @@ public class NotificationsFragment extends SFragment implements
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false); boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false);
if (!useAbsoluteTime) { if (!useAbsoluteTime) {
Observable.interval(1, TimeUnit.MINUTES) Observable.interval(0, 1, TimeUnit.MINUTES)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE))) .to(autoDisposable(from(this, Lifecycle.Event.ON_PAUSE)))
.subscribe( .subscribe(

View File

@ -56,7 +56,7 @@ import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.usecase.TimelineCases;
import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.StatusParsingHelper; import com.keylesspalace.tusky.util.StatusParsingHelper;
import com.keylesspalace.tusky.view.MuteAccountDialog; import com.keylesspalace.tusky.view.MuteAccountDialog;

View File

@ -397,7 +397,7 @@ public final class ViewThreadFragment extends SFragment implements
public void onContentCollapsedChange(boolean isCollapsed, int position) { public void onContentCollapsedChange(boolean isCollapsed, int position) {
adapter.setItem( adapter.setItem(
position, position,
statuses.getPairedItem(position).copyWIthCollapsed(isCollapsed), statuses.getPairedItem(position).copyWithCollapsed(isCollapsed),
true true
); );
} }

View File

@ -0,0 +1,268 @@
package com.keylesspalace.tusky.json
/*
* Copyright (C) 2011 FasterXML, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.google.gson.JsonParseException
import java.util.Calendar
import java.util.Date
import java.util.GregorianCalendar
import java.util.Locale
import java.util.TimeZone
import kotlin.math.min
import kotlin.math.pow
/*
* Jacksons date formatter, pruned to Moshi's needs. Forked from this file:
* https://github.com/FasterXML/jackson-databind/blob/67ebf7305f492285a8f9f4de31545f5f16fc7c3a/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java
*
* Utilities methods for manipulating dates in iso8601 format. This is much much faster and GC
* friendly than using SimpleDateFormat so highly suitable if you (un)serialize lots of date
* objects.
*
* Supported parse format:
* `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]]`
*
* @see [this specification](http://www.w3.org/TR/NOTE-datetime)
*/
/** ID to represent the 'GMT' string */
private const val GMT_ID = "GMT"
/** The GMT timezone, prefetched to avoid more lookups. */
private val TIMEZONE_Z: TimeZone = TimeZone.getTimeZone(GMT_ID)
/** Returns `date` formatted as yyyy-MM-ddThh:mm:ss.sssZ */
internal fun Date.formatIsoDate(): String {
val calendar: Calendar = GregorianCalendar(TIMEZONE_Z, Locale.US)
calendar.time = this
// estimate capacity of buffer as close as we can (yeah, that's pedantic ;)
val capacity = "yyyy-MM-ddThh:mm:ss.sssZ".length
val formatted = StringBuilder(capacity)
padInt(formatted, calendar[Calendar.YEAR], "yyyy".length)
formatted.append('-')
padInt(formatted, calendar[Calendar.MONTH] + 1, "MM".length)
formatted.append('-')
padInt(formatted, calendar[Calendar.DAY_OF_MONTH], "dd".length)
formatted.append('T')
padInt(formatted, calendar[Calendar.HOUR_OF_DAY], "hh".length)
formatted.append(':')
padInt(formatted, calendar[Calendar.MINUTE], "mm".length)
formatted.append(':')
padInt(formatted, calendar[Calendar.SECOND], "ss".length)
formatted.append('.')
padInt(formatted, calendar[Calendar.MILLISECOND], "sss".length)
formatted.append('Z')
return formatted.toString()
}
/**
* Parse a date from ISO-8601 formatted string. It expects a format
* `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh:mm]]`
*
* @receiver ISO string to parse in the appropriate format.
* @return the parsed date
*/
internal fun String.parseIsoDate(): Date {
return try {
var offset = 0
// extract year
val year = parseInt(this, offset, 4.let { offset += it; offset })
if (checkOffset(this, offset, '-')) {
offset += 1
}
// extract month
val month = parseInt(this, offset, 2.let { offset += it; offset })
if (checkOffset(this, offset, '-')) {
offset += 1
}
// extract day
val day = parseInt(this, offset, 2.let { offset += it; offset })
// default time value
var hour = 0
var minutes = 0
var seconds = 0
// always use 0 otherwise returned date will include millis of current time
var milliseconds = 0
// if the value has no time component (and no time zone), we are done
val hasT = checkOffset(this, offset, 'T')
if (!hasT && this.length <= offset) {
return GregorianCalendar(year, month - 1, day).time
}
if (hasT) {
// extract hours, minutes, seconds and milliseconds
hour = parseInt(this, 1.let { offset += it; offset }, 2.let { offset += it; offset })
if (checkOffset(this, offset, ':')) {
offset += 1
}
minutes = parseInt(this, offset, 2.let { offset += it; offset })
if (checkOffset(this, offset, ':')) {
offset += 1
}
// second and milliseconds can be optional
if (this.length > offset) {
val c = this[offset]
if (c != 'Z' && c != '+' && c != '-') {
seconds = parseInt(this, offset, 2.let { offset += it; offset })
if (seconds in 60..62) seconds = 59 // truncate up to 3 leap seconds
// milliseconds can be optional in the format
if (checkOffset(this, offset, '.')) {
offset += 1
val endOffset = indexOfNonDigit(this, offset + 1) // assume at least one digit
val parseEndOffset = min(endOffset, offset + 3) // parse up to 3 digits
val fraction = parseInt(this, offset, parseEndOffset)
milliseconds =
(10.0.pow((3 - (parseEndOffset - offset)).toDouble()) * fraction).toInt()
offset = endOffset
}
}
}
}
// extract timezone
require(this.length > offset) { "No time zone indicator" }
val timezone: TimeZone
val timezoneIndicator = this[offset]
if (timezoneIndicator == 'Z') {
timezone = TIMEZONE_Z
} else if (timezoneIndicator == '+' || timezoneIndicator == '-') {
val timezoneOffset = this.substring(offset)
// 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00"
if ("+0000" == timezoneOffset || "+00:00" == timezoneOffset) {
timezone = TIMEZONE_Z
} else {
// 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC...
// not sure why, but it is what it is.
val timezoneId = GMT_ID + timezoneOffset
timezone = TimeZone.getTimeZone(timezoneId)
val act = timezone.id
if (act != timezoneId) {
/*
* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given
* one without. If so, don't sweat.
* Yes, very inefficient. Hopefully not hit often.
* If it becomes a perf problem, add 'loose' comparison instead.
*/
val cleaned = act.replace(":", "")
if (cleaned != timezoneId) {
throw IndexOutOfBoundsException(
"Mismatching time zone indicator: $timezoneId given, resolves to ${timezone.id}"
)
}
}
}
} else {
throw IndexOutOfBoundsException(
"Invalid time zone indicator '$timezoneIndicator'"
)
}
val calendar: Calendar = GregorianCalendar(timezone)
calendar.isLenient = false
calendar[Calendar.YEAR] = year
calendar[Calendar.MONTH] = month - 1
calendar[Calendar.DAY_OF_MONTH] = day
calendar[Calendar.HOUR_OF_DAY] = hour
calendar[Calendar.MINUTE] = minutes
calendar[Calendar.SECOND] = seconds
calendar[Calendar.MILLISECOND] = milliseconds
calendar.time
// If we get a ParseException it'll already have the right message/offset.
// Other exception types can convert here.
} catch (e: IndexOutOfBoundsException) {
throw JsonParseException("Not an RFC 3339 date: $this", e)
} catch (e: IllegalArgumentException) {
throw JsonParseException("Not an RFC 3339 date: $this", e)
}
}
/**
* Check if the expected character exist at the given offset in the value.
*
* @param value the string to check at the specified offset
* @param offset the offset to look for the expected character
* @param expected the expected character
* @return true if the expected character exist at the given offset
*/
private fun checkOffset(value: String, offset: Int, expected: Char): Boolean {
return offset < value.length && value[offset] == expected
}
/**
* Parse an integer located between 2 given offsets in a string
*
* @param value the string to parse
* @param beginIndex the start index for the integer in the string
* @param endIndex the end index for the integer in the string
* @return the int
* @throws NumberFormatException if the value is not a number
*/
private fun parseInt(value: String, beginIndex: Int, endIndex: Int): Int {
if (beginIndex < 0 || endIndex > value.length || beginIndex > endIndex) {
throw NumberFormatException(value)
}
// use same logic as in Integer.parseInt() but less generic we're not supporting negative values
var i = beginIndex
var result = 0
var digit: Int
if (i < endIndex) {
digit = Character.digit(value[i++], 10)
if (digit < 0) {
throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex))
}
result = -digit
}
while (i < endIndex) {
digit = Character.digit(value[i++], 10)
if (digit < 0) {
throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex))
}
result *= 10
result -= digit
}
return -result
}
/**
* Zero pad a number to a specified length
*
* @param buffer buffer to use for padding
* @param value the integer value to pad if necessary.
* @param length the length of the string we should zero pad
*/
private fun padInt(buffer: StringBuilder, value: Int, length: Int) {
val strValue = value.toString()
for (i in length - strValue.length downTo 1) {
buffer.append('0')
}
buffer.append(strValue)
}
/**
* Returns the index of the first character in the string that is not a digit, starting at offset.
*/
private fun indexOfNonDigit(string: String, offset: Int): Int {
for (i in offset until string.length) {
val c = string[i]
if (c < '0' || c > '9') return i
}
return string.length
}

View File

@ -0,0 +1,49 @@
// https://github.com/google/gson/blob/master/extras/src/main/java/com/google/gson/typeadapters/UtcDateTypeAdapter.java
/*
* Copyright (C) 2011 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.keylesspalace.tusky.json
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
import java.io.IOException
import java.util.Date
class Rfc3339DateJsonAdapter : TypeAdapter<Date?>() {
@Throws(IOException::class)
override fun write(writer: JsonWriter, date: Date?) {
if (date == null) {
writer.nullValue()
} else {
writer.value(date.formatIsoDate())
}
}
@Throws(IOException::class)
override fun read(reader: JsonReader): Date? {
return when (reader.peek()) {
JsonToken.NULL -> {
reader.nextNull()
null
}
else -> {
reader.nextString().parseIsoDate()
}
}
}
}

View File

@ -1,76 +0,0 @@
/* Copyright 2018 charlag
*
* 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.network;
import androidx.annotation.NonNull;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import java.io.IOException;
import okhttp3.HttpUrl;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
/**
* Created by charlag on 31/10/17.
*/
public final class InstanceSwitchAuthInterceptor implements Interceptor {
private AccountManager accountManager;
public InstanceSwitchAuthInterceptor(AccountManager accountManager) {
this.accountManager = accountManager;
}
@Override
public Response intercept(@NonNull Chain chain) throws IOException {
Request originalRequest = chain.request();
// only switch domains if the request comes from retrofit
if (originalRequest.url().host().equals(MastodonApi.PLACEHOLDER_DOMAIN)) {
AccountEntity currentAccount = accountManager.getActiveAccount();
Request.Builder builder = originalRequest.newBuilder();
String instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER);
if (instanceHeader != null) {
// use domain explicitly specified in custom header
builder.url(swapHost(originalRequest.url(), instanceHeader));
builder.removeHeader(MastodonApi.DOMAIN_HEADER);
} else if (currentAccount != null) {
//use domain of current account
builder.url(swapHost(originalRequest.url(), currentAccount.getDomain()))
.header("Authorization",
String.format("Bearer %s", currentAccount.getAccessToken()));
}
Request newRequest = builder.build();
return chain.proceed(newRequest);
} else {
return chain.proceed(originalRequest);
}
}
@NonNull
private static HttpUrl swapHost(@NonNull HttpUrl url, @NonNull String host) {
return url.newBuilder().host(host).build();
}
}

View File

@ -0,0 +1,82 @@
/* 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.network
import android.util.Log
import com.keylesspalace.tusky.db.AccountManager
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import java.io.IOException
class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest: Request = chain.request()
// only switch domains if the request comes from retrofit
return if (originalRequest.url.host == MastodonApi.PLACEHOLDER_DOMAIN) {
val builder: Request.Builder = originalRequest.newBuilder()
val instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER)
if (instanceHeader != null) {
// use domain explicitly specified in custom header
builder.url(swapHost(originalRequest.url, instanceHeader))
builder.removeHeader(MastodonApi.DOMAIN_HEADER)
} else {
val currentAccount = accountManager.activeAccount
if (currentAccount != null) {
val accessToken = currentAccount.accessToken
if (accessToken.isNotEmpty()) {
// use domain of current account
builder.url(swapHost(originalRequest.url, currentAccount.domain))
.header("Authorization", "Bearer %s".format(accessToken))
}
}
}
val newRequest: Request = builder.build()
if (MastodonApi.PLACEHOLDER_DOMAIN == newRequest.url.host) {
Log.w("ISAInterceptor", "no user logged in or no domain header specified - can't make request to " + newRequest.url)
return Response.Builder()
.code(400)
.message("Bad Request")
.protocol(Protocol.HTTP_2)
.body("".toResponseBody("text/plain".toMediaType()))
.request(chain.request())
.build()
}
chain.proceed(newRequest)
} else {
chain.proceed(originalRequest)
}
}
companion object {
private fun swapHost(url: HttpUrl, host: String): HttpUrl {
return url.newBuilder().host(host).build()
}
}
}

View File

@ -15,6 +15,7 @@
package com.keylesspalace.tusky.network package com.keylesspalace.tusky.network
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.entity.AccessToken import com.keylesspalace.tusky.entity.AccessToken
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.entity.Announcement
@ -74,10 +75,10 @@ interface MastodonApi {
} }
@GET("/api/v1/custom_emojis") @GET("/api/v1/custom_emojis")
suspend fun getCustomEmojis(): Result<List<Emoji>> suspend fun getCustomEmojis(): NetworkResult<List<Emoji>>
@GET("api/v1/instance") @GET("api/v1/instance")
suspend fun getInstance(): Result<Instance> suspend fun getInstance(): NetworkResult<Instance>
@GET("api/v1/filters") @GET("api/v1/filters")
fun getFilters(): Single<List<Filter>> fun getFilters(): Single<List<Filter>>
@ -145,7 +146,7 @@ interface MastodonApi {
suspend fun updateMedia( suspend fun updateMedia(
@Path("mediaId") mediaId: String, @Path("mediaId") mediaId: String,
@Field("description") description: String @Field("description") description: String
): Result<Attachment> ): NetworkResult<Attachment>
@GET("api/v1/media/{mediaId}") @GET("api/v1/media/{mediaId}")
suspend fun getMedia( suspend fun getMedia(
@ -158,7 +159,7 @@ interface MastodonApi {
@Header(DOMAIN_HEADER) domain: String, @Header(DOMAIN_HEADER) domain: String,
@Header("Idempotency-Key") idempotencyKey: String, @Header("Idempotency-Key") idempotencyKey: String,
@Body status: NewStatus @Body status: NewStatus
): Result<Status> ): NetworkResult<Status>
@GET("api/v1/statuses/{id}") @GET("api/v1/statuses/{id}")
fun status( fun status(
@ -246,10 +247,13 @@ interface MastodonApi {
@DELETE("api/v1/scheduled_statuses/{id}") @DELETE("api/v1/scheduled_statuses/{id}")
suspend fun deleteScheduledStatus( suspend fun deleteScheduledStatus(
@Path("id") scheduledStatusId: String @Path("id") scheduledStatusId: String
): Result<ResponseBody> ): NetworkResult<ResponseBody>
@GET("api/v1/accounts/verify_credentials") @GET("api/v1/accounts/verify_credentials")
suspend fun accountVerifyCredentials(): Result<Account> suspend fun accountVerifyCredentials(
@Header(DOMAIN_HEADER) domain: String? = null,
@Header("Authorization") auth: String? = null,
): NetworkResult<Account>
@FormUrlEncoded @FormUrlEncoded
@PATCH("api/v1/accounts/update_credentials") @PATCH("api/v1/accounts/update_credentials")
@ -274,7 +278,7 @@ interface MastodonApi {
@Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?, @Part(value = "fields_attributes[2][value]") fieldValue2: RequestBody?,
@Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?, @Part(value = "fields_attributes[3][name]") fieldName3: RequestBody?,
@Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody? @Part(value = "fields_attributes[3][value]") fieldValue3: RequestBody?
): Result<Account> ): NetworkResult<Account>
@GET("api/v1/accounts/search") @GET("api/v1/accounts/search")
suspend fun searchAccounts( suspend fun searchAccounts(
@ -282,15 +286,15 @@ interface MastodonApi {
@Query("resolve") resolve: Boolean? = null, @Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null, @Query("limit") limit: Int? = null,
@Query("following") following: Boolean? = null @Query("following") following: Boolean? = null
): Result<List<TimelineAccount>> ): NetworkResult<List<TimelineAccount>>
@GET("api/v1/accounts/search") @GET("api/v1/accounts/search")
fun searchAccountsCall( fun searchAccountsSync(
@Query("q") query: String, @Query("q") query: String,
@Query("resolve") resolve: Boolean? = null, @Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null, @Query("limit") limit: Int? = null,
@Query("following") following: Boolean? = null @Query("following") following: Boolean? = null
): Call<List<TimelineAccount>> ): NetworkResult<List<TimelineAccount>>
@GET("api/v1/accounts/{id}") @GET("api/v1/accounts/{id}")
fun account( fun account(
@ -445,7 +449,7 @@ interface MastodonApi {
@Field("redirect_uris") redirectUris: String, @Field("redirect_uris") redirectUris: String,
@Field("scopes") scopes: String, @Field("scopes") scopes: String,
@Field("website") website: String @Field("website") website: String
): Result<AppCredentials> ): NetworkResult<AppCredentials>
@FormUrlEncoded @FormUrlEncoded
@POST("oauth/token") @POST("oauth/token")
@ -456,34 +460,42 @@ interface MastodonApi {
@Field("redirect_uri") redirectUri: String, @Field("redirect_uri") redirectUri: String,
@Field("code") code: String, @Field("code") code: String,
@Field("grant_type") grantType: String @Field("grant_type") grantType: String
): Result<AccessToken> ): NetworkResult<AccessToken>
@FormUrlEncoded
@POST("oauth/revoke")
suspend fun revokeOAuthToken(
@Field("client_id") clientId: String,
@Field("client_secret") clientSecret: String,
@Field("token") token: String
): NetworkResult<Unit>
@GET("/api/v1/lists") @GET("/api/v1/lists")
suspend fun getLists(): Result<List<MastoList>> suspend fun getLists(): NetworkResult<List<MastoList>>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/lists") @POST("api/v1/lists")
suspend fun createList( suspend fun createList(
@Field("title") title: String @Field("title") title: String
): Result<MastoList> ): NetworkResult<MastoList>
@FormUrlEncoded @FormUrlEncoded
@PUT("api/v1/lists/{listId}") @PUT("api/v1/lists/{listId}")
suspend fun updateList( suspend fun updateList(
@Path("listId") listId: String, @Path("listId") listId: String,
@Field("title") title: String @Field("title") title: String
): Result<MastoList> ): NetworkResult<MastoList>
@DELETE("api/v1/lists/{listId}") @DELETE("api/v1/lists/{listId}")
suspend fun deleteList( suspend fun deleteList(
@Path("listId") listId: String @Path("listId") listId: String
): Result<Unit> ): NetworkResult<Unit>
@GET("api/v1/lists/{listId}/accounts") @GET("api/v1/lists/{listId}/accounts")
suspend fun getAccountsInList( suspend fun getAccountsInList(
@Path("listId") listId: String, @Path("listId") listId: String,
@Query("limit") limit: Int @Query("limit") limit: Int
): Result<List<TimelineAccount>> ): NetworkResult<List<TimelineAccount>>
@FormUrlEncoded @FormUrlEncoded
// @DELETE doesn't support fields // @DELETE doesn't support fields
@ -491,20 +503,20 @@ interface MastodonApi {
suspend fun deleteAccountFromList( suspend fun deleteAccountFromList(
@Path("listId") listId: String, @Path("listId") listId: String,
@Field("account_ids[]") accountIds: List<String> @Field("account_ids[]") accountIds: List<String>
): Result<Unit> ): NetworkResult<Unit>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/lists/{listId}/accounts") @POST("api/v1/lists/{listId}/accounts")
suspend fun addAccountToList( suspend fun addAccountToList(
@Path("listId") listId: String, @Path("listId") listId: String,
@Field("account_ids[]") accountIds: List<String> @Field("account_ids[]") accountIds: List<String>
): Result<Unit> ): NetworkResult<Unit>
@GET("/api/v1/conversations") @GET("/api/v1/conversations")
suspend fun getConversations( suspend fun getConversations(
@Query("max_id") maxId: String? = null, @Query("max_id") maxId: String? = null,
@Query("limit") limit: Int @Query("limit") limit: Int? = null
): List<Conversation> ): Response<List<Conversation>>
@DELETE("/api/v1/conversations/{id}") @DELETE("/api/v1/conversations/{id}")
suspend fun deleteConversation( suspend fun deleteConversation(
@ -547,24 +559,24 @@ interface MastodonApi {
@GET("api/v1/announcements") @GET("api/v1/announcements")
suspend fun listAnnouncements( suspend fun listAnnouncements(
@Query("with_dismissed") withDismissed: Boolean = true @Query("with_dismissed") withDismissed: Boolean = true
): Result<List<Announcement>> ): NetworkResult<List<Announcement>>
@POST("api/v1/announcements/{id}/dismiss") @POST("api/v1/announcements/{id}/dismiss")
suspend fun dismissAnnouncement( suspend fun dismissAnnouncement(
@Path("id") announcementId: String @Path("id") announcementId: String
): Result<ResponseBody> ): NetworkResult<ResponseBody>
@PUT("api/v1/announcements/{id}/reactions/{name}") @PUT("api/v1/announcements/{id}/reactions/{name}")
suspend fun addAnnouncementReaction( suspend fun addAnnouncementReaction(
@Path("id") announcementId: String, @Path("id") announcementId: String,
@Path("name") name: String @Path("name") name: String
): Result<ResponseBody> ): NetworkResult<ResponseBody>
@DELETE("api/v1/announcements/{id}/reactions/{name}") @DELETE("api/v1/announcements/{id}/reactions/{name}")
suspend fun removeAnnouncementReaction( suspend fun removeAnnouncementReaction(
@Path("id") announcementId: String, @Path("id") announcementId: String,
@Path("name") name: String @Path("name") name: String
): Result<ResponseBody> ): NetworkResult<ResponseBody>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/reports") @POST("api/v1/reports")
@ -601,14 +613,14 @@ interface MastodonApi {
): Single<SearchResult> ): Single<SearchResult>
@GET("api/v2/search") @GET("api/v2/search")
fun searchCall( fun searchSync(
@Query("q") query: String?, @Query("q") query: String?,
@Query("type") type: String? = null, @Query("type") type: String? = null,
@Query("resolve") resolve: Boolean? = null, @Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null, @Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null, @Query("offset") offset: Int? = null,
@Query("following") following: Boolean? = null @Query("following") following: Boolean? = null
): Call<SearchResult> ): NetworkResult<SearchResult>
@FormUrlEncoded @FormUrlEncoded
@POST("api/v1/accounts/{id}/note") @POST("api/v1/accounts/{id}/note")
@ -629,7 +641,7 @@ interface MastodonApi {
// Should be generated dynamically from all the available notification // Should be generated dynamically from all the available notification
// types defined in [com.keylesspalace.tusky.entities.Notification.Types] // types defined in [com.keylesspalace.tusky.entities.Notification.Types]
@FieldMap data: Map<String, Boolean> @FieldMap data: Map<String, Boolean>
): Result<NotificationSubscribeResult> ): NetworkResult<NotificationSubscribeResult>
@FormUrlEncoded @FormUrlEncoded
@PUT("api/v1/push/subscription") @PUT("api/v1/push/subscription")
@ -637,11 +649,11 @@ interface MastodonApi {
@Header("Authorization") auth: String, @Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String, @Header(DOMAIN_HEADER) domain: String,
@FieldMap data: Map<String, Boolean> @FieldMap data: Map<String, Boolean>
): Result<NotificationSubscribeResult> ): NetworkResult<NotificationSubscribeResult>
@DELETE("api/v1/push/subscription") @DELETE("api/v1/push/subscription")
suspend fun unsubscribePushNotifications( suspend fun unsubscribePushNotifications(
@Header("Authorization") auth: String, @Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String, @Header(DOMAIN_HEADER) domain: String,
): Result<ResponseBody> ): NetworkResult<ResponseBody>
} }

View File

@ -1,5 +1,6 @@
package com.keylesspalace.tusky.network package com.keylesspalace.tusky.network
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.entity.MediaUploadResult import com.keylesspalace.tusky.entity.MediaUploadResult
import okhttp3.MultipartBody import okhttp3.MultipartBody
import retrofit2.http.Multipart import retrofit2.http.Multipart
@ -15,5 +16,5 @@ interface MediaUploadApi {
suspend fun uploadMedia( suspend fun uploadMedia(
@Part file: MultipartBody.Part, @Part file: MultipartBody.Part,
@Part description: MultipartBody.Part? = null @Part description: MultipartBody.Part? = null
): Result<MediaUploadResult> ): NetworkResult<MediaUploadResult>
} }

View File

@ -15,6 +15,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent

View File

@ -72,6 +72,6 @@ object PrefKeys {
const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps" const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps"
const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates" const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates"
const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies" const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default.
const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts"
} }

View File

@ -0,0 +1,66 @@
package com.keylesspalace.tusky.usecase
import android.content.Context
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotificationsForAccount
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.removeShortcut
import javax.inject.Inject
class LogoutUsecase @Inject constructor(
private val context: Context,
private val api: MastodonApi,
private val db: AppDatabase,
private val accountManager: AccountManager,
private val draftHelper: DraftHelper
) {
/**
* Logs the current account out and clears all caches associated with it
* @return true if the user is logged in with other accounts, false if it was the only one
*/
suspend fun logout(): Boolean {
accountManager.activeAccount?.let { activeAccount ->
// invalidate the oauth token, if we have the client id & secret
// (could be missing if user logged in with a previous version of Tusky)
val clientId = activeAccount.clientId
val clientSecret = activeAccount.clientSecret
if (clientId != null && clientSecret != null) {
api.revokeOAuthToken(
clientId = clientId,
clientSecret = clientSecret,
token = activeAccount.accessToken
)
}
// disable push notifications
disableUnifiedPushNotificationsForAccount(context, activeAccount)
// disable pull notifications
if (!NotificationHelper.areNotificationsEnabled(context, accountManager)) {
NotificationHelper.disablePullNotifications(context)
}
// clear notification channels
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, context)
// remove account from local AccountManager
val otherAccountAvailable = accountManager.logActiveAccountOut() != null
// clear the database - this could trigger network calls so do it last when all tokens are gone
db.timelineDao().removeAll(activeAccount.id)
db.conversationDao().deleteForAccount(activeAccount.id)
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
// remove shortcut associated with the account
removeShortcut(context, activeAccount)
return otherAccountAvailable
}
return false
}
}

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.network package com.keylesspalace.tusky.usecase
import android.util.Log import android.util.Log
import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BlockEvent
@ -29,6 +29,7 @@ import com.keylesspalace.tusky.appstore.StatusDeletedEvent
import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.addTo import io.reactivex.rxjava3.kotlin.addTo

View File

@ -1,23 +0,0 @@
package com.keylesspalace.tusky.util
import retrofit2.Call
import retrofit2.HttpException
/**
* Synchronously executes the call and returns the response encapsulated in a kotlin.Result.
* Since Result is an inline class it is not possible to do this with a Retrofit adapter unfortunately.
* More efficient then calling a suspending method with runBlocking
*/
fun <T> Call<T>.result(): Result<T> {
return try {
val response = execute()
val responseBody = response.body()
if (response.isSuccessful && responseBody != null) {
Result.success(responseBody)
} else {
Result.failure(HttpException(response))
}
} catch (e: Exception) {
Result.failure(e)
}
}

View File

@ -0,0 +1,26 @@
package com.keylesspalace.tusky.util
import org.json.JSONException
import org.json.JSONObject
import retrofit2.HttpException
/**
* checks if this throwable indicates an error causes by a 4xx/5xx server response and
* tries to retrieve the error message the server sent
* @return the error message, or null if this is no server error or it had no error message
*/
fun Throwable.getServerErrorMessage(): String? {
if (this is HttpException) {
val errorResponse = response()?.errorBody()?.string()
return if (!errorResponse.isNullOrBlank()) {
try {
JSONObject(errorResponse).getString("error")
} catch (e: JSONException) {
null
}
} else {
null
}
}
return null
}

View File

@ -47,8 +47,8 @@ sealed class StatusViewData {
get() = status.id get() = status.id
/** /**
* Specifies whether the content of this post is allowed to be collapsed or if it should show * Specifies whether the content of this post is long enough to be automatically
* all content regardless. * collapsed or if it should show all content regardless.
* *
* @return Whether the post is collapsible or never collapsed. * @return Whether the post is collapsible or never collapsed.
*/ */
@ -109,7 +109,7 @@ sealed class StatusViewData {
} }
/** Helper for Java */ /** Helper for Java */
fun copyWIthCollapsed(isCollapsed: Boolean): Concrete { fun copyWithCollapsed(isCollapsed: Boolean): Concrete {
return copy(isCollapsed = isCollapsed) return copy(isCollapsed = isCollapsed)
} }
} }

View File

@ -19,6 +19,7 @@ package com.keylesspalace.tusky.viewmodel
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either

View File

@ -21,6 +21,7 @@ import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
@ -31,6 +32,7 @@ import com.keylesspalace.tusky.util.Error
import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Loading
import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Resource
import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.getServerErrorMessage
import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.randomAlphanumericString
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
@ -38,9 +40,6 @@ import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONException
import org.json.JSONObject
import retrofit2.HttpException
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -155,21 +154,7 @@ class EditProfileViewModel @Inject constructor(
eventHub.dispatch(ProfileEditedEvent(newProfileData)) eventHub.dispatch(ProfileEditedEvent(newProfileData))
}, },
{ throwable -> { throwable ->
if (throwable is HttpException) { saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage()))
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 {
saveData.postValue(Error())
}
} }
) )
} }

View File

@ -18,6 +18,7 @@ package com.keylesspalace.tusky.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.replacedFirstWhich import com.keylesspalace.tusky.util.replacedFirstWhich

View File

@ -1,110 +0,0 @@
package net.accelf.yuito;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.material.textfield.TextInputEditText;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.Injectable;
import java.io.IOException;
import javax.inject.Inject;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
public class AccessTokenLoginActivity extends AppCompatActivity implements Injectable {
@Inject
AccountManager accountManager;
TextInputEditText domainEditText;
TextInputEditText accessTokenEditText;
TextView logTextView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_access_token_login);
domainEditText = findViewById(R.id.domainEditText);
accessTokenEditText = findViewById(R.id.accessTokenEditText);
Button authorizeButton = findViewById(R.id.authorizeButton);
logTextView = findViewById(R.id.logTextView);
authorizeButton.setOnClickListener(v -> authorize());
log("Input domain and access token to login.");
}
private void log(String text) {
runOnUiThread(() -> logTextView.setText(String.format("%s\n%s", logTextView.getText().toString(), text)));
}
private void authorize() {
if (domainEditText.getText() != null) {
String domain = domainEditText.getText().toString();
String accessToken = accessTokenEditText.getText().toString();
HttpUrl url;
log("Starting login test. [domain: " + domain + ", accessToken: " + accessToken + "]");
try {
url = new HttpUrl.Builder().host(domain).scheme("https")
.addPathSegments("/api/v1/accounts/verify_credentials")
.addQueryParameter("access_token", accessToken)
.build();
} catch (IllegalArgumentException e) {
log("Wrong domain format. " + e.getMessage());
log("Aborting.");
return;
}
log("Access start -> " + url.toString());
OkHttpClient okHttpClient = new OkHttpClient();
Request request = new Request.Builder().url(url).get().build();
okHttpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e) {
log("Login failed. " + e.getMessage());
log("Aborting.");
}
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
if (response.body() != null) {
log(response.body().string());
}
if (response.code() != 200) {
throw new IOException("Invalid response code. Response code was " + response.code());
}
log("Login successful. Moving to account registration phase.");
authSucceeded(domain, accessToken);
}
});
}
}
private void authSucceeded(String domain, String accessToken) {
accountManager.addAccount(accessToken, domain, "");
log("Completed. Enjoy!");
Intent intent = new Intent(this, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
startActivity(intent);
finish();
overridePendingTransition(R.anim.explode, R.anim.explode);
}
}

View File

@ -0,0 +1,71 @@
package net.accelf.yuito
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityAccessTokenLoginBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.viewBinding
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
class AccessTokenLoginActivity : AppCompatActivity(), Injectable {
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var mastodonApi: MastodonApi
private val binding by viewBinding(ActivityAccessTokenLoginBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.authorizeButton.setOnClickListener {
it.isEnabled = false
runBlocking { authorize() }
it.isEnabled = true
}
log("Input domain and access token to login.")
}
private fun log(text: String) {
runOnUiThread {
binding.logTextView.text = String.format("%s\n%s", binding.logTextView.text.toString(), text)
}
}
private suspend fun authorize() {
if (binding.domainEditText.text.isNullOrBlank()) {
return
}
val domain = binding.domainEditText.text.toString()
val accessToken = binding.accessTokenEditText.text.toString()
log("Starting login test. [domain: $domain, accessToken: $accessToken]")
mastodonApi.accountVerifyCredentials(domain, auth = "Bearer $accessToken")
.onSuccess { account ->
log("Login successful. Moving to account registration phase.")
accountManager.addAccount(accessToken, domain, "", "", "", account)
log("Completed. Enjoy!")
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)
}
.onFailure { e ->
log("Login failed. ${e.message}")
log("Aborting.")
}
}
}

View File

@ -4,6 +4,9 @@ import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemDrawerFooterBinding import com.keylesspalace.tusky.databinding.ItemDrawerFooterBinding
import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Instance
@ -34,10 +37,10 @@ class FooterDrawerItem : AbstractDrawerItem<FooterDrawerItem, BindingHolder<Item
override fun getViewHolder(v: View): BindingHolder<ItemDrawerFooterBinding> = throw UnsupportedOperationException() override fun getViewHolder(v: View): BindingHolder<ItemDrawerFooterBinding> = throw UnsupportedOperationException()
fun setInstance(instance: Result<Instance>) { fun setInstance(instance: NetworkResult<Instance>) {
instance instance
.onSuccess { .onSuccess {
binding.instanceData.text = String.format("%s\n%s\n%s", it.title, it.uri, it.version) binding.instanceData.text = listOf(it.title, it.uri, it.version).joinToString("\n")
} }
.onFailure { .onFailure {
binding.instanceData.text = binding.root.context.getString(R.string.instance_data_failed) binding.instanceData.text = binding.root.context.getString(R.string.instance_data_failed)

View File

@ -297,6 +297,7 @@
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" /> app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior" />
<LinearLayout <LinearLayout
android:id="@+id/composeBottomBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom" android:layout_gravity="bottom"

View File

@ -14,11 +14,13 @@
tools:context="com.keylesspalace.tusky.MainActivity"> tools:context="com.keylesspalace.tusky.MainActivity">
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/mainCoordinatorLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1"> android:layout_weight="1">
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:elevation="@dimen/actionbar_elevation" android:elevation="@dimen/actionbar_elevation"
@ -81,6 +83,13 @@
<include layout="@layout/item_status_bottom_sheet" /> <include layout="@layout/item_status_bottom_sheet" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_gravity="center" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
<net.accelf.yuito.QuickTootView <net.accelf.yuito.QuickTootView

View File

@ -332,6 +332,16 @@
app:layout_constraintTop_toBottomOf="@id/status_poll_description" app:layout_constraintTop_toBottomOf="@id/status_poll_description"
app:srcCompat="@drawable/ic_reply_24dp" /> app:srcCompat="@drawable/ic_reply_24dp" />
<TextView
android:id="@+id/status_replies"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="@id/status_reply"
app:layout_constraintStart_toEndOf="@id/status_reply"
app:layout_constraintTop_toTopOf="@id/status_reply"
android:textAlignment="viewEnd"
android:textSize="?attr/status_text_medium" />
<at.connyduck.sparkbutton.SparkButton <at.connyduck.sparkbutton.SparkButton
android:id="@+id/status_inset" android:id="@+id/status_inset"
android:layout_width="30dp" android:layout_width="30dp"

View File

@ -198,7 +198,7 @@
<string name="pref_default_post_privacy">Výchozí soukromí příspěvků</string> <string name="pref_default_post_privacy">Výchozí soukromí příspěvků</string>
<string name="pref_default_media_sensitivity">Vždy označovat média jako citlivá</string> <string name="pref_default_media_sensitivity">Vždy označovat média jako citlivá</string>
<string name="pref_publishing">Publikování (synchronizováno se serverem)</string> <string name="pref_publishing">Publikování (synchronizováno se serverem)</string>
<string name="pref_failed_to_sync">Nepodařilo se synchronizovsat nastavení</string> <string name="pref_failed_to_sync">Nepodařilo se synchronizovat nastavení</string>
<string name="post_privacy_public">Veřejné</string> <string name="post_privacy_public">Veřejné</string>
<string name="post_privacy_unlisted">Neuvedené</string> <string name="post_privacy_unlisted">Neuvedené</string>
<string name="post_privacy_followers_only">Pouze pro sledující</string> <string name="post_privacy_followers_only">Pouze pro sledující</string>
@ -483,4 +483,12 @@
<string name="pref_title_confirm_reblogs">Zobrazit dialogové okno s potvrzením při boostování</string> <string name="pref_title_confirm_reblogs">Zobrazit dialogové okno s potvrzením při boostování</string>
<string name="notification_subscription_format">%s právě vydal</string> <string name="notification_subscription_format">%s právě vydal</string>
<string name="title_announcements">Oznámení</string> <string name="title_announcements">Oznámení</string>
<string name="title_login">Přihlášení</string>
<string name="notification_sign_up_format">%s se zaregistroval</string>
<string name="title_migration_relogin">Přihlaste se znovu pro oznámení</string>
<string name="error_could_not_load_login_page">Nepodařilo se načíst stránku přihlášení.</string>
<string name="drafts_post_failed_to_send">Tento příspěvek se nepodařilo poslat!</string>
<string name="error_loading_account_details">Nepodařilo se načíst detaily účtu</string>
<string name="drafts_failed_loading_reply">Nepodařilo se načíst informace o odpovědi</string>
<string name="error_image_edit_failed">Obrázek se nepodařilo upravit.</string>
</resources> </resources>

View File

@ -536,4 +536,9 @@
<string name="title_login">Anmelden</string> <string name="title_login">Anmelden</string>
<string name="error_could_not_load_login_page">Die Anmeldeseite konnte nicht geladen werden.</string> <string name="error_could_not_load_login_page">Die Anmeldeseite konnte nicht geladen werden.</string>
<string name="notification_update_name">Beitragsbearbeitungen</string> <string name="notification_update_name">Beitragsbearbeitungen</string>
<string name="title_migration_relogin">Neuanmeldung für Push-Benachrichtigungen</string>
<string name="action_dismiss">Ablehnen</string>
<string name="dialog_push_notification_migration_other_accounts">Du hast dich erneut in dein aktuelles Konto eingeloggt, um Tusky die Genehmigung für Push-Abonnements zu erteilen. Du hast jedoch noch andere Konten, die nicht auf diese Weise migriert wurden. Wechsel zu diesen Konten und melde dich nacheinander neu an, um die Unterstützung für UnifiedPush-Benachrichtigungen zu aktivieren.</string>
<string name="dialog_push_notification_migration">Um Push-Benachrichtigungen über UnifiedPush verwenden zu können, benötigt Tusky die Erlaubnis, Benachrichtigungen auf Ihrem Mastodon-Server zu abonnieren. Dies erfordert eine erneute Anmeldung, um die Tusky gewährten OAuth-Bereiche zu ändern. Wenn du die Option zum erneuten Einloggen hier oder in den Kontoeinstellungen verwendest, bleiben alle deine lokalen Entwürfe und der Cache erhalten.</string>
<string name="tips_push_notification_migration">Melde alle Konten neu an, um die Unterstützung für Push-Benachrichtigungen zu aktivieren.</string>
</resources> </resources>

View File

@ -8,22 +8,22 @@
<string name="error_authorization_unknown">خطای احراز هویت ناشناخته‌ای رخ داد.</string> <string name="error_authorization_unknown">خطای احراز هویت ناشناخته‌ای رخ داد.</string>
<string name="error_authorization_denied">احراز هویت رد شد.</string> <string name="error_authorization_denied">احراز هویت رد شد.</string>
<string name="error_retrieving_oauth_token">دریافت ژتون ورود شکست خورد.</string> <string name="error_retrieving_oauth_token">دریافت ژتون ورود شکست خورد.</string>
<string name="error_compose_character_limit">وضعیت خیلی طولانی است!</string> <string name="error_compose_character_limit">فرسته خیلی طولانی است!</string>
<string name="error_image_upload_size">پرونده باید کمتر از ۸ مگابایت باشد.</string> <string name="error_image_upload_size">پرونده باید کمتر از ۸ مگابایت باشد.</string>
<string name="error_video_upload_size">پرونده ویدئویی باید کمتر از ۴۰ مگابایت باشد.</string> <string name="error_video_upload_size">پرونده ویدئویی باید کمتر از ۴۰ مگابایت باشد.</string>
<string name="error_media_upload_type">این گونهٔ پرونده نمی‌تواند بارگذاری شود.</string> <string name="error_media_upload_type">این گونهٔ پرونده نمی‌تواند بارگذاری شود.</string>
<string name="error_media_upload_opening">این پرونده نتوانست گشوده شود.</string> <string name="error_media_upload_opening">این پرونده نتوانست گشوده شود.</string>
<string name="error_media_upload_permission">نیاز به اجازهٔ خواندن رسانه است.</string> <string name="error_media_upload_permission">نیاز به اجازهٔ خواندن رسانه است.</string>
<string name="error_media_download_permission">نیاز به اجازهٔ ذخیرهٔ رسانه است.</string> <string name="error_media_download_permission">نیاز به اجازهٔ ذخیرهٔ رسانه است.</string>
<string name="error_media_upload_image_or_video">تصاویر و فیلم‌ها هر دو نمی‌توانند به یک وضعیت ضمیمه شوند.</string> <string name="error_media_upload_image_or_video">تصاویر و فیلم‌ها نمی‌توانند به یک فرسته پیوست شوند.</string>
<string name="error_media_upload_sending">بارگذاری شکست خورد.</string> <string name="error_media_upload_sending">بارگذاری شکست خورد.</string>
<string name="error_sender_account_gone">خطای فرستادن بوق.</string> <string name="error_sender_account_gone">خطای فرستادن فرسته.</string>
<string name="title_home">خانه</string> <string name="title_home">خانه</string>
<string name="title_notifications">آگاهی‌ها</string> <string name="title_notifications">آگاهی‌ها</string>
<string name="title_public_local">محلّی</string> <string name="title_public_local">محلّی</string>
<string name="title_public_federated">همگانی</string> <string name="title_public_federated">همگانی</string>
<string name="title_view_thread">بوق</string> <string name="title_view_thread">رشته</string>
<string name="title_posts">فرسته</string> <string name="title_posts">فرستهها</string>
<string name="title_posts_with_replies">با پاسخ‌</string> <string name="title_posts_with_replies">با پاسخ‌</string>
<string name="title_follows">دنبال شونده</string> <string name="title_follows">دنبال شونده</string>
<string name="title_followers">پی‌گیر</string> <string name="title_followers">پی‌گیر</string>
@ -41,10 +41,10 @@
<string name="post_content_warning_show_more">نمایش بیش‌تر</string> <string name="post_content_warning_show_more">نمایش بیش‌تر</string>
<string name="post_content_warning_show_less">نمایش کم‌تر</string> <string name="post_content_warning_show_less">نمایش کم‌تر</string>
<string name="post_content_show_more">گسترش</string> <string name="post_content_show_more">گسترش</string>
<string name="post_content_show_less">بستن</string> <string name="post_content_show_less">جمع کردن</string>
<string name="footer_empty">این‌جا هیچ‌چیز نیست. برای تازه‌سازی، به پایین بکشید!</string> <string name="footer_empty">این‌جا هیچ‌چیز نیست. برای تازه‌سازی، به پایین بکشید!</string>
<string name="notification_reblog_format">%s بوقتان را تقویت کرد</string> <string name="notification_reblog_format">%s فرسته‌تان را تقویت کرد</string>
<string name="notification_favourite_format">%s بوقتان را برگزید</string> <string name="notification_favourite_format">%s فرسته‌تان را برگزید</string>
<string name="notification_follow_format">%s پی‌گیرتان شد</string> <string name="notification_follow_format">%s پی‌گیرتان شد</string>
<string name="report_username_format">گزارش @%s</string> <string name="report_username_format">گزارش @%s</string>
<string name="report_comment_hint">نظرهای اضافی؟</string> <string name="report_comment_hint">نظرهای اضافی؟</string>
@ -93,13 +93,13 @@
<string name="action_reject">رد</string> <string name="action_reject">رد</string>
<string name="action_search">جست‌وجو</string> <string name="action_search">جست‌وجو</string>
<string name="action_access_drafts">پیش‌نویس‌ها</string> <string name="action_access_drafts">پیش‌نویس‌ها</string>
<string name="action_toggle_visibility">نمایانی بوق</string> <string name="action_toggle_visibility">نمایانی فرسته</string>
<string name="action_content_warning">هشدار محتوا</string> <string name="action_content_warning">هشدار محتوا</string>
<string name="action_emoji_keyboard">صفحه‌کلید اموجی</string> <string name="action_emoji_keyboard">صفحه‌کلید اموجی</string>
<string name="download_image">درحال بارگیری %1$s</string> <string name="download_image">درحال بارگیری %1$s</string>
<string name="action_copy_link">رونوشت از پیوند</string> <string name="action_copy_link">رونوشت از پیوند</string>
<string name="send_post_link_to">هم‌رسانی نشانی بوق با…</string> <string name="send_post_link_to">هم‌رسانی نشانی فرسته با…</string>
<string name="send_post_content_to">هم‌رسانی بوق با…</string> <string name="send_post_content_to">هم‌رسانی فرسته با…</string>
<string name="send_media_to">هم‌رسانی رسانه با…</string> <string name="send_media_to">هم‌رسانی رسانه با…</string>
<string name="confirmation_reported">فرستاده شد!</string> <string name="confirmation_reported">فرستاده شد!</string>
<string name="confirmation_unblocked">کاربرنامسدود شد</string> <string name="confirmation_unblocked">کاربرنامسدود شد</string>
@ -130,7 +130,7 @@
<string name="dialog_download_image">بارگیری</string> <string name="dialog_download_image">بارگیری</string>
<string name="dialog_message_cancel_follow_request">درخواست دنبال کردن را لغو می‌کنید؟</string> <string name="dialog_message_cancel_follow_request">درخواست دنبال کردن را لغو می‌کنید؟</string>
<string name="dialog_unfollow_warning">ناپیگیری این حساب؟</string> <string name="dialog_unfollow_warning">ناپیگیری این حساب؟</string>
<string name="dialog_delete_post_warning">حذف این بوق؟</string> <string name="dialog_delete_post_warning">حذف این فرسته؟</string>
<string name="visibility_public">عمومی: فرستادن به خط زمانی‌های عمومی</string> <string name="visibility_public">عمومی: فرستادن به خط زمانی‌های عمومی</string>
<string name="visibility_unlisted">فهرست‌نشده: نشان ندادن در خط زمانی‌های عمومی</string> <string name="visibility_unlisted">فهرست‌نشده: نشان ندادن در خط زمانی‌های عمومی</string>
<string name="visibility_private">تنها دنبال‌کنندگان:پست فقط به دنبال‌کنندگان</string> <string name="visibility_private">تنها دنبال‌کنندگان:پست فقط به دنبال‌کنندگان</string>
@ -156,7 +156,7 @@
<string name="pref_title_browser_settings">مرورگر</string> <string name="pref_title_browser_settings">مرورگر</string>
<string name="pref_title_custom_tabs">استفاده از زبانه‌های سفارشی کروم</string> <string name="pref_title_custom_tabs">استفاده از زبانه‌های سفارشی کروم</string>
<string name="pref_title_hide_follow_button">نهفتن دکمهٔ ایجاد، هنگام پیمایش</string> <string name="pref_title_hide_follow_button">نهفتن دکمهٔ ایجاد، هنگام پیمایش</string>
<string name="pref_title_post_filter">فیلتر کردن خط زمانی</string> <string name="pref_title_post_filter">پالایش خط زمانی</string>
<string name="pref_title_post_tabs">زبانه‌ها</string> <string name="pref_title_post_tabs">زبانه‌ها</string>
<string name="pref_title_show_boosts">نمایش تقویت‌ها</string> <string name="pref_title_show_boosts">نمایش تقویت‌ها</string>
<string name="pref_title_show_replies">نمایش پاسخ‌ها</string> <string name="pref_title_show_replies">نمایش پاسخ‌ها</string>
@ -173,10 +173,10 @@
<string name="post_privacy_public">عمومی</string> <string name="post_privacy_public">عمومی</string>
<string name="post_privacy_unlisted">فهرست‌نشده</string> <string name="post_privacy_unlisted">فهرست‌نشده</string>
<string name="post_privacy_followers_only">فقط پی‌گیران</string> <string name="post_privacy_followers_only">فقط پی‌گیران</string>
<string name="pref_post_text_size">اندازهٔ متن وضعیت</string> <string name="pref_post_text_size">اندازهٔ متن فرسته</string>
<string name="post_text_size_smallest">کوچک‌ترین</string> <string name="post_text_size_smallest">کوچک‌ترین</string>
<string name="post_text_size_small">کوچک</string> <string name="post_text_size_small">کوچک</string>
<string name="post_text_size_medium">متوسط</string> <string name="post_text_size_medium">میانه</string>
<string name="post_text_size_large">بزرگ</string> <string name="post_text_size_large">بزرگ</string>
<string name="post_text_size_largest">بزرگ‌ترین</string> <string name="post_text_size_largest">بزرگ‌ترین</string>
<string name="notification_mention_name">اشاره‌های جدید</string> <string name="notification_mention_name">اشاره‌های جدید</string>
@ -184,9 +184,9 @@
<string name="notification_follow_name">پی‌گیران جدید</string> <string name="notification_follow_name">پی‌گیران جدید</string>
<string name="notification_follow_description">آگاهی‌ها دربارهٔ پی‌گیران جدید</string> <string name="notification_follow_description">آگاهی‌ها دربارهٔ پی‌گیران جدید</string>
<string name="notification_boost_name">تقویت‌ها</string> <string name="notification_boost_name">تقویت‌ها</string>
<string name="notification_boost_description">آگاهی‌ها هنگام تقویت شدن بوق‌هایتان</string> <string name="notification_boost_description">آگاهی‌ها هنگام تقویت فرسته‌هایتان</string>
<string name="notification_favourite_name">برگزیدن‌ها</string> <string name="notification_favourite_name">برگزیدن‌ها</string>
<string name="notification_favourite_description">آگاهی‌ها هنگام برگزیده شدن بوق‌هایتان</string> <string name="notification_favourite_description">آگاهی‌ها هنگام برگزیده شدن فرسته‌هایتان</string>
<string name="notification_mention_format">%s به شما اشاره کرد</string> <string name="notification_mention_format">%s به شما اشاره کرد</string>
<string name="notification_summary_large">%1$s، %2$s، %3$s و %4$d دیگر</string> <string name="notification_summary_large">%1$s، %2$s، %3$s و %4$d دیگر</string>
<string name="notification_summary_medium">%1$s، %2$s و %3$s</string> <string name="notification_summary_medium">%1$s، %2$s و %3$s</string>
@ -209,8 +209,8 @@
<string name="about_bug_feature_request_site">گزارش مشکلات و درخواست ویژگی‌ها: <string name="about_bug_feature_request_site">گزارش مشکلات و درخواست ویژگی‌ها:
\n https://github.com/accelforce/Yuito/issues</string> \n https://github.com/accelforce/Yuito/issues</string>
<string name="about_tusky_account">نمایهٔ تاسکی</string> <string name="about_tusky_account">نمایهٔ تاسکی</string>
<string name="post_share_content">هم‌رسانی محتوای بوق</string> <string name="post_share_content">هم‌رسانی محتوای فرسته</string>
<string name="post_share_link">هم‌رسانی پیوند بوق</string> <string name="post_share_link">هم‌رسانی پیوند فرسته</string>
<string name="post_media_images">تصویرها</string> <string name="post_media_images">تصویرها</string>
<string name="post_media_video">ویدیو</string> <string name="post_media_video">ویدیو</string>
<string name="state_follow_requested">تقاضای پیگیری شد</string> <string name="state_follow_requested">تقاضای پیگیری شد</string>
@ -240,19 +240,19 @@
<string name="lock_account_label">قفل حساب</string> <string name="lock_account_label">قفل حساب</string>
<string name="lock_account_label_description">لازم است پی‌گیران را دستی تأیید کنید</string> <string name="lock_account_label_description">لازم است پی‌گیران را دستی تأیید کنید</string>
<string name="compose_save_draft">ذخیرهٔ پیش‌نویس؟</string> <string name="compose_save_draft">ذخیرهٔ پیش‌نویس؟</string>
<string name="send_post_notification_title">در حال فرستادن بوق</string> <string name="send_post_notification_title">فرستادن فرسته</string>
<string name="send_post_notification_error_title">خطای فرستادن بوق</string> <string name="send_post_notification_error_title">خطا در فرستادن فرسته</string>
<string name="send_post_notification_channel_name">در حال فرستادن بوقها</string> <string name="send_post_notification_channel_name">فرستادن فرستهها</string>
<string name="send_post_notification_cancel_title">فرستادن لغو شد</string> <string name="send_post_notification_cancel_title">فرستادن لغو شد</string>
<string name="send_post_notification_saved_content">رونوشتی از بوق در پیش‌نویس‌هایتان ذخیره شد</string> <string name="send_post_notification_saved_content">رونوشتی از فرسته در پیش‌نویس‌هایتان ذخیره شد</string>
<string name="action_compose_shortcut">ایجاد</string> <string name="action_compose_shortcut">ایجاد</string>
<string name="error_no_custom_emojis">نمونه‌تان %s هیچ اموجی سفارشی‌ای ندارد</string> <string name="error_no_custom_emojis">نمونه‌تان %s هیچ اموجی سفارشی‌ای ندارد</string>
<string name="emoji_style">سبک اموجی</string> <string name="emoji_style">سبک اموجی</string>
<string name="system_default">پیش‌گزیدهٔ سامانه</string> <string name="system_default">پیش‌گزیدهٔ سامانه</string>
<string name="download_fonts">نخست باید این مجموعه‌های اموجی را بارگیری کنید</string> <string name="download_fonts">نخست باید این مجموعه‌های اموجی را بارگیری کنید</string>
<string name="performing_lookup_title">در حال جست‌وجو…</string> <string name="performing_lookup_title">در حال جست‌وجو…</string>
<string name="expand_collapse_all_posts">گسترده/جمع کردن تمام وضعیتها</string> <string name="expand_collapse_all_posts">گسترش/جمع کردن تمام فرستهها</string>
<string name="action_open_post">گشودن بوق</string> <string name="action_open_post">گشودن فرسته</string>
<string name="restart_required">نیاز به آغاز دوبارهٔ کاره</string> <string name="restart_required">نیاز به آغاز دوبارهٔ کاره</string>
<string name="restart_emoji">برای اعمال این تغییرات، نیاز به شروع دوبارهٔ تاسکی دارید</string> <string name="restart_emoji">برای اعمال این تغییرات، نیاز به شروع دوبارهٔ تاسکی دارید</string>
<string name="later">بعداً</string> <string name="later">بعداً</string>
@ -278,7 +278,7 @@
<string name="error_network">یک خطای شبکه رخ داد! لطفا اتصال خود را بررسی و دوباره تلاش کنید!</string> <string name="error_network">یک خطای شبکه رخ داد! لطفا اتصال خود را بررسی و دوباره تلاش کنید!</string>
<string name="title_direct_messages">پیام‌های مستقیم</string> <string name="title_direct_messages">پیام‌های مستقیم</string>
<string name="title_tab_preferences">زبانه‌ها</string> <string name="title_tab_preferences">زبانه‌ها</string>
<string name="title_posts_pinned">سنجاقشده</string> <string name="title_posts_pinned">سنجاق شده</string>
<string name="title_domain_mutes">دامنه‌های نهفته</string> <string name="title_domain_mutes">دامنه‌های نهفته</string>
<string name="post_username_format">\@%s</string> <string name="post_username_format">\@%s</string>
<string name="message_empty">این‌جا هیچ‌چیزی نیست.</string> <string name="message_empty">این‌جا هیچ‌چیزی نیست.</string>
@ -304,7 +304,7 @@
<string name="download_media">بارگیری رسانه</string> <string name="download_media">بارگیری رسانه</string>
<string name="downloading_media">در حال بارگیری رسانه</string> <string name="downloading_media">در حال بارگیری رسانه</string>
<string name="confirmation_domain_unmuted">%s نانهفته</string> <string name="confirmation_domain_unmuted">%s نانهفته</string>
<string name="dialog_redraft_post_warning">می‌خواهید این بوق را پاک و بازنویسی کنید؟</string> <string name="dialog_redraft_post_warning">حذف و بازنویسی این فرسته؟</string>
<string name="mute_domain_warning_dialog_ok">نهفتن تمام دامنه</string> <string name="mute_domain_warning_dialog_ok">نهفتن تمام دامنه</string>
<string name="pref_title_notification_filter_poll">پایان نظرسنجی‌ها</string> <string name="pref_title_notification_filter_poll">پایان نظرسنجی‌ها</string>
<string name="pref_title_timeline_filters">پالایه‌ها</string> <string name="pref_title_timeline_filters">پالایه‌ها</string>
@ -320,7 +320,7 @@
<string name="abbreviated_hours_ago">%d ساعت</string> <string name="abbreviated_hours_ago">%d ساعت</string>
<string name="abbreviated_minutes_ago">%d دقیقه</string> <string name="abbreviated_minutes_ago">%d دقیقه</string>
<string name="abbreviated_seconds_ago">%d ثانیه</string> <string name="abbreviated_seconds_ago">%d ثانیه</string>
<string name="pref_title_alway_open_spoiler">گسترش همیشگی بوق‌های علامت‌خورده با هشدار محتوا</string> <string name="pref_title_alway_open_spoiler">گسترش همیشگی فرسته‌های علامت‌خورده با هشدار محتوا</string>
<string name="pref_title_public_filter_keywords">خط زمانی‌های عمومی</string> <string name="pref_title_public_filter_keywords">خط زمانی‌های عمومی</string>
<string name="pref_title_thread_filter_keywords">گفت‌وگوها</string> <string name="pref_title_thread_filter_keywords">گفت‌وگوها</string>
<string name="filter_addition_dialog_title">افزودن پالایه</string> <string name="filter_addition_dialog_title">افزودن پالایه</string>
@ -360,8 +360,8 @@
</plurals> </plurals>
<string name="description_post_media">رسانه: %s</string> <string name="description_post_media">رسانه: %s</string>
<string name="description_post_cw">هشدار محتوا: %s</string> <string name="description_post_cw">هشدار محتوا: %s</string>
<string name="description_post_media_no_description_placeholder">بدون هیچ توضیحی</string> <string name="description_post_media_no_description_placeholder">بدون شرح</string>
<string name="description_post_reblogged">بازبوقیده</string> <string name="description_post_reblogged">تقویت شده</string>
<string name="description_post_favourited">برگزیده</string> <string name="description_post_favourited">برگزیده</string>
<string name="description_visiblity_public">عمومی</string> <string name="description_visiblity_public">عمومی</string>
<string name="description_visiblity_unlisted">فهرست‌نشده</string> <string name="description_visiblity_unlisted">فهرست‌نشده</string>
@ -373,7 +373,7 @@
<string name="notifications_clear">پاک‌سازی</string> <string name="notifications_clear">پاک‌سازی</string>
<string name="notifications_apply_filter">پالایش</string> <string name="notifications_apply_filter">پالایش</string>
<string name="filter_apply">اعمال</string> <string name="filter_apply">اعمال</string>
<string name="compose_shortcut_long_label">ایجاد بوق</string> <string name="compose_shortcut_long_label">ایجاد فرسته</string>
<string name="compose_shortcut_short_label">ایجاد</string> <string name="compose_shortcut_short_label">ایجاد</string>
<string name="notification_clear_text">مطمئنید می‌خواهید تمام آگاهی‌هایتان را برای همیشه پاک کنید؟</string> <string name="notification_clear_text">مطمئنید می‌خواهید تمام آگاهی‌هایتان را برای همیشه پاک کنید؟</string>
<string name="poll_info_time_absolute">پایان در %s</string> <string name="poll_info_time_absolute">پایان در %s</string>
@ -400,7 +400,7 @@
<string name="hint_additional_info">نظرهای اضافی</string> <string name="hint_additional_info">نظرهای اضافی</string>
<string name="report_remote_instance">هدایت به %s</string> <string name="report_remote_instance">هدایت به %s</string>
<string name="failed_report">شکست در گزارش</string> <string name="failed_report">شکست در گزارش</string>
<string name="failed_fetch_posts">شکست در واکشی وضعیتها</string> <string name="failed_fetch_posts">شکست در واکشی فرستهها</string>
<string name="title_accounts">حساب‌ها</string> <string name="title_accounts">حساب‌ها</string>
<string name="failed_search">شکست در جست‌وجو</string> <string name="failed_search">شکست در جست‌وجو</string>
<string name="pref_title_show_notifications_filter">نمایش پالایهٔ آگاهی‌ها</string> <string name="pref_title_show_notifications_filter">نمایش پالایهٔ آگاهی‌ها</string>
@ -416,10 +416,10 @@
<string name="poll_allow_multiple_choices">گزینه‌های چندگانه</string> <string name="poll_allow_multiple_choices">گزینه‌های چندگانه</string>
<string name="poll_new_choice_hint">گزینهٔ %d</string> <string name="poll_new_choice_hint">گزینهٔ %d</string>
<string name="edit_poll">ویرایش</string> <string name="edit_poll">ویرایش</string>
<string name="title_scheduled_posts">بوق‌های زمان‌بسته</string> <string name="title_scheduled_posts">فرسته‌های زمان‌بسته</string>
<string name="action_edit">ویرایش</string> <string name="action_edit">ویرایش</string>
<string name="action_access_scheduled_posts">بوق‌های زمان‌بسته</string> <string name="action_access_scheduled_posts">فرسته‌های زمان‌بسته</string>
<string name="action_schedule_post">بوق زمان‌بسته</string> <string name="action_schedule_post">فرستهٔ زمان‌بسته</string>
<string name="action_reset_schedule">بازنشانی</string> <string name="action_reset_schedule">بازنشانی</string>
<string name="mute_domain_warning">مطمئنید می‌خواهید تمام %s را مسدود کنید؟ محتوای آن دامنه را در هیچ‌یک از خط زمانی‌ها یا در آگاهی‌هایتان نخواهید دید. پی‌گیرانتان از آن دامنه، برداشته خواهند شد.</string> <string name="mute_domain_warning">مطمئنید می‌خواهید تمام %s را مسدود کنید؟ محتوای آن دامنه را در هیچ‌یک از خط زمانی‌ها یا در آگاهی‌هایتان نخواهید دید. پی‌گیرانتان از آن دامنه، برداشته خواهند شد.</string>
<string name="filter_dialog_whole_word_description">هنگامی که کلیدواژه یا عبارت، فقط حروف‌عددی باشد، فقط اگر با تمام واژه مطابق باشد، اعمال خواهد شد</string> <string name="filter_dialog_whole_word_description">هنگامی که کلیدواژه یا عبارت، فقط حروف‌عددی باشد، فقط اگر با تمام واژه مطابق باشد، اعمال خواهد شد</string>
@ -437,7 +437,7 @@
<string name="select_list_title">گزینش فهرست</string> <string name="select_list_title">گزینش فهرست</string>
<string name="list">فهرست</string> <string name="list">فهرست</string>
<string name="no_drafts">هیچ پیش‌نویسی ندارید.</string> <string name="no_drafts">هیچ پیش‌نویسی ندارید.</string>
<string name="no_scheduled_posts">هیچ وضعیت زمان‌بسته‌ای ندارید.</string> <string name="no_scheduled_posts">هیچ فرستهٔ زمان‌بسته‌ای ندارید.</string>
<string name="warning_scheduling_interval">ماستودون، بازهٔ زمان‌بندی‌ای با کمینهٔ ۵ دقیقه دارد.</string> <string name="warning_scheduling_interval">ماستودون، بازهٔ زمان‌بندی‌ای با کمینهٔ ۵ دقیقه دارد.</string>
<string name="pref_title_confirm_reblogs">نمایش گفت‌وگوی تأیید، پیش از تقویت</string> <string name="pref_title_confirm_reblogs">نمایش گفت‌وگوی تأیید، پیش از تقویت</string>
<string name="pref_title_show_cards_in_timelines">پیش‌نمایش پیوندها در خط‌زمانی‌ها</string> <string name="pref_title_show_cards_in_timelines">پیش‌نمایش پیوندها در خط‌زمانی‌ها</string>
@ -482,7 +482,7 @@
<string name="action_unsubscribe_account">عدم اشتراک</string> <string name="action_unsubscribe_account">عدم اشتراک</string>
<string name="action_subscribe_account">اشتراک</string> <string name="action_subscribe_account">اشتراک</string>
<string name="draft_deleted">پیش‌نویس حذف شد</string> <string name="draft_deleted">پیش‌نویس حذف شد</string>
<string name="drafts_post_failed_to_send">فرستادن این بوق شکست خورد!</string> <string name="drafts_post_failed_to_send">فرستادن این فرسته شکست خورد!</string>
<string name="wellbeing_hide_stats_profile">نهفتن آمار کمی روی نمایه‌ها</string> <string name="wellbeing_hide_stats_profile">نهفتن آمار کمی روی نمایه‌ها</string>
<string name="wellbeing_hide_stats_posts">نهفتن آمار کمی روی فرسته‌ها</string> <string name="wellbeing_hide_stats_posts">نهفتن آمار کمی روی فرسته‌ها</string>
<string name="limit_notifications">محدود کردن آگاهی‌های خط‌زمانی</string> <string name="limit_notifications">محدود کردن آگاهی‌های خط‌زمانی</string>
@ -491,17 +491,17 @@
<string name="label_duration">طول</string> <string name="label_duration">طول</string>
<string name="post_media_attachments">پیوست‌ها</string> <string name="post_media_attachments">پیوست‌ها</string>
<string name="post_media_audio">صدا</string> <string name="post_media_audio">صدا</string>
<string name="notification_subscription_description">آگاهی‌ها هنگام انتشار بوقی جدید از کسی که مشترکش هستید</string> <string name="notification_subscription_description">آگاهی‌ها هنگام انتشار فرسته‌ای جدید از کسی که پی‌می‌گیرید</string>
<string name="notification_subscription_name">بوق‌های جدید</string> <string name="notification_subscription_name">فرسته‌های جدید</string>
<string name="pref_title_animate_custom_emojis">اموجی‌های شخصی متحرّک</string> <string name="pref_title_animate_custom_emojis">اموجی‌های شخصی متحرّک</string>
<string name="pref_title_notification_filter_subscriptions">کسی که مشترکش شده‌ام، بوقی جدید منتشر کرد</string> <string name="pref_title_notification_filter_subscriptions">کسی که پی‌می‌گیرم، فرسته‌ای جدید منتشر کرد</string>
<string name="notification_subscription_format">%s چیزی فرستاد</string> <string name="notification_subscription_format">%s چیزی فرستاد</string>
<string name="drafts_post_reply_removed">بوقی که پاسخی به آن را پیش‌نویس کردید، برداشته شده</string> <string name="drafts_post_reply_removed">فرسته‌ای که پاسخی به آن را پیش‌نویس کردید، برداشته شده</string>
<string name="drafts_failed_loading_reply">شکست در بار کردن اطّلاعات پاسخ</string> <string name="drafts_failed_loading_reply">شکست در بار کردن اطّلاعات پاسخ</string>
<string name="wellbeing_mode_notice">برخی اطّلاعات که ممکن است روی سلامتی ذهنیتان تأثیر بگذارد، پنهان خواهند شد. همچون: <string name="wellbeing_mode_notice">برخی اطّلاعات که ممکن است روی سلامتی ذهنیتان تأثیر بگذارد، پنهان خواهند شد. همچون:
\n \n
\n - آگاهی‌های برگزیدن، تقویت و پی‌گیری \n - آگاهی‌های برگزیدن، تقویت و پی‌گیری
\n - شمار برگزیدن و تقویت بوقها \n - شمار برگزیدن و تقویت فرستهها
\n - آمار پی‌گیر و فرسته روی نمایه‌ها \n - آمار پی‌گیر و فرسته روی نمایه‌ها
\n \n
\n فرستادن آگاهی‌ها تأثیر نمی‌پذیرد، ولی می‌توانید ترجیحات آگاهیتان را به صورت دستی بازبینی کنید.</string> \n فرستادن آگاهی‌ها تأثیر نمی‌پذیرد، ولی می‌توانید ترجیحات آگاهیتان را به صورت دستی بازبینی کنید.</string>
@ -514,4 +514,34 @@
<string name="follow_requests_info">با این که حسابتان قفل نیست، کارکنان %1$s فکر کردند ممکن است بخواهید درخواست‌های پی‌گیری از این حساب‌ها را دستی بازبینی کنید.</string> <string name="follow_requests_info">با این که حسابتان قفل نیست، کارکنان %1$s فکر کردند ممکن است بخواهید درخواست‌های پی‌گیری از این حساب‌ها را دستی بازبینی کنید.</string>
<string name="dialog_delete_conversation_warning">حذف این گفت‌وگو؟</string> <string name="dialog_delete_conversation_warning">حذف این گفت‌وگو؟</string>
<string name="action_delete_conversation">حذف گفت‌وگو</string> <string name="action_delete_conversation">حذف گفت‌وگو</string>
<string name="account_date_joined">در %1$s پیوست</string>
<string name="title_login">ورود</string>
<string name="notification_sign_up_format">%s ثبت‌نام کرد</string>
<string name="pref_title_confirm_favourites">نمایش گفت‌وگوی تأیید پیش از برگزیدن</string>
<string name="tusky_compose_post_quicksetting_label">ایجاد فرسته</string>
<string name="tips_push_notification_migration">ورود دوباره به تمامی حساب‌ها برای به کار انداختن پشتیبانی آگاهی‌های ارسالی.</string>
<string name="notification_update_description">آگاهی‌ها هنگام ویرایش فرسته‌هایی که با آن‌ها تعامل داشته‌اید</string>
<string name="action_unbookmark">برداشن نشانک</string>
<string name="dialog_push_notification_migration_other_accounts">برای اعطای اجازهٔ اشتراک آگاهی‌های ارسالی ، دوباره به حسابتان وارد شدید. با این حال هنوز حساب‌هایی دیگر دارید که این‌گونه مهاجرت داده نشده‌اند. به آن‌ها رفته و برای به کار انداختن پشتیبانی آگاهی‌های UnifiedPush یکی‌یکی دوباره وارد شوید.</string>
<string name="action_logout_confirm">مطمئنید که می‌خواهید از حساب %1$s خارج شوید؟</string>
<string name="duration_14_days">۱۴ روز</string>
<string name="duration_30_days">۳۰ روز</string>
<string name="duration_60_days">۶۰ روز</string>
<string name="duration_90_days">۹۰ روز</string>
<string name="duration_365_days">۳۶۵ روز</string>
<string name="duration_180_days">۱۸۰ روز</string>
<string name="status_count_one_plus">۱+</string>
<string name="dialog_push_notification_migration">تاسکی برای استفاده از آگاهی‌های ارسالی با UnifiedPush نیاز به اجازهٔ اشتراک آگاهی‌ها روی کارساز ماستودنتان دارد. این کار نیازمند ورود دوباره برای تغییر حوزه‌های OAuth اعطایی به تاسکی است. استفاده از گزینهٔ ورود دوباره در این‌جا یا در ترجیحات حساب، تمامی انباره‌ها و پیش‌نویس‌های محلیتان را نگه خواهد داشت.</string>
<string name="error_could_not_load_login_page">نتوانست صفحهٔ ورود را بار کند.</string>
<string name="pref_title_notification_filter_sign_ups">کسی ثبت‌نام کرد</string>
<string name="notification_update_name">ویرایش‌های فرسته</string>
<string name="action_edit_image">ویرایش تصویر</string>
<string name="notification_update_format">%s فرسته‌اش را ویراست</string>
<string name="pref_title_notification_filter_updates">فرسته‌ای که با آن تعامل داشته‌ام ویرایش شده</string>
<string name="notification_sign_up_name">ثبت‌نام‌ها</string>
<string name="notification_sign_up_description">آگاهی‌ها دربارهٔ کاربران جدید</string>
<string name="title_migration_relogin">ورود دوباره برای آگاهی‌های ارسالی</string>
<string name="action_dismiss">رد کردن</string>
<string name="action_details">جزییات</string>
<string name="saving_draft">ذخیرهٔ پیش‌نویس…</string>
</resources> </resources>

View File

@ -27,7 +27,7 @@
<string name="title_tab_preferences">Onglets</string> <string name="title_tab_preferences">Onglets</string>
<string name="title_view_thread">Fil</string> <string name="title_view_thread">Fil</string>
<string name="title_posts">Messages</string> <string name="title_posts">Messages</string>
<string name="title_posts_with_replies">Pouets &amp; réponses</string> <string name="title_posts_with_replies">Avec réponses</string>
<string name="title_posts_pinned">Épinglés</string> <string name="title_posts_pinned">Épinglés</string>
<string name="title_follows">Abonnements</string> <string name="title_follows">Abonnements</string>
<string name="title_followers">Abonné·e·s</string> <string name="title_followers">Abonné·e·s</string>
@ -42,7 +42,7 @@
<string name="post_boosted_format">%s a partagé</string> <string name="post_boosted_format">%s a partagé</string>
<string name="post_sensitive_media_title">Contenu sensible</string> <string name="post_sensitive_media_title">Contenu sensible</string>
<string name="post_media_hidden_title">Média caché</string> <string name="post_media_hidden_title">Média caché</string>
<string name="post_sensitive_media_directions">Cliquer pour voir</string> <string name="post_sensitive_media_directions">Appuyer pour voir</string>
<string name="post_content_warning_show_more">Voir plus</string> <string name="post_content_warning_show_more">Voir plus</string>
<string name="post_content_warning_show_less">Voir moins</string> <string name="post_content_warning_show_less">Voir moins</string>
<string name="post_content_show_more">Déplier</string> <string name="post_content_show_more">Déplier</string>
@ -156,7 +156,7 @@
<string name="dialog_download_image">Télécharger</string> <string name="dialog_download_image">Télécharger</string>
<string name="dialog_message_cancel_follow_request">Révoquer la demande dabonnement ?</string> <string name="dialog_message_cancel_follow_request">Révoquer la demande dabonnement ?</string>
<string name="dialog_unfollow_warning">Ne plus suivre ce compte ?</string> <string name="dialog_unfollow_warning">Ne plus suivre ce compte ?</string>
<string name="dialog_delete_post_warning">Supprimer ce pouet ?</string> <string name="dialog_delete_post_warning">Supprimer ce message \?</string>
<string name="visibility_public">Public : afficher dans les fils publics</string> <string name="visibility_public">Public : afficher dans les fils publics</string>
<string name="visibility_unlisted">Non listé : ne pas afficher dans les fils publics</string> <string name="visibility_unlisted">Non listé : ne pas afficher dans les fils publics</string>
<string name="visibility_private">Abonné·e·s uniquement : seul·e·s vos abonné·e·s verront vos statuts</string> <string name="visibility_private">Abonné·e·s uniquement : seul·e·s vos abonné·e·s verront vos statuts</string>
@ -202,7 +202,7 @@
<string name="post_privacy_public">Public</string> <string name="post_privacy_public">Public</string>
<string name="post_privacy_unlisted">Non listé</string> <string name="post_privacy_unlisted">Non listé</string>
<string name="post_privacy_followers_only">Abonné·e·s uniquement</string> <string name="post_privacy_followers_only">Abonné·e·s uniquement</string>
<string name="pref_post_text_size">Taille du texte pour les statuts</string> <string name="pref_post_text_size">Taille du texte des messages</string>
<string name="post_text_size_smallest">Plus petit</string> <string name="post_text_size_smallest">Plus petit</string>
<string name="post_text_size_small">Petit</string> <string name="post_text_size_small">Petit</string>
<string name="post_text_size_medium">Moyen</string> <string name="post_text_size_medium">Moyen</string>
@ -243,17 +243,17 @@
https://github.com/accelforce/Yuito/issues https://github.com/accelforce/Yuito/issues
</string> </string>
<string name="about_tusky_account">Profil de Yuito</string> <string name="about_tusky_account">Profil de Yuito</string>
<string name="post_share_content">Partager le contenu du pouet</string> <string name="post_share_content">Partager le contenu du message</string>
<string name="post_share_link">Partager le lien du pouet</string> <string name="post_share_link">Partager le lien du message</string>
<string name="post_media_images">Images</string> <string name="post_media_images">Images</string>
<string name="post_media_video">Vidéo</string> <string name="post_media_video">Vidéo</string>
<string name="state_follow_requested">Demande dabonnement effectuée</string> <string name="state_follow_requested">Demande dabonnement effectuée</string>
<!--These are for timestamps on statuses. For example: "16s" or "2d"--> <!--These are for timestamps on statuses. For example: "16s" or "2d"-->
<string name="abbreviated_in_years">en %da</string> <string name="abbreviated_in_years">dans %da</string>
<string name="abbreviated_in_days">en %dj</string> <string name="abbreviated_in_days">dans %dj</string>
<string name="abbreviated_in_hours">en %dh</string> <string name="abbreviated_in_hours">dans %dh</string>
<string name="abbreviated_in_minutes">en %dm</string> <string name="abbreviated_in_minutes">dans %dm</string>
<string name="abbreviated_in_seconds">en %ds</string> <string name="abbreviated_in_seconds">dans %ds</string>
<string name="abbreviated_years_ago">%da</string> <string name="abbreviated_years_ago">%da</string>
<string name="abbreviated_days_ago">%dj</string> <string name="abbreviated_days_ago">%dj</string>
<string name="abbreviated_hours_ago">%dh</string> <string name="abbreviated_hours_ago">%dh</string>
@ -264,7 +264,7 @@
<string name="title_media">Média</string> <string name="title_media">Média</string>
<string name="replying_to">Réponse à @%s</string> <string name="replying_to">Réponse à @%s</string>
<string name="load_more_placeholder_text">en charger plus</string> <string name="load_more_placeholder_text">en charger plus</string>
<string name="pref_title_public_filter_keywords">Timelines publiques</string> <string name="pref_title_public_filter_keywords">Fils publics</string>
<string name="pref_title_thread_filter_keywords">Conversations</string> <string name="pref_title_thread_filter_keywords">Conversations</string>
<string name="filter_addition_dialog_title">Ajouter un filtre</string> <string name="filter_addition_dialog_title">Ajouter un filtre</string>
<string name="filter_edit_dialog_title">Modifier un filtre</string> <string name="filter_edit_dialog_title">Modifier un filtre</string>
@ -296,19 +296,19 @@
<string name="lock_account_label">Verrouiller le compte</string> <string name="lock_account_label">Verrouiller le compte</string>
<string name="lock_account_label_description">Vous devez approuver manuellement les abonnements</string> <string name="lock_account_label_description">Vous devez approuver manuellement les abonnements</string>
<string name="compose_save_draft">Enregistrer comme brouillon ?</string> <string name="compose_save_draft">Enregistrer comme brouillon ?</string>
<string name="send_post_notification_title">Envoi du pouet</string> <string name="send_post_notification_title">Envoi du message</string>
<string name="send_post_notification_error_title">Erreur lors de lenvoi du pouet</string> <string name="send_post_notification_error_title">Erreur lors de lenvoi du message</string>
<string name="send_post_notification_channel_name">Envoi des pouets</string> <string name="send_post_notification_channel_name">Envoi des messages</string>
<string name="send_post_notification_cancel_title">Envoi annulé</string> <string name="send_post_notification_cancel_title">Envoi annulé</string>
<string name="send_post_notification_saved_content">Une copie du pouet a été sauvegardée dans vos brouillons</string> <string name="send_post_notification_saved_content">Une copie du message a été sauvegardée dans vos brouillons</string>
<string name="action_compose_shortcut">Écrire</string> <string name="action_compose_shortcut">Écrire</string>
<string name="error_no_custom_emojis">Votre instance %s na pas démojis personnalisés</string> <string name="error_no_custom_emojis">Votre instance %s na pas démojis personnalisés</string>
<string name="emoji_style">Style démojis</string> <string name="emoji_style">Style démojis</string>
<string name="system_default">Par défaut du système</string> <string name="system_default">Par défaut du système</string>
<string name="download_fonts">Vous devez commencer par télécharger ces jeux démojis</string> <string name="download_fonts">Vous devez commencer par télécharger ces jeux démojis</string>
<string name="performing_lookup_title">Recherche en cours…</string> <string name="performing_lookup_title">Recherche en cours…</string>
<string name="expand_collapse_all_posts">Déplier/replier tout les statuts</string> <string name="expand_collapse_all_posts">Déplier/replier tout les messages</string>
<string name="action_open_post">Ouvrir le pouet</string> <string name="action_open_post">Ouvrir le message</string>
<string name="restart_required">Un redémarrage de lapplication est nécessaire</string> <string name="restart_required">Un redémarrage de lapplication est nécessaire</string>
<string name="restart_emoji">Vous devrez redémarrer Yuito pour appliquer ces modifications</string> <string name="restart_emoji">Vous devrez redémarrer Yuito pour appliquer ces modifications</string>
<string name="later">Plus tard</string> <string name="later">Plus tard</string>
@ -350,16 +350,12 @@
<item quantity="one">maximum de %1$d onglet atteint</item> <item quantity="one">maximum de %1$d onglet atteint</item>
<item quantity="other">maximum de %1$d onglets atteint</item> <item quantity="other">maximum de %1$d onglets atteint</item>
</plurals> </plurals>
<string name="description_post_media"> Média : %s <string name="description_post_media">Média : %s</string>
</string>
<string name="description_post_cw"> Avertissement : %s <string name="description_post_cw"> Avertissement : %s
</string> </string>
<string name="description_post_media_no_description_placeholder"> Pas de description <string name="description_post_media_no_description_placeholder">Aucune description</string>
</string> <string name="description_post_reblogged">Partagé</string>
<string name="description_post_reblogged"> Reblogué <string name="description_post_favourited">Mis en favoris</string>
</string>
<string name="description_post_favourited"> Mis en favoris
</string>
<string name="description_visiblity_public"> Public <string name="description_visiblity_public"> Public
</string> </string>
<string name="description_visiblity_unlisted">Non listé</string> <string name="description_visiblity_unlisted">Non listé</string>
@ -377,7 +373,7 @@
<string name="pref_title_bot_overlay">Afficher l\'indicateur de robots</string> <string name="pref_title_bot_overlay">Afficher l\'indicateur de robots</string>
<string name="notification_clear_text">Désirez-vous nettoyer toutes vos notifications de façon permanente \?</string> <string name="notification_clear_text">Désirez-vous nettoyer toutes vos notifications de façon permanente \?</string>
<string name="action_delete_and_redraft">Effacer et ré-écrire</string> <string name="action_delete_and_redraft">Effacer et ré-écrire</string>
<string name="dialog_redraft_post_warning">Effacer et ré-écrire ce pouet\?</string> <string name="dialog_redraft_post_warning">Effacer et ré-écrire ce message\?</string>
<string name="poll_info_time_absolute">Termina à %s</string> <string name="poll_info_time_absolute">Termina à %s</string>
<string name="poll_info_closed">Terminé</string> <string name="poll_info_closed">Terminé</string>
<string name="poll_vote">Voter</string> <string name="poll_vote">Voter</string>
@ -390,15 +386,15 @@
<item quantity="other">%d jours restants</item> <item quantity="other">%d jours restants</item>
</plurals> </plurals>
<plurals name="poll_timespan_hours"> <plurals name="poll_timespan_hours">
<item quantity="one">%d heure restant</item> <item quantity="one">%d heure restante</item>
<item quantity="other">%d heures restantes</item> <item quantity="other">%d heures restantes</item>
</plurals> </plurals>
<plurals name="poll_timespan_minutes"> <plurals name="poll_timespan_minutes">
<item quantity="one">%d minute restant</item> <item quantity="one">%d minute restante</item>
<item quantity="other">%d minutes restantes</item> <item quantity="other">%d minutes restantes</item>
</plurals> </plurals>
<plurals name="poll_timespan_seconds"> <plurals name="poll_timespan_seconds">
<item quantity="one">%d seconde restant</item> <item quantity="one">%d seconde restante</item>
<item quantity="other">%d secondes restantes</item> <item quantity="other">%d secondes restantes</item>
</plurals> </plurals>
<string name="pref_title_animate_gif_avatars">Activer lanimation des avatars</string> <string name="pref_title_animate_gif_avatars">Activer lanimation des avatars</string>
@ -418,7 +414,7 @@
<string name="hint_additional_info">Commentaires additionnels</string> <string name="hint_additional_info">Commentaires additionnels</string>
<string name="report_remote_instance">Transférer à %s</string> <string name="report_remote_instance">Transférer à %s</string>
<string name="failed_report">Échec du signalement</string> <string name="failed_report">Échec du signalement</string>
<string name="failed_fetch_posts">Échec de récupération des statuts</string> <string name="failed_fetch_posts">Échec de récupération des messages</string>
<string name="report_description_1">Le rapport sera envoyé aux modérateur·rice·s de votre instance. Vous pouvez expliquer pourquoi vous signalez le compte ci-dessous :</string> <string name="report_description_1">Le rapport sera envoyé aux modérateur·rice·s de votre instance. Vous pouvez expliquer pourquoi vous signalez le compte ci-dessous :</string>
<string name="mute_domain_warning">Êtes-vous sûr⋅e de vouloir bloquer %s en entier \? Vous ne verrez plus de contenu provenant de ce domaine, ni dans les fils publics, ni dans vos notifications. Vos abonné·e·s utilisant ce domaine seront retiré·e·s.</string> <string name="mute_domain_warning">Êtes-vous sûr⋅e de vouloir bloquer %s en entier \? Vous ne verrez plus de contenu provenant de ce domaine, ni dans les fils publics, ni dans vos notifications. Vos abonné·e·s utilisant ce domaine seront retiré·e·s.</string>
<string name="button_done">Terminé</string> <string name="button_done">Terminé</string>
@ -444,8 +440,8 @@
<string name="edit_poll">Éditer</string> <string name="edit_poll">Éditer</string>
<string name="title_scheduled_posts">Pouets planifiés</string> <string name="title_scheduled_posts">Pouets planifiés</string>
<string name="action_edit">Éditer</string> <string name="action_edit">Éditer</string>
<string name="action_access_scheduled_posts">Pouets programmés</string> <string name="action_access_scheduled_posts">Messages programmés</string>
<string name="action_schedule_post">Planifier le pouet</string> <string name="action_schedule_post">Planifier le message</string>
<string name="action_reset_schedule">Réinitialiser</string> <string name="action_reset_schedule">Réinitialiser</string>
<string name="post_lookup_error_format">Erreur lors de la recherche du post %s</string> <string name="post_lookup_error_format">Erreur lors de la recherche du post %s</string>
<string name="about_powered_by_tusky">Propulsé par Tusky</string> <string name="about_powered_by_tusky">Propulsé par Tusky</string>
@ -457,7 +453,7 @@
<string name="list">Liste</string> <string name="list">Liste</string>
<string name="error_audio_upload_size">Les fichiers audio doivent avoir moins de 40 Mo.</string> <string name="error_audio_upload_size">Les fichiers audio doivent avoir moins de 40 Mo.</string>
<string name="no_drafts">Vous navez aucun brouillon.</string> <string name="no_drafts">Vous navez aucun brouillon.</string>
<string name="no_scheduled_posts">Vous navez aucun pouet planifié.</string> <string name="no_scheduled_posts">Vous navez aucun message planifié.</string>
<string name="warning_scheduling_interval">Lintervalle minimum de planification sur Mastodon est de 5 minutes.</string> <string name="warning_scheduling_interval">Lintervalle minimum de planification sur Mastodon est de 5 minutes.</string>
<string name="notification_follow_request_name">Demandes d\'abonnement</string> <string name="notification_follow_request_name">Demandes d\'abonnement</string>
<string name="dialog_block_warning">Bloquer @%s \?</string> <string name="dialog_block_warning">Bloquer @%s \?</string>
@ -528,7 +524,7 @@
<string name="post_media_audio">Audio</string> <string name="post_media_audio">Audio</string>
<string name="pref_title_confirm_favourites">Demander confirmation avant de mettre en favoris</string> <string name="pref_title_confirm_favourites">Demander confirmation avant de mettre en favoris</string>
<string name="drafts_post_reply_removed">Le message auquel répondait ce brouillon a été supprimé</string> <string name="drafts_post_reply_removed">Le message auquel répondait ce brouillon a été supprimé</string>
<string name="drafts_post_failed_to_send">Échec denvoi du pouet !</string> <string name="drafts_post_failed_to_send">Échec denvoi du message !</string>
<string name="follow_requests_info">Bien que votre compte ne soit pas verrouillé, léquipe de %1$s a pensé que vous voudriez valider manuellement les demandes de dabonnement provenant de ces comptes.</string> <string name="follow_requests_info">Bien que votre compte ne soit pas verrouillé, léquipe de %1$s a pensé que vous voudriez valider manuellement les demandes de dabonnement provenant de ces comptes.</string>
<string name="drafts_failed_loading_reply">Échec du chargement des informations de réponse</string> <string name="drafts_failed_loading_reply">Échec du chargement des informations de réponse</string>
<string name="duration_30_days">30 jours</string> <string name="duration_30_days">30 jours</string>
@ -540,11 +536,23 @@
<string name="tusky_compose_post_quicksetting_label">Rédiger un message</string> <string name="tusky_compose_post_quicksetting_label">Rédiger un message</string>
<string name="notification_sign_up_format">%s a créé un compte</string> <string name="notification_sign_up_format">%s a créé un compte</string>
<string name="notification_sign_up_name">Nouveaux comptes</string> <string name="notification_sign_up_name">Nouveaux comptes</string>
<string name="notification_sign_up_description">Notifications quand quelqu\'un crée un nouveau compte</string> <string name="notification_sign_up_description">Notifications quand quelquun crée un nouveau compte</string>
<string name="pref_title_notification_filter_sign_ups">un nouveau compte a été créé</string> <string name="pref_title_notification_filter_sign_ups">un nouveau compte a été créé</string>
<string name="notification_update_format">%s a modifié son message</string> <string name="notification_update_format">%s a modifié son message</string>
<string name="pref_title_notification_filter_updates">un message avec lequel j\'ai interagi est modifié</string> <string name="pref_title_notification_filter_updates">un message avec lequel jai interagi est modifié</string>
<string name="notification_update_name">Messages modifiés</string> <string name="notification_update_name">Messages modifiés</string>
<string name="notification_update_description">Notifications quand un post avec lequel vous avez interagi est modifié</string> <string name="notification_update_description">Notifications quand un message avec lequel vous avez interagi est modifié</string>
<string name="title_login">Se connecter</string> <string name="title_login">Se connecter</string>
<string name="account_date_joined">Ici depuis %1$s</string>
<string name="action_details">Détails</string>
<string name="saving_draft">Sauvegarde du brouillon …</string>
<string name="status_count_one_plus">&gt;1</string>
<string name="dialog_push_notification_migration_other_accounts">Tusky peut maintenant recevoir les notifications instantanées de ce compte. Cependant, d\'autres de vos comptes n\'ont pas encore accès aux notifications instantanées. Basculez sur chacun de vos comptes et reconnectez les afin de recevoir les notifications avec UnifiedPush.</string>
<string name="error_could_not_load_login_page">La page de connexion ne peut être chargée.</string>
<string name="action_edit_image">Retoucher limage</string>
<string name="action_dismiss">Fermer</string>
<string name="title_migration_relogin">Se reconnecter pour recevoir les notifications instantanées</string>
<string name="dialog_push_notification_migration">Afin de recevoir les notifications via UnifiedPush, Tusky doit demander à votre serveur Mastodon la permission de sinscrire aux notifications. Ceci nécessite une reconnexion de vos comptes afin de changer les droits OAuth accordés a Tusky. En utilisant loption de reconnexion ici ou dans les préférences de compte, vos brouillons et le cache seront préservés.</string>
<string name="tips_push_notification_migration">Reconnectez tous vos comptes pour activer les notifications instantanées.</string>
<string name="error_image_edit_failed">L\'image na pas pu être retouchée.</string>
</resources> </resources>

View File

@ -556,4 +556,11 @@
<string name="title_login">Clàraich a-steach</string> <string name="title_login">Clàraich a-steach</string>
<string name="error_could_not_load_login_page">Cha b urrainn dhuinn duilleag a chlàraidh a-steach fhosgladh.</string> <string name="error_could_not_load_login_page">Cha b urrainn dhuinn duilleag a chlàraidh a-steach fhosgladh.</string>
<string name="saving_draft">A sàbhaladh na dreuchd…</string> <string name="saving_draft">A sàbhaladh na dreuchd…</string>
<string name="action_dismiss">Leig seachad</string>
<string name="action_details">Fiosrachadh</string>
<string name="account_date_joined">Air ballrachd fhaighinn %1$s</string>
<string name="tips_push_notification_migration">Clàraich a-steach às ùr leis a h-uile cunntas a chur na taice ri brathan putaidh an comas.</string>
<string name="title_migration_relogin">Clàraich a-steach às ùr airson brathan putaidh</string>
<string name="status_count_one_plus">1+</string>
<string name="action_edit_image">Deasaich an dealbh</string>
</resources> </resources>

View File

@ -52,8 +52,8 @@
<string name="action_block">Bloquear</string> <string name="action_block">Bloquear</string>
<string name="action_unfollow">Deixar de seguir</string> <string name="action_unfollow">Deixar de seguir</string>
<string name="action_follow">Seguir</string> <string name="action_follow">Seguir</string>
<string name="action_logout_confirm">Tes a certeza de que queres desconectar a conta %1$s\?</string> <string name="action_logout_confirm">Tes a certeza de que queres pechar sesión da conta %1$s\?</string>
<string name="action_logout">Desconectar</string> <string name="action_logout">Pechar sesión</string>
<string name="action_login">Accede con Mastodon</string> <string name="action_login">Accede con Mastodon</string>
<string name="action_compose">Redactar</string> <string name="action_compose">Redactar</string>
<string name="action_more">Máis</string> <string name="action_more">Máis</string>
@ -521,4 +521,20 @@
<string name="pref_title_notification_filter_sign_ups">hai unha nova usuaria</string> <string name="pref_title_notification_filter_sign_ups">hai unha nova usuaria</string>
<string name="notification_sign_up_name">Rexistros</string> <string name="notification_sign_up_name">Rexistros</string>
<string name="notification_sign_up_description">Notificacións sobre novas usuarias</string> <string name="notification_sign_up_description">Notificacións sobre novas usuarias</string>
<string name="pref_title_notification_filter_updates">Foi editada unha publicación coa que interactuei</string>
<string name="notification_update_name">Edicións da publicación</string>
<string name="account_date_joined">Creada %1$s</string>
<string name="tips_push_notification_migration">Volve a acceder con tódalas contas para activar as notificacións push.</string>
<string name="title_login">Acceder</string>
<string name="notification_update_description">Notificacións cando son editadas publicacións coas que interactuaches</string>
<string name="dialog_push_notification_migration">Para poder usar as notificacións push vía UnifiedPush, Tusky require o permiso para subscribirse ás notificacións do teu servidor Mastodon. É necesario volver a acceder para cambiar os ámbitos OAuth concedidos a Tusky. Usando aquí ou nas Preferencias da Conta a opción de volver a acceder conservarás os borradores locais e caché.</string>
<string name="dialog_push_notification_migration_other_accounts">Volveches a acceder para obter as notificacións push en Tusky. Aínda así tes algunha outra conta que non foi migrada a este modo. Cambia a esas contas e volve a conectar unha a unha para activar o soporte para notificacións de UnifiedPush.</string>
<string name="title_migration_relogin">Volve a acceder para ter notificacións push</string>
<string name="notification_update_format">%s editou a publicación</string>
<string name="action_dismiss">Desbotar</string>
<string name="action_details">Detalles</string>
<string name="error_could_not_load_login_page">Non se puido cargar a páxina de inicio.</string>
<string name="saving_draft">Gardando borrador…</string>
<string name="status_count_one_plus">1+</string>
<string name="action_edit_image">Editar imaxe</string>
</resources> </resources>

View File

@ -533,4 +533,18 @@
<string name="notification_update_name">Bejegyzések szerkesztése</string> <string name="notification_update_name">Bejegyzések szerkesztése</string>
<string name="notification_update_description">Értesítések olyan bejegyzések szerkesztéséről, melyekkel már dolgod volt</string> <string name="notification_update_description">Értesítések olyan bejegyzések szerkesztéséről, melyekkel már dolgod volt</string>
<string name="tusky_compose_post_quicksetting_label">Bejegyzés Létrehozása</string> <string name="tusky_compose_post_quicksetting_label">Bejegyzés Létrehozása</string>
<string name="title_migration_relogin">Bejelentkezés újra a leküldési értesítések érdekében</string>
<string name="action_dismiss">Elvetés</string>
<string name="action_details">Részletek</string>
<string name="account_date_joined">Csatlakozva %1$s</string>
<string name="tips_push_notification_migration">Bejelentkezés újra minden fiókkal a leküldéses értesítések engedélyezése érdekében.</string>
<string name="title_login">Bejelentkezés</string>
<string name="status_count_one_plus">1+</string>
<string name="error_could_not_load_login_page">Nem tudtuk betölteni a bejelentkező oldalt.</string>
<string name="saving_draft">Vázlat mentése…</string>
<string name="dialog_push_notification_migration">Ahhoz, hogy használhass leküldési értesítéseket a UnifiedPush szolgáltatással, a Tusky-nak fel kell iratkoznia az értesítésekre a Mastodon szervereden. Ehhez új bejelentkezésre van szükség, hogy a Tusky számára kiosztott OAuth jogosultságok megváltozzanak. Az újbóli bejelentkezés funkció használata itt vagy a Fiókbeállításoknál meg fogja őrizni a helyi piszkozataidat és a cache tartalmát.</string>
<string name="dialog_push_notification_migration_other_accounts">Újra bejelentkeztél a fiókodba, hogy feliratkoztasd a Tusky-t a leküldési értesítések használatára. Ugyanakkor vannak még fiókjaid, melyek még nem lettek így migrálva. Válts át rájuk és jelentkezz be újra mindegyikben, hogy ezekben is engedélyezd a UnifiedPush értesítések támogatását.</string>
<string name="action_edit_image">Kép szerkesztése</string>
<string name="error_image_edit_failed">A kép nem szerkeszthető.</string>
<string name="error_loading_account_details">Nem sikerült betölteni a fiókadatokat</string>
</resources> </resources>

View File

@ -525,4 +525,16 @@
<string name="notification_sign_up_description">Tilkynningar um nýja notendur</string> <string name="notification_sign_up_description">Tilkynningar um nýja notendur</string>
<string name="notification_update_name">Breytingar á færslum</string> <string name="notification_update_name">Breytingar á færslum</string>
<string name="notification_update_description">Tilkynningar þegar færslum sem þú hefur átt við er breytt</string> <string name="notification_update_description">Tilkynningar þegar færslum sem þú hefur átt við er breytt</string>
<string name="dialog_push_notification_migration_other_accounts">Þú hefur skráð þig aftur inn í fyrirliggjandi aðganginn þinn til þess að veita heimild fyrir áskrift að ýti-tilkynningum í Tusky. Aftur á móti ertu með aðra aðganga sem ekki hafa verið yfirfærðir á þennan hátt. Skiptu yfir í þá og skráðu þig þar inn aftur til að virkja stuðning við tilkynningar í gegnum UnifiedPush.</string>
<string name="account_date_joined">Skráði sig %1$s</string>
<string name="tips_push_notification_migration">Skrá aftur inn alla aðganga til að virkja stuðning við ýti-tilkynningar.</string>
<string name="dialog_push_notification_migration">Til þess að geta sent ýti-tilkynningar í gegnum UnifiedPush, þarf Tusky heimild til að gerast áskrifandi að tilkynningum á Mastodon-netþjóninum þínum. Þetta krefst þess að skráð sé inn aftur til að breyta vægi OAuth-heimilda sem Tusky er úthlutað. Notaðu endurinnskráninguna hérna eða í kjörstillingum aðgangsins þíns til að varðveita öll drögin þín og skyndiminni á tækinu.</string>
<string name="title_login">Skrá inn</string>
<string name="status_count_one_plus">1+</string>
<string name="error_could_not_load_login_page">Gat ekki lesið innskráningarsíðuna.</string>
<string name="action_edit_image">Breyta mynd</string>
<string name="saving_draft">Vista drög…</string>
<string name="title_migration_relogin">Skráðu aftur inn fyrir ýti-tilkynningar</string>
<string name="action_dismiss">Hunsa</string>
<string name="action_details">Nánar</string>
</resources> </resources>

View File

@ -30,7 +30,7 @@
<string name="title_posts_with_replies">Con risposte</string> <string name="title_posts_with_replies">Con risposte</string>
<string name="title_posts_pinned">Fissati</string> <string name="title_posts_pinned">Fissati</string>
<string name="title_follows">Seguiti</string> <string name="title_follows">Seguiti</string>
<string name="title_followers">Seguono</string> <string name="title_followers">Seguaci</string>
<string name="title_favourites">Preferiti</string> <string name="title_favourites">Preferiti</string>
<string name="title_mutes">Utenti silenziati</string> <string name="title_mutes">Utenti silenziati</string>
<string name="title_blocks">Utenti bloccati</string> <string name="title_blocks">Utenti bloccati</string>
@ -39,7 +39,7 @@
<string name="title_drafts">Bozze</string> <string name="title_drafts">Bozze</string>
<string name="title_licenses">Licenze</string> <string name="title_licenses">Licenze</string>
<string name="post_username_format">\@%s</string> <string name="post_username_format">\@%s</string>
<string name="post_boosted_format">%s ha boostato</string> <string name="post_boosted_format">%s ha condiviso</string>
<string name="post_sensitive_media_title">Contenuto sensibile</string> <string name="post_sensitive_media_title">Contenuto sensibile</string>
<string name="post_media_hidden_title">Media nascosto</string> <string name="post_media_hidden_title">Media nascosto</string>
<string name="post_sensitive_media_directions">Clicca per visualizzare</string> <string name="post_sensitive_media_directions">Clicca per visualizzare</string>
@ -49,15 +49,15 @@
<string name="post_content_show_less">Riduci</string> <string name="post_content_show_less">Riduci</string>
<string name="message_empty">Qui non c\'è nulla.</string> <string name="message_empty">Qui non c\'è nulla.</string>
<string name="footer_empty">Qui non c\'è nulla. Trascina verso il basso per aggiornare!</string> <string name="footer_empty">Qui non c\'è nulla. Trascina verso il basso per aggiornare!</string>
<string name="notification_reblog_format">%s ha boostato il tuo post</string> <string name="notification_reblog_format">%s ha condiviso il tuo post</string>
<string name="notification_favourite_format">%s ha messo il tuo post nei preferiti</string> <string name="notification_favourite_format">%s ha messo il tuo post nei preferiti</string>
<string name="notification_follow_format">%s ti ha seguito</string> <string name="notification_follow_format">%s ti ha seguito</string>
<string name="report_username_format">Segnala @%s</string> <string name="report_username_format">Segnala @%s</string>
<string name="report_comment_hint">Commenti aggiuntivi?</string> <string name="report_comment_hint">Commenti aggiuntivi?</string>
<string name="action_quick_reply">Risposta veloce</string> <string name="action_quick_reply">Risposta veloce</string>
<string name="action_reply">Rispondi</string> <string name="action_reply">Rispondi</string>
<string name="action_reblog">Boosta</string> <string name="action_reblog">Condividi</string>
<string name="action_unreblog">Rimuovi boost</string> <string name="action_unreblog">Rimuovi condivisione</string>
<string name="action_favourite">Aggiungi ai preferiti</string> <string name="action_favourite">Aggiungi ai preferiti</string>
<string name="action_unfavourite">Rimuovi preferito</string> <string name="action_unfavourite">Rimuovi preferito</string>
<string name="action_more">Di più</string> <string name="action_more">Di più</string>
@ -69,8 +69,8 @@
<string name="action_unfollow">Smetti di seguire</string> <string name="action_unfollow">Smetti di seguire</string>
<string name="action_block">Blocca</string> <string name="action_block">Blocca</string>
<string name="action_unblock">Sblocca</string> <string name="action_unblock">Sblocca</string>
<string name="action_hide_reblogs">Nascondi boost</string> <string name="action_hide_reblogs">Nascondi condivisioni</string>
<string name="action_show_reblogs">Mostra boost</string> <string name="action_show_reblogs">Mostra condivisioni</string>
<string name="action_report">Segnala</string> <string name="action_report">Segnala</string>
<string name="action_delete">Elimina</string> <string name="action_delete">Elimina</string>
<string name="action_send">TOOT</string> <string name="action_send">TOOT</string>
@ -109,8 +109,8 @@
<string name="action_links">Collegamenti</string> <string name="action_links">Collegamenti</string>
<string name="action_mentions">Menzioni</string> <string name="action_mentions">Menzioni</string>
<string name="action_hashtags">Hashtag</string> <string name="action_hashtags">Hashtag</string>
<string name="action_open_reblogger">Vai all\'autore del boost</string> <string name="action_open_reblogger">Vai all\'autore della condivisione</string>
<string name="action_open_reblogged_by">Mostra boost</string> <string name="action_open_reblogged_by">Mostra condivisioni</string>
<string name="action_open_faved_by">Mostra preferiti</string> <string name="action_open_faved_by">Mostra preferiti</string>
<string name="title_hashtags_dialog">Hashtag</string> <string name="title_hashtags_dialog">Hashtag</string>
<string name="title_mentions_dialog">Menzioni</string> <string name="title_mentions_dialog">Menzioni</string>
@ -153,8 +153,8 @@
<string name="dialog_message_cancel_follow_request">Revocare la richiesta di seguire?</string> <string name="dialog_message_cancel_follow_request">Revocare la richiesta di seguire?</string>
<string name="dialog_unfollow_warning">Smettere di seguire questo account?</string> <string name="dialog_unfollow_warning">Smettere di seguire questo account?</string>
<string name="dialog_delete_post_warning">Eliminare questo post\?</string> <string name="dialog_delete_post_warning">Eliminare questo post\?</string>
<string name="visibility_public">Pubblico: visibile sulla timeline pubblica</string> <string name="visibility_public">Pubblico: visibile sulle timeline pubbliche</string>
<string name="visibility_unlisted">Non in elenco: non visibile sulla timeline pubblica e locale</string> <string name="visibility_unlisted">Non in elenco: non visibile sulle timeline pubbliche</string>
<string name="visibility_private">Solo follower: visibile solo dai tuoi follower</string> <string name="visibility_private">Solo follower: visibile solo dai tuoi follower</string>
<string name="visibility_direct">Diretto: visibile solo agli utenti menzionati</string> <string name="visibility_direct">Diretto: visibile solo agli utenti menzionati</string>
<string name="pref_title_edit_notification_settings">Notifiche</string> <string name="pref_title_edit_notification_settings">Notifiche</string>
@ -166,7 +166,7 @@
<string name="pref_title_notification_filters">Notificami quando</string> <string name="pref_title_notification_filters">Notificami quando</string>
<string name="pref_title_notification_filter_mentions">vengo menzionato</string> <string name="pref_title_notification_filter_mentions">vengo menzionato</string>
<string name="pref_title_notification_filter_follows">vengo seguito</string> <string name="pref_title_notification_filter_follows">vengo seguito</string>
<string name="pref_title_notification_filter_reblogs">i miei post vengono boostati</string> <string name="pref_title_notification_filter_reblogs">i miei post vengono condivisi</string>
<string name="pref_title_notification_filter_favourites">i miei post vengono messi nei preferiti</string> <string name="pref_title_notification_filter_favourites">i miei post vengono messi nei preferiti</string>
<string name="pref_title_appearance_settings">Aspetto</string> <string name="pref_title_appearance_settings">Aspetto</string>
<string name="pref_title_app_theme">Tema dell\'app</string> <string name="pref_title_app_theme">Tema dell\'app</string>
@ -183,7 +183,7 @@
<string name="pref_title_language">Lingua</string> <string name="pref_title_language">Lingua</string>
<string name="pref_title_post_filter">Filtraggio della timeline</string> <string name="pref_title_post_filter">Filtraggio della timeline</string>
<string name="pref_title_post_tabs">Schede</string> <string name="pref_title_post_tabs">Schede</string>
<string name="pref_title_show_boosts">Mostra boost</string> <string name="pref_title_show_boosts">Mostra condivisioni</string>
<string name="pref_title_show_replies">Mostra risposte</string> <string name="pref_title_show_replies">Mostra risposte</string>
<string name="pref_title_show_media_preview">Mostra anteprime media</string> <string name="pref_title_show_media_preview">Mostra anteprime media</string>
<string name="pref_title_proxy_settings">Proxy</string> <string name="pref_title_proxy_settings">Proxy</string>
@ -208,8 +208,8 @@
<string name="notification_mention_descriptions">Notifiche di quando vieni menzionato da qualcuno</string> <string name="notification_mention_descriptions">Notifiche di quando vieni menzionato da qualcuno</string>
<string name="notification_follow_name">Nuovi follower</string> <string name="notification_follow_name">Nuovi follower</string>
<string name="notification_follow_description">Notifiche su nuovi follower</string> <string name="notification_follow_description">Notifiche su nuovi follower</string>
<string name="notification_boost_name">Boost</string> <string name="notification_boost_name">Condivisioni</string>
<string name="notification_boost_description">Notifiche sui tuoi post che vengono boostati</string> <string name="notification_boost_description">Notifiche sui tuoi post che vengono condivisi</string>
<string name="notification_favourite_name">Preferiti</string> <string name="notification_favourite_name">Preferiti</string>
<string name="notification_favourite_description">Notifiche sui tuoi post che vengono segnati come preferiti</string> <string name="notification_favourite_description">Notifiche sui tuoi post che vengono segnati come preferiti</string>
<string name="notification_mention_format">%s ti ha menzionato</string> <string name="notification_mention_format">%s ti ha menzionato</string>
@ -312,8 +312,8 @@
<string name="download_failed">Download fallito</string> <string name="download_failed">Download fallito</string>
<string name="profile_badge_bot_text">Bot</string> <string name="profile_badge_bot_text">Bot</string>
<string name="account_moved_description">%1$s si è spostato su:</string> <string name="account_moved_description">%1$s si è spostato su:</string>
<string name="reblog_private">Boost con la visibilità del post di origine</string> <string name="reblog_private">Condividi con la visibilità del post originale</string>
<string name="unreblog_private">Annulla boost</string> <string name="unreblog_private">Annulla condivisione</string>
<string name="license_description">Yuito contiene codice e risorse dai seguenti progetti open source:</string> <string name="license_description">Yuito contiene codice e risorse dai seguenti progetti open source:</string>
<string name="license_apache_2">Licenziata sotto la Licenza Apache (copia sotto)</string> <string name="license_apache_2">Licenziata sotto la Licenza Apache (copia sotto)</string>
<string name="license_cc_by_4">CC-BY 4.0</string> <string name="license_cc_by_4">CC-BY 4.0</string>
@ -334,7 +334,7 @@
<item quantity="one">&lt;b&gt;%s&lt;/b&gt; Boost</item> <item quantity="one">&lt;b&gt;%s&lt;/b&gt; Boost</item>
<item quantity="other">&lt;b&gt;%s&lt;/b&gt; Boost</item> <item quantity="other">&lt;b&gt;%s&lt;/b&gt; Boost</item>
</plurals> </plurals>
<string name="title_reblogged_by">Boostato da</string> <string name="title_reblogged_by">Condiviso da</string>
<string name="title_favourited_by">Aggiunto ai preferiti da</string> <string name="title_favourited_by">Aggiunto ai preferiti da</string>
<string name="conversation_1_recipients">%1$s</string> <string name="conversation_1_recipients">%1$s</string>
<string name="conversation_2_recipients">%1$s e %2$s</string> <string name="conversation_2_recipients">%1$s e %2$s</string>
@ -402,7 +402,7 @@
<string name="select_list_title">Scegli lista</string> <string name="select_list_title">Scegli lista</string>
<string name="list">Lista</string> <string name="list">Lista</string>
<string name="compose_preview_image_description">Azioni per l\'immagine %s</string> <string name="compose_preview_image_description">Azioni per l\'immagine %s</string>
<string name="poll_ended_voted">Un sondaggio che hai votato si è concluso</string> <string name="poll_ended_voted">Un sondaggio in cui hai votato si è concluso</string>
<string name="poll_ended_created">Un sondaggio che hai creato si è concluso</string> <string name="poll_ended_created">Un sondaggio che hai creato si è concluso</string>
<plurals name="poll_timespan_days"> <plurals name="poll_timespan_days">
<item quantity="one">%d giorno rimasto</item> <item quantity="one">%d giorno rimasto</item>
@ -470,7 +470,7 @@
<string name="account_note_saved">Salvato!</string> <string name="account_note_saved">Salvato!</string>
<string name="account_note_hint">La tua nota privata su questo account</string> <string name="account_note_hint">La tua nota privata su questo account</string>
<string name="pref_title_hide_top_toolbar">Nascondi il titolo della barra degli strumenti in alto</string> <string name="pref_title_hide_top_toolbar">Nascondi il titolo della barra degli strumenti in alto</string>
<string name="pref_title_confirm_reblogs">Mostra la finestra di conferma prima di boostare</string> <string name="pref_title_confirm_reblogs">Mostra la finestra di conferma prima di condividere</string>
<string name="pref_title_show_cards_in_timelines">Mostra le anteprime dei collegamenti nelle timelines</string> <string name="pref_title_show_cards_in_timelines">Mostra le anteprime dei collegamenti nelle timelines</string>
<string name="warning_scheduling_interval">Mastodon ha un intervallo di programmazione minimo di 5 minuti.</string> <string name="warning_scheduling_interval">Mastodon ha un intervallo di programmazione minimo di 5 minuti.</string>
<string name="no_announcements">Non ci sono annunci.</string> <string name="no_announcements">Non ci sono annunci.</string>
@ -488,7 +488,7 @@
<string name="pref_title_notification_filter_follow_requests">mi viene richiesto di seguirmi</string> <string name="pref_title_notification_filter_follow_requests">mi viene richiesto di seguirmi</string>
<string name="wellbeing_hide_stats_profile">Nascondi statistiche quantitative sui profili</string> <string name="wellbeing_hide_stats_profile">Nascondi statistiche quantitative sui profili</string>
<string name="wellbeing_hide_stats_posts">Nascondi le statistiche quantitative sui post</string> <string name="wellbeing_hide_stats_posts">Nascondi le statistiche quantitative sui post</string>
<string name="limit_notifications">Limita le notifiche dalla timeline</string> <string name="limit_notifications">Limita notifiche riguardo statistiche quantitative</string>
<string name="review_notifications">Rivedi le notifiche</string> <string name="review_notifications">Rivedi le notifiche</string>
<string name="pref_title_wellbeing_mode">Benessere</string> <string name="pref_title_wellbeing_mode">Benessere</string>
<string name="notification_subscription_description">Notifiche di nuovi post di qualcuno a cui sei iscritto</string> <string name="notification_subscription_description">Notifiche di nuovi post di qualcuno a cui sei iscritto</string>
@ -515,13 +515,13 @@
<string name="action_delete_conversation">Elimina conversazione</string> <string name="action_delete_conversation">Elimina conversazione</string>
<string name="wellbeing_mode_notice">Alcune informazioni che potrebbero influenzare il tuo benessere mentale saranno nascoste. Questo include: <string name="wellbeing_mode_notice">Alcune informazioni che potrebbero influenzare il tuo benessere mentale saranno nascoste. Questo include:
\n \n
\n - Notifiche riguardo a Preferiti/Boost/Following \n - Notifiche riguardo a Preferiti/Condivisioni/Following
\n - Conteggio dei Preferiti/Boost nei post \n - Conteggio dei Preferiti/Condivisioni nei post
\n - Statistiche riguardo a Preferiti/Post nei profili \n - Statistiche riguardo a Preferiti/Post nei profili
\n \n
\n Le notifiche push non saranno influenzate, ma puoi modificare le tue impostazioni delle notifiche manualmente.</string> \n Le notifiche push non saranno influenzate, ma puoi modificare le tue impostazioni delle notifiche manualmente.</string>
<string name="action_unbookmark">Rimuovi segnalibro</string> <string name="action_unbookmark">Rimuovi segnalibro</string>
<string name="pref_title_confirm_favourites">Chiedi conferma prima di boostare</string> <string name="pref_title_confirm_favourites">Chiedi conferma prima di condividere</string>
<string name="duration_14_days">14 giorni</string> <string name="duration_14_days">14 giorni</string>
<string name="duration_30_days">30 giorni</string> <string name="duration_30_days">30 giorni</string>
<string name="duration_60_days">60 giorni</string> <string name="duration_60_days">60 giorni</string>
@ -540,4 +540,8 @@
<string name="notification_update_name">Modifiche ai post</string> <string name="notification_update_name">Modifiche ai post</string>
<string name="notification_update_description">Notifiche di quando i post con cui hai interagito vengono modificati</string> <string name="notification_update_description">Notifiche di quando i post con cui hai interagito vengono modificati</string>
<string name="error_could_not_load_login_page">Non è stato possibile caricare la pagina di login.</string> <string name="error_could_not_load_login_page">Non è stato possibile caricare la pagina di login.</string>
<string name="action_edit_image">Modifica immagine</string>
<string name="saving_draft">Salvataggio bozza…</string>
<string name="action_dismiss">Scartare</string>
<string name="action_details">Dettagli</string>
</resources> </resources>

View File

@ -527,4 +527,16 @@
<string name="notification_update_description">Varslinger når et innlegg du har hatt en interaksjon med er redigert</string> <string name="notification_update_description">Varslinger når et innlegg du har hatt en interaksjon med er redigert</string>
<string name="title_login">Innlogging</string> <string name="title_login">Innlogging</string>
<string name="error_could_not_load_login_page">Klarte ikke å laste innloggingssiden.</string> <string name="error_could_not_load_login_page">Klarte ikke å laste innloggingssiden.</string>
<string name="title_migration_relogin">Logg inn på nytt for pushvarsler</string>
<string name="action_dismiss">Avvis</string>
<string name="action_details">Detaljer</string>
<string name="account_date_joined">Ble med %1$s</string>
<string name="tips_push_notification_migration">Logg inn all konti på nytt for å skru på pushvarsler.</string>
<string name="dialog_push_notification_migration">For å kunne sende pushvarsler via UnifiedPush trenger Tusky tillatelse til å abonnere på varsler på Mastodon-serveren. Dette krever at du logger inn på nytt. Ved å bruke muligheten til å logge inn på nytt her eller i kontoinstillinger vil alle lokale kladder være tilgjengelig også etter at du har logget inn på nytt.</string>
<string name="dialog_push_notification_migration_other_accounts">Du har logget inn på nytt for å tillate Tusky til å sende pushvarsler, men du har fortsatt andre konti som ikke har fått den nødvendige tillatelsen. Bytt til dem og logg inn på nytt på samme måte for å skru på støtte for pushvarsler via UnifiedPush.</string>
<string name="saving_draft">Lagrer kladd…</string>
<string name="status_count_one_plus">1+</string>
<string name="action_edit_image">Rediger bilde</string>
<string name="error_image_edit_failed">Bildet kunne ikke redigeres.</string>
<string name="error_loading_account_details">Lasting av kontodetaljer feilet</string>
</resources> </resources>

View File

@ -65,10 +65,10 @@
<string name="title_announcements">Anúncios</string> <string name="title_announcements">Anúncios</string>
<string name="title_licenses">Licenças</string> <string name="title_licenses">Licenças</string>
<string name="post_username_format">\@%s</string> <string name="post_username_format">\@%s</string>
<string name="post_boosted_format">%s fez boost</string> <string name="post_boosted_format">%s deu boost</string>
<string name="message_empty">Nada aqui.</string> <string name="message_empty">Nada aqui.</string>
<string name="footer_empty">Nada para ver aqui. Arraste para baixo para atualizar!</string> <string name="footer_empty">Nada para ver aqui. Arraste para baixo para atualizar!</string>
<string name="notification_reblog_format">%s fez boost ao seu toot</string> <string name="notification_reblog_format">%s deu boost ao seu toot</string>
<string name="notification_favourite_format">%s adicionou o seu toot aos favoritos</string> <string name="notification_favourite_format">%s adicionou o seu toot aos favoritos</string>
<string name="notification_follow_format">%s está a seguir-te</string> <string name="notification_follow_format">%s está a seguir-te</string>
<string name="notification_follow_request_format">%s pediu para te seguir</string> <string name="notification_follow_request_format">%s pediu para te seguir</string>
@ -134,7 +134,7 @@
<string name="action_open_drawer">Abrir menu</string> <string name="action_open_drawer">Abrir menu</string>
<string name="action_search">Pesquisar</string> <string name="action_search">Pesquisar</string>
<string name="action_access_drafts">Rascunhos</string> <string name="action_access_drafts">Rascunhos</string>
<string name="action_access_scheduled_posts">Toots agendados</string> <string name="action_access_scheduled_posts">Toots Agendados</string>
<string name="action_toggle_visibility">Privacidade do toot</string> <string name="action_toggle_visibility">Privacidade do toot</string>
<string name="action_content_warning">Aviso de conteúdo</string> <string name="action_content_warning">Aviso de conteúdo</string>
<string name="action_emoji_keyboard">Teclado de emojis</string> <string name="action_emoji_keyboard">Teclado de emojis</string>
@ -325,7 +325,6 @@
<string name="action_lists">Listas</string> <string name="action_lists">Listas</string>
<string name="error_rename_list">Não foi possível renomear a lista</string> <string name="error_rename_list">Não foi possível renomear a lista</string>
<string name="title_lists">Listas</string> <string name="title_lists">Listas</string>
<string name="error_create_list">Não foi possível criar a lista</string> <string name="error_create_list">Não foi possível criar a lista</string>
<string name="error_delete_list">Não foi possível apagar a lista</string> <string name="error_delete_list">Não foi possível apagar a lista</string>
<string name="action_create_list">Criar uma lista</string> <string name="action_create_list">Criar uma lista</string>

View File

@ -550,4 +550,15 @@
<string name="title_login">Вхід</string> <string name="title_login">Вхід</string>
<string name="error_could_not_load_login_page">Не вдалося завантажити сторінку входу.</string> <string name="error_could_not_load_login_page">Не вдалося завантажити сторінку входу.</string>
<string name="saving_draft">Збереження чернетки…</string> <string name="saving_draft">Збереження чернетки…</string>
<string name="action_dismiss">Відхилити</string>
<string name="action_details">Подробиці</string>
<string name="title_migration_relogin">Увійдіть повторно, щоб отримувати push-сповіщення</string>
<string name="tips_push_notification_migration">Увійдіть повторно до всіх облікових записів, щоб увімкнути підтримку push-сповіщень.</string>
<string name="dialog_push_notification_migration">Щоб використовувати push-сповіщення через UnifiedPush, Tusky потребує дозволу стежити за сповіщеннями на вашому сервері Mastodon. Це вимагає повторного входу, щоб змінити області OAuth, надані Tusky. Використання параметра повторного входу тут або в налаштуваннях облікового запису збереже всі ваші локальні чернетки та кеш.</string>
<string name="dialog_push_notification_migration_other_accounts">Ви повторно увійшли до свого поточного облікового запису, щоб надати дозвіл на стеження Tusky. Однак у вас все ще є інші облікові записи, які не мігрували таким чином. Перейдіть до них і повторно увійдіть до них по одному, щоб забезпечити підтримку UnifiedPush сповіщень.</string>
<string name="account_date_joined">Приєднується %1$s</string>
<string name="action_edit_image">Редагувати зображення</string>
<string name="status_count_one_plus">1+</string>
<string name="error_image_edit_failed">Неможливо редагувати зображення.</string>
<string name="error_loading_account_details">Не вдалося завантажити подробиці облікового запису</string>
</resources> </resources>

View File

@ -517,4 +517,15 @@
<string name="title_login">Đăng nhập</string> <string name="title_login">Đăng nhập</string>
<string name="error_could_not_load_login_page">Không thể tải trang đăng nhập.</string> <string name="error_could_not_load_login_page">Không thể tải trang đăng nhập.</string>
<string name="saving_draft">Đang lưu nháp…</string> <string name="saving_draft">Đang lưu nháp…</string>
<string name="action_dismiss">Bỏ qua</string>
<string name="title_migration_relogin">Đăng nhập lại để hiện thông báo đẩy</string>
<string name="action_details">Chi tiết</string>
<string name="account_date_joined">Tham gia vào %1$s</string>
<string name="tips_push_notification_migration">Đăng nhập lại tất cả tài khoản để kích hoạt thông báo đẩy.</string>
<string name="dialog_push_notification_migration_other_accounts">Bạn đã đăng nhập lại vào tài khoản hiện tại của mình để cấp quyền thông báo đẩy cho Tusky. Tuy nhiên, bạn vẫn có các tài khoản khác chưa kích hoạt thông báo đẩy theo cách này. Chuyển sang chúng và đăng nhập từng cái một để cho phép hỗ trợ thông báo UnifiedPush.</string>
<string name="dialog_push_notification_migration">Để sử dụng thông báo đẩy qua UnifiedPush, Tusky cần có quyền đăng ký thông báo trên máy chủ Mastodon của bạn. Bạn hãy thoát ra rồi đăng nhập lại để thay đổi phạm vi OAuth được cấp cho Tusky. Sử dụng đăng nhập lại ở đây hoặc trong cài đặt Tài khoản sẽ bảo toàn tất cả các tút nháp và bộ nhớ đệm trên điện thoại của bạn.</string>
<string name="status_count_one_plus">1+</string>
<string name="action_edit_image">Sửa ảnh</string>
<string name="error_image_edit_failed">Hình ảnh này không thể sửa.</string>
<string name="error_loading_account_details">Không thể tải thông tin tài khoản</string>
</resources> </resources>

View File

@ -31,7 +31,7 @@
<string name="title_posts_pinned">已置顶</string> <string name="title_posts_pinned">已置顶</string>
<string name="title_follows">正在关注</string> <string name="title_follows">正在关注</string>
<string name="title_followers">关注者</string> <string name="title_followers">关注者</string>
<string name="title_favourites">收藏</string> <string name="title_favourites">喜欢</string>
<string name="title_mutes">被隐藏的用户</string> <string name="title_mutes">被隐藏的用户</string>
<string name="title_blocks">被屏蔽的用户</string> <string name="title_blocks">被屏蔽的用户</string>
<string name="title_follow_requests">关注请求</string> <string name="title_follow_requests">关注请求</string>
@ -50,7 +50,7 @@
<string name="message_empty">还没有内容。</string> <string name="message_empty">还没有内容。</string>
<string name="footer_empty">还没有内容,向下拉动即可刷新!</string> <string name="footer_empty">还没有内容,向下拉动即可刷新!</string>
<string name="notification_reblog_format">%s 转嘟了你的嘟文</string> <string name="notification_reblog_format">%s 转嘟了你的嘟文</string>
<string name="notification_favourite_format">%s 收藏了你的嘟文</string> <string name="notification_favourite_format">%s 喜欢了你的嘟文</string>
<string name="notification_follow_format">%s 关注了你</string> <string name="notification_follow_format">%s 关注了你</string>
<string name="report_username_format">举报 @%s</string> <string name="report_username_format">举报 @%s</string>
<string name="report_comment_hint">是否有更多信息需报告?</string> <string name="report_comment_hint">是否有更多信息需报告?</string>
@ -58,8 +58,8 @@
<string name="action_reply">回复</string> <string name="action_reply">回复</string>
<string name="action_reblog">转嘟</string> <string name="action_reblog">转嘟</string>
<string name="action_unreblog">取消转嘟</string> <string name="action_unreblog">取消转嘟</string>
<string name="action_favourite">收藏</string> <string name="action_favourite">喜欢</string>
<string name="action_unfavourite">取消收藏</string> <string name="action_unfavourite">取消喜欢</string>
<string name="action_more">更多</string> <string name="action_more">更多</string>
<string name="action_compose">发表嘟文</string> <string name="action_compose">发表嘟文</string>
<string name="action_login">登录 Mastodon 帐号</string> <string name="action_login">登录 Mastodon 帐号</string>
@ -81,7 +81,7 @@
<string name="action_view_profile">个人资料</string> <string name="action_view_profile">个人资料</string>
<string name="action_view_preferences">设置</string> <string name="action_view_preferences">设置</string>
<string name="action_view_account_preferences">帐户设置</string> <string name="action_view_account_preferences">帐户设置</string>
<string name="action_view_favourites">收藏</string> <string name="action_view_favourites">喜欢</string>
<string name="action_view_mutes">被隐藏的用户</string> <string name="action_view_mutes">被隐藏的用户</string>
<string name="action_view_blocks">被屏蔽的用户</string> <string name="action_view_blocks">被屏蔽的用户</string>
<string name="action_view_follow_requests">关注请求</string> <string name="action_view_follow_requests">关注请求</string>
@ -112,7 +112,7 @@
<string name="action_hashtags">话题</string> <string name="action_hashtags">话题</string>
<string name="action_open_reblogger">打开转嘟用户主页</string> <string name="action_open_reblogger">打开转嘟用户主页</string>
<string name="action_open_reblogged_by">显示转嘟</string> <string name="action_open_reblogged_by">显示转嘟</string>
<string name="action_open_faved_by">显示收藏</string> <string name="action_open_faved_by">显示喜欢</string>
<string name="title_hashtags_dialog">话题</string> <string name="title_hashtags_dialog">话题</string>
<string name="title_mentions_dialog">提及</string> <string name="title_mentions_dialog">提及</string>
<string name="title_links_dialog">链接</string> <string name="title_links_dialog">链接</string>
@ -171,7 +171,7 @@
<string name="pref_title_notification_filter_mentions">被提及</string> <string name="pref_title_notification_filter_mentions">被提及</string>
<string name="pref_title_notification_filter_follows">有新的关注者</string> <string name="pref_title_notification_filter_follows">有新的关注者</string>
<string name="pref_title_notification_filter_reblogs">嘟文被转嘟</string> <string name="pref_title_notification_filter_reblogs">嘟文被转嘟</string>
<string name="pref_title_notification_filter_favourites">嘟文被收藏</string> <string name="pref_title_notification_filter_favourites">嘟文被喜欢</string>
<string name="pref_title_notification_filter_poll">投票已结束</string> <string name="pref_title_notification_filter_poll">投票已结束</string>
<string name="pref_title_appearance_settings">外观</string> <string name="pref_title_appearance_settings">外观</string>
<string name="pref_title_app_theme">应用主题</string> <string name="pref_title_app_theme">应用主题</string>
@ -215,8 +215,8 @@
<string name="notification_follow_description">当有用户关注我时</string> <string name="notification_follow_description">当有用户关注我时</string>
<string name="notification_boost_name">转嘟</string> <string name="notification_boost_name">转嘟</string>
<string name="notification_boost_description">当我的嘟文被转发时通知</string> <string name="notification_boost_description">当我的嘟文被转发时通知</string>
<string name="notification_favourite_name">收藏</string> <string name="notification_favourite_name">喜欢</string>
<string name="notification_favourite_description">当有用户收藏了我的嘟文时通知</string> <string name="notification_favourite_description">当有用户喜欢了我的嘟文时</string>
<string name="notification_poll_name">投票</string> <string name="notification_poll_name">投票</string>
<string name="notification_poll_description">当我参与的投票结束时</string> <string name="notification_poll_description">当我参与的投票结束时</string>
<string name="notification_mention_format">%s 提及了你</string> <string name="notification_mention_format">%s 提及了你</string>
@ -337,13 +337,13 @@
<string name="unpin_action">取消置顶</string> <string name="unpin_action">取消置顶</string>
<string name="pin_action">置顶</string> <string name="pin_action">置顶</string>
<plurals name="favs"> <plurals name="favs">
<item quantity="other">&lt;b&gt;%1$s&lt;/b&gt;收藏</item> <item quantity="other">&lt;b&gt;%1$s&lt;/b&gt;喜欢</item>
</plurals> </plurals>
<plurals name="reblogs"> <plurals name="reblogs">
<item quantity="other">&lt;b&gt;%s&lt;/b&gt; 次转嘟</item> <item quantity="other">&lt;b&gt;%s&lt;/b&gt; 次转嘟</item>
</plurals> </plurals>
<string name="title_reblogged_by">转嘟</string> <string name="title_reblogged_by">转嘟</string>
<string name="title_favourited_by">收藏</string> <string name="title_favourited_by">喜欢</string>
<string name="conversation_1_recipients">%1$s</string> <string name="conversation_1_recipients">%1$s</string>
<string name="conversation_2_recipients">%1$s 和 %2$s</string> <string name="conversation_2_recipients">%1$s 和 %2$s</string>
<string name="conversation_more_recipients">%1$s%2$s 和 %3$d 等人</string> <string name="conversation_more_recipients">%1$s%2$s 和 %3$d 等人</string>
@ -354,7 +354,7 @@
<string name="description_post_cw">内容警告:%s</string> <string name="description_post_cw">内容警告:%s</string>
<string name="description_post_media_no_description_placeholder">没有描述信息</string> <string name="description_post_media_no_description_placeholder">没有描述信息</string>
<string name="description_post_reblogged">被转嘟</string> <string name="description_post_reblogged">被转嘟</string>
<string name="description_post_favourited">收藏</string> <string name="description_post_favourited">喜欢</string>
<string name="description_visiblity_public"> <string name="description_visiblity_public">
公开 公开
</string> </string>
@ -495,8 +495,8 @@
<string name="limit_notifications">限制时间线通知</string> <string name="limit_notifications">限制时间线通知</string>
<string name="wellbeing_mode_notice">一些可能影响您精神状态的信息将被隐藏,这些信息包括: <string name="wellbeing_mode_notice">一些可能影响您精神状态的信息将被隐藏,这些信息包括:
\n \n
\n - 收藏、转发、关注通知 \n - 喜欢、转发、关注通知
\n - 收藏、转发数 \n - 喜欢、转发数
\n - 账号的已关注数量、嘟文数量 \n - 账号的已关注数量、嘟文数量
\n \n
\n 推送通知不会被影响,但可以在通知设置中手动禁用。</string> \n 推送通知不会被影响,但可以在通知设置中手动禁用。</string>
@ -516,7 +516,7 @@
<string name="follow_requests_info">即使您的账号未上锁,管理员 %1$s 认为您可能需要手动处理来自这些账号的关注请求。</string> <string name="follow_requests_info">即使您的账号未上锁,管理员 %1$s 认为您可能需要手动处理来自这些账号的关注请求。</string>
<string name="dialog_delete_conversation_warning">删除此对话吗?</string> <string name="dialog_delete_conversation_warning">删除此对话吗?</string>
<string name="action_delete_conversation">删除对话</string> <string name="action_delete_conversation">删除对话</string>
<string name="pref_title_confirm_favourites">收藏前显示确认对话框</string> <string name="pref_title_confirm_favourites">喜欢前显示确认对话框</string>
<string name="action_unbookmark">删除书签</string> <string name="action_unbookmark">删除书签</string>
<string name="duration_30_days">30 天</string> <string name="duration_30_days">30 天</string>
<string name="duration_60_days">60 天</string> <string name="duration_60_days">60 天</string>
@ -536,4 +536,15 @@
<string name="notification_update_description">当你进行过互动的嘟文被编辑时发出通知</string> <string name="notification_update_description">当你进行过互动的嘟文被编辑时发出通知</string>
<string name="error_could_not_load_login_page">无法加载登录页。</string> <string name="error_could_not_load_login_page">无法加载登录页。</string>
<string name="saving_draft">正在保存草稿…</string> <string name="saving_draft">正在保存草稿…</string>
<string name="title_migration_relogin">重新登陆以启用通知推送</string>
<string name="action_dismiss">不理会</string>
<string name="action_details">详情</string>
<string name="dialog_push_notification_migration_other_accounts">你已重新登录当前账户,向 Tusky 授予推送订阅权限。但是,你仍然有其他没有以这种方式迁移的账户。切换到它们,逐个重新登录,以启用 UnifiedPush 通知支持。</string>
<string name="account_date_joined">加入于%1$s</string>
<string name="tips_push_notification_migration">重新登录所有账户来启用推送通知支持。</string>
<string name="dialog_push_notification_migration">为了通过 UnifiedPush 使用推送通知Tusky 需要订阅你 Mastodon 服务器通知的权限。这需要重新登录来更改授予 Tusky 的 OAuth 作用域。使用此处或账户首选项中“重新登录”选项将保留你所有的本地草稿和缓存。</string>
<string name="status_count_one_plus">1+</string>
<string name="action_edit_image">编辑图片</string>
<string name="error_image_edit_failed">无法编辑图片。</string>
<string name="error_loading_account_details">加载账户详情失败</string>
</resources> </resources>

View File

@ -31,7 +31,7 @@
<string name="title_posts_pinned">已置顶</string> <string name="title_posts_pinned">已置顶</string>
<string name="title_follows">正在关注</string> <string name="title_follows">正在关注</string>
<string name="title_followers">关注者</string> <string name="title_followers">关注者</string>
<string name="title_favourites">收藏</string> <string name="title_favourites">喜欢</string>
<string name="title_mutes">被隐藏的用户</string> <string name="title_mutes">被隐藏的用户</string>
<string name="title_blocks">被屏蔽的用户</string> <string name="title_blocks">被屏蔽的用户</string>
<string name="title_follow_requests">关注请求</string> <string name="title_follow_requests">关注请求</string>
@ -50,7 +50,7 @@
<string name="message_empty">还没有内容</string> <string name="message_empty">还没有内容</string>
<string name="footer_empty">还没有内容,向下拉动即可刷新</string> <string name="footer_empty">还没有内容,向下拉动即可刷新</string>
<string name="notification_reblog_format">%s 转嘟了你的嘟文</string> <string name="notification_reblog_format">%s 转嘟了你的嘟文</string>
<string name="notification_favourite_format">%s 收藏了你的嘟文</string> <string name="notification_favourite_format">%s 喜欢了你的嘟文</string>
<string name="notification_follow_format">%s 关注了你</string> <string name="notification_follow_format">%s 关注了你</string>
<string name="report_username_format">报告用户 @%s 的滥用行为</string> <string name="report_username_format">报告用户 @%s 的滥用行为</string>
<string name="report_comment_hint">报告更多信息</string> <string name="report_comment_hint">报告更多信息</string>
@ -58,8 +58,8 @@
<string name="action_reply">回复</string> <string name="action_reply">回复</string>
<string name="action_reblog">转嘟</string> <string name="action_reblog">转嘟</string>
<string name="action_unreblog">取消转嘟</string> <string name="action_unreblog">取消转嘟</string>
<string name="action_favourite">收藏</string> <string name="action_favourite">喜欢</string>
<string name="action_unfavourite">取消收藏</string> <string name="action_unfavourite">取消喜欢</string>
<string name="action_more">更多</string> <string name="action_more">更多</string>
<string name="action_compose">新嘟文</string> <string name="action_compose">新嘟文</string>
<string name="action_login">登录 Mastodon 帐号</string> <string name="action_login">登录 Mastodon 帐号</string>
@ -81,7 +81,7 @@
<string name="action_view_profile">个人资料</string> <string name="action_view_profile">个人资料</string>
<string name="action_view_preferences">设置</string> <string name="action_view_preferences">设置</string>
<string name="action_view_account_preferences">帐户设置</string> <string name="action_view_account_preferences">帐户设置</string>
<string name="action_view_favourites">收藏</string> <string name="action_view_favourites">喜欢</string>
<string name="action_view_mutes">被隐藏的用户</string> <string name="action_view_mutes">被隐藏的用户</string>
<string name="action_view_blocks">被屏蔽的用户</string> <string name="action_view_blocks">被屏蔽的用户</string>
<string name="action_view_follow_requests">关注请求</string> <string name="action_view_follow_requests">关注请求</string>
@ -112,7 +112,7 @@
<string name="action_hashtags">话题</string> <string name="action_hashtags">话题</string>
<string name="action_open_reblogger">打开转嘟用户主页</string> <string name="action_open_reblogger">打开转嘟用户主页</string>
<string name="action_open_reblogged_by">显示转嘟</string> <string name="action_open_reblogged_by">显示转嘟</string>
<string name="action_open_faved_by">显示收藏</string> <string name="action_open_faved_by">显示喜欢</string>
<string name="title_hashtags_dialog">话题</string> <string name="title_hashtags_dialog">话题</string>
<string name="title_mentions_dialog">提及</string> <string name="title_mentions_dialog">提及</string>
<string name="title_links_dialog">链接</string> <string name="title_links_dialog">链接</string>
@ -168,7 +168,7 @@
<string name="pref_title_notification_filter_mentions">被提及</string> <string name="pref_title_notification_filter_mentions">被提及</string>
<string name="pref_title_notification_filter_follows">有新的关注者</string> <string name="pref_title_notification_filter_follows">有新的关注者</string>
<string name="pref_title_notification_filter_reblogs">嘟文被转嘟</string> <string name="pref_title_notification_filter_reblogs">嘟文被转嘟</string>
<string name="pref_title_notification_filter_favourites">嘟文被收藏</string> <string name="pref_title_notification_filter_favourites">嘟文被喜欢</string>
<string name="pref_title_notification_filter_poll">投票已结束</string> <string name="pref_title_notification_filter_poll">投票已结束</string>
<string name="pref_title_appearance_settings">外观</string> <string name="pref_title_appearance_settings">外观</string>
<string name="pref_title_app_theme">应用主题</string> <string name="pref_title_app_theme">应用主题</string>
@ -212,8 +212,8 @@
<string name="notification_follow_description">当有用户关注我时</string> <string name="notification_follow_description">当有用户关注我时</string>
<string name="notification_boost_name">转嘟</string> <string name="notification_boost_name">转嘟</string>
<string name="notification_boost_description">当有用户转嘟了我的嘟文时</string> <string name="notification_boost_description">当有用户转嘟了我的嘟文时</string>
<string name="notification_favourite_name">收藏</string> <string name="notification_favourite_name">喜欢</string>
<string name="notification_favourite_description">当有用户收藏了我的嘟文时</string> <string name="notification_favourite_description">当有用户喜欢了我的嘟文时</string>
<string name="notification_poll_name">投票</string> <string name="notification_poll_name">投票</string>
<string name="notification_poll_description">当我参与的投票结束时</string> <string name="notification_poll_description">当我参与的投票结束时</string>
<string name="notification_mention_format">%s 提及了你</string> <string name="notification_mention_format">%s 提及了你</string>
@ -333,13 +333,13 @@
<string name="unpin_action">取消置顶</string> <string name="unpin_action">取消置顶</string>
<string name="pin_action">置顶</string> <string name="pin_action">置顶</string>
<plurals name="favs"> <plurals name="favs">
<item quantity="other">&lt;b&gt;%1$s&lt;/b&gt; 次收藏</item> <item quantity="other"><b>%1$s</b> 次喜欢</item>
</plurals> </plurals>
<plurals name="reblogs"> <plurals name="reblogs">
<item quantity="other">&lt;b&gt;%s&lt;/b&gt; 次转嘟</item> <item quantity="other">&lt;b&gt;%s&lt;/b&gt; 次转嘟</item>
</plurals> </plurals>
<string name="title_reblogged_by">转嘟</string> <string name="title_reblogged_by">转嘟</string>
<string name="title_favourited_by">收藏</string> <string name="title_favourited_by">喜欢</string>
<string name="conversation_1_recipients">%1$s</string> <string name="conversation_1_recipients">%1$s</string>
<string name="conversation_2_recipients">%1$s 和 %2$s</string> <string name="conversation_2_recipients">%1$s 和 %2$s</string>
<string name="conversation_more_recipients">%1$s, %2$s 和 %3$d 等人</string> <string name="conversation_more_recipients">%1$s, %2$s 和 %3$d 等人</string>
@ -358,9 +358,7 @@
<string name="description_post_reblogged"> <string name="description_post_reblogged">
被转嘟 被转嘟
</string> </string>
<string name="description_post_favourited"> <string name="description_post_favourited">被喜欢</string>
被收藏
</string>
<string name="description_visiblity_public"> <string name="description_visiblity_public">
公开 公开
</string> </string>

View File

@ -50,7 +50,7 @@
<string name="message_empty">沒有內容。</string> <string name="message_empty">沒有內容。</string>
<string name="footer_empty">還沒有內容,向下拉動即可重新整理!</string> <string name="footer_empty">還沒有內容,向下拉動即可重新整理!</string>
<string name="notification_reblog_format">%s 轉嘟了你的嘟文</string> <string name="notification_reblog_format">%s 轉嘟了你的嘟文</string>
<string name="notification_favourite_format">%s 收藏了你的嘟文</string> <string name="notification_favourite_format">%s 最愛了你的嘟文</string>
<string name="notification_follow_format">%s 關注了你</string> <string name="notification_follow_format">%s 關注了你</string>
<string name="report_username_format">檢舉使用者 @%s 的濫用行為</string> <string name="report_username_format">檢舉使用者 @%s 的濫用行為</string>
<string name="report_comment_hint">更多評論?</string> <string name="report_comment_hint">更多評論?</string>
@ -58,8 +58,8 @@
<string name="action_reply">回覆</string> <string name="action_reply">回覆</string>
<string name="action_reblog">轉嘟</string> <string name="action_reblog">轉嘟</string>
<string name="action_unreblog">取消轉嘟</string> <string name="action_unreblog">取消轉嘟</string>
<string name="action_favourite">收藏</string> <string name="action_favourite">最愛</string>
<string name="action_unfavourite">取消收藏</string> <string name="action_unfavourite">取消最愛</string>
<string name="action_more">更多</string> <string name="action_more">更多</string>
<string name="action_compose">撰寫嘟文</string> <string name="action_compose">撰寫嘟文</string>
<string name="action_login">登入 Mastodon 帳號</string> <string name="action_login">登入 Mastodon 帳號</string>
@ -81,7 +81,7 @@
<string name="action_view_profile">個人資料</string> <string name="action_view_profile">個人資料</string>
<string name="action_view_preferences">設定</string> <string name="action_view_preferences">設定</string>
<string name="action_view_account_preferences">帳戶設定</string> <string name="action_view_account_preferences">帳戶設定</string>
<string name="action_view_favourites">我的收藏</string> <string name="action_view_favourites">我的最愛</string>
<string name="action_view_mutes">被靜音的使用者</string> <string name="action_view_mutes">被靜音的使用者</string>
<string name="action_view_blocks">被封鎖的使用者</string> <string name="action_view_blocks">被封鎖的使用者</string>
<string name="action_view_follow_requests">關注請求</string> <string name="action_view_follow_requests">關注請求</string>
@ -171,7 +171,7 @@
<string name="pref_title_notification_filter_mentions">被提及</string> <string name="pref_title_notification_filter_mentions">被提及</string>
<string name="pref_title_notification_filter_follows">有新的關注者</string> <string name="pref_title_notification_filter_follows">有新的關注者</string>
<string name="pref_title_notification_filter_reblogs">嘟文被轉嘟</string> <string name="pref_title_notification_filter_reblogs">嘟文被轉嘟</string>
<string name="pref_title_notification_filter_favourites">嘟文被加入收藏</string> <string name="pref_title_notification_filter_favourites">嘟文被加入最愛</string>
<string name="pref_title_notification_filter_poll">投票已結束</string> <string name="pref_title_notification_filter_poll">投票已結束</string>
<string name="pref_title_appearance_settings">外觀</string> <string name="pref_title_appearance_settings">外觀</string>
<string name="pref_title_app_theme">佈景主題</string> <string name="pref_title_app_theme">佈景主題</string>
@ -215,8 +215,8 @@
<string name="notification_follow_description">當有使用者關注我時</string> <string name="notification_follow_description">當有使用者關注我時</string>
<string name="notification_boost_name">轉嘟</string> <string name="notification_boost_name">轉嘟</string>
<string name="notification_boost_description">當有使用者轉嘟了我的嘟文時</string> <string name="notification_boost_description">當有使用者轉嘟了我的嘟文時</string>
<string name="notification_favourite_name">收藏</string> <string name="notification_favourite_name">最愛</string>
<string name="notification_favourite_description">當有使用者把我的嘟文加入收藏</string> <string name="notification_favourite_description">當有使用者把我的嘟文加入最愛</string>
<string name="notification_poll_name">投票</string> <string name="notification_poll_name">投票</string>
<string name="notification_poll_description">當我參與的投票結束時</string> <string name="notification_poll_description">當我參與的投票結束時</string>
<string name="notification_mention_format">%s 提及了你</string> <string name="notification_mention_format">%s 提及了你</string>
@ -335,13 +335,13 @@
<string name="unpin_action">取消置頂</string> <string name="unpin_action">取消置頂</string>
<string name="pin_action">置頂</string> <string name="pin_action">置頂</string>
<plurals name="favs"> <plurals name="favs">
<item quantity="other">&lt;b&gt;%1$s&lt;/b&gt; 次收藏</item> <item quantity="other"><b>%1$s</b> 次最愛</item>
</plurals> </plurals>
<plurals name="reblogs"> <plurals name="reblogs">
<item quantity="other">&lt;b&gt;%s&lt;/b&gt; 次轉嘟</item> <item quantity="other">&lt;b&gt;%s&lt;/b&gt; 次轉嘟</item>
</plurals> </plurals>
<string name="title_reblogged_by">轉嘟</string> <string name="title_reblogged_by">轉嘟</string>
<string name="title_favourited_by">收藏</string> <string name="title_favourited_by">最愛</string>
<string name="conversation_1_recipients">%1$s</string> <string name="conversation_1_recipients">%1$s</string>
<string name="conversation_2_recipients">%1$s 和 %2$s</string> <string name="conversation_2_recipients">%1$s 和 %2$s</string>
<string name="conversation_more_recipients">%1$s, %2$s 和 %3$d 等人</string> <string name="conversation_more_recipients">%1$s, %2$s 和 %3$d 等人</string>
@ -360,9 +360,7 @@
<string name="description_post_reblogged"> <string name="description_post_reblogged">
被轉嘟 被轉嘟
</string> </string>
<string name="description_post_favourited"> <string name="description_post_favourited">被最愛</string>
被收藏
</string>
<string name="description_visiblity_public"> <string name="description_visiblity_public">
公開 公開
</string> </string>
@ -444,8 +442,8 @@
<string name="review_notifications">檢查通知設定</string> <string name="review_notifications">檢查通知設定</string>
<string name="wellbeing_mode_notice">有些資訊可能會影響你的心理健康將會被隱藏。包括: <string name="wellbeing_mode_notice">有些資訊可能會影響你的心理健康將會被隱藏。包括:
\n \n
\n- 收藏/轉嘟/關注 通知 \n- 最愛/轉嘟/關注 通知
\n- 收藏/轉嘟 數量 \n- 最愛/轉嘟 數量
\n- 關注/貼文 在個人頁面的狀態 \n- 關注/貼文 在個人頁面的狀態
\n \n
\n推播通知不會受到影響但你可以手動檢查你的通知設定。</string> \n推播通知不會受到影響但你可以手動檢查你的通知設定。</string>

View File

@ -9,11 +9,13 @@
<string name="error_authorization_unknown">An unidentified authorization error occurred.</string> <string name="error_authorization_unknown">An unidentified authorization error occurred.</string>
<string name="error_authorization_denied">Authorization was denied.</string> <string name="error_authorization_denied">Authorization was denied.</string>
<string name="error_retrieving_oauth_token">Failed getting a login token.</string> <string name="error_retrieving_oauth_token">Failed getting a login token.</string>
<string name="error_loading_account_details">Failed loading account details</string>
<string name="error_could_not_load_login_page">Could not load the login page.</string> <string name="error_could_not_load_login_page">Could not load the login page.</string>
<string name="error_compose_character_limit">The post is too long!</string> <string name="error_compose_character_limit">The post is too long!</string>
<string name="error_image_upload_size">The file must be less than 8MB.</string> <string name="error_image_upload_size">The file must be less than 8MB.</string>
<string name="error_video_upload_size">Video files must be less than 40MB.</string> <string name="error_video_upload_size">Video files must be less than 40MB.</string>
<string name="error_audio_upload_size">Audio files must be less than 40MB.</string> <string name="error_audio_upload_size">Audio files must be less than 40MB.</string>
<string name="error_image_edit_failed">The image could not be edited.</string>
<string name="error_media_upload_type">That type of file cannot be uploaded.</string> <string name="error_media_upload_type">That type of file cannot be uploaded.</string>
<string name="error_media_upload_opening">That file could not be opened.</string> <string name="error_media_upload_opening">That file could not be opened.</string>
<string name="error_media_upload_permission">Permission to read media is required.</string> <string name="error_media_upload_permission">Permission to read media is required.</string>
@ -378,6 +380,7 @@
<string name="post_media_video">Video</string> <string name="post_media_video">Video</string>
<string name="post_media_audio">Audio</string> <string name="post_media_audio">Audio</string>
<string name="post_media_attachments">Attachments</string> <string name="post_media_attachments">Attachments</string>
<string name="status_count_one_plus">1+</string>
<string name="state_follow_requested">Follow requested</string> <string name="state_follow_requested">Follow requested</string>
@ -434,6 +437,7 @@
<item quantity="other">Describe for visually impaired\n(%d character limit)</item> <item quantity="other">Describe for visually impaired\n(%d character limit)</item>
</plurals> </plurals>
<string name="action_set_caption">Set caption</string> <string name="action_set_caption">Set caption</string>
<string name="action_edit_image">Edit image</string>
<string name="action_remove">Remove</string> <string name="action_remove">Remove</string>
<string name="lock_account_label">Lock account</string> <string name="lock_account_label">Lock account</string>
<string name="lock_account_label_description">Requires you to manually approve followers</string> <string name="lock_account_label_description">Requires you to manually approve followers</string>

View File

@ -74,6 +74,7 @@ class BottomSheetActivityTest {
emojis = emptyList(), emojis = emptyList(),
reblogsCount = 0, reblogsCount = 0,
favouritesCount = 0, favouritesCount = 0,
repliesCount = 0,
reblogged = false, reblogged = false,
favourited = false, favourited = false,
bookmarked = false, bookmarked = false,

View File

@ -19,6 +19,7 @@ import android.content.Intent
import android.os.Looper.getMainLooper import android.os.Looper.getMainLooper
import android.widget.EditText import android.widget.EditText
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import at.connyduck.calladapter.networkresult.NetworkResult
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
@ -67,6 +68,8 @@ class ComposeActivityTest {
id = 1, id = 1,
domain = instanceDomain, domain = instanceDomain,
accessToken = "token", accessToken = "token",
clientId = "id",
clientSecret = "secret",
isActive = true, isActive = true,
accountId = "1", accountId = "1",
username = "username", username = "username",
@ -95,12 +98,12 @@ class ComposeActivityTest {
} }
apiMock = mock { apiMock = mock {
onBlocking { getCustomEmojis() } doReturn Result.success(emptyList()) onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList())
onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance -> onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance ->
if (instance == null) { if (instance == null) {
Result.failure(Throwable()) NetworkResult.failure(Throwable())
} else { } else {
Result.success(instance) NetworkResult.success(instance)
} }
} }
} }

View File

@ -166,6 +166,7 @@ class FilterTest {
emojis = emptyList(), emojis = emptyList(),
reblogsCount = 0, reblogsCount = 0,
favouritesCount = 0, favouritesCount = 0,
repliesCount = 0,
reblogged = false, reblogged = false,
favourited = false, favourited = false,
bookmarked = false, bookmarked = false,

View File

@ -46,6 +46,8 @@ class CachedTimelineRemoteMediatorTest {
id = 1, id = 1,
domain = "mastodon.example", domain = "mastodon.example",
accessToken = "token", accessToken = "token",
clientId = "id",
clientSecret = "secret",
isActive = true isActive = true
) )
} }

View File

@ -38,6 +38,8 @@ class NetworkTimelineRemoteMediatorTest {
id = 1, id = 1,
domain = "mastodon.example", domain = "mastodon.example",
accessToken = "token", accessToken = "token",
clientId = "id",
clientSecret = "secret",
isActive = true isActive = true
) )
} }

View File

@ -29,6 +29,7 @@ fun mockStatus(id: String = "100") = Status(
emojis = emptyList(), emojis = emptyList(),
reblogsCount = 1, reblogsCount = 1,
favouritesCount = 2, favouritesCount = 2,
repliesCount = 3,
reblogged = false, reblogged = false,
favourited = true, favourited = true,
bookmarked = true, bookmarked = true,

View File

@ -443,6 +443,7 @@ class TimelineDaoTest {
emojis = "emojis$statusId", emojis = "emojis$statusId",
reblogsCount = 1 * statusId.toInt(), reblogsCount = 1 * statusId.toInt(),
favouritesCount = 2 * statusId.toInt(), favouritesCount = 2 * statusId.toInt(),
repliesCount = 3 * statusId.toInt(),
reblogged = even, reblogged = even,
favourited = !even, favourited = !even,
bookmarked = false, bookmarked = false,

View File

@ -0,0 +1,143 @@
package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.mock
class InstanceSwitchAuthInterceptorTest {
private val mockWebServer = MockWebServer()
@Before
fun setup() {
mockWebServer.start()
}
@After
fun teardown() {
mockWebServer.shutdown()
}
@Test
fun `should make regular request when requested`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer { null }
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url(mockWebServer.url("/test"))
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(200, response.code)
}
@Test
fun `should make request to instance requested in special header`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer {
AccountEntity(
id = 1,
domain = "test.domain",
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true
)
}
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test")
.header(MastodonApi.DOMAIN_HEADER, mockWebServer.hostName)
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(200, response.code)
assertNull(mockWebServer.takeRequest().getHeader("Authorization"))
}
@Test
fun `should make request to current instance when requested and user is logged in`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer {
AccountEntity(
id = 1,
domain = mockWebServer.hostName,
accessToken = "fakeToken",
clientId = "fakeId",
clientSecret = "fakeSecret",
isActive = true
)
}
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test")
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(200, response.code)
assertEquals("Bearer fakeToken", mockWebServer.takeRequest().getHeader("Authorization"))
}
@Test
fun `should fail to make request when request to current instance is requested but no user is logged in`() {
mockWebServer.enqueue(MockResponse())
val accountManager: AccountManager = mock {
on { activeAccount } doAnswer { null }
}
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
.build()
val request = Request.Builder()
.get()
.url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + "/test")
.build()
val response = okHttpClient.newCall(request).execute()
assertEquals(400, response.code)
assertEquals(0, mockWebServer.requestCount)
}
}

Some files were not shown because too many files have changed in this diff Show More