Merge remote-tracking branch 'tuskyapp/develop'

This commit is contained in:
kyori19 2022-05-01 19:54:22 +09:00
commit 95a1f5632b
No known key found for this signature in database
GPG Key ID: CB37D0651E7F52AA
137 changed files with 2800 additions and 2024 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<NotificationViewData> dataSource;
private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter();
public NotificationsAdapter(String accountId,
AdapterDataSource<NotificationViewData> 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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<BindingHolder<ItemAccountFieldBinding>>() {
var emojis: List<Emoji> = emptyList()
var fields: List<Either<IdentityProof, Field>> = emptyList()
var fields: List<Field> = emptyList()
override fun getItemCount() = fields.size
@ -48,32 +44,20 @@ class AccountFieldAdapter(
}
override fun onBindViewHolder(holder: BindingHolder<ItemAccountFieldBinding>, 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)
}
}
}

View File

@ -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<Boolean>()
private val identityProofData = MutableLiveData<List<IdentityProof>>()
val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs ->
identityProofs.orEmpty().map { Either.Left<IdentityProof, Field>(it) }
.plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) })
}
val isRefreshing = MutableLiveData<Boolean>().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)
}

View File

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

View File

@ -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<Resource<List<Announcement>>>()
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
@ -52,156 +46,130 @@ class AnnouncementsViewModel @Inject constructor(
val emojis: LiveData<List<Emoji>> = emojisMutable
init {
Single.zip(
mastodonApi.getCustomEmojis(),
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { 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 {

View File

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

View File

@ -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<InstanceEntity?> = MutableLiveData(null)
val instanceInfo: MutableLiveData<InstanceInfo> = MutableLiveData()
val instanceParams: LiveData<ComposeInstanceParams> = 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<List<Emoji>?> = 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<Long, Disposable>()
private val mediaToJob = mutableMapOf<Long, Job>()
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<Either<Throwable, QueuedMedia>> {
// 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<Either<Throwable, QueuedMedia>>()
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<QueuedMedia> = 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<String>()
val mediaUris = ArrayList<Uri>()
val mediaDescriptions = ArrayList<String>()
val mediaIds: MutableList<String> = mutableListOf()
val mediaUris: MutableList<Uri> = mutableListOf()
val mediaDescriptions: MutableList<String> = mutableListOf()
val mediaProcessed: MutableList<Boolean> = 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<Boolean> {
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<Boolean>()
media.observeForever(object : Observer<List<QueuedMedia>> {
override fun onChanged(mediaItems: List<QueuedMedia>) {
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<ComposeAutoCompleteAdapter.AutocompleteResult> {
@ -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 <T> mutableLiveData(default: T) = MutableLiveData<T>().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
*/

View File

@ -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 <http://www.gnu.org/licenses>. */
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<Uri, Void, Boolean> {
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();
}
}

View File

@ -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 <http://www.gnu.org/licenses>. */
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
}

View File

@ -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<UploadEvent> {
return Observable
.fromCallable {
if (shouldResizeMedia(media)) {
downsize(media)
} else media
@OptIn(ExperimentalCoroutinesApi::class)
fun uploadMedia(media: QueuedMedia): Flow<UploadEvent> {
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<PreparedMedia> {
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<UploadEvent> {
return Observable.create { emitter ->
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
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())
}

View File

@ -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> T.makeCaptionDialog(
existingDescription: String?,
previewUri: Uri,
onUpdateDescription: (String) -> LiveData<Boolean>
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> 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()
}

View File

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

View File

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

View File

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

View File

@ -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 <http://www.gnu.org/licenses>. */
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
)

View File

@ -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 <http://www.gnu.org/licenses>. */
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<Emoji> = 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<Emoji> =
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
)
}
}

View File

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

View File

@ -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<NotificationChannel> 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;
}

View File

@ -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<RadioButton>()
private var updated = false
private var currentNeedsUpdate = false
private val downloadDisposables = MutableList<Disposable?>(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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -111,9 +111,13 @@ abstract class SearchFragment<T : Any> :
}
}
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)

View File

@ -98,7 +98,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), 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<StatusViewData.Concrete>(), 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<StatusViewData.Concrete>(), 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) {

View File

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

View File

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

View File

@ -31,7 +31,7 @@ import java.io.File;
*/
@Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 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");
}
};
}

View File

@ -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<InstanceEntity>
suspend fun getInstanceInfo(instance: String): InstanceInfoEntity?
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
suspend fun getEmojiInfo(instance: String): EmojisEntity?
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<List<MastoList>>
@GET("/api/v1/custom_emojis")
fun getCustomEmojis(): Single<List<Emoji>>
suspend fun getCustomEmojis(): Result<List<Emoji>>
@GET("api/v1/instance")
suspend fun getInstance(): Result<Instance>
@ -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<MediaUploadResult>
): Result<MediaUploadResult>
@FormUrlEncoded
@PUT("api/v1/media/{mediaId}")
fun updateMedia(
suspend fun updateMedia(
@Path("mediaId") mediaId: String,
@Field("description") description: String
): Single<Attachment>
): Result<Attachment>
@GET("api/v1/media/{mediaId}")
suspend fun getMedia(
@Path("mediaId") mediaId: String
): Response<MediaUploadResult>
@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<Status>
): Result<Status>
@GET("api/v1/statuses/{id}")
fun status(
@ -367,11 +371,6 @@ interface MastodonApi {
@Query("id[]") accountIds: List<String>
): Single<List<Relationship>>
@GET("api/v1/accounts/{id}/identity_proofs")
fun identityProofs(
@Path("id") accountId: String
): Single<List<IdentityProof>>
@POST("api/v1/pleroma/accounts/{id}/subscribe")
fun subscribeAccount(
@Path("id") accountId: String
@ -544,26 +543,26 @@ interface MastodonApi {
): Single<Poll>
@GET("api/v1/announcements")
fun listAnnouncements(
suspend fun listAnnouncements(
@Query("with_dismissed") withDismissed: Boolean = true
): Single<List<Announcement>>
): Result<List<Announcement>>
@POST("api/v1/announcements/{id}/dismiss")
fun dismissAnnouncement(
suspend fun dismissAnnouncement(
@Path("id") announcementId: String
): Single<ResponseBody>
): Result<ResponseBody>
@PUT("api/v1/announcements/{id}/reactions/{name}")
fun addAnnouncementReaction(
suspend fun addAnnouncementReaction(
@Path("id") announcementId: String,
@Path("name") name: String
): Single<ResponseBody>
): Result<ResponseBody>
@DELETE("api/v1/announcements/{id}/reactions/{name}")
fun removeAnnouncementReaction(
suspend fun removeAnnouncementReaction(
@Path("id") announcementId: String,
@Path("name") name: String
): Single<ResponseBody>
): Result<ResponseBody>
@FormUrlEncoded
@POST("api/v1/reports")

View File

@ -101,7 +101,8 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
accountId = account.id,
draftId = -1,
idempotencyKey = randomAlphanumericString(16),
retries = 0
retries = 0,
mediaProcessed = mutableListOf()
)
)

View File

@ -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<Int, StatusToSend>()
private val sendCalls = ConcurrentHashMap<Int, Call<Status>>()
private val sendJobs = ConcurrentHashMap<Int, Job>()
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<StatusToSend>(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<Status> {
override fun onResponse(call: Call<Status>, response: Response<Status>) {
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<Status>, 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<Boolean>
) : Parcelable

View File

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

View File

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

View File

@ -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 <http://www.gnu.org/licenses>. */
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)
}
}
}

View File

@ -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<Pair<File, List<Int>>>? = 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<Pair<File, List<Int>>> {
// 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<Int> {
if (version == null) return listOf(0)
return version.split(".").map {
it.toIntOrNull() ?: 0
}
}
fun downloadFontFile(
context: Context,
okHttpClient: OkHttpClient
): Observable<Float> {
return Observable.create { emitter: ObservableEmitter<Float> ->
// 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<Int>, versionB: List<Int>): 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")
}
}
}
}

View File

@ -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<Attachment>,
@ -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<InputFilter>(SmartLengthInputFilter)
val NO_INPUT_FILTER = arrayOfNulls<InputFilter>(0)

View File

@ -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 <http://www.gnu.org/licenses>. */
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);
}
}

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M5.7407,0L18.2593,0A5.7407,5.7407 0,0 1,24 5.7407L24,18.2593A5.7407,5.7407 0,0 1,18.2593 24L5.7407,24A5.7407,5.7407 0,0 1,0 18.2593L0,5.7407A5.7407,5.7407 0,0 1,5.7407 0z"
android:fillAlpha="0.75"
android:fillColor="@color/botBadgeBackground" />
<path
android:fillColor="@color/botBadgeForeground"
android:pathData="m12,3.1674a1.6059,1.6059 0,0 1,1.6059 1.6059c0,0.5942 -0.3212,1.1161 -0.803,1.3891v1.0198h0.803a5.6207,5.6207 0,0 1,5.6207 5.6207h0.803a0.803,0.803 0,0 1,0.803 0.803v2.4089a0.803,0.803 0,0 1,-0.803 0.803h-0.803v0.803a1.6059,1.6059 0,0 1,-1.6059 1.6059H6.3793A1.6059,1.6059 0,0 1,4.7733 17.6207V16.8178H3.9704A0.803,0.803 0,0 1,3.1674 16.0148V13.6059A0.803,0.803 0,0 1,3.9704 12.803H4.7733a5.6207,5.6207 0,0 1,5.6207 -5.6207h0.803V6.1625C10.7153,5.8894 10.3941,5.3675 10.3941,4.7733A1.6059,1.6059 0,0 1,12 3.1674M8.3867,12A2.0074,2.0074 0,0 0,6.3793 14.0074,2.0074 2.0074,0 0,0 8.3867,16.0148 2.0074,2.0074 0,0 0,10.3941 14.0074,2.0074 2.0074,0 0,0 8.3867,12m7.2267,0a2.0074,2.0074 0,0 0,-2.0074 2.0074,2.0074 2.0074,0 0,0 2.0074,2.0074 2.0074,2.0074 0,0 0,2.0074 -2.0074A2.0074,2.0074 0,0 0,15.6133 12Z" />
</vector>

View File

@ -4,6 +4,6 @@
android:viewportHeight="24"
android:viewportWidth="24">
<path
android:fillColor="#000"
android:fillColor="@color/textColorTertiary"
android:pathData="M20,6C20.58,6 21.05,6.2 21.42,6.59C21.8,7 22,7.45 22,8V19C22,19.55 21.8,20 21.42,20.41C21.05,20.8 20.58,21 20,21H4C3.42,21 2.95,20.8 2.58,20.41C2.2,20 2,19.55 2,19V8C2,7.45 2.2,7 2.58,6.59C2.95,6.2 3.42,6 4,6H8V4C8,3.42 8.2,2.95 8.58,2.58C8.95,2.2 9.42,2 10,2H14C14.58,2 15.05,2.2 15.42,2.58C15.8,2.95 16,3.42 16,4V6H20M4,8V19H20V8H4M14,6V4H10V6H14Z" />
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View File

@ -112,7 +112,7 @@
app:layout_constraintStart_toStartOf="@id/guideAvatar"
app:layout_constraintTop_toTopOf="@+id/accountFollowButton" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/accountDisplayNameTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -215,7 +215,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/accountNoteTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -248,63 +248,71 @@
app:layout_constraintTop_toBottomOf="@id/accountFieldList"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Group
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/accountMovedView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="accountMovedText,accountMovedAvatar,accountMovedDisplayName,accountMovedUsername" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/accountMovedText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:drawablePadding="6dp"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="parent"
android:layout_marginTop="4dp"
app:layout_constraintTop_toBottomOf="@id/accountRemoveView"
tools:text="Account has moved" />
tools:visibility="visible">
<ImageView
android:id="@+id/accountMovedAvatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:layout_marginTop="8dp"
android:layout_marginEnd="24dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountMovedText"
tools:src="@drawable/avatar_default" />
<TextView
android:id="@+id/accountMovedText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:drawablePadding="6dp"
android:drawableStart="@drawable/ic_briefcase"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Account has moved" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/accountMovedDisplayName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
android:textStyle="normal|bold"
app:layout_constraintBottom_toTopOf="@id/accountMovedUsername"
app:layout_constraintStart_toEndOf="@id/accountMovedAvatar"
app:layout_constraintTop_toTopOf="@id/accountMovedAvatar"
tools:text="Display name" />
<ImageView
android:importantForAccessibility="no"
android:id="@+id/accountMovedAvatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:layout_marginEnd="24dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountMovedText"
tools:src="@drawable/avatar_default" />
<TextView
android:id="@+id/accountMovedUsername"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
app:layout_constraintBottom_toBottomOf="@id/accountMovedAvatar"
app:layout_constraintStart_toEndOf="@id/accountMovedAvatar"
app:layout_constraintTop_toBottomOf="@id/accountMovedDisplayName"
tools:text="\@username" />
<TextView
android:id="@+id/accountMovedDisplayName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
android:textStyle="normal|bold"
app:layout_constraintBottom_toTopOf="@id/accountMovedUsername"
app:layout_constraintStart_toEndOf="@id/accountMovedAvatar"
app:layout_constraintTop_toTopOf="@id/accountMovedAvatar"
tools:text="Display name" />
<TextView
android:id="@+id/accountMovedUsername"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
app:layout_constraintBottom_toBottomOf="@id/accountMovedAvatar"
app:layout_constraintStart_toEndOf="@id/accountMovedAvatar"
app:layout_constraintTop_toBottomOf="@id/accountMovedDisplayName"
tools:text="\@username" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
android:id="@+id/accountStatuses"
@ -315,7 +323,7 @@
android:orientation="vertical"
app:layout_constraintEnd_toStartOf="@id/accountFollowing"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar">
app:layout_constraintTop_toBottomOf="@id/accountMovedView">
<TextView
android:id="@+id/accountStatusesTextView"
@ -346,7 +354,7 @@
android:orientation="vertical"
app:layout_constraintEnd_toStartOf="@id/accountFollowers"
app:layout_constraintStart_toEndOf="@id/accountStatuses"
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar">
app:layout_constraintTop_toBottomOf="@id/accountMovedView">
<TextView
android:id="@+id/accountFollowingTextView"
@ -376,7 +384,7 @@
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/accountFollowing"
app:layout_constraintTop_toBottomOf="@id/accountMovedAvatar">
app:layout_constraintTop_toBottomOf="@id/accountMovedView">
<TextView
android:id="@+id/accountFollowersTextView"

View File

@ -72,7 +72,7 @@
tools:text="Reply to @username"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/composeReplyContentView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -102,7 +102,7 @@
tools:text="Quote @username"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<androidx.emoji2.widget.EmojiTextView
android:id="@+id/composeQuoteContentView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -124,7 +124,7 @@
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.emoji.widget.EmojiEditText
<androidx.emoji2.widget.EmojiEditText
android:id="@+id/composeContentWarningField"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@ -64,6 +64,7 @@
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:tabIndicator="@null"
app:tabGravity="fill"
app:tabMode="fixed" />
</com.google.android.material.bottomappbar.BottomAppBar>
@ -98,4 +99,3 @@
android:fitsSystemWindows="true" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingTop="16dp">
<include
android:id="@+id/item_blobmoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_twemoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_notoemoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_nomoji"
layout="@layout/item_emoji_pref" />
<TextView
android:id="@+id/emoji_download_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lineSpacingMultiplier="1.1"
android:paddingStart="24dp"
android:paddingTop="16dp"
android:paddingEnd="24dp"
android:paddingBottom="8dp"
android:text="@string/download_fonts"
android:textColor="?android:attr/textColorSecondary" />
</LinearLayout>

View File

@ -32,7 +32,7 @@
tools:src="#000"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/account_display_name"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@ -7,7 +7,7 @@
android:paddingTop="4dp">
<!-- 30% width for the field name, 70% for the value -->
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/accountFieldName"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -21,7 +21,7 @@
app:layout_constraintWidth_percent=".3"
tools:text="Field title" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/accountFieldValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@ -4,7 +4,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@ -22,7 +22,7 @@
android:gravity="center_vertical"
android:orientation="vertical">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@ -38,7 +38,7 @@
android:gravity="center_vertical"
android:orientation="vertical">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/blocked_user_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@ -11,7 +11,7 @@
android:paddingStart="12dp"
android:paddingEnd="14dp">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/conversation_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -79,7 +79,7 @@
tools:src="#000"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -122,7 +122,7 @@
app:layout_constraintTop_toTopOf="@id/status_display_name"
tools:text="13:37" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_content_warning_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -157,7 +157,7 @@
tools:text="@string/post_content_warning_show_more"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_content"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@ -28,7 +28,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/contentWarning"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -42,7 +42,7 @@
app:layout_constraintTop_toBottomOf="@id/draftSendingInfo"
tools:text="Some content warning" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/content"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@ -17,7 +17,7 @@
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<androidx.emoji.widget.EmojiEditText
<androidx.emoji2.widget.EmojiEditText
android:id="@+id/accountFieldName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -26,7 +26,7 @@
android:textColorHint="?android:attr/textColorTertiary"
android:textSize="?attr/status_text_medium" />
<androidx.emoji.widget.EmojiEditText
<androidx.emoji2.widget.EmojiEditText
android:id="@+id/accountFieldValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@ -9,7 +9,7 @@
android:paddingRight="14dp"
android:paddingBottom="10dp">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/notification_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -41,7 +41,7 @@
app:layout_constraintTop_toTopOf="@id/notification_display_name"
tools:src="@drawable/avatar_default" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/notification_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@ -8,7 +8,7 @@
android:paddingRight="16dp"
android:paddingBottom="10dp">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/notificationTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -36,7 +36,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/notificationTextView" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/displayNameTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@ -51,7 +51,7 @@
android:gravity="center_vertical"
android:orientation="vertical">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/muted_user_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@ -5,7 +5,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_poll_option_result"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@ -14,7 +14,7 @@
android:orientation="vertical"
app:layout_constraintGuide_begin="8dp" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/statusContentWarningDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -49,7 +49,7 @@
tools:text="@string/post_content_warning_show_more"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/statusContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -237,7 +237,7 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_poll_option_result_0"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -257,7 +257,7 @@
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
tools:text="40%" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_poll_option_result_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -277,7 +277,7 @@
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_0"
tools:text="10%" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_poll_option_result_2"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -297,7 +297,7 @@
app:layout_constraintTop_toBottomOf="@id/status_poll_option_result_1"
tools:text="20%" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_poll_option_result_3"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@ -5,7 +5,7 @@
android:layout_height="wrap_content"
android:orientation="horizontal">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@ -12,7 +12,7 @@
android:paddingLeft="14dp"
android:paddingRight="14dp">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
@ -55,7 +55,7 @@
tools:src="#000"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -102,7 +102,7 @@
app:layout_constraintTop_toTopOf="@id/status_display_name"
tools:text="13:37" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_content_warning_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -142,7 +142,7 @@
tools:text="@string/post_content_warning_show_more"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -192,7 +192,7 @@
android:paddingRight="6dp"
android:paddingBottom="6dp">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/card_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -203,7 +203,7 @@
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/card_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@ -36,7 +36,7 @@
tools:src="#000"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -79,7 +79,7 @@
app:layout_constraintTop_toBottomOf="@id/status_display_name"
tools:text="\@ConnyDuck\@mastodon.social" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_content_warning_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -113,7 +113,7 @@
app:layout_constraintTop_toBottomOf="@+id/status_content_warning_description"
tools:text="@string/post_content_warning_show_more" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
@ -123,6 +123,7 @@
android:hyphenationFrequency="full"
android:importantForAccessibility="no"
android:lineSpacingMultiplier="1.1"
android:textIsSelectable="true"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
app:layout_constraintEnd_toEndOf="parent"
@ -174,7 +175,7 @@
android:paddingRight="6dp"
android:paddingBottom="6dp">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/card_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -185,7 +186,7 @@
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/card_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@ -8,7 +8,7 @@
android:paddingLeft="14dp"
android:paddingRight="14dp">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/notification_top_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -32,7 +32,7 @@
android:layout_toEndOf="@+id/notification_status_avatar"
android:paddingBottom="4dp">
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/status_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -71,7 +71,7 @@
</RelativeLayout>
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/notification_content_warning_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -102,7 +102,7 @@
style="@style/TuskyButton.Outlined"
android:textSize="?attr/status_text_medium" />
<androidx.emoji.widget.EmojiTextView
<TextView
android:id="@+id/notification_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@ -16,6 +16,12 @@
</com.google.android.material.appbar.AppBarLayout>
<ProgressBar
android:id="@+id/loginProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<WebView
android:id="@+id/loginWebView"
android:layout_width="match_parent"

View File

@ -24,7 +24,7 @@
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/avatar_default" />
<androidx.emoji.widget.EmojiTextView
<androidx.emoji2.widget.EmojiTextView
android:id="@+id/status_quote_inline_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -56,7 +56,7 @@
app:layout_constraintTop_toTopOf="@id/status_quote_inline_avatar"
tools:text="\@ars42525\@odakyu.app" />
<androidx.emoji.widget.EmojiTextView
<androidx.emoji2.widget.EmojiTextView
android:id="@+id/status_quote_inline_content_warning_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -88,10 +88,10 @@
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/status_quote_inline_content_warning_description"
app:layout_constraintTop_toTopOf="@id/status_quote_inline_content_warning_description"
tools:text="@string/status_content_warning_show_more"
tools:text="@string/post_content_warning_show_more"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
<androidx.emoji2.widget.EmojiTextView
android:id="@+id/status_quote_inline_content"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@ -298,7 +298,6 @@
<string name="send_post_notification_saved_content">تم الاحتفاظ بنسخة مِن التبويق في مسوداتك</string>
<string name="action_compose_shortcut">حرر</string>
<string name="error_no_custom_emojis">لا يحتوي مثيل خادومكم %s على أية حزمة إيموجي مخصصة</string>
<string name="copy_to_clipboard_success">تم نسخه إلى الحافظة</string>
<string name="emoji_style">نوع الإيموجي</string>
<string name="system_default">الإفتراضي في النظام</string>
<string name="download_fonts">يجب عليك أولا تنزيل حزمة الإيموجي هذه</string>

View File

@ -123,7 +123,6 @@
<string name="performing_lookup_title">Извършва се търсене…</string>
<string name="system_default">По подразбиране от системата</string>
<string name="emoji_style">Стил на емоджи</string>
<string name="copy_to_clipboard_success">Копирано в клипборда</string>
<string name="error_no_custom_emojis">Инстанцията ви %s няма персонализирани емоджита</string>
<string name="action_compose_shortcut">Композиране</string>
<string name="send_post_notification_saved_content">Копие от публикацията е запазено във вашите чернови</string>

View File

@ -48,7 +48,6 @@
<string name="download_fonts">আপনাকে প্রথমে এই ইমোজি সেটগুলি ডাউনলোড করতে হবে</string>
<string name="system_default">সিস্টেমের ডিফল্ট</string>
<string name="emoji_style">ইমোজি স্টাইল</string>
<string name="copy_to_clipboard_success">ক্লিপবোর্ডে অনুলিপি করা হয়েছে</string>
<string name="error_no_custom_emojis">আপনার ইনস্ট্যান্স %s এর কোনো কাস্টম ইমোজিস নেই</string>
<string name="action_compose_shortcut">রচনা</string>
<string name="send_post_notification_saved_content">টুট এর একটি কপি আপনার ড্রাফটে সংরক্ষণ করা হয়েছে</string>

View File

@ -304,7 +304,6 @@
<string name="send_post_notification_saved_content">টুট এর একটি কপি আপনার ড্রাফটে সংরক্ষণ করা হয়েছে</string>
<string name="action_compose_shortcut">রচনা</string>
<string name="error_no_custom_emojis">আপনার ইনস্ট্যান্স %s এর কোনো কাস্টম ইমোজিস নেই</string>
<string name="copy_to_clipboard_success">ক্লিপবোর্ডে অনুলিপি করা হয়েছে</string>
<string name="emoji_style">ইমোজি স্টাইল</string>
<string name="system_default">সিস্টেমের ডিফল্ট</string>
<string name="download_fonts">আপনাকে প্রথমে এই ইমোজি সেটগুলি ডাউনলোড করতে হবে</string>

View File

@ -304,7 +304,6 @@
<string name="send_post_notification_saved_content">Una copia del toot s\'ha guardat a esborranys</string>
<string name="action_compose_shortcut">Escriure</string>
<string name="error_no_custom_emojis">La teva instància %s no te emojis personalitzats</string>
<string name="copy_to_clipboard_success">Copia al porta papers</string>
<string name="emoji_style">Estil dels emojis</string>
<string name="system_default">Sistema per defecte</string>
<string name="download_fonts">Hauràs de descarregar el joc d\'emojis</string>

View File

@ -23,7 +23,7 @@
<string name="title_hashtags_dialog">هاشتاگی</string>
<string name="action_open_faved_by">پیشاندانی دڵخوازەکان</string>
<string name="action_open_reblogged_by">پیشاندانی بەهێزکردنەکان</string>
<string name="action_open_reblogger">کردنەوەی بەهێزکردنی نووسەر</string>
<string name="action_open_reblogger">پۆستکەرەوەکە ببینە</string>
<string name="action_hashtags">هاشتاگ</string>
<string name="action_mentions">ئاماژەکان</string>
<string name="action_links">بەستەرەکان</string>
@ -57,14 +57,14 @@
<string name="action_photo_take">وێنە بگرە</string>
<string name="action_add_poll">زیادکردنی ڕاپرسی</string>
<string name="action_add_media">زیادکردنی میدیا</string>
<string name="action_open_in_web">کردنەوە لە وێبگەڕ</string>
<string name="action_open_in_web">لە وێبگەڕ بیکەوە</string>
<string name="action_view_media">میدیا</string>
<string name="action_view_follow_requests">بەدواداچونی داواکاریەکان بکە</string>
<string name="action_view_domain_mutes">دۆمەینە شاراوەکان</string>
<string name="action_view_blocks">بەکارهێنەرە بلۆککراوەکان</string>
<string name="action_view_mutes">بەکارهێنەرە گۆڕاوەکان</string>
<string name="action_view_bookmarks">نیشانەکان</string>
<string name="action_view_favourites">دڵخوازەکان</string>
<string name="action_view_favourites">بەدڵبوونەکان</string>
<string name="action_view_account_preferences">پەسەندکراوەکانی ئەژمێر</string>
<string name="action_view_preferences">پەسەندەکان</string>
<string name="action_view_profile">پرۆفایل</string>
@ -76,12 +76,12 @@
<string name="action_delete">سڕینەوە</string>
<string name="action_edit">دەستکاری</string>
<string name="action_report">گوزارشەکان</string>
<string name="action_show_reblogs">پیشاندانی بەهێزکردنەکان</string>
<string name="action_show_reblogs">پۆستکردنەوەکان نیشان بدە</string>
<string name="action_hide_reblogs">شاردنەوەی بەهێزکردنەکان</string>
<string name="action_unblock">بەربەست کردن لاببە</string>
<string name="action_block">بلۆک</string>
<string name="action_unfollow">بەدوادانەچو</string>
<string name="action_follow">بەدواداکەوتن</string>
<string name="action_follow">شوێنی بکەوە</string>
<string name="action_logout_confirm">ئایا دڵنیایت لەوەی دەتەوێت بچیتەدەرەوە لە هەژماری %1$s؟</string>
<string name="action_logout">چوونەدەرەوە</string>
<string name="action_login">چوونەژوورەوە لەگەڵ ماستۆدۆن</string>
@ -90,7 +90,7 @@
<string name="action_unfavourite">لابردنی دڵخوازەکان</string>
<string name="action_bookmark">نیشانه</string>
<string name="action_favourite">دڵخواز</string>
<string name="action_unreblog">لابردنی بەهێزکردن</string>
<string name="action_unreblog">پۆستکردنەوەکە بگەڕێنەوە</string>
<string name="action_reblog">بەهێزکردن</string>
<string name="action_reply">وەڵام</string>
<string name="action_quick_reply">وەڵامدانەوەی خێرا</string>
@ -110,7 +110,7 @@
<string name="post_sensitive_media_directions">کرتە بکە بۆ بینین</string>
<string name="post_media_hidden_title">میدیا شاراوە</string>
<string name="post_sensitive_media_title">ناوەڕۆکی هەستیار</string>
<string name="post_boosted_format">%s بەرزکرا</string>
<string name="post_boosted_format">%s پۆستی کردەوە</string>
<string name="post_username_format">\@%s</string>
<string name="title_licenses">مۆڵەتەکان</string>
<string name="title_announcements">ڕاگه یه نراوەکان</string>
@ -122,12 +122,12 @@
<string name="title_mutes">بەکارهێنەرە بێدەنگ</string>
<string name="title_bookmarks">نیشانەکان</string>
<string name="title_favourites">دڵخوازەکان</string>
<string name="title_followers">شوێنکەوتوان</string>
<string name="title_follows">بەدوادا</string>
<string name="title_followers">شوێنکەوتوو</string>
<string name="title_follows">شوێنکەوتنەکان</string>
<string name="title_posts_pinned">چەسپا</string>
<string name="title_posts_with_replies">لەگەڵ وەڵامەکان</string>
<string name="title_posts">بابەتەکان</string>
<string name="title_view_thread">توت</string>
<string name="title_posts">پۆست</string>
<string name="title_view_thread">زنجیرە</string>
<string name="title_tab_preferences">سەرخشتەکان</string>
<string name="title_direct_messages">نامە ڕاستەوخۆکان</string>
<string name="title_public_federated">گشتی</string>
@ -140,19 +140,19 @@
<string name="error_media_download_permission">مۆڵەت بۆ پاشکەوتکردنی میدیا پێویستە.</string>
<string name="error_media_upload_permission">مۆڵەت بۆ خوێندنەوەی میدیا پێویستە.</string>
<string name="error_media_upload_opening">ئەم فایلە ناتوانرێت بکرێتەوە.</string>
<string name="error_media_upload_type">ناتوانرێت ئەو جۆرە فایلە باربکرێت.</string>
<string name="error_audio_upload_size">فایلەکانی دەنگ دەبێت کەمتر بێت لە ٤٠MB.</string>
<string name="error_video_upload_size">پێویستە فایلەکانی ڤیدیۆ کەمتر لە 40 مێگابایت بن.</string>
<string name="error_image_upload_size">فایلەکە دەبێت کەمتر بێت لە 8 مێگابایت.</string>
<string name="error_compose_character_limit">ڕەستە زۆر درێژە!</string>
<string name="error_media_upload_type">ناتوانیت لەم جۆرە فایلانە بەرز بکەیتەوە.</string>
<string name="error_audio_upload_size">دەبێت فایلە دەنگییەکان لە 40 مێگابایت گەورەتر نەبن.</string>
<string name="error_video_upload_size">دەبێت ڤیدیۆکان لە 40 مێگابایت گەورەتر نەبن.</string>
<string name="error_image_upload_size">فایلەکە دەبێت لە 8 مێگابایت بچووکتر بێت.</string>
<string name="error_compose_character_limit">ئەم نووسینە زۆر درێژە!</string>
<string name="error_retrieving_oauth_token">سەرکەوتوو نەبوو لە بەدەستهێنانی نیشانەی چوونەژوورەوە.</string>
<string name="error_authorization_denied">ڕێپێدان ڕەتکرایەوە.</string>
<string name="error_authorization_unknown">هەڵەیەک بۆ مۆڵەتدانی نەناسراو ڕووی دا.</string>
<string name="error_no_web_browser_found">نەیتوانی وێبگەڕبدۆزێتەوە بۆ بەکارهێنان.</string>
<string name="error_failed_app_registration">سەرکەوتوو نەبوو، ڕاستکردنەوە لەگەڵ ئەم نمونەیە.</string>
<string name="error_invalid_domain">دۆمەینی نادروست تێنووسکرا</string>
<string name="error_empty">ئەمە ناتوانێت بەتاڵ بێت.</string>
<string name="error_network">هەڵەیەک لە تۆڕ ڕوویدا! تکایە پەیوەندیت بپشکنە و دوبارە هەوڵ بدە!</string>
<string name="error_invalid_domain">دۆمەینێکی نادروستت نووسیوە</string>
<string name="error_empty">ناکرێت ئەمە بەتاڵ بێت.</string>
<string name="error_network">هەڵەیەک لە پەیوەندییەکەدا ڕوویدا. تکایە دڵنیا ببەوە لە بەردەستبوونی هێڵی ئینتەرنێت.</string>
<string name="error_generic">هەڵەیەک ڕوویدا.</string>
<string name="pref_default_post_privacy">تایبەتمەندی بابەت گریمانەیی</string>
<string name="pref_title_http_proxy_port">دەرگای پرۆکسی HTTP</string>
@ -249,7 +249,7 @@
\n
\nکارتێکردنی ئاگانامەکانی پاڵپێوەنان، بەڵام دەتوانیت بە پەسەندکردنە ئاگانامەکانت دا بخشێنیەوە بە دەستی.</string>
<string name="account_note_saved">ڕزگارکرا</string>
<string name="account_note_hint">تێبینی تایبەتی تۆ دەربارەی ئەم ئەژمێرە</string>
<string name="account_note_hint">تێبینیی تایبەتیت بۆ ئەم هەژمارە</string>
<string name="pref_title_wellbeing_mode">Wellbeing</string>
<string name="pref_title_hide_top_toolbar">شاردنەوەی ناونیشانی شریتی ئامڕازی سەرەوە</string>
<string name="pref_title_confirm_reblogs">پیشاندانی دیالۆگی دووپاتکردنەوە پێش بەهێزکردن</string>
@ -294,7 +294,7 @@
<string name="poll_ended_created">ڕاپرسییەک کە دروستت کردووە کۆتایی هات</string>
<string name="poll_ended_voted">ڕاپرسییەک کە دەنگی پێداویت کۆتایی هات</string>
<string name="poll_vote">دەنگ</string>
<string name="poll_info_closed">داخراوە</string>
<string name="poll_info_closed">کۆتایی هاتووە</string>
<string name="poll_info_time_absolute">کۆتایی دێت لە %s</string>
<plurals name="poll_info_people">
<item quantity="one">%s کەس</item>
@ -336,10 +336,10 @@
<string name="conversation_2_recipients">%1$s و %2$s</string>
<string name="conversation_1_recipients">%1$s</string>
<string name="title_favourited_by">پەسەندکراوە لەلایەن</string>
<string name="title_reblogged_by">بەرزکراوە لەلایەن</string>
<string name="title_reblogged_by">پۆست کراوەتەوە لەلایەن</string>
<plurals name="reblogs">
<item quantity="one"><b>%s</b> بەهێزکردن</item>
<item quantity="other"><b>%s</b> بەهێزکردن</item>
<item quantity="one"><b>%s</b> پۆستکردنەوە</item>
<item quantity="other"><b>%s</b> پۆستکردنەوە</item>
</plurals>
<plurals name="favs">
<item quantity="one"><b>%1$s</b> دڵخواز</item>
@ -375,7 +375,6 @@
<string name="download_fonts">تۆ پێویستە سەرەتا ئەم سێتە ئیمۆجییانە دابگریت</string>
<string name="system_default">سیستەمی بنەڕەت</string>
<string name="emoji_style">شێوازی ئیمۆجی</string>
<string name="copy_to_clipboard_success">ڕوونووسکراوە بۆ کلیپ بۆرد</string>
<string name="error_no_custom_emojis">نموونەکەت %s هیچ ئیمۆجییەکی ئاسایی نییە</string>
<string name="action_compose_shortcut">دروستکردن</string>
<string name="send_post_notification_saved_content">کۆپیەکی دەستنووسەکە خەزن کراوە بۆ ڕەشنووسەکانت</string>

View File

@ -302,7 +302,6 @@
<string name="send_post_notification_saved_content">Kopie vašeho tootu byla uložena do vašich konceptů</string>
<string name="action_compose_shortcut">Napsat</string>
<string name="error_no_custom_emojis">Vaše instance %s nemá žádná vlastní emoji</string>
<string name="copy_to_clipboard_success">Zkopírováno do schránky</string>
<string name="emoji_style">Styl emoji</string>
<string name="system_default">Výchozí nastavení systému</string>
<string name="download_fonts">Musíte si nejprve stáhnout tyto sady emoji</string>

View File

@ -251,7 +251,6 @@
<string name="send_post_notification_saved_content">Cadwyd copi o\'r tŵt i\'ch drafftiau </string>
<string name="action_compose_shortcut">Creu</string>
<string name="error_no_custom_emojis">Nid oes gan eich achos %s emoji bersonol</string>
<string name="copy_to_clipboard_success">Copïwyd i\'r clipfwrdd</string>
<string name="emoji_style">Arddull emoji</string>
<string name="system_default">Rhagosodiad system</string>
<string name="download_fonts">Bydd angen i chi lawrlwytho\'r setiau emoji hyn yn gyntaf </string>

View File

@ -208,7 +208,7 @@
<string name="notification_mention_name">Neue Erwähnungen</string>
<string name="notification_mention_descriptions">Benachrichtigungen über neue Erwähnungen</string>
<string name="notification_follow_name">Neue Folgende</string>
<string name="notification_follow_description">Benachrichtigunen über neue Folgende</string>
<string name="notification_follow_description">Benachrichtigungen über neue Folgende</string>
<string name="notification_boost_name">Geteilte Beiträge</string>
<string name="notification_boost_description">Benachrichtigungen, wenn deine Beiträge geteilt werden</string>
<string name="notification_favourite_name">Favorisierte Beiträge</string>
@ -279,7 +279,6 @@
<string name="send_post_notification_saved_content">Eine Kopie des Beitrags wurde in deine Entwürfe gespeichert</string>
<string name="action_compose_shortcut">Beitrag erstellen</string>
<string name="error_no_custom_emojis">Deine Instanz %s hat keine Emojis definiert</string>
<string name="copy_to_clipboard_success">In die Zwischenablage kopiert</string>
<string name="emoji_style">Emoji-Stil</string>
<string name="system_default">System-Standard</string>
<string name="download_fonts">Du musst diese Emoji-Sets zunächst herunterladen</string>
@ -528,4 +527,11 @@
<string name="duration_14_days">14 Tage</string>
<string name="duration_180_days">180 Tage</string>
<string name="tusky_compose_post_quicksetting_label">Beitrag erstellen</string>
<string name="notification_update_format">%s hat den Beitrag bearbeitet</string>
<string name="pref_title_notification_filter_updates">Ein Beitrag, mit dem ich interagiert habe, wurde bearbeitet</string>
<string name="notification_sign_up_name">Registrierungen</string>
<string name="notification_sign_up_description">Benachrichtigungen über neue Profile</string>
<string name="notification_sign_up_format">%s hat sich registriert</string>
<string name="pref_title_notification_filter_sign_ups">Jemand hat sich registriert</string>
<string name="notification_update_description">Benachrichtigungen, wenn Beiträge bearbeitet werden, mit denen du interagiert hast</string>
</resources>

View File

@ -299,7 +299,6 @@
<string name="send_post_notification_saved_content">Kopio de la mesaĝo estis konservita en viaj malnetoj</string>
<string name="action_compose_shortcut">Verki</string>
<string name="error_no_custom_emojis">Via nodo %s ne havas proprajn emoĝiojn</string>
<string name="copy_to_clipboard_success">Kopiita en tondujo</string>
<string name="emoji_style">Stilo de emoĝioj</string>
<string name="system_default">Sistema valoro</string>
<string name="download_fonts">Vi unue devos elŝuti ĉi tiujn emoĝiarojn</string>

View File

@ -269,7 +269,6 @@
<string name="send_post_notification_saved_content">Una copia del estado se ha guardado en borradores</string>
<string name="action_compose_shortcut">Redactar</string>
<string name="error_no_custom_emojis">Su instancia %s no ofrece emojis personalizados</string>
<string name="copy_to_clipboard_success">Copiado al portapapeles</string>
<string name="emoji_style">Estilo de los emojis</string>
<string name="system_default">Sistema</string>
<string name="download_fonts">Tendrás que descargarlos primero</string>

View File

@ -253,7 +253,6 @@
<string name="send_post_notification_saved_content">Tutaren kopia zirriborroetan sartu da</string>
<string name="action_compose_shortcut">Idatzi</string>
<string name="error_no_custom_emojis">%s instantziak ez ditu emoji pertsonalizatuak eskaintzen</string>
<string name="copy_to_clipboard_success">Arbelean kopiatua</string>
<string name="emoji_style">Emojien estiloa</string>
<string name="system_default">Sistema</string>
<string name="download_fonts">Lehenago jaitsi beharko dituzu</string>

View File

@ -248,7 +248,6 @@
<string name="send_post_notification_saved_content">رونوشتی از بوق در پیش‌نویس‌هایتان ذخیره شد</string>
<string name="action_compose_shortcut">ایجاد</string>
<string name="error_no_custom_emojis">نمونه‌تان %s هیچ اموجی سفارشی‌ای ندارد</string>
<string name="copy_to_clipboard_success">در تخته‌گیره رونوشت شد</string>
<string name="emoji_style">سبک اموجی</string>
<string name="system_default">پیش‌گزیدهٔ سامانه</string>
<string name="download_fonts">نخست باید این مجموعه‌های اموجی را بارگیری کنید</string>

View File

@ -304,7 +304,6 @@
<string name="send_post_notification_saved_content">Une copie du pouet a été sauvegardée dans vos brouillons</string>
<string name="action_compose_shortcut">Écrire</string>
<string name="error_no_custom_emojis">Votre instance %s na pas démojis personnalisés</string>
<string name="copy_to_clipboard_success">Copié dans le presse-papier</string>
<string name="emoji_style">Style démojis</string>
<string name="system_default">Par défaut du système</string>
<string name="download_fonts">Vous devez commencer par télécharger ces jeux démojis</string>
@ -540,4 +539,12 @@
<string name="duration_14_days">14 jours</string>
<string name="duration_180_days">180 jours</string>
<string name="tusky_compose_post_quicksetting_label">Rédiger un message</string>
<string name="notification_sign_up_format">%s a créé un compte</string>
<string name="notification_sign_up_name">Nouveaux comptes</string>
<string name="notification_sign_up_description">Notifications quand quelqu\'un crée un nouveau compte</string>
<string name="pref_title_notification_filter_sign_ups">un nouveau compte a été créé</string>
<string name="notification_update_format">%s a modifié son message</string>
<string name="pref_title_notification_filter_updates">un message avec lequel j\'ai interagi est modifié</string>
<string name="notification_update_name">Messages modifiés</string>
<string name="notification_update_description">Notifications quand un post avec lequel vous avez interagi est modifié</string>
</resources>

View File

@ -4,7 +4,6 @@
<string name="error_empty">Dit mei net leech wêze.</string>
<string name="system_default">Systeem standert</string>
<string name="emoji_style">Emoji styl</string>
<string name="copy_to_clipboard_success">Nei it klemboerd kopiearre</string>
<string name="action_compose_shortcut">Gearstelle</string>
<string name="send_post_notification_cancel_title">Ferstjoeren ôfbrutsen</string>
<string name="send_post_notification_channel_name">Toots oan it ferstjoeren</string>

View File

@ -339,7 +339,6 @@
<string name="send_post_notification_saved_content">Sábháladh cóip den tút ar do dhréachtaí</string>
<string name="action_compose_shortcut">Cum</string>
<string name="error_no_custom_emojis">Níl aon emojis saincheaptha ag do shampla %s</string>
<string name="copy_to_clipboard_success">Cóipeáladh chuig an gearrthaisce</string>
<string name="emoji_style">Stíl Emoji</string>
<string name="system_default">Réamhshocrú an chórais</string>
<string name="download_fonts">Beidh ort na tacair emoji seo a íoslódáil ar dtús</string>

View File

@ -239,7 +239,6 @@
<string name="download_fonts">Feumaidh tu na seataichean seo de dhEmojis a luchdadh a-nuas an toiseach</string>
<string name="system_default">Bun-roghainn an t-siostaim</string>
<string name="emoji_style">Stoidhle nan Emojis</string>
<string name="copy_to_clipboard_success">Chaidh lethbhreac dheth a chur air an stòr-bhòrd</string>
<string name="error_no_custom_emojis">Chan eil Emojis gnàthaichte aig an ionstans %s agad</string>
<string name="send_post_notification_saved_content">Chaidh lethbhreac dhen phost agad a shàbhaladh na dhreachd</string>
<string name="send_post_notification_cancel_title">Chaidh sgur dhen chur</string>
@ -249,10 +248,16 @@
<string name="compose_save_draft">A bheil thu airson a shàbhaladh na dhreachd\?</string>
<string name="lock_account_label_description">Feumaidh tu gabhail ri luchd-leantainn ùr a làimh</string>
<string name="lock_account_label">Glais an cunntas</string>
<string name="action_set_caption">Suidhidh am fo-thiotal</string>
<string name="action_set_caption">Suidhich am fo-thiotal</string>
<plurals name="hint_describe_for_visually_impaired">
<item quantity="one">Mìnich e dhan fheadhainn air a bheil cion-lèirsinn
\n(%d caractar(an) air a char as fhaide)</item>
\n(%d charactar air a char as fhaide)</item>
<item quantity="two">Mìnich e dhan fheadhainn air a bheil cion-lèirsinn
\n(%d charactar air a char as fhaide)</item>
<item quantity="few">Mìnich e dhan fheadhainn air a bheil cion-lèirsinn
\n(%d caractaran air a char as fhaide)</item>
<item quantity="other">Mìnich e dhan fheadhainn air a bheil cion-lèirsinn
\n(%d caractar air a char as fhaide)</item>
</plurals>
<string name="error_failed_set_caption">Cha deach leinn am fo-thiotal a shuidheachadh</string>
<string name="compose_active_account_description">A postadh leis a chunntas %1$s</string>
@ -323,7 +328,7 @@
<string name="abbreviated_in_hours">an ceann %du</string>
<string name="abbreviated_in_days">an ceann %dl</string>
<string name="abbreviated_in_years">an ceann %db</string>
<string name="state_follow_requested">Iarrar leantainn orm</string>
<string name="state_follow_requested">Iarrtas leantainn air</string>
<string name="post_media_video">Videothan</string>
<string name="post_media_images">Dealbhan</string>
<string name="about_tusky_account">Pròifil Tusky</string>
@ -541,4 +546,13 @@
<string name="duration_14_days">14 làithean</string>
<string name="duration_60_days">60 latha</string>
<string name="tusky_compose_post_quicksetting_label">Sgrìobh post</string>
<string name="notification_sign_up_format">Chlàraich %s</string>
<string name="notification_sign_up_name">Clàraidhean</string>
<string name="notification_sign_up_description">Brathan mu cleachdaichean ùra</string>
<string name="pref_title_notification_filter_sign_ups">chlàraich cuideigin</string>
<string name="notification_update_format">Dheasaich %s am post aca</string>
<string name="notification_update_name">Deasachadh puist</string>
<string name="notification_update_description">Brathan nuair a thèid postaichean a rinn thu conaltradh leotha a dheasachadh</string>
<string name="pref_title_notification_filter_updates">chaidh post a rinn mi conaltradh leis a deasachadh</string>
<string name="title_login">Clàraich a-steach</string>
</resources>

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