diff --git a/app/build.gradle b/app/build.gradle index 9805d9670..d41547233 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -103,6 +103,8 @@ ext.okhttpVersion = '4.9.3' ext.glideVersion = '4.13.1' ext.daggerVersion = '2.41' ext.materialdrawerVersion = '8.4.5' +ext.emoji2_version = '1.1.0' +ext.filemojicompat_version = '3.2.1' repositories { maven { @@ -125,8 +127,9 @@ dependencies { implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.preference:preference-ktx:1.2.0" implementation "androidx.sharetarget:sharetarget:1.2.0-rc01" - implementation "androidx.emoji:emoji:1.1.0" - implementation "androidx.emoji:emoji-appcompat:1.1.0" + implementation "androidx.emoji2:emoji2:$emoji2_version" + implementation "androidx.emoji2:emoji2-views:$emoji2_version" + implementation "androidx.emoji2:emoji2-views-helper:$emoji2_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" @@ -137,7 +140,6 @@ dependencies { implementation "androidx.work:work-runtime:2.7.1" implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-paging:$roomVersion" - implementation "androidx.room:room-rxjava3:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion" implementation 'androidx.core:core-splashscreen:1.0.0-beta02' @@ -184,7 +186,9 @@ dependencies { implementation "com.github.CanHub:Android-Image-Cropper:4.1.0" - implementation "de.c1710:filemojicompat:1.0.18" + implementation "de.c1710:filemojicompat-ui:$filemojicompat_version" + implementation "de.c1710:filemojicompat:$filemojicompat_version" + implementation "de.c1710:filemojicompat-defaults:$filemojicompat_version" testImplementation "androidx.test.ext:junit:1.1.3" testImplementation "org.robolectric:robolectric:4.4" diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/34.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/34.json new file mode 100644 index 000000000..c13546908 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/34.json @@ -0,0 +1,815 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "7f766d68ab5d72a7988cd81c183e9a9d", + "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)", + "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 + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "instance" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7f766d68ab5d72a7988cd81c183e9a9d')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index a0963fdb6..51b104629 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -44,8 +44,7 @@ import androidx.appcompat.widget.PopupMenu import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.content.pm.ShortcutManagerCompat -import androidx.emoji.text.EmojiCompat -import androidx.emoji.text.EmojiCompat.InitCallback +import androidx.core.view.GravityCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager @@ -129,6 +128,7 @@ import com.mikepenz.materialdrawer.util.updateBadge import com.mikepenz.materialdrawer.widget.AccountHeaderView import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector +import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers @@ -177,13 +177,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private var accountLocked: Boolean = false - private val emojiInitCallback = object : InitCallback() { - override fun onInitialized() { - if (!isDestroyed) { - updateProfiles() - } - } - } + // We need to know if the emoji pack has been changed + private var selectedEmojiPack: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -308,6 +303,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje // Flush old media that was cached for sharing deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) } + + selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") } override fun onPause() { @@ -318,11 +315,25 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje override fun onResume() { super.onResume() NotificationHelper.clearNotificationsForActiveAccount(this, accountManager) + val currentEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") + if (currentEmojiPack != selectedEmojiPack) { + Log.d( + TAG, + "onResume: EmojiPack has been changed from %s to %s" + .format(selectedEmojiPack, currentEmojiPack) + ) + selectedEmojiPack = currentEmojiPack + recreate() + } streamingManager.resume() } override fun onStart() { super.onStart() + // For some reason the navigation drawer is opened when the activity is recreated + if (binding.mainDrawerLayout.isOpen) { + binding.mainDrawerLayout.closeDrawer(GravityCompat.START, false) + } keepScreenOn() } @@ -394,11 +405,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } - override fun onDestroy() { - super.onDestroy() - EmojiCompat.get().unregisterInitCallback(emojiInitCallback) - } - private fun forwardShare(intent: Intent) { val composeIntent = Intent(this, ComposeActivity::class.java) composeIntent.action = intent.action @@ -604,8 +610,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje textColor = ColorStateList.valueOf(ThemeUtils.getColor(this@MainActivity, R.attr.colorInfo)) } ) - - EmojiCompat.get().registerInitCallback(emojiInitCallback) } override fun onSaveInstanceState(outState: Bundle) { @@ -962,18 +966,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun fetchAnnouncements() { - mastodonApi.listAnnouncements(false) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_DESTROY) - .subscribe( - { announcements -> - unreadAnnouncementsCount = announcements.count { !it.read } - updateAnnouncementsBadge() - }, - { - Log.w(TAG, "Failed to fetch announcements.", it) - } - ) + lifecycleScope.launch { + mastodonApi.listAnnouncements(false) + .fold( + { announcements -> + unreadAnnouncementsCount = announcements.count { !it.read } + updateAnnouncementsBadge() + }, + { throwable -> + Log.w(TAG, "Failed to fetch announcements.", throwable) + } + ) + } } private fun updateAnnouncementsBadge() { @@ -983,11 +987,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun updateProfiles() { val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val profiles: MutableList = accountManager.getAllAccountsOrderedByActive().map { acc -> - val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header, animateEmojis)) - ProfileDrawerItem().apply { isSelected = acc.isActive - nameText = emojifiedName + nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis) iconUrl = acc.profilePictureUrl isNameShown = true identifier = acc.id diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 0339a7bcc..ded947a84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -19,18 +19,18 @@ import android.app.Application import android.content.Context import android.content.res.Configuration import android.util.Log -import androidx.emoji.text.EmojiCompat import androidx.preference.PreferenceManager import androidx.work.WorkManager import autodispose2.AutoDisposePlugins import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory import com.keylesspalace.tusky.di.AppInjector -import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.EmojiCompatFont import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.ThemeUtils import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector +import de.c1710.filemojicompat_defaults.DefaultEmojiPackList +import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper +import de.c1710.filemojicompat_ui.helpers.EmojiPreference import io.reactivex.rxjava3.plugins.RxJavaPlugins import org.conscrypt.Conscrypt import java.security.Security @@ -65,12 +65,10 @@ class TuskyApplication : Application(), HasAndroidInjector { val preferences = PreferenceManager.getDefaultSharedPreferences(this) - // init the custom emoji fonts - val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0) - val emojiConfig = EmojiCompatFont.byId(emojiSelection) - .getConfig(this) - .setReplaceAll(true) - EmojiCompat.init(emojiConfig) + // In this case, we want to have the emoji preferences merged with the other ones + // Copied from PreferenceManager.getDefaultSharedPreferenceName + EmojiPreference.sharedPreferenceName = packageName + "_preferences" + EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) // init night mode val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java index f48243899..6672fff39 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java @@ -45,8 +45,7 @@ public class AccountViewHolder extends RecyclerView.ViewHolder { ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar); if (showBotOverlay && account.getBot()) { avatarInset.setVisibility(View.VISIBLE); - avatarInset.setImageResource(R.drawable.ic_bot_24dp); - avatarInset.setBackgroundColor(0x50ffffff); + avatarInset.setImageResource(R.drawable.bot_badge); } else { avatarInset.setVisibility(View.GONE); } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 747ce4bfb..1ee28d35b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -32,6 +32,8 @@ import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.ColorRes; +import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.constraintlayout.widget.ConstraintLayout; @@ -49,6 +51,7 @@ import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; @@ -62,10 +65,8 @@ import com.keylesspalace.tusky.viewdata.StatusViewData; import net.accelf.yuito.QuoteInlineHelper; -import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; -import java.util.Locale; import at.connyduck.sparkbutton.helpers.Utils; @@ -94,6 +95,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private NotificationActionListener notificationActionListener; private AccountActionListener accountActionListener; private AdapterDataSource dataSource; + private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); public NotificationsAdapter(String accountId, AdapterDataSource dataSource, @@ -123,7 +125,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case VIEW_TYPE_STATUS_NOTIFICATION: { View view = inflater .inflate(R.layout.item_status_notification, parent, false); - return new StatusNotificationViewHolder(view, statusDisplayOptions); + return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter); } case VIEW_TYPE_FOLLOW: { View view = inflater @@ -182,8 +184,16 @@ public class NotificationsAdapter extends RecyclerView.Adapter { case VIEW_TYPE_STATUS: { StatusViewHolder holder = (StatusViewHolder) viewHolder; StatusViewData.Concrete status = concreteNotificaton.getStatusViewData(); - holder.setupWithStatus(status, - statusListener, statusDisplayOptions, payloadForHolder); + if (status == null) { + /* in some very rare cases servers sends null status even though they should not, + * we have to handle it somehow */ + holder.showStatusContent(false); + } else { + if (payloads == null) { + holder.showStatusContent(true); + } + holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); + } if (concreteNotificaton.getType() == Notification.Type.POLL) { holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId())); } else { @@ -196,6 +206,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { StatusViewData.Concrete statusViewData = concreteNotificaton.getStatusViewData(); if (payloadForHolder == null) { if (statusViewData == null) { + /* in some very rare cases servers sends null status even though they should not, + * we have to handle it somehow */ holder.showNotificationContent(false); } else { holder.showNotificationContent(true); @@ -205,7 +217,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { holder.setUsername(status.getAccount().getUsername()); holder.setCreatedAt(status.getCreatedAt()); - if (concreteNotificaton.getType() == Notification.Type.STATUS) { + if (concreteNotificaton.getType() == Notification.Type.STATUS || + concreteNotificaton.getType() == Notification.Type.UPDATE) { holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); } else { holder.setAvatars(status.getAccount().getAvatar(), @@ -285,7 +298,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } case STATUS: case FAVOURITE: - case REBLOG: { + case REBLOG: + case UPDATE: { return VIEW_TYPE_STATUS_NOTIFICATION; } case FOLLOW: @@ -389,19 +403,22 @@ public class NotificationsAdapter extends RecyclerView.Adapter { private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder private ConstraintLayout quoteContainer; private StatusDisplayOptions statusDisplayOptions; + private final AbsoluteTimeFormatter absoluteTimeFormatter; private String accountId; private String notificationId; private NotificationActionListener notificationActionListener; private StatusViewData.Concrete statusViewData; - private SimpleDateFormat shortSdf; - private SimpleDateFormat longSdf; private int avatarRadius48dp; private int avatarRadius36dp; private int avatarRadius24dp; - StatusNotificationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { + StatusNotificationViewHolder( + View itemView, + StatusDisplayOptions statusDisplayOptions, + AbsoluteTimeFormatter absoluteTimeFormatter + ) { super(itemView); message = itemView.findViewById(R.id.notification_top_text); statusNameBar = itemView.findViewById(R.id.status_name_bar); @@ -415,6 +432,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); this.statusDisplayOptions = statusDisplayOptions; + this.absoluteTimeFormatter = absoluteTimeFormatter; quoteContainer = itemView.findViewById(R.id.status_quote_inline_container); @@ -425,8 +443,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter { itemView.setOnClickListener(this); message.setOnClickListener(this); statusContent.setOnClickListener(this); - shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); - longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); @@ -456,17 +472,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { protected void setCreatedAt(@Nullable Date createdAt) { if (statusDisplayOptions.useAbsoluteTime()) { - String time; - if (createdAt != null) { - if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) { - time = longSdf.format(createdAt); - } else { - time = shortSdf.format(createdAt); - } - } else { - time = "??:??:??"; - } - timestampInfo.setText(time); + timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); } else { // This is the visible timestampInfo. String readout; @@ -490,6 +496,14 @@ public class NotificationsAdapter extends RecyclerView.Adapter { } } + Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) { + Drawable icon = ContextCompat.getDrawable(context, drawable); + if (icon != null) { + icon.setColorFilter(ContextCompat.getColor(context, color), PorterDuff.Mode.SRC_ATOP); + } + return icon; + } + void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) { this.statusViewData = notificationViewData.getStatusViewData(); @@ -502,35 +516,25 @@ public class NotificationsAdapter extends RecyclerView.Adapter { switch (type) { default: case FAVOURITE: { - icon = ContextCompat.getDrawable(context, R.drawable.ic_star_24dp); - if (icon != null) { - icon.setColorFilter(ContextCompat.getColor(context, - R.color.tusky_orange), PorterDuff.Mode.SRC_ATOP); - } - + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange); format = context.getString(R.string.notification_favourite_format); break; } case REBLOG: { - icon = ContextCompat.getDrawable(context, R.drawable.ic_repeat_24dp); - if (icon != null) { - icon.setColorFilter(ContextCompat.getColor(context, - R.color.tusky_blue), PorterDuff.Mode.SRC_ATOP); - } - + icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue); format = context.getString(R.string.notification_reblog_format); break; } case STATUS: { - icon = ContextCompat.getDrawable(context, R.drawable.ic_home_24dp); - if (icon != null) { - icon.setColorFilter(ContextCompat.getColor(context, - R.color.tusky_blue), PorterDuff.Mode.SRC_ATOP); - } - + icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue); format = context.getString(R.string.notification_subscription_format); break; } + case UPDATE: { + icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue); + format = context.getString(R.string.notification_update_format); + break; + } } message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); String wholeMessage = String.format(format, displayName); @@ -579,9 +583,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter { if (statusDisplayOptions.showBotOverlay() && isBot) { notificationAvatar.setVisibility(View.VISIBLE); - notificationAvatar.setBackgroundColor(0x50ffffff); Glide.with(notificationAvatar) - .load(R.drawable.ic_bot_24dp) + .load(ContextCompat.getDrawable(notificationAvatar.getContext(), R.drawable.bot_badge)) .into(notificationAvatar); } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt index 1a60d8603..9ffeca9ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -19,7 +19,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.content.ContextCompat -import androidx.emoji.text.EmojiCompat +import androidx.emoji2.text.EmojiCompat import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemPollBinding diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 83411d671..fdffc1a50 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -21,6 +21,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; @@ -28,6 +29,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestBuilder; +import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.resource.bitmap.CenterCrop; import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners; import com.google.android.material.button.MaterialButton; @@ -42,6 +44,7 @@ import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; @@ -58,10 +61,8 @@ import com.keylesspalace.tusky.viewdata.StatusViewData; import net.accelf.yuito.QuoteInlineHelper; import java.text.NumberFormat; -import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; -import java.util.Locale; import at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.helpers.Utils; @@ -82,6 +83,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private ImageButton quoteButton; private SparkButton bookmarkButton; private ImageButton moreButton; + private ConstraintLayout mediaContainer; protected MediaPreviewImageView[] mediaPreviews; private ImageView[] mediaOverlays; private TextView sensitiveMediaWarning; @@ -110,10 +112,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private TextView cardUrl; private PollAdapter pollAdapter; - private SimpleDateFormat shortSdf; - private SimpleDateFormat longSdf; - private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); + private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); protected int avatarRadius48dp; private int avatarRadius36dp; @@ -135,7 +135,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { bookmarkButton = itemView.findViewById(R.id.status_bookmark); moreButton = itemView.findViewById(R.id.status_more); - itemView.findViewById(R.id.status_media_preview_container).setClipToOutline(true); + mediaContainer = itemView.findViewById(R.id.status_media_preview_container); + mediaContainer.setClipToOutline(true); mediaPreviews = new MediaPreviewImageView[]{ itemView.findViewById(R.id.status_media_preview_0), @@ -180,9 +181,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); ((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false); - this.shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); - this.longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()); - this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); @@ -300,11 +298,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (statusDisplayOptions.showBotOverlay() && isBot) { avatarInset.setVisibility(View.VISIBLE); - avatarInset.setBackgroundColor(0x50ffffff); Glide.with(avatarInset) - .load(R.drawable.ic_bot_24dp) + // passing the drawable id directly into .load() ignores night mode https://github.com/bumptech/glide/issues/4692 + .load(ContextCompat.getDrawable(avatarInset.getContext(), R.drawable.bot_badge)) .into(avatarInset); - } else { avatarInset.setVisibility(View.GONE); } @@ -330,7 +327,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { protected void setCreatedAt(Date createdAt, StatusDisplayOptions statusDisplayOptions) { if (statusDisplayOptions.useAbsoluteTime()) { - timestampInfo.setText(getAbsoluteTime(createdAt)); + timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); } else { if (createdAt == null) { timestampInfo.setText("?m"); @@ -343,21 +340,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private String getAbsoluteTime(Date createdAt) { - if (createdAt == null) { - return "??:??:??"; - } - if (DateUtils.isToday(createdAt.getTime())) { - return shortSdf.format(createdAt); - } else { - return longSdf.format(createdAt); - } - } - private CharSequence getCreatedAtDescription(Date createdAt, StatusDisplayOptions statusDisplayOptions) { if (statusDisplayOptions.useAbsoluteTime()) { - return getAbsoluteTime(createdAt); + return absoluteTimeFormatter.format(createdAt, true); } else { /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" * as 17 meters instead of minutes. */ @@ -844,9 +830,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { this.setupWithStatus(status, listener, statusDisplayOptions, null); } - public void setupWithStatus(StatusViewData.Concrete status, - final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions, + public void setupWithStatus(@NonNull StatusViewData.Concrete status, + @NonNull final StatusActionListener listener, + @NonNull StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { if (payloads == null) { Status actionable = status.getActionable(); @@ -1139,7 +1125,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { return votesText; } else { if (statusDisplayOptions.useAbsoluteTime()) { - pollDurationInfo = context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.getExpiresAt())); + pollDurationInfo = context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.getExpiresAt(), false)); } else { pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp); } @@ -1261,6 +1247,28 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } + public void showStatusContent(boolean show) { + int visibility = show ? View.VISIBLE : View.GONE; + avatar.setVisibility(visibility); + avatarInset.setVisibility(visibility); + displayName.setVisibility(visibility); + username.setVisibility(visibility); + timestampInfo.setVisibility(visibility); + contentWarningDescription.setVisibility(visibility); + contentWarningButton.setVisibility(visibility); + content.setVisibility(visibility); + cardView.setVisibility(visibility); + mediaContainer.setVisibility(visibility); + pollOptions.setVisibility(visibility); + pollButton.setVisibility(visibility); + pollDescription.setVisibility(visibility); + replyButton.setVisibility(visibility); + reblogButton.setVisibility(visibility); + favouriteButton.setVisibility(visibility); + bookmarkButton.setVisibility(visibility); + moreButton.setVisibility(visibility); + } + private static String formatDuration(double durationInSeconds) { int seconds = (int) Math.round(durationInSeconds) % 60; int minutes = (int) durationInSeconds % 3600 / 60; diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index e2017e957..1a68af7e3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -1,15 +1,13 @@ package com.keylesspalace.tusky.adapter; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; import android.graphics.drawable.Drawable; import android.text.method.LinkMovementMethod; import android.view.View; import android.widget.TextView; -import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; @@ -105,10 +103,10 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { } @Override - public void setupWithStatus(final StatusViewData.Concrete status, - final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions, - @Nullable Object payloads) { + public void setupWithStatus(@NonNull final StatusViewData.Concrete status, + @NonNull final StatusActionListener listener, + @NonNull StatusDisplayOptions statusDisplayOptions, + @Nullable Object payloads) { super.setupWithStatus(status, listener, statusDisplayOptions, payloads); setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status if (payloads == null) { @@ -121,20 +119,6 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder { } setApplication(status.getActionable().getApplication()); - - View.OnLongClickListener longClickListener = view -> { - TextView textView = (TextView) view; - ClipboardManager clipboard = (ClipboardManager) view.getContext().getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("toot", textView.getText()); - clipboard.setPrimaryClip(clip); - - Toast.makeText(view.getContext(), R.string.copy_to_clipboard_success, Toast.LENGTH_SHORT).show(); - - return true; - }; - - content.setOnLongClickListener(longClickListener); - contentWarningDescription.setOnLongClickListener(longClickListener); } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index b054aea9c..93c475643 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -22,6 +22,7 @@ import android.view.View; import android.widget.Button; import android.widget.TextView; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; @@ -58,9 +59,9 @@ public class StatusViewHolder extends StatusBaseViewHolder { } @Override - public void setupWithStatus(StatusViewData.Concrete status, - final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions, + public void setupWithStatus(@NonNull StatusViewData.Concrete status, + @NonNull final StatusActionListener listener, + @NonNull StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { if (payloads == null) { @@ -129,4 +130,9 @@ public class StatusViewHolder extends StatusBaseViewHolder { content.setFilters(NO_INPUT_FILTER); } } + + public void showStatusContent(boolean show) { + super.showStatusContent(show); + contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE); + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 33987604f..a22f90f30 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -20,8 +20,6 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter import android.os.Bundle import android.text.Editable import android.view.Menu @@ -39,7 +37,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding -import androidx.emoji.text.EmojiCompat +import androidx.emoji2.text.EmojiCompat import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.MarginPageTransformer @@ -379,12 +377,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI .show() } } - viewModel.accountFieldData.observe( - this - ) { - accountFieldAdapter.fields = it - accountFieldAdapter.notifyDataSetChanged() - } viewModel.noteSaved.observe(this) { binding.saveNoteInfo.visible(it, View.INVISIBLE) } @@ -416,7 +408,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) - // accountFieldAdapter.fields = account.fields ?: emptyList() + accountFieldAdapter.fields = account.fields ?: emptyList() accountFieldAdapter.emojis = account.emojis ?: emptyList() accountFieldAdapter.notifyDataSetChanged() @@ -504,13 +496,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI loadAvatar(movedAccount.avatar, binding.accountMovedAvatar, avatarRadius, animateAvatar) binding.accountMovedText.text = getString(R.string.account_moved_description, movedAccount.name) - - // this is necessary because API 19 can't handle vector compound drawables - val movedIcon = ContextCompat.getDrawable(this, R.drawable.ic_briefcase)?.mutate() - val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary) - movedIcon?.colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) - - binding.accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt index d51bb1452..86acb8132 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.components.account -import android.text.method.LinkMovementMethod import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView @@ -23,11 +22,8 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAccountFieldBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Field -import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.BindingHolder -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.createClickableText import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText @@ -38,7 +34,7 @@ class AccountFieldAdapter( ) : RecyclerView.Adapter>() { var emojis: List = emptyList() - var fields: List> = emptyList() + var fields: List = emptyList() override fun getItemCount() = fields.size @@ -48,32 +44,20 @@ class AccountFieldAdapter( } override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val proofOrField = fields[position] + val field = fields[position] val nameTextView = holder.binding.accountFieldName val valueTextView = holder.binding.accountFieldValue - if (proofOrField.isLeft()) { - val identityProof = proofOrField.asLeft() + val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis) + nameTextView.text = emojifiedName - nameTextView.text = identityProof.provider - valueTextView.text = createClickableText(identityProof.username, identityProof.profileUrl) - - valueTextView.movementMethod = LinkMovementMethod.getInstance() + val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis) + setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener) + if (field.verifiedAt != null) { valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) } else { - val field = proofOrField.asRight() - val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis) - nameTextView.text = emojifiedName - - val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis) - setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener) - - if (field.verifiedAt != null) { - valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) - } else { - valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) - } + valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt index 6fa988ace..664651eb2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt @@ -10,17 +10,13 @@ import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account -import com.keylesspalace.tusky.entity.Field -import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.Success -import com.keylesspalace.tusky.util.combineOptionalLiveData import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.Disposable import retrofit2.Call @@ -40,13 +36,6 @@ class AccountViewModel @Inject constructor( val noteSaved = MutableLiveData() - private val identityProofData = MutableLiveData>() - - val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs -> - identityProofs.orEmpty().map { Either.Left(it) } - .plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) }) - } - val isRefreshing = MutableLiveData().apply { value = false } private var isDataLoading = false @@ -106,22 +95,6 @@ class AccountViewModel @Inject constructor( } } - private fun obtainIdentityProof(reload: Boolean = false) { - if (identityProofData.value == null || reload) { - - mastodonApi.identityProofs(accountId) - .subscribe( - { proofs -> - identityProofData.postValue(proofs) - }, - { t -> - Log.w(TAG, "failed obtaining identity proofs", t) - } - ) - .autoDispose() - } - } - fun changeFollowState() { val relationship = relationshipData.value?.data if (relationship?.following == true || relationship?.requested == true) { @@ -314,7 +287,6 @@ class AccountViewModel @Inject constructor( return accountId.let { obtainAccount(isReload) - obtainIdentityProof() if (!isSelf) obtainRelationship(isReload) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt index 4b5e7aa51..70ebfc7dc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -32,6 +32,7 @@ import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.EmojiSpan import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText import java.lang.ref.WeakReference @@ -60,7 +61,7 @@ class AnnouncementAdapter( val chips = holder.binding.chipGroup val addReactionChip = holder.binding.addReactionChip - val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis) + val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify(item.emojis, text, animateEmojis) setClickableText(text, emojifiedText, item.mentions, item.tags, listener) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt index 10dc303f6..0934c48fc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt @@ -18,32 +18,26 @@ package com.keylesspalace.tusky.components.announcements import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.InstanceEntity +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource -import com.keylesspalace.tusky.util.RxAwareViewModel import com.keylesspalace.tusky.util.Success -import io.reactivex.rxjava3.core.Single -import kotlinx.coroutines.rx3.rxSingle +import kotlinx.coroutines.launch import javax.inject.Inject class AnnouncementsViewModel @Inject constructor( - accountManager: AccountManager, - private val appDatabase: AppDatabase, + private val instanceInfoRepo: InstanceInfoRepository, private val mastodonApi: MastodonApi, private val eventHub: EventHub -) : RxAwareViewModel() { +) : ViewModel() { private val announcementsMutable = MutableLiveData>>() val announcements: LiveData>> = announcementsMutable @@ -52,156 +46,130 @@ class AnnouncementsViewModel @Inject constructor( val emojis: LiveData> = emojisMutable init { - Single.zip( - mastodonApi.getCustomEmojis(), - appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) - .map> { Either.Left(it) } - .onErrorResumeNext { - rxSingle { - mastodonApi.getInstance().getOrThrow() - }.map { Either.Right(it) } - } - ) { emojis, either -> - either.asLeftOrNull()?.copy(emojiList = emojis) - ?: InstanceEntity( - accountManager.activeAccount?.domain!!, - emojis, - either.asRight().configuration?.statuses?.maxCharacters ?: either.asRight().maxTootChars, - either.asRight().configuration?.polls?.maxOptions ?: either.asRight().pollConfiguration?.maxOptions, - either.asRight().configuration?.polls?.maxCharactersPerOption ?: either.asRight().pollConfiguration?.maxOptionChars, - either.asRight().configuration?.polls?.minExpiration ?: either.asRight().pollConfiguration?.minExpiration, - either.asRight().configuration?.polls?.maxExpiration ?: either.asRight().pollConfiguration?.maxExpiration, - either.asRight().configuration?.statuses?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH, - either.asRight().version - ) + viewModelScope.launch { + emojisMutable.postValue(instanceInfoRepo.getEmojis()) } - .doOnSuccess { - appDatabase.instanceDao().insertOrReplace(it) - } - .subscribe( - { - emojisMutable.postValue(it.emojiList.orEmpty()) - }, - { - Log.w(TAG, "Failed to get custom emojis.", it) - } - ) - .autoDispose() } fun load() { - announcementsMutable.postValue(Loading()) - mastodonApi.listAnnouncements() - .subscribe( - { - announcementsMutable.postValue(Success(it)) - it.filter { announcement -> !announcement.read } - .forEach { announcement -> - mastodonApi.dismissAnnouncement(announcement.id) - .subscribe( - { - eventHub.dispatch(AnnouncementReadEvent(announcement.id)) - }, - { throwable -> - Log.d(TAG, "Failed to mark announcement as read.", throwable) - } - ) - .autoDispose() - } - }, - { - announcementsMutable.postValue(Error(cause = it)) - } - ) - .autoDispose() + viewModelScope.launch { + announcementsMutable.postValue(Loading()) + mastodonApi.listAnnouncements() + .fold( + { + announcementsMutable.postValue(Success(it)) + it.filter { announcement -> !announcement.read } + .forEach { announcement -> + mastodonApi.dismissAnnouncement(announcement.id) + .fold( + { + eventHub.dispatch(AnnouncementReadEvent(announcement.id)) + }, + { throwable -> + Log.d( + TAG, + "Failed to mark announcement as read.", + throwable + ) + } + ) + } + }, + { + announcementsMutable.postValue(Error(cause = it)) + } + ) + } } fun addReaction(announcementId: String, name: String) { - mastodonApi.addAnnouncementReaction(announcementId, name) - .subscribe( - { - announcementsMutable.postValue( - Success( - announcements.value!!.data!!.map { announcement -> - if (announcement.id == announcementId) { - announcement.copy( - reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { - announcement.reactions.map { reaction -> + viewModelScope.launch { + mastodonApi.addAnnouncementReaction(announcementId, name) + .fold( + { + announcementsMutable.postValue( + Success( + announcements.value!!.data!!.map { announcement -> + if (announcement.id == announcementId) { + announcement.copy( + reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { + announcement.reactions.map { reaction -> + if (reaction.name == name) { + reaction.copy( + count = reaction.count + 1, + me = true + ) + } else { + reaction + } + } + } else { + listOf( + *announcement.reactions.toTypedArray(), + emojis.value!!.find { emoji -> emoji.shortcode == name } + !!.run { + Announcement.Reaction( + name, + 1, + true, + url, + staticUrl + ) + } + ) + } + ) + } else { + announcement + } + } + ) + ) + }, + { + Log.w(TAG, "Failed to add reaction to the announcement.", it) + } + ) + } + } + + fun removeReaction(announcementId: String, name: String) { + viewModelScope.launch { + mastodonApi.removeAnnouncementReaction(announcementId, name) + .fold( + { + announcementsMutable.postValue( + Success( + announcements.value!!.data!!.map { announcement -> + if (announcement.id == announcementId) { + announcement.copy( + reactions = announcement.reactions.mapNotNull { reaction -> if (reaction.name == name) { - reaction.copy( - count = reaction.count + 1, - me = true - ) + if (reaction.count > 1) { + reaction.copy( + count = reaction.count - 1, + me = false + ) + } else { + null + } } else { reaction } } - } else { - listOf( - *announcement.reactions.toTypedArray(), - emojis.value!!.find { emoji -> emoji.shortcode == name } - !!.run { - Announcement.Reaction( - name, - 1, - true, - url, - staticUrl - ) - } - ) - } - ) - } else { - announcement + ) + } else { + announcement + } } - } + ) ) - ) - }, - { - Log.w(TAG, "Failed to add reaction to the announcement.", it) - } - ) - .autoDispose() - } - - fun removeReaction(announcementId: String, name: String) { - mastodonApi.removeAnnouncementReaction(announcementId, name) - .subscribe( - { - announcementsMutable.postValue( - Success( - announcements.value!!.data!!.map { announcement -> - if (announcement.id == announcementId) { - announcement.copy( - reactions = announcement.reactions.mapNotNull { reaction -> - if (reaction.name == name) { - if (reaction.count > 1) { - reaction.copy( - count = reaction.count - 1, - me = false - ) - } else { - null - } - } else { - reaction - } - } - ) - } else { - announcement - } - } - ) - ) - }, - { - Log.w(TAG, "Failed to remove reaction from the announcement.", it) - } - ) - .autoDispose() + }, + { + Log.w(TAG, "Failed to remove reaction from the announcement.", it) + } + ) + } } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index 18ce34e9e..8aaa54cff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -53,6 +53,7 @@ import androidx.core.view.OnReceiveContentListener import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager @@ -69,6 +70,7 @@ import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityComposeBinding import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.DraftAttachment @@ -97,6 +99,7 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import java.io.File import java.io.IOException @@ -129,8 +132,8 @@ class ComposeActivity : private var photoUploadUri: Uri? = null @VisibleForTesting - var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT - var charactersReservedPerUrl = DEFAULT_MAXIMUM_URL_LENGTH + var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT + var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL private val viewModel: ComposeViewModel by viewModels { viewModelFactory } @@ -382,11 +385,10 @@ class ComposeActivity : private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) { withLifecycleContext { - viewModel.instanceParams.observe { instanceData -> + viewModel.instanceInfo.observe { instanceData -> maximumTootCharacters = instanceData.maxChars charactersReservedPerUrl = instanceData.charactersReservedPerUrl updateVisibleCharactersLeft() - binding.composeScheduleButton.visible(instanceData.supportsScheduled) } viewModel.emoji.observe { emoji -> setEmojiList(emoji) } combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning -> @@ -740,7 +742,7 @@ class ComposeActivity : private fun openPollDialog() { addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - val instanceParams = viewModel.instanceParams.value!! + val instanceParams = viewModel.instanceInfo.value!! showAddPollDialog( this, viewModel.poll.value, instanceParams.pollMaxOptions, instanceParams.pollMaxLength, instanceParams.pollMinDuration, instanceParams.pollMaxDuration, @@ -947,25 +949,15 @@ class ComposeActivity : } private fun pickMedia(uri: Uri) { - withLifecycleContext { - viewModel.pickMedia(uri).observe { exceptionOrItem -> - exceptionOrItem.asLeftOrNull()?.let { - val errorId = when (it) { - is VideoSizeException -> { - R.string.error_video_upload_size - } - is AudioSizeException -> { - R.string.error_audio_upload_size - } - is VideoOrImageException -> { - R.string.error_media_upload_image_or_video - } - else -> { - R.string.error_media_upload_opening - } - } - displayTransientError(errorId) + lifecycleScope.launch { + viewModel.pickMedia(uri).onFailure { throwable -> + val errorId = when (throwable) { + is VideoSizeException -> R.string.error_video_upload_size + is AudioSizeException -> R.string.error_audio_upload_size + is VideoOrImageException -> R.string.error_media_upload_image_or_video + else -> R.string.error_media_upload_opening } + displayTransientError(errorId) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index dc1253e80..4d88ec279 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -20,14 +20,14 @@ import android.util.Log import androidx.core.net.toUri import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.drafts.DraftHelper +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.InstanceEntity import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll @@ -35,9 +35,6 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.StatusToSend -import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.RxAwareViewModel -import com.keylesspalace.tusky.util.VersionUtils import com.keylesspalace.tusky.util.combineLiveData import com.keylesspalace.tusky.util.filter import com.keylesspalace.tusky.util.map @@ -45,10 +42,12 @@ import com.keylesspalace.tusky.util.randomAlphanumericString import com.keylesspalace.tusky.util.toLiveData import com.keylesspalace.tusky.util.withoutFirstWhich import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.Disposable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.rxSingle +import kotlinx.coroutines.withContext import java.util.Locale import javax.inject.Inject @@ -58,8 +57,8 @@ class ComposeViewModel @Inject constructor( private val mediaUploader: MediaUploader, private val serviceClient: ServiceClient, private val draftHelper: DraftHelper, - private val db: AppDatabase -) : RxAwareViewModel() { + private val instanceInfoRepo: InstanceInfoRepository +) : ViewModel() { private var replyingStatusAuthor: String? = null private var replyingStatusContent: String? = null @@ -76,19 +75,8 @@ class ComposeViewModel @Inject constructor( private var contentWarningStateChanged: Boolean = false private var modifiedInitialState: Boolean = false - private val instance: MutableLiveData = MutableLiveData(null) + val instanceInfo: MutableLiveData = MutableLiveData() - val instanceParams: LiveData = instance.map { instance -> - ComposeInstanceParams( - maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, - pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, - pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, - pollMinDuration = instance?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, - pollMaxDuration = instance?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, - charactersReservedPerUrl = instance?.charactersReservedPerUrl ?: DEFAULT_MAXIMUM_URL_LENGTH, - supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false - ) - } val emoji: MutableLiveData?> = MutableLiveData() val markMediaAsSensitive = mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) @@ -104,74 +92,41 @@ class ComposeViewModel @Inject constructor( val domain = accountManager.activeAccount?.domain!! - private val mediaToDisposable = mutableMapOf() + private val mediaToJob = mutableMapOf() private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty() fun loadInstanceDataFromNetwork(loadActually: Boolean) { - when (loadActually) { - true -> Single.zip( - api.getCustomEmojis(), rxSingle { api.getInstance().getOrThrow() } - ) { emojis, instance -> - InstanceEntity( - instance = accountManager.activeAccount?.domain!!, - emojiList = emojis, - maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars, - maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions, - maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars, - minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration, - maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration, - charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl, - version = instance.version - ) - } - false -> Single.error(Exception("skipped network access")) + viewModelScope.launch { + emoji.postValue(when (loadActually) { + true -> instanceInfoRepo.getEmojis() + false -> instanceInfoRepo.getCachedEmojis() + }) + } + viewModelScope.launch { + instanceInfo.postValue(when (loadActually) { + true -> instanceInfoRepo.getInstanceInfo() + false -> instanceInfoRepo.getCachedInstanceInfo() + }) } - .doOnSuccess { - db.instanceDao().insertOrReplace(it) - } - .onErrorResumeNext { - db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!) - } - .subscribe( - { instanceEntity -> - emoji.postValue(instanceEntity.emojiList) - instance.postValue(instanceEntity) - }, - { throwable -> - // this can happen on network error when no cached data is available - Log.w(TAG, "error loading instance data", throwable) - } - ) - .autoDispose() } - fun pickMedia(uri: Uri, description: String? = null): LiveData> { - // We are not calling .toLiveData() here because we don't want to stop the process when - // the Activity goes away temporarily (like on screen rotation). - val liveData = MutableLiveData>() - mediaUploader.prepareMedia(uri) - .map { (type, uri, size) -> - val mediaItems = media.value!! - if (type != QueuedMedia.Type.IMAGE && - mediaItems.isNotEmpty() && - mediaItems[0].type == QueuedMedia.Type.IMAGE - ) { - throw VideoOrImageException() - } else { - addMediaToQueue(type, uri, size, description) - } + suspend fun pickMedia(mediaUri: Uri, description: String? = null): Result = withContext(Dispatchers.IO) { + try { + val (type, uri, size) = mediaUploader.prepareMedia(mediaUri) + val mediaItems = media.value!! + if (type != QueuedMedia.Type.IMAGE && + mediaItems.isNotEmpty() && + mediaItems[0].type == QueuedMedia.Type.IMAGE + ) { + Result.failure(VideoOrImageException()) + } else { + val queuedMedia = addMediaToQueue(type, uri, size, description) + Result.success(queuedMedia) } - .subscribe( - { queuedMedia -> - liveData.postValue(Either.Right(queuedMedia)) - }, - { error -> - liveData.postValue(Either.Left(error)) - } - ) - .autoDispose() - return liveData + } catch (e: Exception) { + Result.failure(e) + } } private fun addMediaToQueue( @@ -187,13 +142,17 @@ class ComposeViewModel @Inject constructor( mediaSize = mediaSize, description = description ) - media.value = media.value!! + mediaItem - mediaToDisposable[mediaItem.localId] = mediaUploader - .uploadMedia(mediaItem) - .subscribe( - { event -> + media.postValue(media.value!! + mediaItem) + mediaToJob[mediaItem.localId] = viewModelScope.launch { + mediaUploader + .uploadMedia(mediaItem) + .catch { error -> + media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList()) + uploadError.postValue(error) + } + .collect { event -> val item = media.value?.find { it.localId == mediaItem.localId } - ?: return@subscribe + ?: return@collect val newMediaItem = when (event) { is UploadEvent.ProgressEvent -> item.copy(uploadPercent = event.percentage) @@ -211,12 +170,8 @@ class ComposeViewModel @Inject constructor( } ) } - }, - { error -> - media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList()) - uploadError.postValue(error) } - ) + } return mediaItem } @@ -226,7 +181,7 @@ class ComposeViewModel @Inject constructor( } fun removeMediaFromQueue(item: QueuedMedia) { - mediaToDisposable[item.localId]?.dispose() + mediaToJob[item.localId]?.cancel() media.value = media.value!!.withoutFirstWhich { it.localId == item.localId } } @@ -307,13 +262,15 @@ class ComposeViewModel @Inject constructor( val sendObservable = media .filter { items -> items.all { it.uploadPercent == -1 } } .map { - val mediaIds = ArrayList() - val mediaUris = ArrayList() - val mediaDescriptions = ArrayList() + val mediaIds: MutableList = mutableListOf() + val mediaUris: MutableList = mutableListOf() + val mediaDescriptions: MutableList = mutableListOf() + val mediaProcessed: MutableList = mutableListOf() for (item in media.value!!) { mediaIds.add(item.id!!) mediaUris.add(item.uri) mediaDescriptions.add(item.description ?: "") + mediaProcessed.add(false) } val tootToSend = StatusToSend( @@ -333,7 +290,8 @@ class ComposeViewModel @Inject constructor( accountId = accountManager.activeAccount!!.id, draftId = draftId, idempotencyKey = randomAlphanumericString(16), - retries = 0 + retries = 0, + mediaProcessed = mediaProcessed ) serviceClient.sendToot(tootToSend) @@ -342,35 +300,24 @@ class ComposeViewModel @Inject constructor( return combineLiveData(deletionObservable, sendObservable) { _, _ -> } } - fun updateDescription(localId: Long, description: String): LiveData { + suspend fun updateDescription(localId: Long, description: String): Boolean { val newList = media.value!!.toMutableList() val index = newList.indexOfFirst { it.localId == localId } if (index != -1) { newList[index] = newList[index].copy(description = description) } media.value = newList - val completedCaptioningLiveData = MutableLiveData() - media.observeForever(object : Observer> { - override fun onChanged(mediaItems: List) { - val updatedItem = mediaItems.find { it.localId == localId } - if (updatedItem == null) { - media.removeObserver(this) - } else if (updatedItem.id != null) { - api.updateMedia(updatedItem.id, description) - .subscribe( - { - completedCaptioningLiveData.postValue(true) - }, - { - completedCaptioningLiveData.postValue(false) - } - ) - .autoDispose() - media.removeObserver(this) - } - } - }) - return completedCaptioningLiveData + val updatedItem = newList.find { it.localId == localId } + if (updatedItem?.id != null) { + return api.updateMedia(updatedItem.id, description) + .fold({ + true + }, { throwable -> + Log.w(TAG, "failed to update media", throwable) + false + }) + } + return true } fun searchAutocompleteSuggestions(token: String): List { @@ -456,7 +403,11 @@ class ComposeViewModel @Inject constructor( val draftAttachments = composeOptions?.draftAttachments if (draftAttachments != null) { // when coming from DraftActivity - draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) } + draftAttachments.forEach { attachment -> + viewModelScope.launch { + pickMedia(attachment.uri, attachment.description) + } + } } else composeOptions?.mediaAttachments?.forEach { a -> // when coming from redraft or ScheduledTootActivity val mediaType = when (a.type) { @@ -510,13 +461,6 @@ class ComposeViewModel @Inject constructor( scheduledAt.value = newScheduledAt } - override fun onCleared() { - for (uploadDisposable in mediaToDisposable.values) { - uploadDisposable.dispose() - } - super.onCleared() - } - private companion object { const val TAG = "ComposeViewModel" } @@ -524,29 +468,6 @@ class ComposeViewModel @Inject constructor( fun mutableLiveData(default: T) = MutableLiveData().apply { value = default } -const val DEFAULT_CHARACTER_LIMIT = 500 -private const val DEFAULT_MAX_OPTION_COUNT = 4 -private const val DEFAULT_MAX_OPTION_LENGTH = 50 -private const val DEFAULT_MIN_POLL_DURATION = 300 -private const val DEFAULT_MAX_POLL_DURATION = 604800 - -// Mastodon only counts URLs as this long in terms of status character limits -const val DEFAULT_MAXIMUM_URL_LENGTH = 23 - -val CAN_USE_QUOTE_ID = arrayOf("odakyu.app", "itabashi.0j0.jp", "biwakodon.com", "dtp-mstdn.jp", "nitiasa.com", - "comm.cx", "fedibird.com", "qoto.org", "kurage.cc", "m.eula.dev", "otogamer.me", "sgp.hostdon.ne.jp", - "pomdon.work", "obapom.work") - -data class ComposeInstanceParams( - val maxChars: Int, - val pollMaxOptions: Int, - val pollMaxLength: Int, - val pollMinDuration: Int, - val pollMaxDuration: Int, - val charactersReservedPerUrl: Int, - val supportsScheduled: Boolean -) - /** * Thrown when trying to add an image when video is already present or the other way around */ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java deleted file mode 100644 index 880a41679..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/DownsizeImageTask.java +++ /dev/null @@ -1,154 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * 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 . */ - -package com.keylesspalace.tusky.components.compose; - -import android.content.ContentResolver; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.net.Uri; -import android.os.AsyncTask; - -import com.keylesspalace.tusky.util.IOUtils; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.InputStream; -import java.io.OutputStream; - -import static com.keylesspalace.tusky.util.MediaUtilsKt.calculateInSampleSize; -import static com.keylesspalace.tusky.util.MediaUtilsKt.getImageOrientation; -import static com.keylesspalace.tusky.util.MediaUtilsKt.reorientBitmap; - -/** - * Reduces the file size of images to fit under a given limit by resizing them, maintaining both - * aspect ratio and orientation. - */ -public class DownsizeImageTask extends AsyncTask { - private int sizeLimit; - private ContentResolver contentResolver; - private Listener listener; - private File tempFile; - - /** - * @param sizeLimit the maximum number of bytes each image can take - * @param contentResolver to resolve the specified images' URIs - * @param tempFile the file where the result will be stored - * @param listener to whom the results are given - */ - public DownsizeImageTask(int sizeLimit, ContentResolver contentResolver, File tempFile, Listener listener) { - this.sizeLimit = sizeLimit; - this.contentResolver = contentResolver; - this.tempFile = tempFile; - this.listener = listener; - } - - @Override - protected Boolean doInBackground(Uri... uris) { - boolean result = DownsizeImageTask.resize(uris, sizeLimit, contentResolver, tempFile); - if (isCancelled()) { - return false; - } - return result; - } - - @Override - protected void onPostExecute(Boolean successful) { - if (successful) { - listener.onSuccess(tempFile); - } else { - listener.onFailure(); - } - super.onPostExecute(successful); - } - - public static boolean resize(Uri[] uris, int sizeLimit, ContentResolver contentResolver, - File tempFile) { - for (Uri uri : uris) { - InputStream inputStream; - try { - inputStream = contentResolver.openInputStream(uri); - } catch (FileNotFoundException e) { - return false; - } - // Initially, just get the image dimensions. - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inJustDecodeBounds = true; - BitmapFactory.decodeStream(inputStream, null, options); - IOUtils.closeQuietly(inputStream); - // Get EXIF data, for orientation info. - int orientation = getImageOrientation(uri, contentResolver); - /* Unfortunately, there isn't a determined worst case compression ratio for image - * formats. So, the only way to tell if they're too big is to compress them and - * test, and keep trying at smaller sizes. The initial estimate should be good for - * many cases, so it should only iterate once, but the loop is used to be absolutely - * sure it gets downsized to below the limit. */ - int scaledImageSize = 1024; - do { - OutputStream stream; - try { - stream = new FileOutputStream(tempFile); - } catch (FileNotFoundException e) { - return false; - } - try { - inputStream = contentResolver.openInputStream(uri); - } catch (FileNotFoundException e) { - return false; - } - options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize); - options.inJustDecodeBounds = false; - Bitmap scaledBitmap; - try { - scaledBitmap = BitmapFactory.decodeStream(inputStream, null, options); - } catch (OutOfMemoryError error) { - return false; - } finally { - IOUtils.closeQuietly(inputStream); - } - if (scaledBitmap == null) { - return false; - } - Bitmap reorientedBitmap = reorientBitmap(scaledBitmap, orientation); - if (reorientedBitmap == null) { - scaledBitmap.recycle(); - return false; - } - Bitmap.CompressFormat format; - /* It's not likely the user will give transparent images over the upload limit, but - * if they do, make sure the transparency is retained. */ - if (!reorientedBitmap.hasAlpha()) { - format = Bitmap.CompressFormat.JPEG; - } else { - format = Bitmap.CompressFormat.PNG; - } - reorientedBitmap.compress(format, 85, stream); - reorientedBitmap.recycle(); - scaledImageSize /= 2; - } while (tempFile.length() > sizeLimit); - } - return true; - } - - /** - * Used to communicate the results of the task. - */ - public interface Listener { - void onSuccess(File file); - - void onFailure(); - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt new file mode 100644 index 000000000..a0215847e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt @@ -0,0 +1,101 @@ +/* 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 . */ + +package com.keylesspalace.tusky.components.compose + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat +import android.graphics.BitmapFactory +import android.net.Uri +import com.keylesspalace.tusky.util.IOUtils +import com.keylesspalace.tusky.util.calculateInSampleSize +import com.keylesspalace.tusky.util.getImageOrientation +import com.keylesspalace.tusky.util.reorientBitmap +import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream + +/** + * @param uri the uri pointing to the input file + * @param sizeLimit the maximum number of bytes the output image is allowed to have + * @param contentResolver to resolve the specified input uri + * @param tempFile the file where the result will be stored + * @return true when the image was successfully resized, false otherwise + */ +fun downsizeImage( + uri: Uri, + sizeLimit: Int, + contentResolver: ContentResolver, + tempFile: File +): Boolean { + + val decodeBoundsInputStream = try { + contentResolver.openInputStream(uri) + } catch (e: FileNotFoundException) { + return false + } + // Initially, just get the image dimensions. + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeStream(decodeBoundsInputStream, null, options) + IOUtils.closeQuietly(decodeBoundsInputStream) + // Get EXIF data, for orientation info. + val orientation = getImageOrientation(uri, contentResolver) + /* Unfortunately, there isn't a determined worst case compression ratio for image + * formats. So, the only way to tell if they're too big is to compress them and + * test, and keep trying at smaller sizes. The initial estimate should be good for + * many cases, so it should only iterate once, but the loop is used to be absolutely + * sure it gets downsized to below the limit. */ + var scaledImageSize = 1024 + do { + val outputStream = try { + FileOutputStream(tempFile) + } catch (e: FileNotFoundException) { + return false + } + val decodeBitmapInputStream = try { + contentResolver.openInputStream(uri) + } catch (e: FileNotFoundException) { + return false + } + options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize) + options.inJustDecodeBounds = false + val scaledBitmap: Bitmap = try { + BitmapFactory.decodeStream(decodeBitmapInputStream, null, options) + } catch (error: OutOfMemoryError) { + return false + } finally { + IOUtils.closeQuietly(decodeBitmapInputStream) + } ?: return false + + val reorientedBitmap = reorientBitmap(scaledBitmap, orientation) + if (reorientedBitmap == null) { + scaledBitmap.recycle() + return false + } + /* Retain transparency if there is any by encoding as png */ + val format: CompressFormat = if (!reorientedBitmap.hasAlpha()) { + CompressFormat.JPEG + } else { + CompressFormat.PNG + } + reorientedBitmap.compress(format, 85, outputStream) + reorientedBitmap.recycle() + scaledImageSize /= 2 + } while (tempFile.length() > sizeLimit) + + return true +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index 631e15dd9..dfa7e6bf8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -32,9 +32,14 @@ import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN import com.keylesspalace.tusky.util.getImageSquarePixels import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.randomAlphanumericString -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import java.io.File @@ -72,61 +77,40 @@ class MediaUploader @Inject constructor( private val context: Context, private val mastodonApi: MastodonApi ) { - fun uploadMedia(media: QueuedMedia): Observable { - return Observable - .fromCallable { - if (shouldResizeMedia(media)) { - downsize(media) - } else media + + @OptIn(ExperimentalCoroutinesApi::class) + fun uploadMedia(media: QueuedMedia): Flow { + return flow { + if (shouldResizeMedia(media)) { + emit(downsize(media)) + } else { + emit(media) } - .switchMap { upload(it) } - .subscribeOn(Schedulers.io()) + } + .flatMapLatest { upload(it) } + .flowOn(Dispatchers.IO) } - fun prepareMedia(inUri: Uri): Single { - return Single.fromCallable { - var mediaSize = MEDIA_SIZE_UNKNOWN - var uri = inUri - var mimeType: String? = null + fun prepareMedia(inUri: Uri): PreparedMedia { + var mediaSize = MEDIA_SIZE_UNKNOWN + var uri = inUri + val mimeType: String? - try { - when (inUri.scheme) { - ContentResolver.SCHEME_CONTENT -> { + try { + when (inUri.scheme) { + ContentResolver.SCHEME_CONTENT -> { - mimeType = contentResolver.getType(uri) + mimeType = contentResolver.getType(uri) - val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") + val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") - contentResolver.openInputStream(inUri).use { input -> - if (input == null) { - Log.w(TAG, "Media input is null") - uri = inUri - return@use - } - val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) - FileOutputStream(file.absoluteFile).use { out -> - input.copyTo(out) - uri = FileProvider.getUriForFile( - context, - BuildConfig.APPLICATION_ID + ".fileprovider", - file - ) - mediaSize = getMediaSize(contentResolver, uri) - } + contentResolver.openInputStream(inUri).use { input -> + if (input == null) { + Log.w(TAG, "Media input is null") + uri = inUri + return@use } - } - ContentResolver.SCHEME_FILE -> { - val path = uri.path - if (path == null) { - Log.w(TAG, "empty uri path $uri") - throw CouldNotOpenFileException() - } - val inputFile = File(path) - val suffix = inputFile.name.substringAfterLast('.', "tmp") - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix) - val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir) - val input = FileInputStream(inputFile) - + val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) FileOutputStream(file.absoluteFile).use { out -> input.copyTo(out) uri = FileProvider.getUriForFile( @@ -137,53 +121,74 @@ class MediaUploader @Inject constructor( mediaSize = getMediaSize(contentResolver, uri) } } - else -> { - Log.w(TAG, "Unknown uri scheme $uri") + } + ContentResolver.SCHEME_FILE -> { + val path = uri.path + if (path == null) { + Log.w(TAG, "empty uri path $uri") throw CouldNotOpenFileException() } - } - } catch (e: IOException) { - Log.w(TAG, e) - throw CouldNotOpenFileException() - } - if (mediaSize == MEDIA_SIZE_UNKNOWN) { - Log.w(TAG, "Could not determine file size of upload") - throw MediaTypeException() - } + val inputFile = File(path) + val suffix = inputFile.name.substringAfterLast('.', "tmp") + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix) + val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir) + val input = FileInputStream(inputFile) - if (mimeType != null) { - val topLevelType = mimeType.substring(0, mimeType.indexOf('/')) - when (topLevelType) { - "video" -> { - if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) { - throw VideoSizeException() - } - PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize) - } - "image" -> { - PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize) - } - "audio" -> { - if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) { - throw AudioSizeException() - } - PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize) - } - else -> { - throw MediaTypeException() + FileOutputStream(file.absoluteFile).use { out -> + input.copyTo(out) + uri = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".fileprovider", + file + ) + mediaSize = getMediaSize(contentResolver, uri) } } - } else { - Log.w(TAG, "Could not determine mime type of upload") - throw MediaTypeException() + else -> { + Log.w(TAG, "Unknown uri scheme $uri") + throw CouldNotOpenFileException() + } } + } catch (e: IOException) { + Log.w(TAG, e) + throw CouldNotOpenFileException() + } + if (mediaSize == MEDIA_SIZE_UNKNOWN) { + Log.w(TAG, "Could not determine file size of upload") + throw MediaTypeException() + } + + if (mimeType != null) { + return when (mimeType.substring(0, mimeType.indexOf('/'))) { + "video" -> { + if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) { + throw VideoSizeException() + } + PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize) + } + "image" -> { + PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize) + } + "audio" -> { + if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) { + throw AudioSizeException() + } + PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize) + } + else -> { + throw MediaTypeException() + } + } + } else { + Log.w(TAG, "Could not determine mime type of upload") + throw MediaTypeException() } } private val contentResolver = context.contentResolver - private fun upload(media: QueuedMedia): Observable { - return Observable.create { emitter -> + private suspend fun upload(media: QueuedMedia): Flow { + return callbackFlow { var mimeType = contentResolver.getType(media.uri) val map = MimeTypeMap.getSingleton() val fileExtension = map.getExtensionFromMimeType(mimeType) @@ -200,11 +205,11 @@ class MediaUploader @Inject constructor( var lastProgress = -1 val fileBody = ProgressRequestBody( - stream, media.mediaSize, - mimeType.toMediaTypeOrNull() + stream!!, media.mediaSize, + mimeType.toMediaTypeOrNull()!! ) { percentage -> if (percentage != lastProgress) { - emitter.onNext(UploadEvent.ProgressEvent(percentage)) + trySend(UploadEvent.ProgressEvent(percentage)) } lastProgress = percentage } @@ -217,34 +222,20 @@ class MediaUploader @Inject constructor( null } - val uploadDisposable = mastodonApi.uploadMedia(body, description) - .subscribe( - { result -> - if (media.uri.scheme == "file") { - media.uri.path?.let { - File(it).delete() - } - } - - emitter.onNext(UploadEvent.FinishedEvent(result.id)) - emitter.onComplete() - }, - { e -> - emitter.onError(e) - } - ) - - // Cancel the request when our observable is cancelled - emitter.setDisposable(uploadDisposable) + val result = mastodonApi.uploadMedia(body, description).getOrThrow() + if (media.uri.scheme == "file") { + media.uri.path?.let { + File(it).delete() + } + } + send(UploadEvent.FinishedEvent(result.id)) + awaitClose() } } private fun downsize(media: QueuedMedia): QueuedMedia { val file = createNewImageFile(context) - DownsizeImageTask.resize( - arrayOf(media.uri), - STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file - ) + downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file) return media.copy(uri = file.toUri(), mediaSize = file.length()) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt index 0c15eff0d..71789611b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -27,7 +27,7 @@ import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData +import androidx.lifecycle.lifecycleScope import at.connyduck.sparkbutton.helpers.Utils import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy @@ -35,7 +35,7 @@ import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.github.chrisbanes.photoview.PhotoView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.util.withLifecycleContext +import kotlinx.coroutines.launch // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 @@ -43,7 +43,7 @@ private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 fun T.makeCaptionDialog( existingDescription: String?, previewUri: Uri, - onUpdateDescription: (String) -> LiveData + onUpdateDescription: suspend (String) -> Boolean ) where T : Activity, T : LifecycleOwner { val dialogLayout = LinearLayout(this) val padding = Utils.dpToPx(this, 8) @@ -77,12 +77,11 @@ fun T.makeCaptionDialog( input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) val okListener = { dialog: DialogInterface, _: Int -> - onUpdateDescription(input.text.toString()) - withLifecycleContext { - onUpdateDescription(input.text.toString()) - .observe { success -> if (!success) showFailedCaptionMessage() } + lifecycleScope.launch { + if (!onUpdateDescription(input.text.toString())) { + showFailedCaptionMessage() + } } - dialog.dismiss() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt index dca696d84..2a1c74467 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt @@ -26,7 +26,7 @@ import androidx.core.view.OnReceiveContentListener import androidx.core.view.ViewCompat import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat -import androidx.emoji.widget.EmojiEditTextHelper +import androidx.emoji2.viewsintegration.EmojiEditTextHelper class EditTextTyped @JvmOverloads constructor( context: Context, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 08f97b5bd..647bab428 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -32,7 +32,7 @@ import androidx.recyclerview.widget.SimpleItemAnimator import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.components.account.AccountActivity -import com.keylesspalace.tusky.components.compose.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.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index e580f554f..db6a8a313 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -35,6 +35,7 @@ import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.databinding.ActivityDraftsBinding import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.visible import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import kotlinx.coroutines.flow.collectLatest @@ -100,7 +101,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener { content = draft.content, contentWarning = draft.contentWarning, inReplyToId = draft.inReplyToId, - replyingStatusContent = status.content.toString(), + replyingStatusContent = status.content.parseAsMastodonHtml().toString(), replyingStatusAuthor = status.account.localUsername, draftAttachments = draft.attachments, poll = draft.poll, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt new file mode 100644 index 000000000..05e10b6bc --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt @@ -0,0 +1,25 @@ +/* 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 . */ + +package com.keylesspalace.tusky.components.instanceinfo + +data class InstanceInfo( + val maxChars: Int, + val pollMaxOptions: Int, + val pollMaxLength: Int, + val pollMinDuration: Int, + val pollMaxDuration: Int, + val charactersReservedPerUrl: Int +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt new file mode 100644 index 000000000..a238f510a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -0,0 +1,130 @@ +/* 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 . */ + +package com.keylesspalace.tusky.components.instanceinfo + +import android.util.Log +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.EmojisEntity +import com.keylesspalace.tusky.db.InstanceInfoEntity +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class InstanceInfoRepository @Inject constructor( + private val api: MastodonApi, + db: AppDatabase, + accountManager: AccountManager +) { + + private val dao = db.instanceDao() + private val instanceName = accountManager.activeAccount!!.domain + + /** + * Returns the custom emojis of the instance. + * Will always try to fetch them from the api, falls back to cached Emojis in case it is not available. + * Never throws, returns empty list in case of error. + */ + suspend fun getEmojis(): List = withContext(Dispatchers.IO) { + api.getCustomEmojis() + .onSuccess { emojiList -> dao.insertOrReplace(EmojisEntity(instanceName, emojiList)) } + .getOrElse { throwable -> + Log.w(TAG, "failed to load custom emojis, falling back to cache", throwable) + getCachedEmojis() + } + } + + suspend fun getCachedEmojis(): List = + dao.getEmojiInfo(instanceName)?.emojiList.orEmpty() + + /** + * Returns information about the instance. + * Will always try to fetch the most up-to-date data from the api, falls back to cache in case it is not available. + * Never throws, returns defaults of vanilla Mastodon in case of error. + */ + suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) { + api.getInstance() + .fold( + { instance -> + val instanceEntity = InstanceInfoEntity( + instance = instanceName, + maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars, + maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions, + maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars, + minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration, + maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration, + charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl, + version = instance.version + ) + dao.insertOrReplace(instanceEntity) + instanceEntity + }, + { throwable -> + Log.w(TAG, "failed to instance, falling back to cache and default values", throwable) + getCachedInstanceInfoEntity() + } + ).toInstanceInfo() + } + + private suspend fun getCachedInstanceInfoEntity(): InstanceInfoEntity? = + dao.getInstanceInfo(instanceName) + + suspend fun getCachedInstanceInfo(): InstanceInfo = + getCachedInstanceInfoEntity().toInstanceInfo() + + companion object { + private const val TAG = "InstanceInfoRepo" + + const val DEFAULT_CHARACTER_LIMIT = 500 + private const val DEFAULT_MAX_OPTION_COUNT = 4 + private const val DEFAULT_MAX_OPTION_LENGTH = 50 + private const val DEFAULT_MIN_POLL_DURATION = 300 + private const val DEFAULT_MAX_POLL_DURATION = 604800 + + @JvmField + val CAN_USE_QUOTE_ID = arrayOf( + "odakyu.app", + "itabashi.0j0.jp", + "biwakodon.com", + "dtp-mstdn.jp", + "nitiasa.com", + "comm.cx", + "fedibird.com", + "qoto.org", + "kurage.cc", + "m.eula.dev", + "otogamer.me", + "sgp.hostdon.ne.jp", + "pomdon.work", + "obapom.work", + ) + + // Mastodon only counts URLs as this long in terms of status character limits + const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23 + + fun InstanceInfoEntity?.toInstanceInfo(): InstanceInfo = + InstanceInfo( + maxChars = this?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, + pollMaxOptions = this?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, + pollMaxLength = this?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, + pollMinDuration = this?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, + pollMaxDuration = this?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, + charactersReservedPerUrl = this?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL + ) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt index 827b56208..58f745e79 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt @@ -19,8 +19,10 @@ import androidx.activity.result.contract.ActivityResultContract import androidx.core.net.toUri import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.LoginWebviewBinding import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.viewBinding import kotlinx.parcelize.Parcelize @@ -87,7 +89,9 @@ class LoginWebViewActivity : BaseActivity(), Injectable { setSupportActionBar(binding.loginToolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.setDisplayShowTitleEnabled(false) + supportActionBar?.setDisplayShowTitleEnabled(true) + + setTitle(R.string.title_login) val webView = binding.loginWebView webView.settings.allowContentAccess = false @@ -103,13 +107,17 @@ class LoginWebViewActivity : BaseActivity(), Injectable { val oauthUrl = data.oauthRedirectUrl webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + binding.loginProgress.hide() + } + override fun onReceivedError( view: WebView, request: WebResourceRequest, error: WebResourceError ) { Log.d("LoginWeb", "Failed to load ${data.url}: $error") - finish() + finishWithoutSlideOutAnimation() } override fun shouldOverrideUrlLoading( @@ -165,10 +173,14 @@ class LoginWebViewActivity : BaseActivity(), Injectable { super.onDestroy() } + override fun finish() { + super.finishWithoutSlideOutAnimation() + } + override fun requiresLogin() = false private fun sendResult(result: LoginResult) { setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result)) - finish() + finishWithoutSlideOutAnimation() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index 83682ab28..795868976 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -118,6 +118,7 @@ public class NotificationHelper { public static final String CHANNEL_POLL = "CHANNEL_POLL"; public static final String CHANNEL_SUBSCRIPTIONS = "CHANNEL_SUBSCRIPTIONS"; public static final String CHANNEL_SIGN_UP = "CHANNEL_SIGN_UP"; + public static final String CHANNEL_UPDATES = "CHANNEL_UPDATES"; /** * WorkManager Tag @@ -395,6 +396,7 @@ public class NotificationHelper { CHANNEL_POLL + account.getIdentifier(), CHANNEL_SUBSCRIPTIONS + account.getIdentifier(), CHANNEL_SIGN_UP + account.getIdentifier(), + CHANNEL_UPDATES + account.getIdentifier(), }; int[] channelNames = { R.string.notification_mention_name, @@ -405,6 +407,7 @@ public class NotificationHelper { R.string.notification_poll_name, R.string.notification_subscription_name, R.string.notification_sign_up_name, + R.string.notification_update_name, }; int[] channelDescriptions = { R.string.notification_mention_descriptions, @@ -415,6 +418,7 @@ public class NotificationHelper { R.string.notification_poll_description, R.string.notification_subscription_description, R.string.notification_sign_up_description, + R.string.notification_update_description, }; List channels = new ArrayList<>(6); @@ -567,6 +571,8 @@ public class NotificationHelper { return account.getNotificationsPolls(); case SIGN_UP: return account.getNotificationsSignUps(); + case UPDATE: + return account.getNotificationsUpdates(); default: return false; } @@ -674,6 +680,8 @@ public class NotificationHelper { } case SIGN_UP: return String.format(context.getString(R.string.notification_sign_up_format), accountName); + case UPDATE: + return String.format(context.getString(R.string.notification_update_format), accountName); } return null; } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt deleted file mode 100644 index 47cb37ae7..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/EmojiPreference.kt +++ /dev/null @@ -1,240 +0,0 @@ -package com.keylesspalace.tusky.components.preference - -import android.app.AlarmManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.widget.RadioButton -import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.preference.Preference -import androidx.preference.PreferenceManager -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.SplashActivity -import com.keylesspalace.tusky.components.notifications.NotificationHelper -import com.keylesspalace.tusky.databinding.DialogEmojicompatBinding -import com.keylesspalace.tusky.databinding.ItemEmojiPrefBinding -import com.keylesspalace.tusky.util.EmojiCompatFont -import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.BLOBMOJI -import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.FONTS -import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.NOTOEMOJI -import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.SYSTEM_DEFAULT -import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.TWEMOJI -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.show -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.Disposable -import okhttp3.OkHttpClient -import kotlin.system.exitProcess - -/** - * This Preference lets the user select their preferred emoji font - */ -class EmojiPreference( - context: Context, - private val okHttpClient: OkHttpClient -) : Preference(context) { - - private lateinit var selected: EmojiCompatFont - private lateinit var original: EmojiCompatFont - private val radioButtons = mutableListOf() - private var updated = false - private var currentNeedsUpdate = false - - private val downloadDisposables = MutableList(FONTS.size) { null } - - override fun onAttachedToHierarchy(preferenceManager: PreferenceManager) { - super.onAttachedToHierarchy(preferenceManager) - - // Find out which font is currently active - selected = EmojiCompatFont.byId( - PreferenceManager.getDefaultSharedPreferences(context).getInt(key, 0) - ) - // We'll use this later to determine if anything has changed - original = selected - summary = selected.getDisplay(context) - } - - override fun onClick() { - val binding = DialogEmojicompatBinding.inflate(LayoutInflater.from(context)) - - setupItem(BLOBMOJI, binding.itemBlobmoji) - setupItem(TWEMOJI, binding.itemTwemoji) - setupItem(NOTOEMOJI, binding.itemNotoemoji) - setupItem(SYSTEM_DEFAULT, binding.itemNomoji) - - AlertDialog.Builder(context) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun setupItem(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { - // Initialize all the views - binding.emojiName.text = font.getDisplay(context) - binding.emojiCaption.setText(font.caption) - binding.emojiThumbnail.setImageResource(font.img) - - // There needs to be a list of all the radio buttons in order to uncheck them when one is selected - radioButtons.add(binding.emojiRadioButton) - updateItem(font, binding) - - // Set actions - binding.emojiDownload.setOnClickListener { startDownload(font, binding) } - binding.emojiDownloadCancel.setOnClickListener { cancelDownload(font, binding) } - binding.emojiRadioButton.setOnClickListener { radioButton: View -> select(font, radioButton as RadioButton) } - binding.root.setOnClickListener { - select(font, binding.emojiRadioButton) - } - } - - private fun startDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { - // Switch to downloading style - binding.emojiDownload.hide() - binding.emojiCaption.visibility = View.INVISIBLE - binding.emojiProgress.show() - binding.emojiProgress.progress = 0 - binding.emojiDownloadCancel.show() - font.downloadFontFile(context, okHttpClient) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { progress -> - // The progress is returned as a float between 0 and 1, or -1 if it could not determined - if (progress >= 0) { - binding.emojiProgress.isIndeterminate = false - val max = binding.emojiProgress.max.toFloat() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - binding.emojiProgress.setProgress((max * progress).toInt(), true) - } else { - binding.emojiProgress.progress = (max * progress).toInt() - } - } else { - binding.emojiProgress.isIndeterminate = true - } - }, - { - Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show() - updateItem(font, binding) - }, - { - finishDownload(font, binding) - } - ).also { downloadDisposables[font.id] = it } - } - - private fun cancelDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { - font.deleteDownloadedFile(context) - downloadDisposables[font.id]?.dispose() - downloadDisposables[font.id] = null - updateItem(font, binding) - } - - private fun finishDownload(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { - select(font, binding.emojiRadioButton) - updateItem(font, binding) - // Set the flag to restart the app (because an update has been downloaded) - if (selected === original && currentNeedsUpdate) { - updated = true - currentNeedsUpdate = false - } - } - - /** - * Select a font both visually and logically - * - * @param font The font to be selected - * @param radio The radio button associated with it's visual item - */ - private fun select(font: EmojiCompatFont, radio: RadioButton) { - selected = font - radioButtons.forEach { radioButton -> - radioButton.isChecked = radioButton == radio - } - } - - /** - * Called when a "consistent" state is reached, i.e. it's not downloading the font - * - * @param font The font to be displayed - * @param binding The ItemEmojiPrefBinding to show the item in - */ - private fun updateItem(font: EmojiCompatFont, binding: ItemEmojiPrefBinding) { - // There's no download going on - binding.emojiProgress.hide() - binding.emojiDownloadCancel.hide() - binding.emojiCaption.show() - if (font.isDownloaded(context)) { - // Make it selectable - binding.emojiDownload.hide() - binding.emojiRadioButton.show() - binding.root.isClickable = true - } else { - // Make it downloadable - binding.emojiDownload.show() - binding.emojiRadioButton.hide() - binding.root.isClickable = false - } - - // Select it if necessary - if (font === selected) { - binding.emojiRadioButton.isChecked = true - // Update available - if (!font.isDownloaded(context)) { - currentNeedsUpdate = true - } - } else { - binding.emojiRadioButton.isChecked = false - } - } - - private fun saveSelectedFont() { - val index = selected.id - Log.i(TAG, "saveSelectedFont: Font ID: $index") - PreferenceManager - .getDefaultSharedPreferences(context) - .edit() - .putInt(key, index) - .apply() - summary = selected.getDisplay(context) - } - - /** - * User clicked ok -> save the selected font and offer to restart the app if something changed - */ - private fun onDialogOk() { - saveSelectedFont() - if (selected !== original || updated) { - AlertDialog.Builder(context) - .setTitle(R.string.restart_required) - .setMessage(R.string.restart_emoji) - .setNegativeButton(R.string.later, null) - .setPositiveButton(R.string.restart) { _, _ -> - // Restart the app - // From https://stackoverflow.com/a/17166729/5070653 - val launchIntent = Intent(context, SplashActivity::class.java) - val mPendingIntent = PendingIntent.getActivity( - context, - 0x1f973, // This is the codepoint of the party face emoji :D - launchIntent, - NotificationHelper.pendingIntentFlags(false) - ) - val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - mgr.set( - AlarmManager.RTC, - System.currentTimeMillis() + 100, - mPendingIntent - ) - exitProcess(0) - }.show() - } - } - - companion object { - private const val TAG = "EmojiPreference" - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt index 82ee0a384..6fdc1e8ab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt @@ -133,6 +133,17 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat(), Injectable { true } } + + switchPreference { + setTitle(R.string.pref_title_notification_filter_updates) + key = PrefKeys.NOTIFICATION_FILTER_UPDATES + isIconSpaceReserved = false + isChecked = activeAccount.notificationsUpdates + setOnPreferenceChangeListener { _, newValue -> + updateAccount { it.notificationsUpdates = newValue as Boolean } + true + } + } } preferenceCategory(R.string.pref_title_notification_alerts) { category -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index 961d6d565..946834f3f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -41,14 +41,11 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizePx -import okhttp3.OkHttpClient +import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import javax.inject.Inject class PreferencesFragment : PreferenceFragmentCompat(), Injectable { - @Inject - lateinit var okhttpclient: OkHttpClient - @Inject lateinit var accountManager: AccountManager @@ -71,11 +68,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { icon = makeIcon(GoogleMaterial.Icon.gmd_palette) } - emojiPreference(okhttpclient) { - setDefaultValue("system_default") - setIcon(R.drawable.ic_emoji_24dp) - key = PrefKeys.EMOJI - setSummary(R.string.system_default) + emojiPreference(requireActivity()) { setTitle(R.string.emoji_style) icon = makeIcon(GoogleMaterial.Icon.gmd_sentiment_satisfied) } @@ -377,6 +370,12 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { } } + override fun onDisplayPreferenceDialog(preference: Preference) { + if (!EmojiPickerPreference.onDisplayPreferenceDialog(this, preference)) { + super.onDisplayPreferenceDialog(preference) + } + } + companion object { fun newInstance(): PreferencesFragment { return PreferencesFragment() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 3c022d3b8..f03ddac3f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -26,6 +26,7 @@ import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusViewHelper import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER @@ -51,6 +52,7 @@ class StatusViewHolder( private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) private val statusViewHelper = StatusViewHelper(itemView) + private val absoluteTimeFormatter = AbsoluteTimeFormatter() private val previewListener = object : StatusViewHelper.MediaPreviewListener { override fun onViewMedia(v: View?, idx: Int) { @@ -155,7 +157,7 @@ class StatusViewHolder( private fun setCreatedAt(createdAt: Date?) { if (statusDisplayOptions.useAbsoluteTime) { - binding.timestampInfo.text = statusViewHelper.getAbsoluteTime(createdAt) + binding.timestampInfo.text = absoluteTimeFormatter.format(createdAt) } else { binding.timestampInfo.text = if (createdAt != null) { val then = createdAt.time diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index 1614735ce..9dddfae4d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -32,7 +32,7 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.components.account.AccountActivity -import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.report.Screen import com.keylesspalace.tusky.components.report.adapter.AdapterHandler diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index 7b3a325bc..3d60fcdf8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -85,6 +85,10 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector { return true } + override fun finish() { + super.finishWithoutSlideOutAnimation() + } + private fun getPageTitle(position: Int): CharSequence { return when (position) { 0 -> getString(R.string.title_posts) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 4e69b027d..5e321c08c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -20,7 +20,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn -import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID import com.keylesspalace.tusky.components.search.adapter.SearchNotestockPagingSourceFactory import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory import com.keylesspalace.tusky.db.AccountEntity diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index 97a27ed06..7e9479948 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -111,9 +111,13 @@ abstract class SearchFragment : } } - override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id)) + override fun onViewAccount(id: String) { + bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id)) + } - override fun onViewTag(tag: String) = startActivity(StatusListActivity.newHashtagIntent(requireContext(), tag)) + override fun onViewTag(tag: String) { + bottomSheetActivity?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) + } override fun onViewUrl(url: String, text: String) { bottomSheetActivity?.viewUrl(url, text = text) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index de0fec73f..2c4f112aa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -98,7 +98,7 @@ class SearchStatusesFragment : SearchFragment(), Status } override fun onReply(position: Int) { - searchAdapter.peek(position)?.status?.let { status -> + searchAdapter.peek(position)?.let { status -> reply(status) } } @@ -206,8 +206,8 @@ class SearchStatusesFragment : SearchFragment(), Status fun newInstance() = SearchStatusesFragment() } - private fun reply(status: Status) { - val actionableStatus = status.actionableStatus + private fun reply(status: StatusViewData.Concrete) { + val actionableStatus = status.actionable val mentionedUsernames = actionableStatus.mentions.map { it.username } .toMutableSet() .apply { @@ -223,10 +223,10 @@ class SearchStatusesFragment : SearchFragment(), Status contentWarning = actionableStatus.spoilerText, mentionedUsernames = mentionedUsernames, replyingStatusAuthor = actionableStatus.account.localUsername, - replyingStatusContent = actionableStatus.content.toString() + replyingStatusContent = status.content.toString() ) ) - startActivity(intent) + bottomSheetActivity?.startActivityWithSlideInAnimation(intent) } private fun quote(status: Status) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 6b0c2f05f..c5ad77344 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -43,7 +43,7 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.QuickReplyEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent -import com.keylesspalace.tusky.components.compose.CAN_USE_QUOTE_ID +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.Companion.CAN_USE_QUOTE_ID import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel @@ -53,7 +53,11 @@ import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.fragment.SFragment -import com.keylesspalace.tusky.interfaces.* +import com.keylesspalace.tusky.interfaces.ActionButtonActivity +import com.keylesspalace.tusky.interfaces.RefreshableFragment +import com.keylesspalace.tusky.interfaces.ReselectableFragment +import com.keylesspalace.tusky.interfaces.ResettableFragment +import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate @@ -177,7 +181,7 @@ class TimelineFragment : setupRecyclerView() adapter.addLoadStateListener { loadState -> - if (loadState.refresh != LoadState.Loading) { + if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index 5da91e201..400eb0730 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -51,6 +51,7 @@ data class AccountEntity( var notificationsPolls: Boolean = true, var notificationsSubscriptions: Boolean = true, var notificationsSignUps: Boolean = true, + var notificationsUpdates: Boolean = true, var notificationSound: Boolean = true, var notificationVibration: Boolean = true, var notificationLight: Boolean = true, diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index c541958ae..293db65e8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -31,7 +31,7 @@ import java.io.File; */ @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 33) + }, version = 34) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -527,4 +527,11 @@ public abstract class AppDatabase extends RoomDatabase { "PRIMARY KEY(`id`, `accountId`))"); } }; + + public static final Migration MIGRATION_33_34 = new Migration(33, 34) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsUpdates` INTEGER NOT NULL DEFAULT 1"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt index 52fc3aa86..9b190bc7f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceDao.kt @@ -19,13 +19,19 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import io.reactivex.rxjava3.core.Single @Dao interface InstanceDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertOrReplace(instance: InstanceEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class) + suspend fun insertOrReplace(instance: InstanceInfoEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE, entity = InstanceEntity::class) + suspend fun insertOrReplace(emojis: EmojisEntity) @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") - fun loadMetadataForInstance(instance: String): Single + suspend fun getInstanceInfo(instance: String): InstanceInfoEntity? + + @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") + suspend fun getEmojiInfo(instance: String): EmojisEntity? } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt index dd8e85d07..01767f321 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt @@ -23,7 +23,7 @@ import com.keylesspalace.tusky.entity.Emoji @Entity @TypeConverters(Converters::class) data class InstanceEntity( - @field:PrimaryKey var instance: String, + @PrimaryKey val instance: String, val emojiList: List?, val maximumTootCharacters: Int?, val maxPollOptions: Int?, @@ -33,3 +33,20 @@ data class InstanceEntity( val charactersReservedPerUrl: Int?, val version: String? ) + +@TypeConverters(Converters::class) +data class EmojisEntity( + @PrimaryKey val instance: String, + val emojiList: List? +) + +data class InstanceInfoEntity( + @PrimaryKey val instance: String, + val maximumTootCharacters: Int?, + val maxPollOptions: Int?, + val maxPollOptionLength: Int?, + val minPollDuration: Int?, + val maxPollDuration: Int?, + val charactersReservedPerUrl: Int?, + val version: String? +) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 326428fa8..5e719f28c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -69,7 +69,7 @@ class AppModule { AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32, - AppDatabase.MIGRATION_32_33 + AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt b/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt deleted file mode 100644 index 98af734bf..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/entity/IdentityProof.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.keylesspalace.tusky.entity - -import com.google.gson.annotations.SerializedName - -data class IdentityProof( - val provider: String, - @SerializedName("provider_username") val username: String, - @SerializedName("profile_url") val profileUrl: String -) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index ddcf5e618..f6e381502 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -39,6 +39,7 @@ data class Notification( POLL("poll"), STATUS("status"), SIGN_UP("admin.sign_up"), + UPDATE("update"), ; companion object { @@ -51,7 +52,7 @@ data class Notification( } return UNKNOWN } - val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP) + val asList = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE) } override fun toString(): String { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java index 16ca79d44..4f6978d2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.fragment; +import static com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.CAN_USE_QUOTE_ID; import static com.keylesspalace.tusky.util.StringUtils.isLessThan; import static autodispose2.AutoDispose.autoDisposable; import static autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from; @@ -66,7 +67,6 @@ import com.keylesspalace.tusky.appstore.PinEvent; import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; import com.keylesspalace.tusky.appstore.QuickReplyEvent; import com.keylesspalace.tusky.appstore.ReblogEvent; -import com.keylesspalace.tusky.components.compose.ComposeViewModelKt; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.di.Injectable; @@ -264,7 +264,7 @@ public class NotificationsFragment extends SFragment implements preferences.getBoolean("confirmFavourites", false), preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - Arrays.asList(ComposeViewModelKt.getCAN_USE_QUOTE_ID()).contains(accountManager.getActiveAccount().getDomain()) + Arrays.asList(CAN_USE_QUOTE_ID).contains(accountManager.getActiveAccount().getDomain()) ); adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), @@ -718,6 +718,8 @@ public class NotificationsFragment extends SFragment implements return getString(R.string.notification_subscription_name); case SIGN_UP: return getString(R.string.notification_sign_up_name); + case UPDATE: + return getString(R.string.notification_update_name); default: return "Unknown"; } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 1c9b21bdb..1a1c880e3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -15,6 +15,8 @@ package com.keylesspalace.tusky.fragment; +import static com.keylesspalace.tusky.util.StatusParsingHelper.parseAsMastodonHtml; + import android.Manifest; import android.app.DownloadManager; import android.content.ClipData; @@ -150,7 +152,7 @@ public abstract class SFragment extends Fragment implements Injectable { composeOptions.setContentWarning(contentWarning); composeOptions.setMentionedUsernames(mentionedUsernames); composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername()); - composeOptions.setReplyingStatusContent(actionableStatus.getContent().toString()); + composeOptions.setReplyingStatusContent(parseAsMastodonHtml(actionableStatus.getContent()).toString()); Intent intent = ComposeActivity.startIntent(getContext(), composeOptions); getActivity().startActivity(intent); diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 33635f1b5..78adc9ed8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -15,6 +15,8 @@ package com.keylesspalace.tusky.fragment; +import static com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository.CAN_USE_QUOTE_ID; + import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; @@ -147,7 +149,7 @@ public final class ViewThreadFragment extends SFragment implements preferences.getBoolean("confirmFavourites", false), preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - Arrays.asList(ComposeViewModelKt.getCAN_USE_QUOTE_ID()).contains(accountManager.getActiveAccount().getDomain()) + Arrays.asList(CAN_USE_QUOTE_ID).contains(accountManager.getActiveAccount().getDomain()) ); adapter = new ThreadAdapter(statusDisplayOptions, this); } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 111cad56c..25fe03d4d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -24,7 +24,6 @@ import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Filter -import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.MastoList @@ -77,7 +76,7 @@ interface MastodonApi { fun getLists(): Single> @GET("/api/v1/custom_emojis") - fun getCustomEmojis(): Single> + suspend fun getCustomEmojis(): Result> @GET("api/v1/instance") suspend fun getInstance(): Result @@ -145,25 +144,30 @@ interface MastodonApi { @Multipart @POST("api/v2/media") - fun uploadMedia( + suspend fun uploadMedia( @Part file: MultipartBody.Part, @Part description: MultipartBody.Part? = null - ): Single + ): Result @FormUrlEncoded @PUT("api/v1/media/{mediaId}") - fun updateMedia( + suspend fun updateMedia( @Path("mediaId") mediaId: String, @Field("description") description: String - ): Single + ): Result + + @GET("api/v1/media/{mediaId}") + suspend fun getMedia( + @Path("mediaId") mediaId: String + ): Response @POST("api/v1/statuses") - fun createStatus( + suspend fun createStatus( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @Header("Idempotency-Key") idempotencyKey: String, @Body status: NewStatus - ): Call + ): Result @GET("api/v1/statuses/{id}") fun status( @@ -367,11 +371,6 @@ interface MastodonApi { @Query("id[]") accountIds: List ): Single> - @GET("api/v1/accounts/{id}/identity_proofs") - fun identityProofs( - @Path("id") accountId: String - ): Single> - @POST("api/v1/pleroma/accounts/{id}/subscribe") fun subscribeAccount( @Path("id") accountId: String @@ -544,26 +543,26 @@ interface MastodonApi { ): Single @GET("api/v1/announcements") - fun listAnnouncements( + suspend fun listAnnouncements( @Query("with_dismissed") withDismissed: Boolean = true - ): Single> + ): Result> @POST("api/v1/announcements/{id}/dismiss") - fun dismissAnnouncement( + suspend fun dismissAnnouncement( @Path("id") announcementId: String - ): Single + ): Result @PUT("api/v1/announcements/{id}/reactions/{name}") - fun addAnnouncementReaction( + suspend fun addAnnouncementReaction( @Path("id") announcementId: String, @Path("name") name: String - ): Single + ): Result @DELETE("api/v1/announcements/{id}/reactions/{name}") - fun removeAnnouncementReaction( + suspend fun removeAnnouncementReaction( @Path("id") announcementId: String, @Path("name") name: String - ): Single + ): Result @FormUrlEncoded @POST("api/v1/reports") diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 680117b91..63ec04f1d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -101,7 +101,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { accountId = account.id, draftId = -1, idempotencyKey = randomAlphanumericString(16), - retries = 0 + retries = 0, + mediaProcessed = mutableListOf() ) ) diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index 8ce8ea38f..7414f4fff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -11,6 +11,7 @@ import android.content.Intent import android.os.Build import android.os.IBinder import android.os.Parcelable +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat @@ -29,13 +30,12 @@ import com.keylesspalace.tusky.network.MastodonApi import dagger.android.AndroidInjection import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response +import retrofit2.HttpException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -55,7 +55,7 @@ class SendStatusService : Service(), Injectable { private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob) private val statusesToSend = ConcurrentHashMap() - private val sendCalls = ConcurrentHashMap>() + private val sendJobs = ConcurrentHashMap() private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } @@ -64,12 +64,9 @@ class SendStatusService : Service(), Injectable { super.onCreate() } - override fun onBind(intent: Intent): IBinder? { - return null - } + override fun onBind(intent: Intent): IBinder? = null override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - if (intent.hasExtra(KEY_STATUS)) { val statusToSend = intent.getParcelableExtra(KEY_STATUS) ?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra") @@ -129,83 +126,95 @@ class SendStatusService : Service(), Injectable { statusToSend.retries++ - val newStatus = NewStatus( - statusToSend.text, - statusToSend.warningText, - statusToSend.inReplyToId, - statusToSend.visibility, - statusToSend.sensitive, - statusToSend.mediaIds, - statusToSend.scheduledAt, - statusToSend.poll, - statusToSend.quoteId, - ) + sendJobs[statusId] = serviceScope.launch { + try { + var mediaCheckRetries = 0 + while (statusToSend.mediaProcessed.any { !it }) { + delay(1000L * mediaCheckRetries) + statusToSend.mediaProcessed.forEachIndexed { index, processed -> + if (!processed) { + // Mastodon returns 206 if the media was not yet processed + statusToSend.mediaProcessed[index] = mastodonApi.getMedia(statusToSend.mediaIds[index]).code() == 200 + } + } + mediaCheckRetries ++ + } + } catch (e: Exception) { + Log.w(TAG, "failed getting media status", e) + retrySending(statusId) + return@launch + } - val sendCall = mastodonApi.createStatus( - "Bearer " + account.accessToken, - account.domain, - statusToSend.idempotencyKey, - newStatus - ) + val newStatus = NewStatus( + statusToSend.text, + statusToSend.warningText, + statusToSend.inReplyToId, + statusToSend.visibility, + statusToSend.sensitive, + statusToSend.mediaIds, + statusToSend.scheduledAt, + statusToSend.poll, + statusToSend.quoteId, + ) - sendCalls[statusId] = sendCall + mastodonApi.createStatus( + "Bearer " + account.accessToken, + account.domain, + statusToSend.idempotencyKey, + newStatus + ).fold({ sentStatus -> + statusesToSend.remove(statusId) + // If the status was loaded from a draft, delete the draft and associated media files. + if (statusToSend.draftId != 0) { + draftHelper.deleteDraftAndAttachments(statusToSend.draftId) + } - val callback = object : Callback { - override fun onResponse(call: Call, response: Response) { - serviceScope.launch { + val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() - val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() + if (scheduled) { + eventHub.dispatch(StatusScheduledEvent(sentStatus)) + } else { + eventHub.dispatch(StatusComposedEvent(sentStatus)) + } + + notificationManager.cancel(statusId) + }, { throwable -> + Log.w(TAG, "failed sending status", throwable) + if (throwable is HttpException) { + // the server refused to accept the status, save status & show error message statusesToSend.remove(statusId) + saveStatusToDrafts(statusToSend) - if (response.isSuccessful) { - // If the status was loaded from a draft, delete the draft and associated media files. - if (statusToSend.draftId != 0) { - draftHelper.deleteDraftAndAttachments(statusToSend.draftId) - } - - if (scheduled) { - response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch) - } else { - response.body()?.let(::StatusComposedEvent)?.let(eventHub::dispatch) - } - - notificationManager.cancel(statusId) - } else { - // the server refused to accept the status, save status & show error message - saveStatusToDrafts(statusToSend) - - val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notify) - .setContentTitle(getString(R.string.send_post_notification_error_title)) - .setContentText(getString(R.string.send_post_notification_saved_content)) - .setColor( - ContextCompat.getColor( - this@SendStatusService, - R.color.notification_color - ) + val builder = NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(getString(R.string.send_post_notification_error_title)) + .setContentText(getString(R.string.send_post_notification_saved_content)) + .setColor( + ContextCompat.getColor( + this@SendStatusService, + R.color.notification_color ) + ) - notificationManager.cancel(statusId) - notificationManager.notify(errorNotificationId--, builder.build()) - } - stopSelfWhenDone() + notificationManager.cancel(statusId) + notificationManager.notify(errorNotificationId--, builder.build()) + } else { + // a network problem occurred, let's retry sending the status + retrySending(statusId) } - } - - override fun onFailure(call: Call, t: Throwable) { - serviceScope.launch { - var backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong()) - if (backoff > MAX_RETRY_INTERVAL) { - backoff = MAX_RETRY_INTERVAL - } - - delay(backoff) - sendStatus(statusId) - } - } + }) + stopSelfWhenDone() } + } - sendCall.enqueue(callback) + private suspend fun retrySending(statusId: Int) { + // when statusToSend == null, sending has been canceled + val statusToSend = statusesToSend[statusId] ?: return + + val backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong()).coerceAtMost(MAX_RETRY_INTERVAL) + + delay(backoff) + sendStatus(statusId) } private fun stopSelfWhenDone() { @@ -219,8 +228,8 @@ class SendStatusService : Service(), Injectable { private fun cancelSending(statusId: Int) = serviceScope.launch { val statusToCancel = statusesToSend.remove(statusId) if (statusToCancel != null) { - val sendCall = sendCalls.remove(statusId) - sendCall?.cancel() + val sendJob = sendJobs.remove(statusId) + sendJob?.cancel() saveStatusToDrafts(statusToCancel) @@ -264,6 +273,7 @@ class SendStatusService : Service(), Injectable { } companion object { + private const val TAG = "SendStatusService" private const val KEY_STATUS = "status" private const val KEY_CANCEL = "cancel_id" @@ -321,5 +331,6 @@ data class StatusToSend( val accountId: Long, val draftId: Int, val idempotencyKey: String, - var retries: Int + var retries: Int, + val mediaProcessed: MutableList ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index db2bf9cf5..d9e7f4ff4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -70,6 +70,7 @@ object PrefKeys { const val NOTIFICATIONS_FILTER_FOLLOWS = "notificationFilterFollows" const val NOTIFICATION_FILTER_SUBSCRIPTIONS = "notificationFilterSubscriptions" const val NOTIFICATION_FILTER_SIGN_UPS = "notificationFilterSignUps" + const val NOTIFICATION_FILTER_UPDATES = "notificationFilterUpdates" const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies" const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt index 1569cb151..852700811 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -1,7 +1,9 @@ package com.keylesspalace.tusky.settings import android.content.Context +import androidx.activity.result.ActivityResultRegistryOwner import androidx.annotation.StringRes +import androidx.lifecycle.LifecycleOwner import androidx.preference.CheckBoxPreference import androidx.preference.EditTextPreference import androidx.preference.ListPreference @@ -10,8 +12,7 @@ import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreference -import com.keylesspalace.tusky.components.preference.EmojiPreference -import okhttp3.OkHttpClient +import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference class PreferenceParent( val context: Context, @@ -32,8 +33,9 @@ inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit): return pref } -inline fun PreferenceParent.emojiPreference(okHttpClient: OkHttpClient, builder: EmojiPreference.() -> Unit): EmojiPreference { - val pref = EmojiPreference(context, okHttpClient) +inline fun PreferenceParent.emojiPreference(activity: A, builder: EmojiPickerPreference.() -> Unit): EmojiPickerPreference + where A : Context, A : ActivityResultRegistryOwner, A : LifecycleOwner { + val pref = EmojiPickerPreference.get(activity) builder(pref) addPref(pref) return pref diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt b/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt new file mode 100644 index 000000000..7d46388ba --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt @@ -0,0 +1,59 @@ +/* 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 . */ + +package com.keylesspalace.tusky.util + +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class AbsoluteTimeFormatter @JvmOverloads constructor(private val tz: TimeZone = TimeZone.getDefault()) { + private val sameDaySdf = SimpleDateFormat("HH:mm", Locale.getDefault()).apply { this.timeZone = tz } + private val sameYearSdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz } + private val otherYearSdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).apply { this.timeZone = tz } + private val otherYearCompleteSdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz } + + @JvmOverloads + fun format(time: Date?, shortFormat: Boolean = true, now: Date = Date()): String { + return when { + time == null -> "??" + isSameDate(time, now, tz) -> sameDaySdf.format(time) + isSameYear(time, now, tz) -> sameYearSdf.format(time) + shortFormat -> otherYearSdf.format(time) + else -> otherYearCompleteSdf.format(time) + } + } + + companion object { + + private fun isSameDate(dateOne: Date, dateTwo: Date, tz: TimeZone): Boolean { + val calendarOne = Calendar.getInstance(tz).apply { time = dateOne } + val calendarTwo = Calendar.getInstance(tz).apply { time = dateTwo } + + return calendarOne.get(Calendar.YEAR) == calendarTwo.get(Calendar.YEAR) && + calendarOne.get(Calendar.MONTH) == calendarTwo.get(Calendar.MONTH) && + calendarOne.get(Calendar.DAY_OF_MONTH) == calendarTwo.get(Calendar.DAY_OF_MONTH) + } + + private fun isSameYear(dateOne: Date, dateTwo: Date, timeZone1: TimeZone): Boolean { + val calendarOne = Calendar.getInstance(timeZone1).apply { time = dateOne } + val calendarTwo = Calendar.getInstance(timeZone1).apply { time = dateTwo } + + return calendarOne.get(Calendar.YEAR) == calendarTwo.get(Calendar.YEAR) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt b/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt deleted file mode 100644 index 385be6c12..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/EmojiCompatFont.kt +++ /dev/null @@ -1,364 +0,0 @@ -package com.keylesspalace.tusky.util - -import android.content.Context -import android.util.Log -import android.util.Pair -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.annotation.VisibleForTesting -import com.keylesspalace.tusky.R -import de.c1710.filemojicompat.FileEmojiCompatConfig -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.ObservableEmitter -import io.reactivex.rxjava3.schedulers.Schedulers -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody -import okhttp3.internal.toLongOrDefault -import okio.Source -import okio.buffer -import okio.sink -import java.io.EOFException -import java.io.File -import java.io.FilenameFilter -import java.io.IOException -import kotlin.math.max - -/** - * This class bundles information about an emoji font as well as many convenient actions. - */ -class EmojiCompatFont( - val name: String, - private val display: String, - @StringRes val caption: Int, - @DrawableRes val img: Int, - val url: String, - // The version is stored as a String in the x.xx.xx format (to be able to compare versions) - val version: String -) { - - private val versionCode = getVersionCode(version) - - // A list of all available font files and whether they are older than the current version or not - // They are ordered by their version codes in ascending order - private var existingFontFileCache: List>>? = null - - val id: Int - get() = FONTS.indexOf(this) - - fun getDisplay(context: Context): String { - return if (this !== SYSTEM_DEFAULT) display else context.getString(R.string.system_default) - } - - /** - * This method will return the actual font file (regardless of its existence) for - * the current version (not necessarily the latest!). - * - * @return The font (TTF) file or null if called on SYSTEM_FONT - */ - private fun getFontFile(context: Context): File? { - return if (this !== SYSTEM_DEFAULT) { - val directory = File(context.getExternalFilesDir(null), DIRECTORY) - File(directory, "$name$version.ttf") - } else { - null - } - } - - fun getConfig(context: Context): FileEmojiCompatConfig { - return FileEmojiCompatConfig(context, getLatestFontFile(context)) - } - - fun isDownloaded(context: Context): Boolean { - return this === SYSTEM_DEFAULT || getFontFile(context)?.exists() == true || fontFileExists(context) - } - - /** - * Checks whether there is already a font version that satisfies the current version, i.e. it - * has a higher or equal version code. - * - * @param context The Context - * @return Whether there is a font file with a higher or equal version code to the current - */ - private fun fontFileExists(context: Context): Boolean { - val existingFontFiles = getExistingFontFiles(context) - return if (existingFontFiles.isNotEmpty()) { - compareVersions(existingFontFiles.last().second, versionCode) >= 0 - } else { - false - } - } - - /** - * Deletes any older version of a font - * - * @param context The current Context - */ - private fun deleteOldVersions(context: Context) { - val existingFontFiles = getExistingFontFiles(context) - Log.d(TAG, "deleting old versions...") - Log.d(TAG, String.format("deleteOldVersions: Found %d other font files", existingFontFiles.size)) - for (fileExists in existingFontFiles) { - if (compareVersions(fileExists.second, versionCode) < 0) { - val file = fileExists.first - // Uses side effects! - Log.d( - TAG, - String.format( - "Deleted %s successfully: %s", file.absolutePath, - file.delete() - ) - ) - } - } - } - - /** - * Loads all font files that are inside the files directory into an ArrayList with the information - * on whether they are older than the currently available version or not. - * - * @param context The Context - */ - private fun getExistingFontFiles(context: Context): List>> { - // Only load it once - existingFontFileCache?.let { - return it - } - // If we call this on the system default font, just return nothing... - if (this === SYSTEM_DEFAULT) { - existingFontFileCache = emptyList() - return emptyList() - } - - val directory = File(context.getExternalFilesDir(null), DIRECTORY) - // It will search for old versions using a regex that matches the font's name plus - // (if present) a version code. No version code will be regarded as version 0. - val fontRegex = "$name(\\d+(\\.\\d+)*)?\\.ttf".toPattern() - val ttfFilter = FilenameFilter { _, name: String -> name.endsWith(".ttf") } - val foundFontFiles = directory.listFiles(ttfFilter).orEmpty() - Log.d( - TAG, - String.format( - "loadExistingFontFiles: %d other font files found", - foundFontFiles.size - ) - ) - - return foundFontFiles.map { file -> - val matcher = fontRegex.matcher(file.name) - val versionCode = if (matcher.matches()) { - val version = matcher.group(1) - getVersionCode(version) - } else { - listOf(0) - } - Pair(file, versionCode) - }.sortedWith { a, b -> - compareVersions(a.second, b.second) - }.also { - existingFontFileCache = it - } - } - - /** - * Returns the current or latest version of this font file (if there is any) - * - * @param context The Context - * @return The file for this font with the current or (if not existent) highest version code or null if there is no file for this font. - */ - private fun getLatestFontFile(context: Context): File? { - val current = getFontFile(context) - if (current != null && current.exists()) return current - val existingFontFiles = getExistingFontFiles(context) - return existingFontFiles.firstOrNull()?.first - } - - private fun getVersionCode(version: String?): List { - if (version == null) return listOf(0) - return version.split(".").map { - it.toIntOrNull() ?: 0 - } - } - - fun downloadFontFile( - context: Context, - okHttpClient: OkHttpClient - ): Observable { - return Observable.create { emitter: ObservableEmitter -> - // It is possible (and very likely) that the file does not exist yet - val downloadFile = getFontFile(context)!! - if (!downloadFile.exists()) { - downloadFile.parentFile?.mkdirs() - downloadFile.createNewFile() - } - val request = Request.Builder().url(url) - .build() - - val sink = downloadFile.sink().buffer() - var source: Source? = null - try { - // Download! - val response = okHttpClient.newCall(request).execute() - - val responseBody = response.body - if (response.isSuccessful && responseBody != null) { - val size = response.length() - var progress = 0f - source = responseBody.source() - try { - while (!emitter.isDisposed) { - sink.write(source, CHUNK_SIZE) - progress += CHUNK_SIZE.toFloat() - if (size > 0) { - emitter.onNext(progress / size) - } else { - emitter.onNext(-1f) - } - } - } catch (ex: EOFException) { - /* - This means we've finished downloading the file since sink.write - will throw an EOFException when the file to be read is empty. - */ - } - } else { - Log.e(TAG, "Downloading $url failed. Status code: ${response.code}") - emitter.tryOnError(Exception()) - } - } catch (ex: IOException) { - Log.e(TAG, "Downloading $url failed.", ex) - downloadFile.deleteIfExists() - emitter.tryOnError(ex) - } finally { - source?.close() - sink.close() - if (emitter.isDisposed) { - downloadFile.deleteIfExists() - } else { - deleteOldVersions(context) - emitter.onComplete() - } - } - } - .subscribeOn(Schedulers.io()) - } - - /** - * Deletes the downloaded file, if it exists. Should be called when a download gets cancelled. - */ - fun deleteDownloadedFile(context: Context) { - getFontFile(context)?.deleteIfExists() - } - - override fun toString(): String { - return display - } - - companion object { - private const val TAG = "EmojiCompatFont" - - /** - * This String represents the sub-directory the fonts are stored in. - */ - private const val DIRECTORY = "emoji" - - private const val CHUNK_SIZE = 4096L - - // The system font gets some special behavior... - val SYSTEM_DEFAULT = EmojiCompatFont( - "system-default", - "System Default", - R.string.caption_systememoji, - R.drawable.ic_emoji_34dp, - "", - "0" - ) - val BLOBMOJI = EmojiCompatFont( - "Blobmoji", - "Blobmoji", - R.string.caption_blobmoji, - R.drawable.ic_blobmoji, - "https://tusky.app/hosted/emoji/BlobmojiCompat.ttf", - "14.0.1" - ) - val TWEMOJI = EmojiCompatFont( - "Twemoji", - "Twemoji", - R.string.caption_twemoji, - R.drawable.ic_twemoji, - "https://tusky.app/hosted/emoji/TwemojiCompat.ttf", - "14.0.0" - ) - val NOTOEMOJI = EmojiCompatFont( - "NotoEmoji", - "Noto Emoji", - R.string.caption_notoemoji, - R.drawable.ic_notoemoji, - "https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf", - "14.0.0" - ) - - /** - * This array stores all available EmojiCompat fonts. - * References to them can simply be saved by saving their indices - */ - val FONTS = listOf(SYSTEM_DEFAULT, BLOBMOJI, TWEMOJI, NOTOEMOJI) - - /** - * Returns the Emoji font associated with this ID - * - * @param id the ID of this font - * @return the corresponding font. Will default to SYSTEM_DEFAULT if not in range. - */ - fun byId(id: Int): EmojiCompatFont = FONTS.getOrElse(id) { SYSTEM_DEFAULT } - - /** - * Compares two version codes to each other - * - * @param versionA The first version - * @param versionB The second version - * @return -1 if versionA < versionB, 1 if versionA > versionB and 0 otherwise - */ - @VisibleForTesting - fun compareVersions(versionA: List, versionB: List): Int { - val len = max(versionB.size, versionA.size) - for (i in 0 until len) { - - val vA = versionA.getOrElse(i) { 0 } - val vB = versionB.getOrElse(i) { 0 } - - // It needs to be decided on the next level - if (vA == vB) continue - // Okay, is version B newer or version A? - return vA.compareTo(vB) - } - - // The versions are equal - return 0 - } - - /** - * This method is needed because when transparent compression is used OkHttp reports - * [ResponseBody.contentLength] as -1. We try to get the header which server sent - * us manually here. - * - * @see [OkHttp issue 259](https://github.com/square/okhttp/issues/259) - */ - private fun Response.length(): Long { - networkResponse?.let { - val header = it.header("Content-Length") ?: return -1 - return header.toLongOrDefault(-1) - } - - // In case it's a fully cached response - return body?.contentLength() ?: -1 - } - - private fun File.deleteIfExists() { - if (exists() && !delete()) { - Log.e(TAG, "Could not delete file $this") - } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt index 60ac73f47..0752c4e5c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -34,20 +34,16 @@ import com.keylesspalace.tusky.viewdata.PollViewData import com.keylesspalace.tusky.viewdata.buildDescription import com.keylesspalace.tusky.viewdata.calculatePercent import java.text.NumberFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale import kotlin.math.min class StatusViewHelper(private val itemView: View) { + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + interface MediaPreviewListener { fun onViewMedia(v: View?, idx: Int) fun onContentHiddenChange(isShowing: Boolean) } - private val shortSdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault()) - fun setMediasPreview( statusDisplayOptions: StatusDisplayOptions, attachments: List, @@ -295,7 +291,7 @@ class StatusViewHelper(private val itemView: View) { context.getString(R.string.poll_info_closed) } else { if (useAbsoluteTime) { - context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.expiresAt)) + context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.expiresAt, false)) } else { TimestampUtils.formatPollDuration(context, poll.expiresAt!!.time, timestamp) } @@ -330,18 +326,6 @@ class StatusViewHelper(private val itemView: View) { } } - fun getAbsoluteTime(time: Date?): String { - return if (time != null) { - if (android.text.format.DateUtils.isToday(time.time)) { - shortSdf.format(time) - } else { - longSdf.format(time) - } - } else { - "??:??:??" - } - } - companion object { val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) val NO_INPUT_FILTER = arrayOfNulls(0) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java deleted file mode 100644 index dceef0f30..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/VersionUtils.java +++ /dev/null @@ -1,44 +0,0 @@ -/* Copyright 2019 kyori19 - * - * 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 . */ - -package com.keylesspalace.tusky.util; - -import androidx.annotation.NonNull; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class VersionUtils { - - private int major; - private int minor; - private int patch; - - public VersionUtils(@NonNull String versionString) { - String regex = "([0-9]+)\\.([0-9]+)\\.([0-9]+).*"; - Pattern pattern = Pattern.compile(regex); - Matcher matcher = pattern.matcher(versionString); - if (matcher.find()) { - major = Integer.parseInt(matcher.group(1)); - minor = Integer.parseInt(matcher.group(2)); - patch = Integer.parseInt(matcher.group(3)); - } - } - - public boolean supportsScheduledToots() { - return (major == 2) ? ( (minor == 7) ? (patch >= 0) : (minor > 7) ) : (major > 2); - } - -} diff --git a/app/src/main/res/drawable/bot_badge.xml b/app/src/main/res/drawable/bot_badge.xml new file mode 100644 index 000000000..6f857df56 --- /dev/null +++ b/app/src/main/res/drawable/bot_badge.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_briefcase.xml b/app/src/main/res/drawable/ic_briefcase.xml index eeb80619d..6df5b88bd 100644 --- a/app/src/main/res/drawable/ic_briefcase.xml +++ b/app/src/main/res/drawable/ic_briefcase.xml @@ -4,6 +4,6 @@ android:viewportHeight="24" android:viewportWidth="24"> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_edit_24dp.xml b/app/src/main/res/drawable/ic_edit_24dp.xml new file mode 100644 index 000000000..2844bafeb --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index 570644127..31b37ad89 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -112,7 +112,7 @@ app:layout_constraintStart_toStartOf="@id/guideAvatar" app:layout_constraintTop_toTopOf="@+id/accountFollowButton" /> - - - - - + tools:visibility="visible"> - + - + - + + + + + + app:layout_constraintTop_toBottomOf="@id/accountMovedView"> + app:layout_constraintTop_toBottomOf="@id/accountMovedView"> + app:layout_constraintTop_toBottomOf="@id/accountMovedView"> - - - @@ -98,4 +99,3 @@ android:fitsSystemWindows="true" /> - diff --git a/app/src/main/res/layout/dialog_emojicompat.xml b/app/src/main/res/layout/dialog_emojicompat.xml deleted file mode 100644 index 6850e045a..000000000 --- a/app/src/main/res/layout/dialog_emojicompat.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_account.xml b/app/src/main/res/layout/item_account.xml index a7b3a0ef6..c1565e093 100644 --- a/app/src/main/res/layout/item_account.xml +++ b/app/src/main/res/layout/item_account.xml @@ -32,7 +32,7 @@ tools:src="#000" tools:visibility="visible" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - تم الاحتفاظ بنسخة مِن التبويق في مسوداتك حرر لا يحتوي مثيل خادومكم %s على أية حزمة إيموجي مخصصة - تم نسخه إلى الحافظة نوع الإيموجي الإفتراضي في النظام يجب عليك أولا تنزيل حزمة الإيموجي هذه diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index a6ca7a656..092db2183 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -123,7 +123,6 @@ Извършва се търсене… По подразбиране от системата Стил на емоджи - Копирано в клипборда Инстанцията ви %s няма персонализирани емоджита Композиране Копие от публикацията е запазено във вашите чернови diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index 082f6dead..7d87e3ed8 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -48,7 +48,6 @@ আপনাকে প্রথমে এই ইমোজি সেটগুলি ডাউনলোড করতে হবে সিস্টেমের ডিফল্ট ইমোজি স্টাইল - ক্লিপবোর্ডে অনুলিপি করা হয়েছে আপনার ইনস্ট্যান্স %s এর কোনো কাস্টম ইমোজিস নেই রচনা টুট এর একটি কপি আপনার ড্রাফটে সংরক্ষণ করা হয়েছে diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index de3d8d818..be1d306b6 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -304,7 +304,6 @@ টুট এর একটি কপি আপনার ড্রাফটে সংরক্ষণ করা হয়েছে রচনা আপনার ইনস্ট্যান্স %s এর কোনো কাস্টম ইমোজিস নেই - ক্লিপবোর্ডে অনুলিপি করা হয়েছে ইমোজি স্টাইল সিস্টেমের ডিফল্ট আপনাকে প্রথমে এই ইমোজি সেটগুলি ডাউনলোড করতে হবে diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9b652fa1c..8e3be5637 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -304,7 +304,6 @@ Una copia del toot s\'ha guardat a esborranys Escriure La teva instància %s no te emojis personalitzats - Copia al porta papers Estil dels emojis Sistema per defecte Hauràs de descarregar el joc d\'emojis diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index 8dd867582..ba275b0b4 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -23,7 +23,7 @@ هاشتاگی پیشاندانی دڵخوازەکان پیشاندانی بەهێزکردنەکان - کردنەوەی بەهێزکردنی نووسەر + پۆستکەرەوەکە ببینە هاشتاگ ئاماژەکان بەستەرەکان @@ -57,14 +57,14 @@ وێنە بگرە زیادکردنی ڕاپرسی زیادکردنی میدیا - کردنەوە لە وێبگەڕ + لە وێبگەڕ بیکەوە میدیا بەدواداچونی داواکاریەکان بکە دۆمەینە شاراوەکان بەکارهێنەرە بلۆککراوەکان بەکارهێنەرە گۆڕاوەکان نیشانەکان - دڵخوازەکان + بەدڵبوونەکان پەسەندکراوەکانی ئەژمێر پەسەندەکان پرۆفایل @@ -76,12 +76,12 @@ سڕینەوە دەستکاری گوزارشەکان - پیشاندانی بەهێزکردنەکان + پۆستکردنەوەکان نیشان بدە شاردنەوەی بەهێزکردنەکان بەربەست کردن لاببە بلۆک بەدوادانەچو - بەدواداکەوتن + شوێنی بکەوە ئایا دڵنیایت لەوەی دەتەوێت بچیتەدەرەوە لە هەژماری %1$s؟ چوونەدەرەوە چوونەژوورەوە لەگەڵ ماستۆدۆن @@ -90,7 +90,7 @@ لابردنی دڵخوازەکان نیشانه دڵخواز - لابردنی بەهێزکردن + پۆستکردنەوەکە بگەڕێنەوە بەهێزکردن وەڵام وەڵامدانەوەی خێرا @@ -110,7 +110,7 @@ کرتە بکە بۆ بینین میدیا شاراوە ناوەڕۆکی هەستیار - %s بەرزکرا + %s پۆستی کردەوە \@%s مۆڵەتەکان ڕاگه یه نراوەکان @@ -122,12 +122,12 @@ بەکارهێنەرە بێدەنگ نیشانەکان دڵخوازەکان - شوێنکەوتوان - بەدوادا + شوێنکەوتوو + شوێنکەوتنەکان چەسپا لەگەڵ وەڵامەکان - بابەتەکان - توت + پۆست + زنجیرە سەرخشتەکان نامە ڕاستەوخۆکان گشتی @@ -140,19 +140,19 @@ مۆڵەت بۆ پاشکەوتکردنی میدیا پێویستە. مۆڵەت بۆ خوێندنەوەی میدیا پێویستە. ئەم فایلە ناتوانرێت بکرێتەوە. - ناتوانرێت ئەو جۆرە فایلە باربکرێت. - فایلەکانی دەنگ دەبێت کەمتر بێت لە ٤٠MB. - پێویستە فایلەکانی ڤیدیۆ کەمتر لە 40 مێگابایت بن. - فایلەکە دەبێت کەمتر بێت لە 8 مێگابایت. - ڕەستە زۆر درێژە! + ناتوانیت لەم جۆرە فایلانە بەرز بکەیتەوە. + دەبێت فایلە دەنگییەکان لە 40 مێگابایت گەورەتر نەبن. + دەبێت ڤیدیۆکان لە 40 مێگابایت گەورەتر نەبن. + فایلەکە دەبێت لە 8 مێگابایت بچووکتر بێت. + ئەم نووسینە زۆر درێژە! سەرکەوتوو نەبوو لە بەدەستهێنانی نیشانەی چوونەژوورەوە. ڕێپێدان ڕەتکرایەوە. هەڵەیەک بۆ مۆڵەتدانی نەناسراو ڕووی دا. نەیتوانی وێبگەڕبدۆزێتەوە بۆ بەکارهێنان. سەرکەوتوو نەبوو، ڕاستکردنەوە لەگەڵ ئەم نمونەیە. - دۆمەینی نادروست تێنووسکرا - ئەمە ناتوانێت بەتاڵ بێت. - هەڵەیەک لە تۆڕ ڕوویدا! تکایە پەیوەندیت بپشکنە و دوبارە هەوڵ بدە! + دۆمەینێکی نادروستت نووسیوە + ناکرێت ئەمە بەتاڵ بێت. + هەڵەیەک لە پەیوەندییەکەدا ڕوویدا. تکایە دڵنیا ببەوە لە بەردەستبوونی هێڵی ئینتەرنێت. هەڵەیەک ڕوویدا. تایبەتمەندی بابەت گریمانەیی دەرگای پرۆکسی HTTP @@ -249,7 +249,7 @@ \n \nکارتێکردنی ئاگانامەکانی پاڵپێوەنان، بەڵام دەتوانیت بە پەسەندکردنە ئاگانامەکانت دا بخشێنیەوە بە دەستی. ڕزگارکرا - تێبینی تایبەتی تۆ دەربارەی ئەم ئەژمێرە + تێبینیی تایبەتیت بۆ ئەم هەژمارە Wellbeing شاردنەوەی ناونیشانی شریتی ئامڕازی سەرەوە پیشاندانی دیالۆگی دووپاتکردنەوە پێش بەهێزکردن @@ -294,7 +294,7 @@ ڕاپرسییەک کە دروستت کردووە کۆتایی هات ڕاپرسییەک کە دەنگی پێداویت کۆتایی هات دەنگ - داخراوە + کۆتایی هاتووە کۆتایی دێت لە %s %s کەس @@ -336,10 +336,10 @@ %1$s و %2$s %1$s پەسەندکراوە لەلایەن - بەرزکراوە لەلایەن + پۆست کراوەتەوە لەلایەن - %s بەهێزکردن - %s بەهێزکردن + %s پۆستکردنەوە + %s پۆستکردنەوە %1$s دڵخواز @@ -375,7 +375,6 @@ تۆ پێویستە سەرەتا ئەم سێتە ئیمۆجییانە دابگریت سیستەمی بنەڕەت شێوازی ئیمۆجی - ڕوونووسکراوە بۆ کلیپ بۆرد نموونەکەت %s هیچ ئیمۆجییەکی ئاسایی نییە دروستکردن کۆپیەکی دەستنووسەکە خەزن کراوە بۆ ڕەشنووسەکانت diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 34d376e65..71966926d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -302,7 +302,6 @@ Kopie vašeho tootu byla uložena do vašich konceptů Napsat Vaše instance %s nemá žádná vlastní emoji - Zkopírováno do schránky Styl emoji Výchozí nastavení systému Musíte si nejprve stáhnout tyto sady emoji diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 4e4ecb3c9..6ec7cca82 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -251,7 +251,6 @@ Cadwyd copi o\'r tŵt i\'ch drafftiau Creu Nid oes gan eich achos %s emoji bersonol - Copïwyd i\'r clipfwrdd Arddull emoji Rhagosodiad system Bydd angen i chi lawrlwytho\'r setiau emoji hyn yn gyntaf diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 357b86c71..699ba3789 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -208,7 +208,7 @@ Neue Erwähnungen Benachrichtigungen über neue Erwähnungen Neue Folgende - Benachrichtigunen über neue Folgende + Benachrichtigungen über neue Folgende Geteilte Beiträge Benachrichtigungen, wenn deine Beiträge geteilt werden Favorisierte Beiträge @@ -279,7 +279,6 @@ Eine Kopie des Beitrags wurde in deine Entwürfe gespeichert Beitrag erstellen Deine Instanz %s hat keine Emojis definiert - In die Zwischenablage kopiert Emoji-Stil System-Standard Du musst diese Emoji-Sets zunächst herunterladen @@ -528,4 +527,11 @@ 14 Tage 180 Tage Beitrag erstellen + %s hat den Beitrag bearbeitet + Ein Beitrag, mit dem ich interagiert habe, wurde bearbeitet + Registrierungen + Benachrichtigungen über neue Profile + %s hat sich registriert + Jemand hat sich registriert + Benachrichtigungen, wenn Beiträge bearbeitet werden, mit denen du interagiert hast diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index f834d2ae9..922a385b9 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -299,7 +299,6 @@ Kopio de la mesaĝo estis konservita en viaj malnetoj Verki Via nodo %s ne havas proprajn emoĝiojn - Kopiita en tondujo Stilo de emoĝioj Sistema valoro Vi unue devos elŝuti ĉi tiujn emoĝiarojn diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a33ea05b1..bfee420fa 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -269,7 +269,6 @@ Una copia del estado se ha guardado en borradores Redactar Su instancia %s no ofrece emojis personalizados - Copiado al portapapeles Estilo de los emojis Sistema Tendrás que descargarlos primero diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 8671a8be7..7a3a4ded2 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -253,7 +253,6 @@ Tutaren kopia zirriborroetan sartu da Idatzi %s instantziak ez ditu emoji pertsonalizatuak eskaintzen - Arbelean kopiatua Emojien estiloa Sistema Lehenago jaitsi beharko dituzu diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 8ddeb49a3..72b2453ba 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -248,7 +248,6 @@ رونوشتی از بوق در پیش‌نویس‌هایتان ذخیره شد ایجاد نمونه‌تان %s هیچ اموجی سفارشی‌ای ندارد - در تخته‌گیره رونوشت شد سبک اموجی پیش‌گزیدهٔ سامانه نخست باید این مجموعه‌های اموجی را بارگیری کنید diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8490c4cb9..dda7fc418 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -304,7 +304,6 @@ Une copie du pouet a été sauvegardée dans vos brouillons Écrire Votre instance %s n’a pas d’émojis personnalisés - Copié dans le presse-papier Style d’émojis Par défaut du système Vous devez commencer par télécharger ces jeux d’émojis @@ -540,4 +539,12 @@ 14 jours 180 jours Rédiger un message + %s a créé un compte + Nouveaux comptes + Notifications quand quelqu\'un crée un nouveau compte + un nouveau compte a été créé + %s a modifié son message + un message avec lequel j\'ai interagi est modifié + Messages modifiés + Notifications quand un post avec lequel vous avez interagi est modifié diff --git a/app/src/main/res/values-fy/strings.xml b/app/src/main/res/values-fy/strings.xml index 97ad4052e..f902d7426 100644 --- a/app/src/main/res/values-fy/strings.xml +++ b/app/src/main/res/values-fy/strings.xml @@ -4,7 +4,6 @@ Dit mei net leech wêze. Systeem standert Emoji styl - Nei it klemboerd kopiearre Gearstelle Ferstjoeren ôfbrutsen Toots oan it ferstjoeren diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 5cdf52f49..2ea95dc97 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -339,7 +339,6 @@ Sábháladh cóip den tút ar do dhréachtaí Cum Níl aon emojis saincheaptha ag do shampla %s - Cóipeáladh chuig an gearrthaisce Stíl Emoji Réamhshocrú an chórais Beidh ort na tacair emoji seo a íoslódáil ar dtús diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index bf2a2d521..10d64e0c1 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -239,7 +239,6 @@ Feumaidh tu na seataichean seo de dh’Emojis a luchdadh a-nuas an toiseach Bun-roghainn an t-siostaim Stoidhle nan Emojis - Chaidh lethbhreac dheth a chur air an stòr-bhòrd Chan eil Emojis gnàthaichte aig an ionstans %s agad Chaidh lethbhreac dhen phost agad a shàbhaladh ’na dhreachd Chaidh sgur dhen chur @@ -249,10 +248,16 @@ A bheil thu airson a shàbhaladh ’na dhreachd\? Feumaidh tu gabhail ri luchd-leantainn ùr a làimh Glais an cunntas - Suidhidh am fo-thiotal + Suidhich am fo-thiotal Mìnich e dhan fheadhainn air a bheil cion-lèirsinn -\n(%d caractar(an) air a char as fhaide) +\n(%d charactar air a char as fhaide) + Mìnich e dhan fheadhainn air a bheil cion-lèirsinn +\n(%d charactar air a char as fhaide) + Mìnich e dhan fheadhainn air a bheil cion-lèirsinn +\n(%d caractaran air a char as fhaide) + Mìnich e dhan fheadhainn air a bheil cion-lèirsinn +\n(%d caractar air a char as fhaide) Cha deach leinn am fo-thiotal a shuidheachadh A’ postadh leis a’ chunntas %1$s @@ -323,7 +328,7 @@ an ceann %du an ceann %dl an ceann %db - Iarrar leantainn orm + Iarrtas leantainn air Videothan Dealbhan Pròifil Tusky @@ -541,4 +546,13 @@ 14 làithean 60 latha Sgrìobh post + Chlàraich %s + Clàraidhean + Brathan mu cleachdaichean ùra + chlàraich cuideigin + Dheasaich %s am post aca + Deasachadh puist + Brathan nuair a thèid postaichean a rinn thu conaltradh leotha a dheasachadh + chaidh post a rinn mi conaltradh leis a deasachadh + Clàraich a-steach \ No newline at end of file diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index bfabe4c8e..5b488df36 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -247,7 +247,6 @@ Deberás descargar primeiro estos conxuntos de emojis Por defecto no sistema Estilo dos emoji - Copiado ao portapapeis A túa instancia %s non ten emojis personalizados Redactar Gardouse unha copia do toot nos borradores @@ -519,4 +518,8 @@ 180 días 365 días Redactar publicación + %s rexistrouse + hai unha nova usuaria + Rexistros + Notificacións sobre novas usuarias \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index d6b7f8ca8..2ecee5413 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -306,7 +306,6 @@ बाद में एप्लिकेशन को पुनः आरंभ की आवश्यकता है आपको पहले इस इमोजी सेट को डाउनलोड करना होगा - क्लिपबोर्ड पर कॉपी किया गया लिखें टूट की एक प्रति आपके ड्राफ्ट में सहेज ली गई है टूट भेजने में त्रुटि diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index b6ace6eef..4bcd9a109 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -9,26 +9,26 @@ Azonosítatlan engedélyezési hiba történt. Engedély megtagadva. Bejelentkezési token megszerzése sikertelen. - Túl hosszú a tülkölés! + Túl hosszú a bejegyzés! A fájlnak kisebbnek kell lennie, mint 8 MB. A videofájloknak kisebbnek kell lenniük, mint 40 MB. Ilyen típusú fájlt nem lehet feltölteni. Fájl megnyitása sikertelen. Média olvasási engedély szükséges. Média tárolási engedély szükséges. - Képek és videók egyszerre nem csatolhatók ugyanazon tülköléshez. + Képek és videók egyszerre nem csatolhatóak ugyanazon bejegyzéshez. Feltöltés sikertelen. - Nem sikerült elküldeni a tülköt. + Nem sikerült elküldeni a bejegyzést. Kezdőlap Értesítések Helyi Föderációs Közvetlen üzenetek Fülek - Tülk - Tülkök + Szál + Bejegyzések Válaszokkal - Rögzített + Kitűzött Követett Követő Kedvencek @@ -40,17 +40,17 @@ Licenszek \@%s %s megtolta - Kényes tartalom + Érzékeny tartalom Rejtett média - Kattints a megnézéshez + Kattints a megtekintéshez Mutass többet Mutass kevesebbet Kibontás Összecsukás Nincs itt semmi. Üres tartalom. Húzd le a frissítéshez! - %s megtolta a tülködet - %s kedvencnek jelölte tülködet + %s megtolta a bejegyzésedet + %s kedvencnek jelölte a bejegyzésedet %s bekövetett \@%s jelentése Egyéb megjegyzés? @@ -100,7 +100,7 @@ Elutasítás Keresés Piszkozatok - Tülkök láthatósága + Bejegyzés láthatósága Tartalom figyelmeztetés Emoji billentyűzet Fül hozzáadása @@ -115,8 +115,8 @@ Link másolása Megnyitás mint %s Megosztás mint … - Tülk URL megosztása… - Tülk megosztása… + Bejegyzés URL megosztása… + Bejegyzés megosztása… Elküldve! Felhasználó letiltása feloldva Felhasználó némítása feloldva @@ -132,7 +132,7 @@ Válasz… Profilkép Fejléc - Mi az a szerver\? + Mi az a példány\? Csatlakozás… Bármely példány címét vagy domain nevét beírhatod ide, mint a mastodon.social, az icosahedron.website, a social.tchncs.de és mások! \n @@ -146,11 +146,11 @@ Letöltés Visszavonod a követési kérelmet? Követés megszüntetése? - Törlöd ezt a tülköt? - Nyilvános: Tülkölés nyilvános idővonalra + Törlöd ezt a bejegyzést\? + Nyilvános: Bejegyzés nyilvános idővonalra Listázatlan: Nem jelenik meg a nyilvános idővonalon - Csak követőknek: Tülkölés csak követőknek - Közvetlen: Tülkölés csak a megemlített felhasználóknak + Csak követőknek: Bejegyzés csak követőknek + Közvetlen: Bejegyzés csak a megemlített felhasználóknak Értesítések Értesítések Figyelmeztetések @@ -160,8 +160,8 @@ Értesítsen, ha megemlítettek bekövettek - tülkömet megtolták - tülkömet kedvenccé tették + bejegyzésemet megtolták + bejegyzésemet kedvencnek jelölték Megjelenés Idővonalak Sötét @@ -181,13 +181,13 @@ HTTP proxy engedélyezése HTTP proxy szerver HTTP Proxy port - Tülkök alapértelmezett láthatósága + Bejegyzések alapértelmezett láthatósága Minden média kényesnek jelölése A beállítások szinkronizálása nem sikerült Nyilvános Listázatlan Csak követőknek - Tülkölés szöveg mérete + Bejegyzés szövegének mérete Legkisebb Kicsi Közepes @@ -198,9 +198,9 @@ Új követők Értesítések új követőkről Megtolások - Értesítések tülkjeid megtolása esetén + Értesítések bejegyzéseid megtolása esetén Kedvencek - Értesítések mikor tülkjeidet kedvencnek jelölik + Értesítések amikor a bejegyzéseidet kedvencnek jelölik %s megemlített téged %1$s, %2$s, %3$s és még %4$d %1$s, %2$s meg %3$s @@ -224,10 +224,10 @@ Hibajelentés & új funkciók igénylése: \n https://github.com/accelforce/Yuito/issues Yuito profilja - Tülk tartalmának megosztása - Tülk linkjének megosztása + Bejegyzés tartalmának megosztása + Bejegyzés hivatkozásának megosztása Képek - Videók + Videó Követés kérelmezve Követ téged @@ -241,19 +241,18 @@ Törlés Fiók zárolása Elmented a piszkozatot\? - Tülk elküldése… - A tülk elküldése nem sikerült - Tülkök elküldése + Bejegyzés küldése… + A bejegyzés elküldése sikertelen + Bejegyzések elküldése Küldés megszakítva - A tülk másolatát elmentettük a piszkozataid közé + A bejegyzés másolatát elmentettük a piszkozataid közé Szerkesztés - A %s szervernek nincsenek egyedi emoji-jai - Vágólapra másolva + A %s példánynak nincsenek egyedi emoji-jai Emoji stílus Rendszer alapértelmezés Először le kell töltened ezeket az emoji készleteket Keresés… - Tülk megnyitása + Bejegyzés megnyitása Az app újraindítása szükséges A beállítások érvényesítéséhez újra kell indítani a Tuskyt Később @@ -280,8 +279,7 @@ elérted a fülek maximális számát (%1$d) elérted a fülek maximális számát (%1$d) - Nincs leírás - + Nincs leírás Nyilvános Követők Kedvenc eltávolítása @@ -290,7 +288,7 @@ Média letöltése Média letöltése Média megosztása következővel… - Törlöd és újraírod ezt a tülköt\? + Törlöd és újraírod ezt a bejegyzést\? befejeződött egy szavazás Szűrők Rendszer téma használata @@ -342,7 +340,7 @@ Általad követettek keresése Fiók hozzáadása a listához Fiók eltávolítása a listából - Tülkölés %1$s fiókkal + Bejegyzés %1$s fiókkal Cím beállítása nem sikerült Leírás látássérülteknek @@ -350,7 +348,7 @@ Cím beállítása Minden követődet külön engedélyezned kell - Minden tülk kibontása/összecsukása + Összes bejegyzés kibontása/összecsukása A Google jelenlegi emodzsi készlete Megtolás az eredeti közönségnek Megtolás visszavonása @@ -368,7 +366,7 @@ %1$s %1$s, %2$s és még %3$d Média: %s - Tartalom figyelmeztetés: %s + Tartalomfigyelmeztetés: %s Megtolt Kedvelt Listázatlan @@ -379,7 +377,7 @@ Törlés Szűrés Alkalmaz - Tülk szerkesztése + Bejegyzés létrehozása Szerkesztés Biztos, hogy minden értesítésedet véglegesen törlöd\? Műveletek a(z) %s képpel @@ -416,11 +414,11 @@ Egyéb megjegyzések Továbbítás neki %s Nem sikerült a bejelentés - Nem sikerült a tülkök letöltése + Sikertelen a bejegyzések letöltése A bejelentést a szervered moderátorának küldjük el. Alább megadhatsz egy magyarázatot arra, hogy miért jelented be ezt a fiókot: A fiók egy másik szerverről származik. Küldjünk oda is egy anonimizált másolatot a bejelentésről\? Értesítések szűrőjének mutatása - Tartalom-figyelmeztetéssel ellátott tülkök kifejtése mindig + Tartalomfigyelmeztetéssel ellátott bejegyzések kinyitása mindig Fiókok Sikertelen keresés Szavazás hozzáadása @@ -436,12 +434,12 @@ Több lehetőség Válasz %d Szerkesztés - Időzített tülkök + Időzített bejegyzések Szerkesztés - Időzített tülkök - Tülk Időzítése + Időzített bejegyzések + Bejegyzés Időzítése Visszaállítás - Nem találjuk ezt a tülköt %s + Nem találjuk ezt a bejegyzést %s Könyvjelzők Könyvjelzőzés Könyvjelzők @@ -451,7 +449,7 @@ Lista A hangfájloknak kisebbnek kell lenniük, mint 40 MB. Nincs egy piszkozatod sem. - Nincs egy ütemezett tülköd sem. + Nincs egy ütemezett bejegyzésed sem. A Mastodonban a legrövidebb ütemezhető időintervallum 5 perc. Követési kérelmek Jóváhagyó ablak mutatása megtolás előtt @@ -484,34 +482,34 @@ Saját, mások számára nem látható megjegyzés erről a fiókról Nincsenek közlemények. Közlemények - A tülköt, melyre válaszul piszkozatot készítettél törölték + A bejegyzést, melyre válaszul piszkozatot készítettél törölték Piszkozat törölve Nem sikerült a Válasz információit betölteni - Ez a tülk nem küldődött el! + Ezt a bejegyzést nem tudtuk elküldeni! Tényleg le akarod törölni a %s listát\? Nem tölthetsz fel %1$d médiacsatolmányból többet. Nem tölthetsz fel %1$d médiacsatolmányból többet. Profilok mérőszámainak elrejtése - Tülkök mérőszámainak elrejtése + Bejegyzések mérőszámainak elrejtése Idővonali értesítések korlátozása Értesítések Áttekintése - Pár információ, ami befolyásolhatja a mentális egészségedet rejtve marad. Ilyenek pl.: + Pár információ, ami befolyásolhatja a mentális jóllétedet rejtve marad. Ilyenek pl.: \n \n - Kedvenc/Megtolás/Bekövetés értesítései -\n - Kedvenc/Megtolás számlálók a tülkökön -\n - Követő/Tülk statisztikák a profilokon +\n - Kedvenc/Megtolás számlálók a bejegyzéseken +\n - Követő/Bejegyzés statisztikák a profilokon \n \nA Push-értesítéseket ez nem befolyásolja, de kézzel átállíthatod az értesítési beállításaidat. Végtelen Időtartam Csatolmányok Audio - Értesítések általam követett személy új tülkjeiről - Új tülkök - valaki, akit követek újat tülkölt - %s épp tülkölt + Értesítések általam követett személy új bejegyzéseiről + Új bejegyzések + valaki, akit követek új bejegyzést tett közzé + %s épp bejegyzést írt Jóllét Egyedi emojik animálása Leiratkozás @@ -521,4 +519,19 @@ Beszélgetés törlése Könyvjelző törlése Jóváhagyás mutatása kedvencnek jelölés előtt + %s szerkesztette a bejegyzését + szerkesztették a bejegyzést, mellyel dolgod volt + %s regisztrált + valaki regisztrált + Regisztrációk + Értesítések új felhasználókról + 14 nap + 30 nap + 60 nap + 90 nap + 180 nap + 365 nap + Bejegyzések szerkesztése + Értesítések olyan bejegyzések szerkesztéséről, melyekkel már dolgod volt + Bejegyzés Létrehozása diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 0dc453c5c..14a38b275 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -317,7 +317,6 @@ Afrit af tístinu þínu hefur verið vistað drögunum þínum Semja skilaboð Tilvikið þitt %s er ekki með nein sérsniðin tjáningartákn - Afritað á klippispjald Stíll tjáningartákna Sjálfgefið í kerfinu Þú þarft fyrst að ná í þessi táknmyndasett @@ -519,4 +518,12 @@ 365 dagar 14 dagar Semja færslu + %s skráði sig + einhver skráði sig + %s breytti færslunni sinni + færsla sem ég hef átt við er breytt + Nýskráningar + Tilkynningar um nýja notendur + Breytingar á færslum + Tilkynningar þegar færslum sem þú hefur átt við er breytt \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 32abc3240..45d426f97 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -297,7 +297,6 @@ Una copia del toot è stata salvata nelle tue bozze Componi La tua istanza %s non ha nessuna emoji personalizzata - Copiato negli appunti Stile di emoji Predefiniti del sistema Dovrai prima scaricare questo pacchetto di emoji diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 41fcc31fb..cf5c9f9b5 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -284,7 +284,6 @@ トゥートのコピーが下書きに保存されました 新規投稿 インスタンス %s にはカスタム絵文字がありません - クリップボードにコピーされました 絵文字スタイル システムのデフォルト 最初にこれらの絵文字セットをダウンロードする必要があります diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 24cfbdad1..fc81f2d3f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -311,7 +311,6 @@ 복사본이 임시 저장에 저장되었습니다 글쓰기 이 인스턴스 %s 은(는) 커스텀 이모지가 없습니다. - 클립보드에 복사되었습니다 이모지 스타일 시스템 기본 시스템 기본 외의 이모지를 설정하시려면 우선 다운로드해야 합니다 diff --git a/app/src/main/res/values-night/theme_colors.xml b/app/src/main/res/values-night/theme_colors.xml index 81b58754a..8fe189620 100644 --- a/app/src/main/res/values-night/theme_colors.xml +++ b/app/src/main/res/values-night/theme_colors.xml @@ -24,6 +24,9 @@ false + @color/white + @color/tusky_grey_10 + @color/tusky_orange_light diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 13e62b104..f746bef34 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -277,7 +277,6 @@ Een kopie van de toot werd opgeslagen als concept Toot schrijven Jouw server %s heeft geen lokale emojis - Naar het klembord gekopieerd Emojistijl Systeemstandaard Je moet eerst deze emoji-sets downloaden diff --git a/app/src/main/res/values-no-rNB/strings.xml b/app/src/main/res/values-no-rNB/strings.xml index 9c2ce9aa3..53ab17742 100644 --- a/app/src/main/res/values-no-rNB/strings.xml +++ b/app/src/main/res/values-no-rNB/strings.xml @@ -278,7 +278,6 @@ En kopi av tootet er lagret i kladdene dine Skriv Instansen %s har ingen egendefinerte emojis - Kopiert til utklippstavlen Emoji-stil Systemstandard Du må laste ned emoji-samlingene før de kan brukes @@ -519,4 +518,13 @@ 365 dager 14 dager Komponer toot + %s registrerte seg + noen registrerte seg + Registreringer + Varslinger om nye brukere + %s redigerte innlegget sitt + et innlegg jeg har hatt en interaksjon med er redigert + Redigerte innlegg + Varslinger når et innlegg du har hatt en interaksjon med er redigert + Innlogging diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index 2cfd37109..8c2557e58 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -243,7 +243,6 @@ Una còpia del tut es estat salvat dins los borrolhons Redactar L’instància %s es pas compatibla amb los emoji personalizats - Copiat al quichapapièr Estil dels Emoji Çò del sistèma D’en primièr vos cal telecargar los emojis seguents @@ -337,8 +336,8 @@ %1$s Favorits - %s partatge - %s partatges + %s Partatge + %s Partatges Partejat per Aimat per diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 04f1a3e8b..cf6b392aa 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -244,7 +244,6 @@ Kopia wpisu została zapisana jako szkic Nowy wpis Twoja instancja %s nie używa żadnych niestandardowych emoji - Skopiowano do schowka Styl emoji Domyślny systemu Musisz najpierw pobrać te zestawy emoji @@ -519,7 +518,7 @@ ktoś kogo zasubskrybowałem/zasubskrybowałam opublikował nowy wpis Wysłano prośbę o obserwowanie Ogłoszenia - Samopoczucie + Zdrowie Anuluj subskrypcję Zasubskrybuj Mimo tego, że twoje konto nie jest zablokowane, administracja %1$s uznała, że możesz chcieć ręcznie przejrzeć te prośby o możliwość śledzenia od tych kont. @@ -530,7 +529,7 @@ Przejrzyj powiadomienia Zapisano! Twoja prywatna notatka o tym koncie - Czas nieokreślony + Nieskończona Dźwięk Powiadomienia o opublikowaniu nowego wpisu przez kogoś, kogo obserwujesz Pozycja głównego paska nawigacji @@ -549,4 +548,13 @@ 180 dni 365 dni Utwórz wpis + Login + %s zarejestrował(a) się + Rejestracje + Powiadomienia o nowych użytkownikach + Powiadomienia o edycji wpisów z którymi interaktowałeś/aś + ktoś zarejestrował się + wpis, z którym interaktowałem/am został edytowany + %s edytował(a) swój wpis + Edycje wpisów \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index bac0c960a..5c558b3d7 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -261,7 +261,6 @@ Uma cópia do toot foi salva nos seus rascunhos Compor A sua instância %s não possui emojis personalizados - Copiado para a área de transferência Estilo de emoji Padrão do sistema É necessário baixar estes pacotes de emojis primeiro diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 000000000..e501ed2ac --- /dev/null +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,531 @@ + + + Maior + Toots novos + Criação de contas + %1$s e %2$s + + %d nova interação + %d novas interações + + A responder a @%s + Editar a lista + Exige a aprovação manual de seguidores + Guardar rascunho\? + Depois + Desafixar + %1$s e %2$s + Bem-estar + Escrever Toot + Deseja excluir a lista %s\? + Apesar do seu perfil não ser trancado, %1$s exige que você revise a solicitação para te seguir destes perfis manualmente. + Notificar + Cancelar + Autorização negada. + Erro ao adquirir token de login. + O toot é muito longo! + O ficheiro deve ter menor de 8MB. + Os ficheiros de vídeo devem ter menor de 40MB. + Os ficheiros de áudio devem ter menor de 40MB. + Esse tipo de ficheiro não pode ser enviado. + Não foi possível abrir esse ficheiro. + É necessária permissão para ler o armazenamento. + É necessária permissão para escrever no armazenamento. + Não é possível anexar imagens e vídeos no mesmo toot. + Erro ao enviar. + Erro ao publicar o toot. + Página inicial + Notificações + Local + Federada + Mensagens Diretas + Separadores + Conversa + Toots + Com respostas + Fixado + Segue + Seguidores + Favoritos + Itens guardados + Utilizadors silenciados + Utilizadors bloqueados + Instâncias bloqueadas + Seguidores Pendentes + Conteúdo sensível + Editar perfil + Conteúdo ocultado + Rascunhos + Toque para ver + Mostrar Mais + Mostrar Menos + Expandir + Contrair + Toots agendados + Anúncios + Licenças + \@%s + %s fez boost + Nada aqui. + Nada aqui. Arraste para baixo para atualizar! + %s fez boost ao seu toot + %s adicionou o seu toot aos favoritos + %s está a seguir-te + %s pediu para te seguir + %s criou conta + %s acabou de publicar um toot + %s editou um toot + Denunciar @%s + Comentários adicionais\? + Resposta rápida + Responder + Fazer boost + Desfazer boost + Adicionar aos favoritos + Remover dos favoritos + Guardar + Remover dos itens guardados + Mais + Escrever + Entrar com Mastodon + Sair + Tem certeza de que deseja sair da conta %1$s\? + Seguir + Deixar de seguir + Bloquear + Desbloquear + Esconder boosts + Mostrar boosts + Denunciar + Editar + Apagar + Apagar conversa + Apagar e criar novo rascunho + TOOT + TOOT! + Tentar novamente + Fechar + Perfil + Preferências + Preferências da Conta + Favoritos + Guardados + utilizadors silenciados + utilizadors bloqueados + Instâncias bloqueadas + Seguidores Pendentes + Conteúdo multimédia + Abrir no navegador + Adicionar conteúdo multimédia + Adicionar votação + Tirar foto + Partilhar + Silenciar + Remover silêncio + Remover %s do silêncio + Remover notificações de %s do silêncio + Silencie notificações de %s + Silenciar %s + Remover %s do silêncio + Silenciar conversa + Remover conversa do silêncio + Mencionar + Esconder conteúdo multimédia + Abrir menu + Pesquisar + Rascunhos + Toots agendados + Privacidade do toot + Aviso de conteúdo + Teclado de emojis + Agendar toot + Redefinir + Adicionar Separador + Hiperligações + Menções + Hashtags + Ver quem fez boost + Mostrar boosts + Mostrar favoritos + Hashtags + Menções + Hiperligações + Abrir conteúdo multimédia #%d + A descarregar %1$s + Copiar hiperligação + Abrir como %s + Partilhar como… + Descarregar conteúdo multimédia + A descarregar conteúdo multimédia + Partilhar hiperligação do toot via… + Partilhar toot via… + Partilhar conteúdo multimédia via… + Enviado! + Utilizador desbloqueado + Utilizador removido do silêncio + %s desbloqueada + Enviado! + Resposta enviada com sucesso. + Que instância\? + Em que está a pensar\? + Aviso de conteúdo + Nome + Biografia + Pesquisar… + Sem resultados + Responder… + Avatar + Cabeçalho + O que é uma instância\? + A ligar… + O endereço IP ou domínio de qualquer instância pode ser inserido aqui, como por exemplo mastodon.social, masto.donte.com.br, colorid.es ou qualquer outro! +\n +\n Se ainda não tem uma conta, insira o nome da instância onde pretende participar e crie uma conta lá. +\n +\n Uma instância é um lugar onde sua conta é hospedada, mas pode facilmente seguir e comunicar com pessoas de outras instâncias como se todos estivessem no mesmo site. +\n +\nMais informações disponíveis em joinmastodon.org. + Envio de Conteúdo Multimédia Terminando + A enviar… + Descarregar + Cancelar pedido para seguir\? + Deixar de seguir esta conta\? + Apagar este toot\? + Apagar e criar novo rascunho\? + Apagar esta conversa\? + Tem certeza que pretende bloquear a instância %s\? Deixará de poder ver quaisquer conteúdos dessa instância em qualquer timeline pública ou nas suas notificações. Os seus seguidores dessa instância serão removidos. + Bloquear instância + Bloquear @%s\? + Silenciar @%s\? + Esconder notificações + Público: Publicar em timelines públicas + Não listado: Não publicar em timelines públicas + Privado: Publicar apenas para os seguidores + Direto: Publicar apenas para os utilizadores mencionados + Editar notificações + Notificações + Alertas + Notificar com som + Notificar com vibração + Notificar com luz + Notifique-me quando + for mencionado + for seguido + alguém para quem ativei os alertas publicar um toot novo + fizerem pedido para me seguir + derem boosts nos meus toots + adicionarem os meus toots aos favoritos + votações terminarem + alguém criar conta + um toot com o qual interagi for editado + Aparência + Temas + Timelines + Filtros + Noturno + Diurno + AMOLED + Automático ao pôr-do-sol + Usar o Tema do Sistema + Navegador + Usar Separadores Personalizados do Chrome + Esconder o botão de criação de toots ao fazer scroll + Idioma + Mostrar indicador para bots + Reproduzir avatares em GIFs + Mostrar desfocagem em conteúdo multimédia sensível + Animar emojis personalizados + Filtro da timeline + Separadors + Mostrar boosts + Mostrar respostas + Mostrar pré-visualização de conteúdo multimédia + Proxy + Proxy HTTP + Ativar proxy HTTP + Servidor da proxy HTTP + Privacidade padrão dos toots + Classificar sempre conteúdo multimédia como sensível + Toots (sincronizados com a instância) + Erro ao sincronizar configurações + Posição do menu principal + Superior + Inferior + Público + Porta da proxy HTTP + Não listado + Privado + Menor + Pequeno + Médio + Grande + Menções Novas + Notificações para menções novas + Novos Seguidores + Tamanho do texto do toot + Notificações para seguidores novos + Seguidores Pendentes + Notificações para seguidores pendentes + Boosts + Votações + Notificações para votações que terminaram + Notificações quando alguém para quem ativei os alertas publicar um toot novo + Notificações para novos utilizadores + Edições a toots + Notificações para boosts recebidos + Favoritos + Notificações quando os teus toots são adicionados aos favoritos + Notificações quando toots com os quais interagi foram editados + %s mencionou-te + %1$s, %2$s, %3$s e %4$d outros + %1$s, %2$s e %3$s + Perfil Bloqueado + Sobre + Tusky %s + A correr o Tusky + Atualizar + Tusky é um software livre e de código aberto e é ljcenciado com a versão 3 da GNU General Public License. Leia a licença aqui: https://www.gnu.org/licenses/gpl-3.0.pt-br.html + Página do projeto: +\n https://tusky.app + Reporte de erros e pedidos de funcionalidades: +\n https://github.com/tuskyapp/Tusky/issues + Perfil do Tusky + Partilhar conteúdo do toot + Partilhar hiperligação do toot + Imagens + Vídeo + Áudio + Anexos + Pedido enviado + em %dy + em %dd + em %dh + em %dm + em %ds + %da + %dd + %dh + %dm + %ds + Segue-te + Mostrar sempre conteúdo multimédia sensível + Expandir sempre toots com Aviso de Conteúdo + Palavra completa + Conteúdo Multimédia + carregar mais + Timelines públicas + Conversas + Criar filtro + Editar filtro + Remover + Se a palavra ou frase for alfanumérica, só será aplicado se corresponder à palavra completa + Frase para filtrar + Adicionar Conta + Adicionar nova Conta Mastodon + Listas + Não foi possível renomear a lista + Listas + Lista da timeline + Não foi possível criar a lista + Não foi possível apagar a lista + Criar uma lista + Renomear a lista + Apagar a lista + Pesquisar pessoas que você segue + Adicionar conta à lista + Remover conta da lista + Publicar com a conta %1$s + Erro ao incluir descrição + + Descrição para deficientes visuais +\n(até %d caracteres) + + + Descrever + Remover + Bloquear perfil + A enviar o toot… + Erro ao enviar o toot + A Enviar os Toots + Envio cancelado + Uma cópia do toot foi guardada nos seus rascunhos + Escrever + A sua instância, %s, não tem emojis personalizados + Estilo de emoji + Padrão do sistema + É necessário descarregar estes pacotes de emojis primeiro + A fazer pesquisa… + Expandir/Contrair todos os toots + Abrir toot + É necessário reiniciar a aplicação + É necessário reiniciar o aplicativo para aplicar as alterações + Reiniciar + Pacote de emojis padrão do seu dispositivo + Emojis padrão do Android da versão 4.4 até 7.1 + Pacote de emojis padrão do Mastodon + Pacote de emojis atual do Google + Erro ao baixar + Robô + %1$s mudou-se para: + Dar boost para o mesmo público + Desfazer boost + O Tusky contém código e recursos dos seguintes projetos de código aberto: + Licenciado sob a licença Apache (cópia separadorixo) + CC-BY 4.0 + CC-BY-SA 4.0 + Metadados do perfil + Adicionar + Rótulo + Conteúdo + Usar tempo absoluto + As informações separadorixo podem refletir incompletamente o perfil do utilizador. Toque aqui para abrir o perfil completo no navegador. + Fixar + + %1$s Favorito + %1$s Favoritos + + + %s Boost + %s Boosts + + Levou boost de + Favoritado por + %1$s + %1$s, %2$s e %3$d outros + + excedeu o máximo de %1$d separador + excedeu o máximo de %1$d separadors + + conteúdo multimédia: %s + Aviso de Conteúdo: %s + Sem descrição + Você fez boost + Favoritado + Salvo + Público + Não-listado + Privado + Direto + Enquete com as opções: %1$s, %2$s, %3$s, %4$s; %5$s + Nome da lista + Adicionar hashtag + Hashtag sem # + Hashtags + Selecionar lista + Lista + Limpar + Filtro + Salvar + Compor toot + Compor + Tem certeza de que deseja limpar permanentemente todas as suas notificações\? + Opções para imagem %s + %1$s • %2$s + + %s voto + %s votos + + + %s pessoa + %s pessoas + + termina em %s + Terminou + Votar + Uma enquete que você votou terminou + Sua enquete terminou + + %d dia restante + %d dias restantes + + + %d hora restante + %d horas restantes + + + %d minuto restante + %d minutos restantes + + + %d segundo restante + %d segundos restantes + + Continuar + Voltar + Ok + \@%s denunciado com sucesso + Comentários adicionais + Encaminhar para %s + Erro ao denunciar + Erro ao carregar toots + A denúncia será enviada aos moderadores da instância. Explique por que denunciou a conta: + A conta está em outra instância. Enviar uma cópia anônima da denúncia para lá\? + Contas + Erro ao pesquisar + Mostrar filtro de notificações + Ativar deslizar para alternar entre separadors + Enquete + Duração + Indefinido + 5 minutos + 30 minutos + 1 hora + 6 horas + 1 dia + 3 dias + 7 dias + 14 dias + 30 dias + 60 dias + 90 dias + 180 dias + 365 dias + Adicionar opção + Múltiplas opções + Opção %d + Editar + Erro ao pesquisar %s + Sem rascunhos. + Sem toots agendados. + Salvo! + Algumas informações que podem afetar seu bem-estar serão ocultadas. Isso inclui: +\n +\n- Notificações de favoritos, boosts e seguidores +\n- Número de favoritos e boosts nos toots +\n- Status de toots e seguidores nos perfis +\n +\nNotificações push não serão afetadas, mas é possível revisar sua preferência manualmente. + Revisar notificações + Limitar notificações da timeline + Sem comunicados. + Mastodon possui um intervalo mínimo de 5 minutos para agendar. + Mostrar prévias de Hiperligações nas linhas + Solicitar confirmação antes de dar boost + Solicitar confirmação antes de favoritar + Esconder o título da barra superior de tarefas + Nota pessoal sobre este perfil aqui + Esconder status dos toots + Esconder status dos perfis + + Não é possível anexar mais de %1$d arquivo de conteúdo multimédia. + Não é possível anexar mais de %1$d arquivos de conteúdo multimédia. + + Erro ao enviar o toot! + Erro ao carregar toot para responder + Rascunho excluído + O toot em que se rascunhou uma resposta foi excluído + Ocorreu um erro. + Ocorreu um erro de conetividade! Por favor, verifique a sua ligação e tente novamente! + Isto não pode estar vazio. + Instância inválida inserida + Erro ao autenticar com esta instância. + Nao foi possível encontrar um navegador. + Ocorreu um erro não identificado de autorização. + Entrar + Guardar + Editar perfil + Editar + Desfazer + Aceitar + Rejeitar + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9ceb5a6c6..c980fb550 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -324,7 +324,6 @@ Копия поста сохранена в ваши черновики Сочинить У вашего узла %s нет собственных эмодзи - Скопировано в буфер обмена Стиль эмодзи Системный Сперва эти наборы эмодзи нужно скачать diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index b69336ede..32b651bf9 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -317,7 +317,6 @@ प्राक्तु भावचिह्नसमूहोऽयमवारोप्यः प्रणाल्यां पूर्वनिविष्टम् भावचिह्नशैली - अंशफलकेऽनुसृतम् भवदीयं विशिष्टस्थलं %s स्वीयानुकूलभावचिह्नरहितं वर्तते लिख्यताम् दौत्यप्रतिलिपिस्तत्र विकर्षेसु रक्षिता diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index df654a291..7ba704981 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -171,7 +171,6 @@ \n https://tusky.app පිළිගන්න පැ. %d කින් - පසුරුපුවරුවට පිටපත් විය මතවිමසුම ඉවත් කරන්න මාධ්‍ය එකතු කරන්න diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 15d05945c..e06555c71 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -77,7 +77,7 @@ Autentizácia servru zlyhala. Nepodarilo sa nájsť použiteľný webový prehliadač. Vyskytla sa neidentifikovaná chyba autorizácie. - Toot je príliš dlhý! + Príspevok je príliš dlhý! Tento typ súboru nemôže byť nahraný. Chyba pri odosielaní tootu. Toot diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 81847ab9d..8fae9fb3e 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -276,7 +276,6 @@ Kopija tuta je bila shranjena v osnutke Sestavi Vaše vozlišče %s nima emotikonov po meri - Kopirano v odložišče Slog emotikonov Privzete nastavitve sistema Najprej boste morali prenesti te emotikone diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index fd54e2d5a..a4c9cabcd 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -297,7 +297,6 @@ En kopia av tooten har sparats i dina utkast Skriv Din instans %s har inga anpassade emojis - Kopierat till urklipp Emojis Systemstandard Du behöver ladda ned dessa emojis först diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 980a23807..f92f66854 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -231,7 +231,6 @@ நகலெடுக்கபட்ட toot வரைவில் சேமிக்கபட்டது எழுது தங்கள் %s instance(களம்)-ல் எந்தவொரு custom emojis-ம் இல்லை - பிடிப்புப்பலகையில் நகலெடுக்க Emoji பாணி அமைப்பின் இயல்புநிலை தாங்கள் முதலில் இந்த Emoji sets-னை பதிவிறக்கவேண்டும் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index b45f96582..98e6ab1e1 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -116,7 +116,6 @@ ต้องดาวน์โหลดชุดเอโมจิเหล่านี้ก่อน ค่าปริยายของระบบ รูปแบบเอโมจิ - คัดลอกไปยังคลิบบอร์ดแล้ว Instance %s ไม่มีเอโมจิแบบกำหนดเอง เขียน สำเนา Toot บันทึกเป็นฉบับร่างแล้ว diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c0c6e3067..5694fe27d 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -261,7 +261,6 @@ Tootun bir kopyası taslaklara kaydedildi Oluştur %s örneğinizin herhangi bir özel ifadesi yok - Panoya kopyalandı İfade stili Sistem varsayılanı Önce bu ifade paketini indirmeniz gerekecek diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 0031334b8..7d4b806ec 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -181,7 +181,6 @@ Спочатку потрібно буде завантажити ці набори емодзі Типовий системний Стиль емодзі - Скопійовано до буфера обміну Ваш сервер %s не має власних емодзі Зберегти чернетку\? Вимагає затвердження підписників власноруч @@ -541,4 +540,13 @@ 180 днів 365 днів Створити допис + %s реєструється + хтось реєструється + Реєстрації + Сповіщення про нових користувачів + %s редагує свій допис + допис, з яким у мене була взаємодія, відредаговано + Сповіщення, коли редагується повідомлення, з яким ви взаємодіяли + Редакції допису + Вхід \ No newline at end of file diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index caf759fee..1ec9ef803 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -192,7 +192,7 @@ Ghim Trả lời Tút - Nội dung tút + Tút Xếp tab Tin nhắn Thế giới @@ -413,7 +413,7 @@ Tusky có sử dụng mã nguồn từ những dự án mã nguồn mở sau: Hủy đăng lại Đăng lại công khai - %1$s đã dời sang: + %1$s đã chuyển sang: Tài khoản Bot Tải về thất bại Emoji của Google @@ -430,7 +430,6 @@ Bạn cần tải về bộ emoji này trước Mặc định của thiết bị Emoji - Đã chép vào clipboard Viết Lưu nháp\? Tự bạn sẽ phê duyệt người theo dõi @@ -508,4 +507,12 @@ 180 ngày 365 ngày Viết tút + ai đó đăng ký trên máy chủ + %s đăng ký + Đăng ký + Thông báo về người dùng mới đăng ký + %s đã sửa tút của họ + khi một tút mà tôi tương tác bị sửa + Sửa tút + Thông báo khi tút mà tôi tương tác bị sửa \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9d8cd7ed2..84fbc05c2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -27,7 +27,7 @@ 标签页 嘟文 嘟文 - 嘟文和回复 + 有回复 已置顶 正在关注 关注者 @@ -42,7 +42,7 @@ %s 转嘟了 敏感内容 已隐藏的照片或视频 - 点击显示 + 点击查看 显示更多 折叠内容 展开 @@ -156,7 +156,7 @@ 移除关注请求? 不再关注此用户? 删除这条嘟文? - 删除并重新编辑这条嘟文? + 删除并重新起草这条嘟文? 公开:所有人可见,并会出现在公共时间轴上 不公开:所有人可见,但不会出现在公共时间轴上 仅关注者:只有经过你确认后关注你的用户可见 @@ -203,7 +203,7 @@ 公开 不公开 仅关注者 - 字体大小 + 嘟文字体大小 最小 标准 @@ -299,14 +299,13 @@ 保护你的帐户(锁嘟) 你需要手动审核所有关注请求 保存为草稿? - 正在发送… - 发送失败 + 正在发送嘟文… + 嘟文发送出错 嘟文发送中 已取消发送 - 嘟文已保存为草稿 + 嘟文副本已保存为草稿 发表嘟文 当前实例 %s 没有自定义表情符号 - 已复制到剪贴板 表情符号风格 系统默认 需要下载表情符号数据 @@ -353,16 +352,10 @@ 标签页不能超过 %1$d 个 媒体:%s - 内容提醒:%s - - 没有媒体描述信息 - - - 被转嘟 - - - 被收藏 - + 内容警告:%s + 没有描述信息 + 被转嘟 + 被收藏 公开 @@ -435,7 +428,7 @@ 附加留言 转发到 %s 举报失败 - 无法获取状态 + 无法获取嘟文 该报告将发送给给您的服务器管理员。您可以在下面提供有关回报此帐户的原因的说明: 该帐户来自其他服务器。向那里发送一份匿名的报告副本? 账户 @@ -533,4 +526,13 @@ 14 天 365 天 撰写嘟文 + %s 已注册 + 某人进行了注册 + 新用户通知 + 注册 + 登录 + %s 编辑了他们的嘟文 + 我进行过互动的嘟文被编辑了 + 嘟文编辑 + 当你进行过互动的嘟文被编辑时发出通知 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 49687a0ea..a07964f3b 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -304,7 +304,6 @@ 嘟文已儲存為草稿 新嘟文 當前站點 %s 沒有自訂表情符號 - 已複製到剪貼簿 表情符號風格 系統預設 你需要先下載這些表情符號包 diff --git a/app/src/main/res/values-zh-rMO/strings.xml b/app/src/main/res/values-zh-rMO/strings.xml index 045ec76c7..2a29b28fa 100644 --- a/app/src/main/res/values-zh-rMO/strings.xml +++ b/app/src/main/res/values-zh-rMO/strings.xml @@ -298,7 +298,6 @@ 嘟文已儲存為草稿 發表新嘟文 當前站點 %s 沒有自訂表情符號 - 已複製到剪貼簿 表情符號風格 系統預設 你需要先下載這些表情符號包 diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml index c85551751..fb2b51ab1 100644 --- a/app/src/main/res/values-zh-rSG/strings.xml +++ b/app/src/main/res/values-zh-rSG/strings.xml @@ -302,7 +302,6 @@ 嘟文已保存为草稿 新嘟文 当前实例 %s 没有自定义表情符号 - 已复制到剪贴板 表情符号风格 系统默认 需要下载表情符号数据 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index e1657e481..476fee9c7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -304,7 +304,6 @@ 嘟文已儲存為草稿 發表新嘟文 當前站點 %s 沒有自訂表情符號 - 已複製到剪貼簿 表情符號風格 系統預設 你需要先下載這些表情符號包 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7d2e6e01..0f32dd0af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ Collecting data… Failed. + Login Home Notifications Local @@ -69,6 +70,7 @@ %s requested to follow you %s signed up %s just posted + %s edited their post Report @%s Additional comments? @@ -247,6 +249,7 @@ polls have ended somebody I\'m subscribed to published a new post somebody signed up + a post I\'ve interacted with is edited Appearance App Theme Timelines @@ -325,6 +328,8 @@ Notifications when somebody you\'re subscribed to published a new post Sign ups Notifications about new users + Post edits + Notifications when posts you\'ve interacted with are edited %s mentioned you %1$s, %2$s, %3$s and %4$d others @@ -438,7 +443,6 @@ Compose Your instance %s does not have any custom emojis - Copied to clipboard Emoji style System default You\'ll need to download these emoji sets first diff --git a/app/src/main/res/values/theme_colors.xml b/app/src/main/res/values/theme_colors.xml index 79553135f..c67ac45fd 100644 --- a/app/src/main/res/values/theme_colors.xml +++ b/app/src/main/res/values/theme_colors.xml @@ -24,6 +24,9 @@ true + @color/tusky_grey_20 + @color/white + @color/tusky_orange_dark diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index 5396a21ec..3a8f2f23e 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -21,20 +21,19 @@ import android.widget.EditText import androidx.test.ext.junit.runners.AndroidJUnit4 import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeViewModel -import com.keylesspalace.tusky.components.compose.DEFAULT_CHARACTER_LIMIT -import com.keylesspalace.tusky.components.compose.DEFAULT_MAXIMUM_URL_LENGTH +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.EmojisEntity import com.keylesspalace.tusky.db.InstanceDao -import com.keylesspalace.tusky.db.InstanceEntity +import com.keylesspalace.tusky.db.InstanceInfoEntity import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.InstanceConfiguration import com.keylesspalace.tusky.entity.StatusConfiguration import com.keylesspalace.tusky.network.MastodonApi -import io.reactivex.rxjava3.core.Single import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -94,7 +93,7 @@ class ComposeActivityTest { } apiMock = mock { - on { getCustomEmojis() } doReturn Single.just(emptyList()) + onBlocking { getCustomEmojis() } doReturn Result.success(emptyList()) onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance -> if (instance == null) { Result.failure(Throwable()) @@ -105,23 +104,25 @@ class ComposeActivityTest { } val instanceDaoMock: InstanceDao = mock { - on { loadMetadataForInstance(any()) } doReturn - Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null)) - on { loadMetadataForInstance(any()) } doReturn - Single.just(InstanceEntity(instanceDomain, emptyList(), null, null, null, null, null, null, null)) + onBlocking { getInstanceInfo(any()) } doReturn + InstanceInfoEntity(instanceDomain, null, null, null, null, null, null, null) + onBlocking { getEmojiInfo(any()) } doReturn + EmojisEntity(instanceDomain, emptyList()) } val dbMock: AppDatabase = mock { on { instanceDao() } doReturn instanceDaoMock } + val instanceInfoRepo = InstanceInfoRepository(apiMock, dbMock, accountManagerMock) + val viewModel = ComposeViewModel( apiMock, accountManagerMock, mock(), mock(), mock(), - dbMock + instanceInfoRepo ) activity.intent = Intent(activity, ComposeActivity::class.java).apply { putExtra(ComposeActivity.COMPOSE_OPTIONS_EXTRA, composeOptions) @@ -135,6 +136,7 @@ class ComposeActivityTest { activity.viewModelFactory = viewModelFactoryMock controller.create().start() + shadowOf(getMainLooper()).idle() } @Test @@ -185,7 +187,7 @@ class ComposeActivityTest { fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() { instanceResponseCallback = { getInstanceWithCustomConfiguration(null) } setupActivity() - assertEquals(DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters) + assertEquals(InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters) } @Test @@ -236,7 +238,7 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = "Check out this @image #search result: " insertSomeTextInContent(additionalContent + url) - assertEquals(activity.calculateTextLength(), additionalContent.length + DEFAULT_MAXIMUM_URL_LENGTH) + assertEquals(activity.calculateTextLength(), additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL) } @Test @@ -245,7 +247,7 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = " Check out this @image #search result: " insertSomeTextInContent(shortUrl + additionalContent + url) - assertEquals(activity.calculateTextLength(), additionalContent.length + (DEFAULT_MAXIMUM_URL_LENGTH * 2)) + assertEquals(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2)) } @Test @@ -253,7 +255,7 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = " Check out this @image #search result: " insertSomeTextInContent(url + additionalContent + url) - assertEquals(activity.calculateTextLength(), additionalContent.length + (DEFAULT_MAXIMUM_URL_LENGTH * 2)) + assertEquals(activity.calculateTextLength(), additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2)) } @Test diff --git a/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt index 7724ba768..9598f2c1e 100644 --- a/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/test/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -18,16 +18,16 @@ package com.keylesspalace.tusky import android.app.Application import android.content.Context import android.content.res.Configuration -import androidx.emoji.text.EmojiCompat import com.keylesspalace.tusky.util.LocaleManager -import de.c1710.filemojicompat.FileEmojiCompatConfig +import de.c1710.filemojicompat_defaults.DefaultEmojiPackList +import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper // override TuskyApplication for Robolectric tests, only initialize the necessary stuff class TuskyApplication : Application() { override fun onCreate() { super.onCreate() - EmojiCompat.init(FileEmojiCompatConfig(this, "")) + EmojiPackHelper.init(this, DefaultEmojiPackList.get(this)) } override fun attachBaseContext(base: Context) { diff --git a/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt new file mode 100644 index 000000000..57f3bed47 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatterTest.kt @@ -0,0 +1,46 @@ +package com.keylesspalace.tusky.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Instant +import java.util.Date +import java.util.TimeZone + +class AbsoluteTimeFormatterTest { + + private val formatter = AbsoluteTimeFormatter(TimeZone.getTimeZone("UTC")) + private val now = Date.from(Instant.parse("2022-04-11T00:00:00.00Z")) + + @Test + fun `null handling`() { + assertEquals("??", formatter.format(null, true, now)) + assertEquals("??", formatter.format(null, false, now)) + } + + @Test + fun `same day formatting`() { + val tenTen = Date.from(Instant.parse("2022-04-11T10:10:00.00Z")) + assertEquals("10:10", formatter.format(tenTen, true, now)) + assertEquals("10:10", formatter.format(tenTen, false, now)) + } + + @Test + fun `same year formatting`() { + val nextDay = Date.from(Instant.parse("2022-04-12T00:10:00.00Z")) + assertEquals("04-12 00:10", formatter.format(nextDay, true, now)) + assertEquals("04-12 00:10", formatter.format(nextDay, false, now)) + val endOfYear = Date.from(Instant.parse("2022-12-31T23:59:00.00Z")) + assertEquals("12-31 23:59", formatter.format(endOfYear, true, now)) + assertEquals("12-31 23:59", formatter.format(endOfYear, false, now)) + } + + @Test + fun `other year formatting`() { + val firstDayNextYear = Date.from(Instant.parse("2023-01-01T00:00:00.00Z")) + assertEquals("2023-01-01", formatter.format(firstDayNextYear, true, now)) + assertEquals("2023-01-01 00:00", formatter.format(firstDayNextYear, false, now)) + val inTenYears = Date.from(Instant.parse("2032-04-11T10:10:00.00Z")) + assertEquals("2032-04-11", formatter.format(inTenYears, true, now)) + assertEquals("2032-04-11 10:10", formatter.format(inTenYears, false, now)) + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt deleted file mode 100644 index 5dd5ea84f..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/util/EmojiCompatFontTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.keylesspalace.tusky.util - -import org.junit.Assert.assertEquals -import org.junit.Test - -class EmojiCompatFontTest { - - @Test - fun testCompareVersions() { - - assertEquals( - -1, - EmojiCompatFont.compareVersions( - listOf(0), - listOf(1, 2, 3) - ) - ) - assertEquals( - 1, - EmojiCompatFont.compareVersions( - listOf(1, 2, 3), - listOf(0, 0, 0) - ) - ) - assertEquals( - -1, - EmojiCompatFont.compareVersions( - listOf(1, 0, 1), - listOf(1, 1, 0) - ) - ) - assertEquals( - 0, - EmojiCompatFont.compareVersions( - listOf(4, 5, 6), - listOf(4, 5, 6) - ) - ) - assertEquals( - 0, - EmojiCompatFont.compareVersions( - listOf(0, 0), - listOf(0) - ) - ) - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt deleted file mode 100644 index 2731228a0..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/util/VersionUtilsTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.keylesspalace.tusky.util - -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized - -@RunWith(Parameterized::class) -class VersionUtilsTest( - private val versionString: String, - private val supportsScheduledToots: Boolean -) { - - companion object { - @JvmStatic - @Parameterized.Parameters - fun data() = listOf( - arrayOf("2.0.0", false), - arrayOf("2a9a0", false), - arrayOf("1.0", false), - arrayOf("error", false), - arrayOf("", false), - arrayOf("2.6.9", false), - arrayOf("2.7.0", true), - arrayOf("2.00008.0", true), - arrayOf("2.7.2 (compatible; Pleroma 1.0.0-1168-ge18c7866-pleroma-dot-site)", true), - arrayOf("3.0.1", true) - ) - } - - @Test - fun testVersionUtils() { - assertEquals(VersionUtils(versionString).supportsScheduledToots(), supportsScheduledToots) - } -}