Merge remote-tracking branch 'tuskyapp/develop'

This commit is contained in:
kyori19 2021-03-04 15:08:48 +09:00
commit a746009f74
195 changed files with 4987 additions and 2230 deletions

View File

@ -74,6 +74,9 @@ android {
androidExtensions { androidExtensions {
experimental = true experimental = true
} }
buildFeatures {
viewBinding true
}
testOptions { testOptions {
unitTests { unitTests {
returnDefaultValues = true returnDefaultValues = true
@ -144,7 +147,7 @@ dependencies {
implementation "androidx.room:room-rxjava2:$roomVersion" implementation "androidx.room:room-rxjava2:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion"
implementation "com.google.android.material:material:1.2.1" implementation "com.google.android.material:material:1.3.0"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"

View File

@ -43,6 +43,10 @@
public *; public *;
} }
-keep enum com.keylesspalace.tusky.db.DraftAttachment$Type {
public *;
}
# preserve line numbers for crash reporting # preserve line numbers for crash reporting
-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile -renamesourcefileattribute SourceFile

View File

@ -0,0 +1,821 @@
{
"formatVersion": 1,
"database": {
"version": 25,
"identityHash": "e2cb844862443c2c5cc884c11f120d43",
"entities": [
{
"tableName": "TootEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "urls",
"columnName": "urls",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "descriptions",
"columnName": "descriptions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToText",
"columnName": "inReplyToText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToUsername",
"columnName": "inReplyToUsername",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "poll",
"columnName": "poll",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"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, `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": "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"
],
"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, `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": "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, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, 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": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"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
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` 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.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.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, 'e2cb844862443c2c5cc884c11f120d43')"
]
}
}

View File

@ -146,6 +146,7 @@
<activity android:name=".components.instancemute.InstanceListActivity" /> <activity android:name=".components.instancemute.InstanceListActivity" />
<activity android:name=".components.scheduled.ScheduledTootActivity" /> <activity android:name=".components.scheduled.ScheduledTootActivity" />
<activity android:name=".components.announcements.AnnouncementsActivity" /> <activity android:name=".components.announcements.AnnouncementsActivity" />
<activity android:name=".components.drafts.DraftsActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" /> <receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
<receiver <receiver

View File

@ -7,7 +7,6 @@ import android.text.SpannableStringBuilder
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.text.style.URLSpan import android.text.style.URLSpan
import android.text.util.Linkify import android.text.util.Linkify
import android.view.MenuItem
import android.widget.TextView import android.widget.TextView
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
@ -59,17 +58,6 @@ class AboutActivity : BottomSheetActivity(), Injectable {
private fun onEasterEggExecute() { private fun onEasterEggExecute() {
startActivityWithSlideInAnimation(Intent(this, AccessTokenLoginActivity::class.java)) startActivityWithSlideInAnimation(Intent(this, AccessTokenLoginActivity::class.java))
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
} }
private fun TextView.setClickableTextWithoutUnderlines(@StringRes textId: Int) { private fun TextView.setClickableTextWithoutUnderlines(@StringRes textId: Int) {

View File

@ -78,7 +78,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private val viewModel: AccountViewModel by viewModels { viewModelFactory } private val viewModel: AccountViewModel by viewModels { viewModelFactory }
private val accountFieldAdapter = AccountFieldAdapter(this) private lateinit var accountFieldAdapter : AccountFieldAdapter
private var followState: FollowState = FollowState.NOT_FOLLOWING private var followState: FollowState = FollowState.NOT_FOLLOWING
private var blocking: Boolean = false private var blocking: Boolean = false
@ -89,6 +89,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private var loadedAccount: Account? = null private var loadedAccount: Account? = null
private var animateAvatar: Boolean = false private var animateAvatar: Boolean = false
private var animateEmojis: Boolean = false
// fields for scroll animation // fields for scroll animation
private var hideFab: Boolean = false private var hideFab: Boolean = false
@ -124,6 +125,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this) val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false) animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false)
animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
hideFab = sharedPrefs.getBoolean("fabHide", false) hideFab = sharedPrefs.getBoolean("fabHide", false)
setupToolbar() setupToolbar()
@ -162,6 +164,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountFollowsYouTextView.hide() accountFollowsYouTextView.hide()
// setup the RecyclerView for the account fields // setup the RecyclerView for the account fields
accountFieldAdapter = AccountFieldAdapter(this, animateEmojis)
accountFieldList.isNestedScrollingEnabled = false accountFieldList.isNestedScrollingEnabled = false
accountFieldList.layoutManager = LinearLayoutManager(this) accountFieldList.layoutManager = LinearLayoutManager(this)
accountFieldList.adapter = accountFieldAdapter accountFieldList.adapter = accountFieldAdapter
@ -375,9 +378,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
val usernameFormatted = getString(R.string.status_username_format, account.username) val usernameFormatted = getString(R.string.status_username_format, account.username)
accountUsernameTextView.text = usernameFormatted accountUsernameTextView.text = usernameFormatted
accountDisplayNameTextView.text = account.name.emojify(account.emojis, accountDisplayNameTextView) accountDisplayNameTextView.text = account.name.emojify(account.emojis, accountDisplayNameTextView, animateEmojis)
val emojifiedNote = account.note.emojify(account.emojis, accountNoteTextView) val emojifiedNote = account.note.emojify(account.emojis, accountNoteTextView, animateEmojis)
LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this) LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this)
// accountFieldAdapter.fields = account.fields ?: emptyList() // accountFieldAdapter.fields = account.fields ?: emptyList()
@ -437,7 +440,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
private fun updateToolbar() { private fun updateToolbar() {
loadedAccount?.let { account -> loadedAccount?.let { account ->
val emojifiedName = account.name.emojify(account.emojis, accountToolbar) val emojifiedName = account.name.emojify(account.emojis, accountToolbar, animateEmojis)
try { try {
supportActionBar?.title = EmojiCompat.get().process(emojifiedName) supportActionBar?.title = EmojiCompat.get().process(emojifiedName)
@ -565,11 +568,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
subscribing = relation.subscribing subscribing = relation.subscribing
} }
// remove the listener so it doesn't fire on non-user changes
accountNoteTextInputLayout.editText?.removeTextChangedListener(noteWatcher)
accountNoteTextInputLayout.visible(relation.note != null) accountNoteTextInputLayout.visible(relation.note != null)
accountNoteTextInputLayout.editText?.setText(relation.note) accountNoteTextInputLayout.editText?.setText(relation.note)
// add the listener late to avoid it firing on the first change
accountNoteTextInputLayout.editText?.removeTextChangedListener(noteWatcher)
accountNoteTextInputLayout.editText?.addTextChangedListener(noteWatcher) accountNoteTextInputLayout.editText?.addTextChangedListener(noteWatcher)
updateButtons() updateButtons()
@ -619,8 +623,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if(subscribing) { if(subscribing) {
accountSubscribeButton.setIconResource(R.drawable.ic_notifications_active_24dp) accountSubscribeButton.setIconResource(R.drawable.ic_notifications_active_24dp)
accountSubscribeButton.contentDescription = getString(R.string.action_unsubscribe_account)
} else { } else {
accountSubscribeButton.setIconResource(R.drawable.ic_notifications_24dp) accountSubscribeButton.setIconResource(R.drawable.ic_notifications_24dp)
accountSubscribeButton.contentDescription = getString(R.string.action_subscribe_account)
} }
} }
@ -650,14 +656,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
menuInflater.inflate(R.menu.account_toolbar, menu) menuInflater.inflate(R.menu.account_toolbar, menu)
if (!viewModel.isSelf) { if (!viewModel.isSelf) {
val follow = menu.findItem(R.id.action_follow)
follow.title = if (followState == FollowState.NOT_FOLLOWING) {
getString(R.string.action_follow)
} else {
getString(R.string.action_unfollow)
}
follow.isVisible = followState != FollowState.REQUESTED
val block = menu.findItem(R.id.action_block) val block = menu.findItem(R.id.action_block)
block.title = if (blocking) { block.title = if (blocking) {
@ -701,8 +699,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} }
} else { } else {
// It shouldn't be possible to block, follow, mute or report yourself. // It shouldn't be possible to block, mute or report yourself.
menu.removeItem(R.id.action_follow)
menu.removeItem(R.id.action_block) menu.removeItem(R.id.action_block)
menu.removeItem(R.id.action_mute) menu.removeItem(R.id.action_mute)
menu.removeItem(R.id.action_mute_domain) menu.removeItem(R.id.action_mute_domain)
@ -759,8 +756,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
showMuteAccountDialog( showMuteAccountDialog(
this, this,
it.username it.username
) { notifications -> ) { notifications, duration ->
viewModel.muteAccount(notifications) viewModel.muteAccount(notifications, duration)
} }
} }
} else { } else {
@ -794,14 +791,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
R.id.action_mention -> {
mention()
return true
}
R.id.action_open_in_web -> { R.id.action_open_in_web -> {
// If the account isn't loaded yet, eat the input. // If the account isn't loaded yet, eat the input.
if (loadedAccount != null) { if (loadedAccount != null) {
@ -809,10 +798,6 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
} }
return true return true
} }
R.id.action_follow -> {
viewModel.changeFollowState()
return true
}
R.id.action_block -> { R.id.action_block -> {
toggleBlock() toggleBlock()
return true return true

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem
import com.keylesspalace.tusky.fragment.AccountListFragment import com.keylesspalace.tusky.fragment.AccountListFragment
import dagger.android.DispatchingAndroidInjector import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector import dagger.android.HasAndroidInjector
@ -68,16 +67,6 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector {
.commit() .commit()
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun androidInjector() = dispatchingAndroidInjector override fun androidInjector() = dispatchingAndroidInjector
companion object { companion object {

View File

@ -31,6 +31,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State import com.keylesspalace.tusky.viewmodel.State
@ -71,7 +72,9 @@ class AccountsInListFragment : DialogFragment(), Injectable {
private val searchAdapter = SearchAdapter() private val searchAdapter = SearchAdapter()
private val radius by lazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) } private val radius by lazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) }
private val animateAvatar by lazy { PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("animateGifAvatars", false) } private val pm by lazy { PreferenceManager.getDefaultSharedPreferences(requireContext()) }
private val animateAvatar by lazy { pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) }
private val animateEmojis by lazy { pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -209,7 +212,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
} }
fun bind(account: Account) { fun bind(account: Account) {
displayNameTextView.text = account.name.emojify(account.emojis, displayNameTextView) displayNameTextView.text = account.name.emojify(account.emojis, displayNameTextView, animateEmojis)
usernameTextView.text = account.username usernameTextView.text = account.username
loadAvatar(account.avatar, avatar, radius, animateAvatar) loadAvatar(account.avatar, avatar, radius, animateAvatar)
} }
@ -252,7 +255,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
override val containerView = itemView override val containerView = itemView
fun bind(account: Account, inAList: Boolean) { fun bind(account: Account, inAList: Boolean) {
displayNameTextView.text = account.name.emojify(account.emojis, displayNameTextView) displayNameTextView.text = account.name.emojify(account.emojis, displayNameTextView, animateEmojis)
usernameTextView.text = account.username usernameTextView.text = account.username
loadAvatar(account.avatar, avatar, radius, animateAvatar) loadAvatar(account.avatar, avatar, radius, animateAvatar)

View File

@ -24,6 +24,7 @@ import android.graphics.Bitmap;
import android.graphics.BitmapFactory; import android.graphics.BitmapFactory;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.view.MenuItem;
import android.view.View; import android.view.View;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@ -127,6 +128,15 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab
overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left); overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left);
} }
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override @Override
public void finish() { public void finish() {
super.finish(); super.finish();

View File

@ -296,10 +296,6 @@ class EditProfileActivity : BaseActivity(), Injectable {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
R.id.action_save -> { R.id.action_save -> {
save() save()
return true return true

View File

@ -1,7 +1,6 @@
package com.keylesspalace.tusky package com.keylesspalace.tusky
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Toast import android.widget.Toast
@ -205,14 +204,4 @@ class FiltersActivity: BaseActivity() {
} }
} }
// Activate back arrow in toolbar
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
} }

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky
import android.os.Bundle import android.os.Bundle
import androidx.annotation.RawRes import androidx.annotation.RawRes
import android.util.Log import android.util.Log
import android.view.MenuItem
import android.widget.TextView import android.widget.TextView
import com.keylesspalace.tusky.util.IOUtils import com.keylesspalace.tusky.util.IOUtils
import kotlinx.android.extensions.CacheImplementation import kotlinx.android.extensions.CacheImplementation
@ -49,16 +48,6 @@ class LicenseActivity : BaseActivity() {
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) { private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) {
val sb = StringBuilder() val sb = StringBuilder()

View File

@ -21,7 +21,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
@ -130,19 +129,27 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
else R.string.action_rename_list) { _, _ -> else R.string.action_rename_list) { _, _ ->
onPickedDialogName(editText.text, list?.id) onPickedDialogName(editText.text, list?.id)
} }
.setNegativeButton(android.R.string.cancel) { d, _ -> .setNegativeButton(android.R.string.cancel, null)
d.dismiss()
}
.show() .show()
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE) val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
editText.onTextChanged { s, _, _, _ -> editText.onTextChanged { s, _, _, _ ->
positiveButton.isEnabled = !s.isBlank() positiveButton.isEnabled = s.isNotBlank()
} }
editText.setText(list?.title) editText.setText(list?.title)
editText.text?.let { editText.setSelection(it.length) } editText.text?.let { editText.setSelection(it.length) }
} }
private fun showListDeleteDialog(list: MastoList) {
AlertDialog.Builder(this)
.setMessage(getString(R.string.dialog_delete_list_warning, list.title))
.setPositiveButton(R.string.action_delete){ _, _ ->
viewModel.deleteList(list.id)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun update(state: ListsViewModel.State) { private fun update(state: ListsViewModel.State) {
adapter.submitList(state.lists) adapter.submitList(state.lists)
@ -199,7 +206,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
when (item.itemId) { when (item.itemId) {
R.id.list_edit -> openListSettings(list) R.id.list_edit -> openListSettings(list)
R.id.list_rename -> renameListDialog(list) R.id.list_rename -> renameListDialog(list)
R.id.list_delete -> viewModel.deleteList(list.id) R.id.list_delete -> showListDeleteDialog(list)
else -> return@setOnMenuItemClickListener false else -> return@setOnMenuItemClickListener false
} }
true true
@ -210,14 +217,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
override fun androidInjector() = dispatchingAndroidInjector override fun androidInjector() = dispatchingAndroidInjector
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
onBackPressed()
return true
}
return false
}
private object ListsDiffer : DiffUtil.ItemCallback<MastoList>() { private object ListsDiffer : DiffUtil.ItemCallback<MastoList>() {
override fun areItemsTheSame(oldItem: MastoList, newItem: MastoList): Boolean { override fun areItemsTheSame(oldItem: MastoList, newItem: MastoList): Boolean {
return oldItem.id == newItem.id return oldItem.id == newItem.id

View File

@ -20,14 +20,13 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.util.Log import android.util.Log
import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
@ -109,14 +108,6 @@ class LoginActivity : BaseActivity(), Injectable {
} }
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
onBackPressed()
return true
}
return super.onOptionsItemSelected(item)
}
/** /**
* Obtain the oauth client credentials for this app. This is only necessary the first time the * Obtain the oauth client credentials for this app. This is only necessary the first time the
* app is run on a given server instance. So, after the first authentication, they are * app is run on a given server instance. So, after the first authentication, they are
@ -339,16 +330,19 @@ class LoginActivity : BaseActivity(), Injectable {
private fun openInCustomTab(uri: Uri, context: Context): Boolean { private fun openInCustomTab(uri: Uri, context: Context): Boolean {
val toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface) val toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface)
val customTabsIntentBuilder = CustomTabsIntent.Builder() val navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor)
val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor)
val colorSchemeParams = CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor) .setToolbarColor(toolbarColor)
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { val customTabsIntent = CustomTabsIntent.Builder()
customTabsIntentBuilder.setNavigationBarColor( .setDefaultColorSchemeParams(colorSchemeParams)
ThemeUtils.getColor(context, android.R.attr.navigationBarColor) .build()
)
}
val customTabsIntent = customTabsIntentBuilder.build()
try { try {
customTabsIntent.launchUrl(context, uri) customTabsIntent.launchUrl(context, uri)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {

View File

@ -40,6 +40,7 @@ import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.emoji.text.EmojiCompat import androidx.emoji.text.EmojiCompat
import androidx.emoji.text.EmojiCompat.InitCallback import androidx.emoji.text.EmojiCompat.InitCallback
@ -61,11 +62,14 @@ import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
import com.keylesspalace.tusky.components.conversation.ConversationsRepository import com.keylesspalace.tusky.components.conversation.ConversationsRepository
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity
import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.fragment.NotificationsFragment import com.keylesspalace.tusky.fragment.NotificationsFragment
@ -115,6 +119,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
@Inject @Inject
lateinit var conversationRepository: ConversationsRepository lateinit var conversationRepository: ConversationsRepository
@Inject
lateinit var appDb: AppDatabase
@Inject
lateinit var draftHelper: DraftHelper
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@ -262,6 +272,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
// Flush old media that was cached for sharing // Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
} }
draftWarning()
} }
override fun onResume() { override fun onResume() {
@ -448,7 +459,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
nameRes = R.string.action_access_saved_toot nameRes = R.string.action_access_saved_toot
iconRes = R.drawable.ic_notebook iconRes = R.drawable.ic_notebook
onClick = { onClick = {
val intent = Intent(context, SavedTootActivity::class.java) val intent = DraftsActivity.newIntent(context)
startActivityWithSlideInAnimation(intent) startActivityWithSlideInAnimation(intent)
} }
}, },
@ -733,6 +744,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0 val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
mainToolbar.title = tabs[activeTabPosition].title(this@MainActivity) mainToolbar.title = tabs[activeTabPosition].title(this@MainActivity)
mainToolbar.setOnClickListener {
(adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
}
keepScreenOn() keepScreenOn()
return popups return popups
@ -783,6 +797,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this) NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this)
cacheUpdater.clearForUser(activeAccount.id) cacheUpdater.clearForUser(activeAccount.id)
conversationRepository.deleteCacheForAccount(activeAccount.id) conversationRepository.deleteCacheForAccount(activeAccount.id)
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
removeShortcut(this, activeAccount) removeShortcut(this, activeAccount)
val newAccount = accountManager.logActiveAccountOut() val newAccount = accountManager.logActiveAccountOut()
if (!NotificationHelper.areNotificationsEnabled(this, accountManager)) { if (!NotificationHelper.areNotificationsEnabled(this, accountManager)) {
@ -861,16 +876,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) { .into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) { override fun onLoadStarted(placeholder: Drawable?) {
if(placeholder != null) { if (placeholder != null) {
mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize) mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
} }
} }
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) { override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
mainToolbar.navigationIcon = resource mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
} }
override fun onLoadCleared(placeholder: Drawable?) { override fun onLoadCleared(placeholder: Drawable?) {
mainToolbar.navigationIcon = placeholder if (placeholder != null) {
mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
} }
}) })
} }
@ -895,8 +912,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
} }
private fun updateProfiles() { private fun updateProfiles() {
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc -> val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc ->
val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header)) val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header, animateEmojis))
ProfileDrawerItem().apply { ProfileDrawerItem().apply {
isSelected = acc.isActive isSelected = acc.isActive
@ -920,6 +938,29 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
header.setActiveProfile(accountManager.activeAccount!!.id) header.setActiveProfile(accountManager.activeAccount!!.id)
} }
private fun draftWarning() {
val sharedPrefsKey = "show_draft_warning"
appDb.tootDao().savedTootCount()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { draftCount ->
val showDraftWarning = preferences.getBoolean(sharedPrefsKey, true)
if (draftCount > 0 && showDraftWarning) {
AlertDialog.Builder(this)
.setMessage(R.string.new_drafts_warning)
.setNegativeButton("Don't show again") { _, _ ->
preferences.edit(commit = true) {
putBoolean(sharedPrefsKey, false)
}
}
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
override fun getActionButton(): FloatingActionButton? = composeButton override fun getActionButton(): FloatingActionButton? = composeButton
override fun androidInjector() = androidInjector override fun androidInjector() = androidInjector

View File

@ -3,7 +3,6 @@ package com.keylesspalace.tusky
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
@ -80,14 +79,6 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn
override fun getActionButton(): FloatingActionButton? = null override fun getActionButton(): FloatingActionButton? = null
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
onBackPressed()
return true
}
return false
}
override fun androidInjector() = dispatchingAndroidInjector override fun androidInjector() = dispatchingAndroidInjector
} }

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky;
import android.content.Intent; import android.content.Intent;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Bundle; import android.os.Bundle;
import android.view.MenuItem;
import android.view.View; import android.view.View;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -89,7 +88,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
setSupportActionBar(toolbar); setSupportActionBar(toolbar);
ActionBar bar = getSupportActionBar(); ActionBar bar = getSupportActionBar();
if (bar != null) { if (bar != null) {
bar.setTitle(getString(R.string.title_saved_toot)); bar.setTitle(getString(R.string.title_drafts));
bar.setDisplayHomeAsUpEnabled(true); bar.setDisplayHomeAsUpEnabled(true);
bar.setDisplayShowHomeEnabled(true); bar.setDisplayShowHomeEnabled(true);
} }
@ -118,17 +117,6 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
if (asyncTask != null) asyncTask.cancel(true); if (asyncTask != null) asyncTask.cancel(true);
} }
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home: {
onBackPressed();
return true;
}
}
return super.onOptionsItemSelected(item);
}
private void fetchToots() { private void fetchToots() {
asyncTask = new FetchPojosTask(this, database.tootDao()) asyncTask = new FetchPojosTask(this, database.tootDao())
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
@ -166,6 +154,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
ComposeOptions composeOptions = new ComposeOptions( ComposeOptions composeOptions = new ComposeOptions(
/*scheduledTootUid*/null, /*scheduledTootUid*/null,
item.getUid(), item.getUid(),
/*drafId*/null,
item.getText(), item.getText(),
jsonUrls, jsonUrls,
descriptions, descriptions,
@ -180,6 +169,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
item.getInReplyToUsername(), item.getInReplyToUsername(),
item.getInReplyToText(), item.getInReplyToText(),
/*mediaAttachments*/null, /*mediaAttachments*/null,
/*draftAttachments*/null,
/*scheduledAt*/null, /*scheduledAt*/null,
/*sensitive*/null, /*sensitive*/null,
/*poll*/null, /*poll*/null,

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -85,14 +84,6 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
floating_btn.setOnClickListener(viewQuickToot::onFABClicked) floating_btn.setOnClickListener(viewQuickToot::onFABClicked)
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home){
onBackPressed()
return true
}
return super.onOptionsItemSelected(item)
}
override fun androidInjector() = dispatchingAndroidInjector override fun androidInjector() = dispatchingAndroidInjector
companion object { companion object {

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
@ -345,14 +344,6 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
} }
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
onBackPressed()
return true
}
return false
}
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
if (tabsChanged) { if (tabsChanged) {

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.view.MenuItem;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
@ -95,17 +94,6 @@ public class ViewTagActivity extends BottomSheetActivity implements HasAndroidIn
.subscribe(quickTootView::handleEvent); .subscribe(quickTootView::handleEvent);
} }
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home: {
onBackPressed();
return true;
}
}
return super.onOptionsItemSelected(item);
}
@Override @Override
public AndroidInjector<Object> androidInjector() { public AndroidInjector<Object> androidInjector() {
return dispatchingAndroidInjector; return dispatchingAndroidInjector;

View File

@ -110,10 +110,6 @@ public class ViewThreadActivity extends BottomSheetActivity implements HasAndroi
@Override @Override
public boolean onOptionsItemSelected(MenuItem item) { public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) { switch (item.getItemId()) {
case android.R.id.home: {
onBackPressed();
return true;
}
case R.id.action_open_in_web: { case R.id.action_open_in_web: {
LinkHelper.openLink(getIntent().getStringExtra(URL_EXTRA), this); LinkHelper.openLink(getIntent().getStringExtra(URL_EXTRA), this);
return true; return true;

View File

@ -33,10 +33,14 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
List<Account> accountList; List<Account> accountList;
AccountActionListener accountActionListener; AccountActionListener accountActionListener;
private boolean bottomLoading; private boolean bottomLoading;
protected final boolean animateEmojis;
protected final boolean animateAvatar;
AccountAdapter(AccountActionListener accountActionListener) { AccountAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
this.accountList = new ArrayList<>(); this.accountList = new ArrayList<>();
this.accountActionListener = accountActionListener; this.accountActionListener = accountActionListener;
this.animateAvatar = animateAvatar;
this.animateEmojis = animateEmojis;
bottomLoading = false; bottomLoading = false;
} }

View File

@ -31,7 +31,7 @@ import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.emojify
import kotlinx.android.synthetic.main.item_account_field.view.* import kotlinx.android.synthetic.main.item_account_field.view.*
class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView.Adapter<AccountFieldAdapter.ViewHolder>() { class AccountFieldAdapter(private val linkListener: LinkListener, private val animateEmojis: Boolean) : RecyclerView.Adapter<AccountFieldAdapter.ViewHolder>() {
var emojis: List<Emoji> = emptyList() var emojis: List<Emoji> = emptyList()
var fields: List<Either<IdentityProof, Field>> = emptyList() var fields: List<Either<IdentityProof, Field>> = emptyList()
@ -57,10 +57,10 @@ class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView
viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
} else { } else {
val field = proofOrField.asRight() val field = proofOrField.asRight()
val emojifiedName = field.name.emojify(emojis, viewHolder.nameTextView) val emojifiedName = field.name.emojify(emojis, viewHolder.nameTextView, animateEmojis)
viewHolder.nameTextView.text = emojifiedName viewHolder.nameTextView.text = emojifiedName
val emojifiedValue = field.value.emojify(emojis, viewHolder.valueTextView) val emojifiedValue = field.value.emojify(emojis, viewHolder.valueTextView, animateEmojis)
LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener) LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener)
if(field.verifiedAt != null) { if(field.verifiedAt != null) {

View File

@ -23,6 +23,7 @@ import android.widget.ArrayAdapter
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.*
import kotlinx.android.synthetic.main.item_autocomplete_account.view.* import kotlinx.android.synthetic.main.item_autocomplete_account.view.*
@ -41,12 +42,14 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter<AccountEntity>(co
val username = view.username val username = view.username
val displayName = view.display_name val displayName = view.display_name
val avatar = view.avatar val avatar = view.avatar
val pm = PreferenceManager.getDefaultSharedPreferences(avatar.context)
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
username.text = account.fullName username.text = account.fullName
displayName.text = account.displayName.emojify(account.emojis, displayName) displayName.text = account.displayName.emojify(account.emojis, displayName, animateEmojis)
val avatarRadius = avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp) val avatarRadius = avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
val animateAvatar = PreferenceManager.getDefaultSharedPreferences(avatar.context) val animateAvatar = pm.getBoolean("animateGifAvatars", false)
.getBoolean("animateGifAvatars", false)
loadAvatar(account.profilePictureUrl, avatar, avatarRadius, animateAvatar) loadAvatar(account.profilePictureUrl, avatar, avatarRadius, animateAvatar)

View File

@ -22,7 +22,6 @@ public class AccountViewHolder extends RecyclerView.ViewHolder {
private ImageView avatarInset; private ImageView avatarInset;
private String accountId; private String accountId;
private boolean showBotOverlay; private boolean showBotOverlay;
private boolean animateAvatar;
public AccountViewHolder(View itemView) { public AccountViewHolder(View itemView) {
super(itemView); super(itemView);
@ -32,15 +31,14 @@ public class AccountViewHolder extends RecyclerView.ViewHolder {
avatarInset = itemView.findViewById(R.id.account_avatar_inset); avatarInset = itemView.findViewById(R.id.account_avatar_inset);
SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(itemView.getContext()); SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(itemView.getContext());
showBotOverlay = sharedPrefs.getBoolean("showBotOverlay", true); showBotOverlay = sharedPrefs.getBoolean("showBotOverlay", true);
animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false);
} }
public void setupWithAccount(Account account) { public void setupWithAccount(Account account, boolean animateAvatar, boolean animateEmojis) {
accountId = account.getId(); accountId = account.getId();
String format = username.getContext().getString(R.string.status_username_format); String format = username.getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.getUsername()); String formattedUsername = String.format(format, account.getUsername());
username.setText(formattedUsername); username.setText(formattedUsername);
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName); CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis);
displayName.setText(emojifiedName); displayName.setText(emojifiedName);
int avatarRadius = avatar.getContext().getResources() int avatarRadius = avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_48dp); .getDimensionPixelSize(R.dimen.avatar_radius_48dp);

View File

@ -34,8 +34,8 @@ import com.keylesspalace.tusky.util.ImageLoadingHelper;
public class BlocksAdapter extends AccountAdapter { public class BlocksAdapter extends AccountAdapter {
public BlocksAdapter(AccountActionListener accountActionListener) { public BlocksAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener); super(accountActionListener, animateAvatar, animateEmojis);
} }
@NonNull @NonNull
@ -60,7 +60,7 @@ public class BlocksAdapter extends AccountAdapter {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
BlockedUserViewHolder holder = (BlockedUserViewHolder) viewHolder; BlockedUserViewHolder holder = (BlockedUserViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position)); holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis);
holder.setupActionListener(accountActionListener); holder.setupActionListener(accountActionListener);
} }
} }
@ -71,7 +71,6 @@ public class BlocksAdapter extends AccountAdapter {
private TextView displayName; private TextView displayName;
private ImageButton unblock; private ImageButton unblock;
private String id; private String id;
private boolean animateAvatar;
BlockedUserViewHolder(View itemView) { BlockedUserViewHolder(View itemView) {
super(itemView); super(itemView);
@ -79,14 +78,12 @@ public class BlocksAdapter extends AccountAdapter {
username = itemView.findViewById(R.id.blocked_user_username); username = itemView.findViewById(R.id.blocked_user_username);
displayName = itemView.findViewById(R.id.blocked_user_display_name); displayName = itemView.findViewById(R.id.blocked_user_display_name);
unblock = itemView.findViewById(R.id.blocked_user_unblock); unblock = itemView.findViewById(R.id.blocked_user_unblock);
animateAvatar = PreferenceManager.getDefaultSharedPreferences(itemView.getContext())
.getBoolean("animateGifAvatars", false);
} }
void setupWithAccount(Account account) { void setupWithAccount(Account account, boolean animateAvatar, boolean animateEmojis) {
id = account.getId(); id = account.getId();
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName); CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis);
displayName.setText(emojifiedName); displayName.setText(emojifiedName);
String format = username.getContext().getString(R.string.status_username_format); String format = username.getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.getUsername()); String formattedUsername = String.format(format, account.getUsername());

View File

@ -27,8 +27,8 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener;
/** Both for follows and following lists. */ /** Both for follows and following lists. */
public class FollowAdapter extends AccountAdapter { public class FollowAdapter extends AccountAdapter {
public FollowAdapter(AccountActionListener accountActionListener) { public FollowAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener); super(accountActionListener, animateAvatar, animateEmojis);
} }
@NonNull @NonNull
@ -53,7 +53,7 @@ public class FollowAdapter extends AccountAdapter {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
AccountViewHolder holder = (AccountViewHolder) viewHolder; AccountViewHolder holder = (AccountViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position)); holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis);
holder.setupActionListener(accountActionListener); holder.setupActionListener(accountActionListener);
} }
} }

View File

@ -10,27 +10,24 @@ import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
import kotlinx.android.synthetic.main.item_follow_request_notification.view.* import kotlinx.android.synthetic.main.item_follow_request_notification.view.*
internal class FollowRequestViewHolder(itemView: View, private val showHeader: Boolean) : RecyclerView.ViewHolder(itemView) { internal class FollowRequestViewHolder(
itemView: View,
private val showHeader: Boolean) : RecyclerView.ViewHolder(itemView) {
private var id: String? = null private var id: String? = null
private val animateAvatar: Boolean = PreferenceManager.getDefaultSharedPreferences(itemView.context)
.getBoolean("animateGifAvatars", false)
fun setupWithAccount(account: Account) { fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) {
id = account.id id = account.id
val wrappedName = account.name.unicodeWrap() val wrappedName = account.name.unicodeWrap()
val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView) val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis)
itemView.displayNameTextView.text = emojifiedName itemView.displayNameTextView.text = emojifiedName
if (showHeader) { if (showHeader) {
val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName) val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName)
itemView.notificationTextView?.text = SpannableStringBuilder(wholeMessage).apply { itemView.notificationTextView?.text = SpannableStringBuilder(wholeMessage).apply {
setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}.emojify(account.emojis, itemView) }.emojify(account.emojis, itemView, animateEmojis)
} }
itemView.notificationTextView?.visible(showHeader) itemView.notificationTextView?.visible(showHeader)
val format = itemView.context.getString(R.string.status_username_format) val format = itemView.context.getString(R.string.status_username_format)

View File

@ -27,8 +27,8 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener;
public class FollowRequestsAdapter extends AccountAdapter { public class FollowRequestsAdapter extends AccountAdapter {
public FollowRequestsAdapter(AccountActionListener accountActionListener) { public FollowRequestsAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener); super(accountActionListener, animateAvatar, animateEmojis);
} }
@NonNull @NonNull
@ -53,7 +53,7 @@ public class FollowRequestsAdapter extends AccountAdapter {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position)); holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis);
holder.setupActionListener(accountActionListener); holder.setupActionListener(accountActionListener);
} }
} }

View File

@ -23,8 +23,8 @@ import java.util.HashMap;
public class MutesAdapter extends AccountAdapter { public class MutesAdapter extends AccountAdapter {
private HashMap<String, Boolean> mutingNotificationsMap; private HashMap<String, Boolean> mutingNotificationsMap;
public MutesAdapter(AccountActionListener accountActionListener) { public MutesAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener); super(accountActionListener, animateAvatar, animateEmojis);
mutingNotificationsMap = new HashMap<String, Boolean>(); mutingNotificationsMap = new HashMap<String, Boolean>();
} }
@ -51,7 +51,7 @@ public class MutesAdapter extends AccountAdapter {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) { if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
MutedUserViewHolder holder = (MutedUserViewHolder) viewHolder; MutedUserViewHolder holder = (MutedUserViewHolder) viewHolder;
Account account = accountList.get(position); Account account = accountList.get(position);
holder.setupWithAccount(account, mutingNotificationsMap.get(account.getId())); holder.setupWithAccount(account, mutingNotificationsMap.get(account.getId()), animateAvatar, animateEmojis);
holder.setupActionListener(accountActionListener); holder.setupActionListener(accountActionListener);
} }
} }
@ -73,7 +73,6 @@ public class MutesAdapter extends AccountAdapter {
private ImageButton unmute; private ImageButton unmute;
private ImageButton muteNotifications; private ImageButton muteNotifications;
private String id; private String id;
private boolean animateAvatar;
private boolean notifications; private boolean notifications;
MutedUserViewHolder(View itemView) { MutedUserViewHolder(View itemView) {
@ -83,13 +82,11 @@ public class MutesAdapter extends AccountAdapter {
displayName = itemView.findViewById(R.id.muted_user_display_name); displayName = itemView.findViewById(R.id.muted_user_display_name);
unmute = itemView.findViewById(R.id.muted_user_unmute); unmute = itemView.findViewById(R.id.muted_user_unmute);
muteNotifications = itemView.findViewById(R.id.muted_user_mute_notifications); muteNotifications = itemView.findViewById(R.id.muted_user_mute_notifications);
animateAvatar = PreferenceManager.getDefaultSharedPreferences(itemView.getContext())
.getBoolean("animateGifAvatars", false);
} }
void setupWithAccount(Account account, Boolean mutingNotifications) { void setupWithAccount(Account account, Boolean mutingNotifications, boolean animateAvatar, boolean animateEmojis) {
id = account.getId(); id = account.getId();
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName); CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis);
displayName.setText(emojifiedName); displayName.setText(emojifiedName);
String format = username.getContext().getString(R.string.status_username_format); String format = username.getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.getUsername()); String formattedUsername = String.format(format, account.getUsername());

View File

@ -236,7 +236,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case VIEW_TYPE_FOLLOW_REQUEST: { case VIEW_TYPE_FOLLOW_REQUEST: {
if (payloadForHolder == null) { if (payloadForHolder == null) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(concreteNotificaton.getAccount()); holder.setupWithAccount(concreteNotificaton.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis());
holder.setupActionListener(accountActionListener); holder.setupActionListener(accountActionListener);
} }
} }
@ -260,6 +260,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
CardViewMode.NONE, CardViewMode.NONE,
statusDisplayOptions.confirmReblogs(), statusDisplayOptions.confirmReblogs(),
statusDisplayOptions.hideStats(), statusDisplayOptions.hideStats(),
statusDisplayOptions.animateEmojis(),
statusDisplayOptions.quoteEnabled() statusDisplayOptions.quoteEnabled()
); );
} }
@ -341,13 +342,17 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
String format = context.getString(R.string.notification_follow_format); String format = context.getString(R.string.notification_follow_format);
String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
String wholeMessage = String.format(format, wrappedDisplayName); String wholeMessage = String.format(format, wrappedDisplayName);
CharSequence emojifiedMessage = CustomEmojiHelper.emojify(wholeMessage, account.getEmojis(), message); CharSequence emojifiedMessage = CustomEmojiHelper.emojify(
wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis()
);
message.setText(emojifiedMessage); message.setText(emojifiedMessage);
String username = context.getString(R.string.status_username_format, account.getUsername()); String username = context.getString(R.string.status_username_format, account.getUsername());
usernameView.setText(username); usernameView.setText(username);
CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(wrappedDisplayName, account.getEmojis(), usernameView); CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify(
wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis()
);
displayNameView.setText(emojifiedDisplayName); displayNameView.setText(emojifiedDisplayName);
@ -433,7 +438,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
} }
private void setDisplayName(String name, List<Emoji> emojis) { private void setDisplayName(String name, List<Emoji> emojis) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName); CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis());
displayName.setText(emojifiedName); displayName.setText(emojifiedName);
} }
@ -527,7 +532,9 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage); final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage);
str.setSpan(new StyleSpan(Typeface.BOLD), 0, displayName.length(), str.setSpan(new StyleSpan(Typeface.BOLD), 0, displayName.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
CharSequence emojifiedText = CustomEmojiHelper.emojify(str, notificationViewData.getAccount().getEmojis(), message); CharSequence emojifiedText = CustomEmojiHelper.emojify(
str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis()
);
message.setText(emojifiedText); message.setText(emojifiedText);
if (statusViewData != null) { if (statusViewData != null) {
@ -650,11 +657,17 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
statusContent.setFilters(NO_INPUT_FILTER); statusContent.setFilters(NO_INPUT_FILTER);
} }
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, statusContent); CharSequence emojifiedText = CustomEmojiHelper.emojify(
content, emojis, statusContent, statusDisplayOptions.animateEmojis()
);
LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener); LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getMentions(), listener);
CharSequence emojifiedContentWarning = CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify(
CustomEmojiHelper.emojify(statusViewData.getSpoilerText(), statusViewData.getStatusEmojis(), contentWarningDescriptionTextView); statusViewData.getSpoilerText(),
statusViewData.getStatusEmojis(),
contentWarningDescriptionTextView,
statusDisplayOptions.animateEmojis()
);
contentWarningDescriptionTextView.setText(emojifiedContentWarning); contentWarningDescriptionTextView.setText(emojifiedContentWarning);
setQuoteContainer(statusViewData.getQuote(), listener, statusDisplayOptions); setQuoteContainer(statusViewData.getQuote(), listener, statusDisplayOptions);

View File

@ -38,6 +38,7 @@ class PollAdapter: RecyclerView.Adapter<PollViewHolder>() {
private var mode = RESULT private var mode = RESULT
private var emojis: List<Emoji> = emptyList() private var emojis: List<Emoji> = emptyList()
private var resultClickListener: View.OnClickListener? = null private var resultClickListener: View.OnClickListener? = null
private var animateEmojis = false
fun setup( fun setup(
options: List<PollOptionViewData>, options: List<PollOptionViewData>,
@ -45,13 +46,15 @@ class PollAdapter: RecyclerView.Adapter<PollViewHolder>() {
votersCount: Int?, votersCount: Int?,
emojis: List<Emoji>, emojis: List<Emoji>,
mode: Int, mode: Int,
resultClickListener: View.OnClickListener?) { resultClickListener: View.OnClickListener?,
animateEmojis: Boolean) {
this.pollOptions = options this.pollOptions = options
this.voteCount = voteCount this.voteCount = voteCount
this.votersCount = votersCount this.votersCount = votersCount
this.emojis = emojis this.emojis = emojis
this.mode = mode this.mode = mode
this.resultClickListener = resultClickListener this.resultClickListener = resultClickListener
this.animateEmojis = animateEmojis
notifyDataSetChanged() notifyDataSetChanged()
} }
@ -81,7 +84,7 @@ class PollAdapter: RecyclerView.Adapter<PollViewHolder>() {
RESULT -> { RESULT -> {
val percent = calculatePercent(option.votesCount, votersCount, voteCount) val percent = calculatePercent(option.votesCount, votersCount, voteCount)
val emojifiedPollOptionText = buildDescription(option.title, percent, holder.resultTextView.context) val emojifiedPollOptionText = buildDescription(option.title, percent, holder.resultTextView.context)
.emojify(emojis, holder.resultTextView) .emojify(emojis, holder.resultTextView, animateEmojis)
holder.resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText) holder.resultTextView.text = EmojiCompat.get().process(emojifiedPollOptionText)
val level = percent * 100 val level = percent * 100
@ -90,7 +93,7 @@ class PollAdapter: RecyclerView.Adapter<PollViewHolder>() {
holder.resultTextView.setOnClickListener(resultClickListener) holder.resultTextView.setOnClickListener(resultClickListener)
} }
SINGLE -> { SINGLE -> {
val emojifiedPollOptionText = option.title.emojify(emojis, holder.radioButton) val emojifiedPollOptionText = option.title.emojify(emojis, holder.radioButton, animateEmojis)
holder.radioButton.text = EmojiCompat.get().process(emojifiedPollOptionText) holder.radioButton.text = EmojiCompat.get().process(emojifiedPollOptionText)
holder.radioButton.isChecked = option.selected holder.radioButton.isChecked = option.selected
holder.radioButton.setOnClickListener { holder.radioButton.setOnClickListener {
@ -101,7 +104,7 @@ class PollAdapter: RecyclerView.Adapter<PollViewHolder>() {
} }
} }
MULTIPLE -> { MULTIPLE -> {
val emojifiedPollOptionText = option.title.emojify(emojis, holder.checkBox) val emojifiedPollOptionText = option.title.emojify(emojis, holder.checkBox, animateEmojis)
holder.checkBox.text = EmojiCompat.get().process(emojifiedPollOptionText) holder.checkBox.text = EmojiCompat.get().process(emojifiedPollOptionText)
holder.checkBox.isChecked = option.selected holder.checkBox.isChecked = option.selected
holder.checkBox.setOnCheckedChangeListener { _, isChecked -> holder.checkBox.setOnCheckedChangeListener { _, isChecked ->

View File

@ -190,8 +190,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected abstract int getMediaPreviewHeight(Context context); protected abstract int getMediaPreviewHeight(Context context);
protected void setDisplayName(String name, List<Emoji> customEmojis) { protected void setDisplayName(String name, List<Emoji> customEmojis, StatusDisplayOptions statusDisplayOptions) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(name, customEmojis, displayName); CharSequence emojifiedName = CustomEmojiHelper.emojify(
name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
);
displayName.setText(emojifiedName); displayName.setText(emojifiedName);
} }
@ -215,7 +217,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
final StatusActionListener listener) { final StatusActionListener listener) {
boolean sensitive = !TextUtils.isEmpty(spoilerText); boolean sensitive = !TextUtils.isEmpty(spoilerText);
if (sensitive) { if (sensitive) {
CharSequence emojiSpoiler = CustomEmojiHelper.emojify(spoilerText, emojis, contentWarningDescription); CharSequence emojiSpoiler = CustomEmojiHelper.emojify(
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
);
contentWarningDescription.setText(emojiSpoiler); contentWarningDescription.setText(emojiSpoiler);
contentWarningDescription.setVisibility(View.VISIBLE); contentWarningDescription.setVisibility(View.VISIBLE);
contentWarningButton.setVisibility(View.VISIBLE); contentWarningButton.setVisibility(View.VISIBLE);
@ -254,7 +258,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener) { final StatusActionListener listener) {
if (expanded) { if (expanded) {
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content); CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener); LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener);
for (int i = 0; i < mediaLabels.length; ++i) { for (int i = 0; i < mediaLabels.length; ++i) {
updateMediaLabel(i, sensitive, expanded); updateMediaLabel(i, sensitive, expanded);
@ -613,7 +617,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
@DrawableRes @DrawableRes
private static int getLabelIcon(Attachment.Type type) { private static int getLabelIcon(Attachment.Type type) {
switch (type) { switch (type) {
default:
case IMAGE: case IMAGE:
return R.drawable.ic_photo_24dp; return R.drawable.ic_photo_24dp;
case GIFV: case GIFV:
@ -621,6 +624,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
return R.drawable.ic_videocam_24dp; return R.drawable.ic_videocam_24dp;
case AUDIO: case AUDIO:
return R.drawable.ic_music_box_24dp; return R.drawable.ic_music_box_24dp;
default:
return R.drawable.ic_attach_file_24dp;
} }
} }
@ -814,7 +819,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
StatusDisplayOptions statusDisplayOptions, StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) { @Nullable Object payloads) {
if (payloads == null) { if (payloads == null) {
setDisplayName(status.getUserFullName(), status.getAccountEmojis()); setDisplayName(status.getUserFullName(), status.getAccountEmojis(), statusDisplayOptions);
setUsername(status.getNickname()); setUsername(status.getNickname());
setCreatedAt(status.getCreatedAt(), statusDisplayOptions); setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
setStatusVisibility(status.getVisibility()); setStatusVisibility(status.getVisibility());
@ -826,7 +831,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setBookmarked(status.isBookmarked()); setBookmarked(status.isBookmarked());
List<Attachment> attachments = status.getAttachments(); List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.isSensitive(); boolean sensitive = status.isSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && !hasAudioAttachment(attachments)) { if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
if (attachments.size() == 0) { if (attachments.size() == 0) {
@ -876,13 +881,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
} }
} }
protected static boolean hasAudioAttachment(List<Attachment> attachments) { protected static boolean hasPreviewableAttachment(List<Attachment> attachments) {
for (Attachment attachment : attachments) { for (Attachment attachment : attachments) {
if (attachment.getType() == Attachment.Type.AUDIO) { if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
return true; return false;
} }
} }
return false; return true;
} }
private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status,
@ -1035,12 +1040,28 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
listener.onViewThread(position); listener.onViewThread(position);
} }
}; };
pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, PollAdapter.RESULT, viewThreadListener); pollAdapter.setup(
poll.getOptions(),
poll.getVotesCount(),
poll.getVotersCount(),
emojis,
PollAdapter.RESULT,
viewThreadListener,
statusDisplayOptions.animateEmojis()
);
pollButton.setVisibility(View.GONE); pollButton.setVisibility(View.GONE);
} else { } else {
// voting possible // voting possible
pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE, null); pollAdapter.setup(
poll.getOptions(),
poll.getVotesCount(),
poll.getVotersCount(),
emojis,
poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE,
null,
statusDisplayOptions.animateEmojis()
);
pollButton.setVisibility(View.VISIBLE); pollButton.setVisibility(View.VISIBLE);

View File

@ -27,8 +27,10 @@ import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;
import at.connyduck.sparkbutton.helpers.Utils; import at.connyduck.sparkbutton.helpers.Utils;
@ -64,7 +66,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
if (rebloggedByDisplayName == null) { if (rebloggedByDisplayName == null) {
hideStatusInfo(); hideStatusInfo();
} else { } else {
setRebloggedByDisplayName(rebloggedByDisplayName); setRebloggedByDisplayName(rebloggedByDisplayName, status, statusDisplayOptions);
statusInfo.setOnClickListener(v -> listener.onOpenReblog(getAdapterPosition())); statusInfo.setOnClickListener(v -> listener.onOpenReblog(getAdapterPosition()));
} }
@ -73,10 +75,16 @@ public class StatusViewHolder extends StatusBaseViewHolder {
} }
private void setRebloggedByDisplayName(final String name) { private void setRebloggedByDisplayName(final CharSequence name,
final StatusViewData.Concrete status,
final StatusDisplayOptions statusDisplayOptions) {
Context context = statusInfo.getContext(); Context context = statusInfo.getContext();
String boostedText = context.getString(R.string.status_boosted_format, name); CharSequence wrappedName = StringUtils.unicodeWrap(name);
statusInfo.setText(boostedText); CharSequence boostedText = context.getString(R.string.status_boosted_format, wrappedName);
CharSequence emojifiedText = CustomEmojiHelper.emojify(
boostedText, status.getRebloggedByAccountEmojis(), statusInfo, statusDisplayOptions.animateEmojis()
);
statusInfo.setText(emojifiedText);
statusInfo.setVisibility(View.VISIBLE); statusInfo.setVisibility(View.VISIBLE);
} }

View File

@ -67,6 +67,7 @@ public final class TimelineAdapter extends RecyclerView.Adapter {
statusDisplayOptions.cardViewMode(), statusDisplayOptions.cardViewMode(),
statusDisplayOptions.confirmReblogs(), statusDisplayOptions.confirmReblogs(),
statusDisplayOptions.hideStats(), statusDisplayOptions.hideStats(),
statusDisplayOptions.animateEmojis(),
statusDisplayOptions.quoteEnabled() statusDisplayOptions.quoteEnabled()
); );
} }

View File

@ -42,7 +42,8 @@ interface AnnouncementActionListener: LinkListener {
class AnnouncementAdapter( class AnnouncementAdapter(
private var items: List<Announcement> = emptyList(), private var items: List<Announcement> = emptyList(),
private val listener: AnnouncementActionListener, private val listener: AnnouncementActionListener,
private val wellbeingEnabled: Boolean = false private val wellbeingEnabled: Boolean = false,
private val animateEmojis: Boolean = false
) : RecyclerView.Adapter<AnnouncementAdapter.AnnouncementViewHolder>() { ) : RecyclerView.Adapter<AnnouncementAdapter.AnnouncementViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AnnouncementViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AnnouncementViewHolder {
@ -99,7 +100,8 @@ class AnnouncementAdapter(
reaction.staticUrl ?: "", reaction.staticUrl ?: "",
null null
)), )),
this this,
animateEmojis
) )
isChecked = reaction.me isChecked = reaction.me

View File

@ -19,7 +19,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.PopupWindow import android.widget.PopupWindow
import androidx.activity.viewModels import androidx.activity.viewModels
@ -83,8 +82,9 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false) val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false)
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
adapter = AnnouncementAdapter(emptyList(), this, wellbeingEnabled) adapter = AnnouncementAdapter(emptyList(), this, wellbeingEnabled, animateEmojis)
announcementsList.adapter = adapter announcementsList.adapter = adapter
@ -123,16 +123,6 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
progressBar.show() progressBar.show()
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun refreshAnnouncements() { private fun refreshAnnouncements() {
viewModel.load() viewModel.load()
swipeRefreshLayout.isRefreshing = true swipeRefreshLayout.isRefreshing = true

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky.components.compose
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.app.ProgressDialog import android.app.ProgressDialog
import android.app.TimePickerDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
@ -32,7 +31,6 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.provider.MediaStore import android.provider.MediaStore
import android.text.TextUtils
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
@ -60,7 +58,6 @@ import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.adapter.EmojiAdapter import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
@ -68,13 +65,16 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog import com.keylesspalace.tusky.components.compose.dialog.makeCaptionDialog
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.* import com.keylesspalace.tusky.util.*
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
@ -86,7 +86,6 @@ import java.io.File
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
import kotlin.collections.ArrayList
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -96,7 +95,7 @@ class ComposeActivity : BaseActivity(),
OnEmojiSelectedListener, OnEmojiSelectedListener,
Injectable, Injectable,
InputConnectionCompat.OnCommitContentListener, InputConnectionCompat.OnCommitContentListener,
TimePickerDialog.OnTimeSetListener { ComposeScheduleView.OnTimeSetListener {
@Inject @Inject
lateinit var viewModelFactory: ViewModelFactory lateinit var viewModelFactory: ViewModelFactory
@ -111,10 +110,10 @@ class ComposeActivity : BaseActivity(),
// this only exists when a status is trying to be sent, but uploads are still occurring // this only exists when a status is trying to be sent, but uploads are still occurring
private var finishingUploadDialog: ProgressDialog? = null private var finishingUploadDialog: ProgressDialog? = null
private var photoUploadUri: Uri? = null private var photoUploadUri: Uri? = null
@VisibleForTesting @VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
private var composeOptions: ComposeOptions? = null
private val viewModel: ComposeViewModel by viewModels { viewModelFactory } private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
private val maxUploadMediaNumber = 4 private val maxUploadMediaNumber = 4
@ -155,28 +154,28 @@ class ComposeActivity : BaseActivity(),
/* If the composer is started up as a reply to another post, override the "starting" state /* If the composer is started up as a reply to another post, override the "starting" state
* based on what the intent from the reply request passes. */ * based on what the intent from the reply request passes. */
if (intent != null) {
this.composeOptions = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA) val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor) viewModel.setup(composeOptions)
setupQuoteView(composeOptions?.quoteStatusAuthor) setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent)
val tootText = composeOptions?.tootText setupQuoteView(composeOptions?.quoteStatusAuthor, composeOptions?.quoteStatusContent)
if (!tootText.isNullOrEmpty()) { val tootText = composeOptions?.tootText
composeEditField.setText(tootText) if (!tootText.isNullOrEmpty()) {
} composeEditField.setText(tootText)
} }
if (loadInstanceData(preferences)) { if (loadInstanceData(preferences, composeOptions?.tootRightNow == true)) {
viewModel.loadInstanceDataFromNetwork() viewModel.loadInstanceDataFromNetwork()
} else { } else {
viewModel.loadInstanceDataFromCache() viewModel.loadInstanceDataFromCache()
} }
if (!TextUtils.isEmpty(composeOptions?.scheduledAt)) { if (!composeOptions?.scheduledAt.isNullOrEmpty()) {
composeScheduleView.setDateTime(composeOptions?.scheduledAt) composeScheduleView.setDateTime(composeOptions?.scheduledAt)
} }
setupComposeField(viewModel.startingText) setupComposeField(preferences, viewModel.startingText)
setupDefaultTagViews(preferences) setupDefaultTagViews(preferences)
setupContentWarningField(composeOptions?.contentWarning) setupContentWarningField(composeOptions?.contentWarning)
setupPollView() setupPollView()
@ -188,8 +187,8 @@ class ComposeActivity : BaseActivity(),
} }
} }
private fun loadInstanceData(preferences: SharedPreferences): Boolean { private fun loadInstanceData(preferences: SharedPreferences, tootRightNow: Boolean): Boolean {
if (composeOptions?.tootRightNow == true) { if (tootRightNow) {
return false // from Quick Toot return false // from Quick Toot
} }
if (!preferences.getBoolean("limitedBandwidthActive", false)) { if (!preferences.getBoolean("limitedBandwidthActive", false)) {
@ -214,38 +213,24 @@ class ComposeActivity : BaseActivity(),
return false return false
} }
private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) { private fun applyShareIntent(intent: Intent, savedInstanceState: Bundle?) {
if (intent != null && savedInstanceState == null) { if (savedInstanceState == null) {
/* Get incoming images being sent through a share action from another app. Only do this /* Get incoming images being sent through a share action from another app. Only do this
* when savedInstanceState is null, otherwise both the images from the intent and the * when savedInstanceState is null, otherwise both the images from the intent and the
* instance state will be re-queued. */ * instance state will be re-queued. */
val type = intent.type intent.type?.also { type ->
if (type != null) {
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) { if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
val uriList = ArrayList<Uri>() when (intent.action) {
if (intent.action != null) { Intent.ACTION_SEND -> {
when (intent.action) { intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uri ->
Intent.ACTION_SEND -> { pickMedia(uri)
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM) }
if (uri != null) { }
uriList.add(uri) Intent.ACTION_SEND_MULTIPLE -> {
} intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.forEach { uri ->
} pickMedia(uri)
Intent.ACTION_SEND_MULTIPLE -> {
val list = intent.getParcelableArrayListExtra<Uri>(
Intent.EXTRA_STREAM)
if (list != null) {
for (uri in list) {
if (uri != null) {
uriList.add(uri)
}
}
}
} }
} }
}
for (uri in uriList) {
pickMedia(uri)
} }
} else if (type == "text/plain" && intent.action == Intent.ACTION_SEND) { } else if (type == "text/plain" && intent.action == Intent.ACTION_SEND) {
@ -263,13 +248,16 @@ class ComposeActivity : BaseActivity(),
val left = min(start, end) val left = min(start, end)
val right = max(start, end) val right = max(start, end)
composeEditField.text.replace(left, right, shareBody, 0, shareBody.length) composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
// move edittext cursor to first when shareBody parsed
composeEditField.text.insert(0, "\n")
composeEditField.setSelection(0)
} }
} }
} }
} }
} }
private fun setupReplyViews(replyingStatusAuthor: String?) { private fun setupReplyViews(replyingStatusAuthor: String?, replyingStatusContent: String?) {
if (replyingStatusAuthor != null) { if (replyingStatusAuthor != null) {
composeReplyView.show() composeReplyView.show()
composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor) composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
@ -293,10 +281,10 @@ class ComposeActivity : BaseActivity(),
} }
} }
} }
composeOptions?.replyingStatusContent?.let { composeReplyContentView.text = it } replyingStatusContent?.let { composeReplyContentView.text = it }
} }
private fun setupQuoteView(quoteStatusAuthor: String?) { private fun setupQuoteView(quoteStatusAuthor: String?, quoteStatusContent: String?) {
if (quoteStatusAuthor != null) { if (quoteStatusAuthor != null) {
composeQuoteView.show() composeQuoteView.show()
composeQuoteView.text = getString(R.string.quote_to, quoteStatusAuthor) composeQuoteView.text = getString(R.string.quote_to, quoteStatusAuthor)
@ -320,7 +308,7 @@ class ComposeActivity : BaseActivity(),
} }
} }
} }
composeOptions?.quoteStatusContent?.let { composeQuoteContentView.text = it } quoteStatusContent?.let { composeQuoteContentView.text = it }
} }
private fun setupContentWarningField(startingContentWarning: String?) { private fun setupContentWarningField(startingContentWarning: String?) {
@ -330,13 +318,18 @@ class ComposeActivity : BaseActivity(),
composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() } composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
} }
private fun setupComposeField(startingText: String?) { private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
composeEditField.setOnCommitContentListener(this) composeEditField.setOnCommitContentListener(this)
composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
composeEditField.setAdapter( composeEditField.setAdapter(
ComposeAutoCompleteAdapter(this)) ComposeAutoCompleteAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
)
composeEditField.setTokenizer(ComposeTokenizer()) composeEditField.setTokenizer(ComposeTokenizer())
composeEditField.setText(startingText) composeEditField.setText(startingText)
@ -446,6 +439,7 @@ class ComposeActivity : BaseActivity(),
composeHideMediaButton.setOnClickListener { toggleHideMedia() } composeHideMediaButton.setOnClickListener { toggleHideMedia() }
composeScheduleButton.setOnClickListener { onScheduleClick() } composeScheduleButton.setOnClickListener { onScheduleClick() }
composeScheduleView.setResetOnClickListener { resetSchedule() } composeScheduleView.setResetOnClickListener { resetSchedule() }
composeScheduleView.setListener(this)
atButton.setOnClickListener { atButtonClicked() } atButton.setOnClickListener { atButtonClicked() }
hashButton.setOnClickListener { hashButtonClicked() } hashButton.setOnClickListener { hashButtonClicked() }
@ -743,7 +737,6 @@ class ComposeActivity : BaseActivity(),
} }
} }
private fun removePoll() { private fun removePoll() {
viewModel.poll.value = null viewModel.poll.value = null
pollPreview.hide() pollPreview.hide()
@ -934,22 +927,22 @@ class ComposeActivity : BaseActivity(),
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent) super.onActivityResult(requestCode, resultCode, intent)
if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) { if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_PICK_RESULT && intent != null) {
if(intent.data != null){ if (intent.data != null) {
// Single media, upload it and done. // Single media, upload it and done.
pickMedia(intent.data!!) pickMedia(intent.data!!)
}else if(intent.clipData != null){ } else if (intent.clipData != null) {
val clipData = intent.clipData!! val clipData = intent.clipData!!
val count = clipData.itemCount val count = clipData.itemCount
if(mediaCount + count > maxUploadMediaNumber){ if (mediaCount + count > maxUploadMediaNumber) {
// check if exist media + upcoming media > 4, then prob error message. // check if exist media + upcoming media > 4, then prob error message.
Toast.makeText(this, getString(R.string.error_upload_max_media_reached, maxUploadMediaNumber), Toast.LENGTH_SHORT).show() Toast.makeText(this, getString(R.string.error_upload_max_media_reached, maxUploadMediaNumber), Toast.LENGTH_SHORT).show()
}else{ } else {
// if not grater then 4, upload all multiple media. // if not grater then 4, upload all multiple media.
for (i in 0 until count) { for (i in 0 until count) {
val imageUri = clipData.getItemAt(i).getUri() val imageUri = clipData.getItemAt(i).getUri()
pickMedia(imageUri) pickMedia(imageUri)
}
} }
}
} }
} else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) { } else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
pickMedia(photoUploadUri!!) pickMedia(photoUploadUri!!)
@ -1099,9 +1092,8 @@ class ComposeActivity : BaseActivity(),
} }
} }
override fun onTimeSet(view: TimePicker, hourOfDay: Int, minute: Int) { override fun onTimeSet(time: String) {
composeScheduleView.onTimeSet(hourOfDay, minute) viewModel.updateScheduledAt(time)
viewModel.updateScheduledAt(composeScheduleView.time)
if (verifyScheduledTime()) { if (verifyScheduledTime()) {
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
} else { } else {
@ -1117,8 +1109,9 @@ class ComposeActivity : BaseActivity(),
@Parcelize @Parcelize
data class ComposeOptions( data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin // Let's keep fields var until all consumers are Kotlin
var scheduledTootUid: String? = null, var scheduledTootId: String? = null,
var savedTootUid: Int? = null, var savedTootUid: Int? = null,
var draftId: Int? = null,
var tootText: String? = null, var tootText: String? = null,
var mediaUrls: List<String>? = null, var mediaUrls: List<String>? = null,
var mediaDescriptions: List<String>? = null, var mediaDescriptions: List<String>? = null,
@ -1133,6 +1126,7 @@ class ComposeActivity : BaseActivity(),
var replyingStatusAuthor: String? = null, var replyingStatusAuthor: String? = null,
var replyingStatusContent: String? = null, var replyingStatusContent: String? = null,
var mediaAttachments: List<Attachment>? = null, var mediaAttachments: List<Attachment>? = null,
var draftAttachments: List<DraftAttachment>? = null,
var scheduledAt: String? = null, var scheduledAt: String? = null,
var sensitive: Boolean? = null, var sensitive: Boolean? = null,
var poll: NewPoll? = null, var poll: NewPoll? = null,
@ -1166,7 +1160,6 @@ class ComposeActivity : BaseActivity(),
} }
} }
@JvmStatic
fun canHandleMimeType(mimeType: String?): Boolean { fun canHandleMimeType(mimeType: String?): Boolean {
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain") return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain")
} }

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not, * You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */ * see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter; package com.keylesspalace.tusky.components.compose;
import android.content.Context; import android.content.Context;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
@ -53,11 +53,15 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
private final ArrayList<AutocompleteResult> resultList; private final ArrayList<AutocompleteResult> resultList;
private final AutocompletionProvider autocompletionProvider; private final AutocompletionProvider autocompletionProvider;
private final boolean animateAvatar;
private final boolean animateEmojis;
public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider) { public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider, boolean animateAvatar, boolean animateEmojis) {
super(); super();
resultList = new ArrayList<>(); resultList = new ArrayList<>();
this.autocompletionProvider = autocompletionProvider; this.autocompletionProvider = autocompletionProvider;
this.animateAvatar = animateAvatar;
this.animateEmojis = animateEmojis;
} }
@Override @Override
@ -147,15 +151,12 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
); );
accountViewHolder.username.setText(formattedUsername); accountViewHolder.username.setText(formattedUsername);
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(),
account.getEmojis(), accountViewHolder.displayName); account.getEmojis(), accountViewHolder.displayName, animateEmojis);
accountViewHolder.displayName.setText(emojifiedName); accountViewHolder.displayName.setText(emojifiedName);
int avatarRadius = accountViewHolder.avatar.getContext().getResources() int avatarRadius = accountViewHolder.avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_42dp); .getDimensionPixelSize(R.dimen.avatar_radius_42dp);
boolean animateAvatar = PreferenceManager.getDefaultSharedPreferences(accountViewHolder.avatar.getContext())
.getBoolean("animateGifAvatars", false);
ImageLoadingHelper.loadAvatar( ImageLoadingHelper.loadAvatar(
account.getAvatar(), account.getAvatar(),
accountViewHolder.avatar, accountViewHolder.avatar,

View File

@ -21,8 +21,8 @@ import androidx.core.net.toUri
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.components.search.SearchType
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
@ -39,18 +39,12 @@ import io.reactivex.rxkotlin.Singles
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
/** class ComposeViewModel @Inject constructor(
* Throw when trying to add an image when video is already present or the other way around
*/
class VideoOrImageException : Exception()
class ComposeViewModel
@Inject constructor(
private val api: MastodonApi, private val api: MastodonApi,
private val accountManager: AccountManager, private val accountManager: AccountManager,
private val mediaUploader: MediaUploader, private val mediaUploader: MediaUploader,
private val serviceClient: ServiceClient, private val serviceClient: ServiceClient,
private val draftHelper: DraftHelper,
private val saveTootHelper: SaveTootHelper, private val saveTootHelper: SaveTootHelper,
private val db: AppDatabase private val db: AppDatabase
) : RxAwareViewModel() { ) : RxAwareViewModel() {
@ -59,7 +53,8 @@ class ComposeViewModel
private var replyingStatusContent: String? = null private var replyingStatusContent: String? = null
internal var startingText: String? = null internal var startingText: String? = null
private var savedTootUid: Int = 0 private var savedTootUid: Int = 0
private var scheduledTootUid: String? = null private var draftId: Int = 0
private var scheduledTootId: String? = null
private var startingContentWarning: String = "" private var startingContentWarning: String = ""
private var inReplyToId: String? = null private var inReplyToId: String? = null
private var quoteId: String? = null private var quoteId: String? = null
@ -84,10 +79,6 @@ class ComposeViewModel
val markMediaAsSensitive = val markMediaAsSensitive =
mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false) mutableLiveData(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
fun toggleMarkSensitive() {
this.markMediaAsSensitive.value = !this.markMediaAsSensitive.value!!
}
val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN) val statusVisibility = mutableLiveData(Status.Visibility.UNKNOWN)
val showContentWarning = mutableLiveData(false) val showContentWarning = mutableLiveData(false)
val setupComplete = mutableLiveData(false) val setupComplete = mutableLiveData(false)
@ -101,7 +92,7 @@ class ComposeViewModel
private val mediaToDisposable = mutableMapOf<Long, Disposable>() private val mediaToDisposable = mutableMapOf<Long, Disposable>()
private val isEditingScheduledToot get() = !scheduledTootUid.isNullOrEmpty() private val isEditingScheduledToot get() = !scheduledTootId.isNullOrEmpty()
fun loadInstanceDataFromNetwork() { fun loadInstanceDataFromNetwork() {
@ -143,7 +134,7 @@ class ComposeViewModel
.autoDispose() .autoDispose()
} }
fun pickMedia(uri: Uri): LiveData<Either<Throwable, QueuedMedia>> { 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 // 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). // the Activity goes away temporarily (like on screen rotation).
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>() val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>()
@ -155,7 +146,7 @@ class ComposeViewModel
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) { && mediaItems[0].type == QueuedMedia.Type.IMAGE) {
throw VideoOrImageException() throw VideoOrImageException()
} else { } else {
addMediaToQueue(type, uri, size) addMediaToQueue(type, uri, size, description)
} }
} }
.subscribe({ queuedMedia -> .subscribe({ queuedMedia ->
@ -167,12 +158,23 @@ class ComposeViewModel
return liveData return liveData
} }
private fun addMediaToQueue(type: QueuedMedia.Type, uri: Uri, mediaSize: Long): QueuedMedia { private fun addMediaToQueue(
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize) type: QueuedMedia.Type,
uri: Uri,
mediaSize: Long,
description: String? = null
): QueuedMedia {
val mediaItem = QueuedMedia(
localId = System.currentTimeMillis(),
uri = uri,
type = type,
mediaSize = mediaSize,
description = description
)
media.value = media.value!! + mediaItem media.value = media.value!! + mediaItem
mediaToDisposable[mediaItem.localId] = mediaUploader mediaToDisposable[mediaItem.localId] = mediaUploader
.uploadMedia(mediaItem) .uploadMedia(mediaItem)
.subscribe ({ event -> .subscribe({ event ->
val item = media.value?.find { it.localId == mediaItem.localId } val item = media.value?.find { it.localId == mediaItem.localId }
?: return@subscribe ?: return@subscribe
val newMediaItem = when (event) { val newMediaItem = when (event) {
@ -207,6 +209,10 @@ class ComposeViewModel
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId } media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
} }
fun toggleMarkSensitive() {
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true
}
fun didChange(content: String?, contentWarning: String?): Boolean { fun didChange(content: String?, contentWarning: String?): Boolean {
val textChanged = !(content.isNullOrEmpty() val textChanged = !(content.isNullOrEmpty()
@ -227,29 +233,37 @@ class ComposeViewModel
} }
fun deleteDraft() { fun deleteDraft() {
saveTootHelper.deleteDraft(this.savedTootUid) if (savedTootUid != 0) {
saveTootHelper.deleteDraft(savedTootUid)
}
if (draftId != 0) {
draftHelper.deleteDraftAndAttachments(draftId)
.subscribe()
}
} }
fun saveDraft(content: String, contentWarning: String) { fun saveDraft(content: String, contentWarning: String) {
val mediaUris = mutableListOf<String>()
val mediaDescriptions = mutableListOf<String?>() val mediaUris: MutableList<String> = mutableListOf()
for (item in media.value!!) { val mediaDescriptions: MutableList<String?> = mutableListOf()
media.value?.forEach { item ->
mediaUris.add(item.uri.toString()) mediaUris.add(item.uri.toString())
mediaDescriptions.add(item.description) mediaDescriptions.add(item.description)
} }
saveTootHelper.saveToot(
content, draftHelper.saveDraft(
contentWarning, draftId = draftId,
null, accountId = accountManager.activeAccount?.id!!,
mediaUris, inReplyToId = inReplyToId,
mediaDescriptions, content = content,
savedTootUid, contentWarning = contentWarning,
inReplyToId, sensitive = markMediaAsSensitive.value!!,
replyingStatusContent, visibility = statusVisibility.value!!,
replyingStatusAuthor, mediaUris = mediaUris,
statusVisibility.value!!, mediaDescriptions = mediaDescriptions,
poll.value poll = poll.value,
) failedToSend = false
).subscribe()
} }
/** /**
@ -263,7 +277,7 @@ class ComposeViewModel
): LiveData<Unit> { ): LiveData<Unit> {
val deletionObservable = if (isEditingScheduledToot) { val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootUid.toString()).toObservable().map { Unit } api.deleteScheduledStatus(scheduledTootId.toString()).toObservable().map { }
} else { } else {
just(Unit) just(Unit)
}.toLiveData() }.toLiveData()
@ -281,22 +295,22 @@ class ComposeViewModel
} }
val tootToSend = TootToSend( val tootToSend = TootToSend(
content, text = content,
spoilerText, warningText = spoilerText,
statusVisibility.value!!.serverString(), visibility = statusVisibility.value!!.serverString(),
mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!), sensitive = mediaUris.isNotEmpty() && (markMediaAsSensitive.value!! || showContentWarning.value!!),
mediaIds, mediaIds = mediaIds,
mediaUris.map { it.toString() }, mediaUris = mediaUris.map { it.toString() },
mediaDescriptions, mediaDescriptions = mediaDescriptions,
scheduledAt = scheduledAt.value, scheduledAt = scheduledAt.value,
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
poll = poll.value, poll = poll.value,
replyingStatusContent = null, replyingStatusContent = null,
replyingStatusAuthorUsername = null, replyingStatusAuthorUsername = null,
savedJsonUrls = null,
quoteId = quoteId, quoteId = quoteId,
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
savedTootUid = 0, savedTootUid = savedTootUid,
draftId = draftId,
idempotencyKey = randomAlphanumericString(16), idempotencyKey = randomAlphanumericString(16),
retries = 0 retries = 0
) )
@ -304,9 +318,7 @@ class ComposeViewModel
serviceClient.sendToot(tootToSend) serviceClient.sendToot(tootToSend)
} }
return combineLiveData(deletionObservable, sendObservable) { _, _ -> Unit } return combineLiveData(deletionObservable, sendObservable) { _, _ -> }
} }
fun updateDescription(localId: Long, description: String): LiveData<Boolean> { fun updateDescription(localId: Long, description: String): LiveData<Boolean> {
@ -337,7 +349,6 @@ class ComposeViewModel
return completedCaptioningLiveData return completedCaptioningLiveData
} }
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> { fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
when (token[0]) { when (token[0]) {
'@' -> { '@' -> {
@ -388,14 +399,12 @@ class ComposeViewModel
} }
} }
override fun onCleared() {
for (uploadDisposable in mediaToDisposable.values) {
uploadDisposable.dispose()
}
super.onCleared()
}
fun setup(composeOptions: ComposeActivity.ComposeOptions?) { fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
if (setupComplete.value == true) {
return
}
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
@ -403,6 +412,7 @@ class ComposeViewModel
preferredVisibility.num.coerceAtLeast(replyVisibility.num)) preferredVisibility.num.coerceAtLeast(replyVisibility.num))
inReplyToId = composeOptions?.inReplyToId inReplyToId = composeOptions?.inReplyToId
modifiedInitialState = composeOptions?.modifiedInitialState == true modifiedInitialState = composeOptions?.modifiedInitialState == true
quoteId = composeOptions?.quoteId quoteId = composeOptions?.quoteId
@ -418,10 +428,11 @@ class ComposeViewModel
} }
// recreate media list // recreate media list
// when coming from SavedTootActivity
val loadedDraftMediaUris = composeOptions?.mediaUrls val loadedDraftMediaUris = composeOptions?.mediaUrls
val loadedDraftMediaDescriptions: List<String?>? = composeOptions?.mediaDescriptions val loadedDraftMediaDescriptions: List<String?>? = composeOptions?.mediaDescriptions
val draftAttachments = composeOptions?.draftAttachments
if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) { if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) {
// when coming from SavedTootActivity
loadedDraftMediaUris.zip(loadedDraftMediaDescriptions) loadedDraftMediaUris.zip(loadedDraftMediaDescriptions)
.forEach { (uri, description) -> .forEach { (uri, description) ->
pickMedia(uri.toUri()).observeForever { errorOrItem -> pickMedia(uri.toUri()).observeForever { errorOrItem ->
@ -430,23 +441,24 @@ class ComposeViewModel
} }
} }
} }
} else if (draftAttachments != null) {
// when coming from DraftActivity
draftAttachments.forEach { attachment -> pickMedia(attachment.uri, attachment.description) }
} else composeOptions?.mediaAttachments?.forEach { a -> } else composeOptions?.mediaAttachments?.forEach { a ->
// when coming from redraft // when coming from redraft or ScheduledTootActivity
val mediaType = when (a.type) { val mediaType = when (a.type) {
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO
else -> QueuedMedia.Type.IMAGE
} }
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description) addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description)
} }
savedTootUid = composeOptions?.savedTootUid ?: 0 savedTootUid = composeOptions?.savedTootUid ?: 0
scheduledTootUid = composeOptions?.scheduledTootUid draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId
startingText = composeOptions?.tootText startingText = composeOptions?.tootText
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
startingVisibility = tootVisibility startingVisibility = tootVisibility
@ -466,7 +478,6 @@ class ComposeViewModel
} }
startingText = builder.toString() startingText = builder.toString()
scheduledAt.value = composeOptions?.scheduledAt scheduledAt.value = composeOptions?.scheduledAt
composeOptions?.sensitive?.let { markMediaAsSensitive.value = it } composeOptions?.sensitive?.let { markMediaAsSensitive.value = it }
@ -487,6 +498,13 @@ class ComposeViewModel
scheduledAt.value = newScheduledAt scheduledAt.value = newScheduledAt
} }
override fun onCleared() {
for (uploadDisposable in mediaToDisposable.values) {
uploadDisposable.dispose()
}
super.onCleared()
}
private companion object { private companion object {
const val TAG = "ComposeViewModel" const val TAG = "ComposeViewModel"
} }
@ -509,3 +527,8 @@ data class ComposeInstanceParams(
val pollMaxLength: Int, val pollMaxLength: Int,
val supportsScheduled: Boolean val supportsScheduled: Boolean
) )
/**
* Thrown when trying to add an image when video is already present or the other way around
*/
class VideoOrImageException : Exception()

View File

@ -173,7 +173,13 @@ class MediaUploaderImpl(
val body = MultipartBody.Part.createFormData("file", filename, fileBody) val body = MultipartBody.Part.createFormData("file", filename, fileBody)
val uploadDisposable = mastodonApi.uploadMedia(body) val description = if (media.description != null) {
MultipartBody.Part.createFormData("description", media.description)
} else {
null
}
val uploadDisposable = mastodonApi.uploadMedia(body, description)
.subscribe({ attachment -> .subscribe({ attachment ->
if (media.uri.scheme == "file") { if (media.uri.scheme == "file") {
media.uri.path?.let { media.uri.path?.let {

View File

@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.compose.view;
import android.content.Context; import android.content.Context;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.widget.Button; import android.widget.Button;
import android.widget.TextView; import android.widget.TextView;
@ -31,8 +30,9 @@ import androidx.core.content.ContextCompat;
import com.google.android.material.datepicker.CalendarConstraints; import com.google.android.material.datepicker.CalendarConstraints;
import com.google.android.material.datepicker.DateValidatorPointForward; import com.google.android.material.datepicker.DateValidatorPointForward;
import com.google.android.material.datepicker.MaterialDatePicker; import com.google.android.material.datepicker.MaterialDatePicker;
import com.google.android.material.timepicker.MaterialTimePicker;
import com.google.android.material.timepicker.TimeFormat;
import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.fragment.TimePickerFragment;
import java.text.DateFormat; import java.text.DateFormat;
import java.text.ParseException; import java.text.ParseException;
@ -44,6 +44,12 @@ import java.util.TimeZone;
public class ComposeScheduleView extends ConstraintLayout { public class ComposeScheduleView extends ConstraintLayout {
public interface OnTimeSetListener {
void onTimeSet(String time);
}
private OnTimeSetListener listener;
private DateFormat dateFormat; private DateFormat dateFormat;
private DateFormat timeFormat; private DateFormat timeFormat;
private SimpleDateFormat iso8601; private SimpleDateFormat iso8601;
@ -92,6 +98,10 @@ public class ComposeScheduleView extends ConstraintLayout {
setEditIcons(); setEditIcons();
} }
public void setListener(OnTimeSetListener listener) {
this.listener = listener;
}
private void setScheduledDateTime() { private void setScheduledDateTime() {
if (scheduleDateTime == null) { if (scheduleDateTime == null) {
scheduledDateTimeView.setText(""); scheduledDateTimeView.setText("");
@ -144,13 +154,20 @@ public class ComposeScheduleView extends ConstraintLayout {
} }
private void openPickTimeDialog() { private void openPickTimeDialog() {
TimePickerFragment picker = new TimePickerFragment(); MaterialTimePicker.Builder pickerBuilder = new MaterialTimePicker.Builder();
if (scheduleDateTime != null) { if (scheduleDateTime != null) {
Bundle args = new Bundle(); pickerBuilder.setHour(scheduleDateTime.get(Calendar.HOUR_OF_DAY))
args.putInt(TimePickerFragment.PICKER_TIME_HOUR, scheduleDateTime.get(Calendar.HOUR_OF_DAY)); .setMinute(scheduleDateTime.get(Calendar.MINUTE));
args.putInt(TimePickerFragment.PICKER_TIME_MINUTE, scheduleDateTime.get(Calendar.MINUTE));
picker.setArguments(args);
} }
if (android.text.format.DateFormat.is24HourFormat(this.getContext())) {
pickerBuilder.setTimeFormat(TimeFormat.CLOCK_24H);
} else {
pickerBuilder.setTimeFormat(TimeFormat.CLOCK_12H);
}
MaterialTimePicker picker = pickerBuilder.build();
picker.addOnPositiveButtonClickListener(v -> onTimeSet(picker.getHour(), picker.getMinute()));
picker.show(((AppCompatActivity) getContext()).getSupportFragmentManager(), "time_picker"); picker.show(((AppCompatActivity) getContext()).getSupportFragmentManager(), "time_picker");
} }
@ -200,11 +217,14 @@ public class ComposeScheduleView extends ConstraintLayout {
openPickTimeDialog(); openPickTimeDialog();
} }
public void onTimeSet(int hourOfDay, int minute) { private void onTimeSet(int hourOfDay, int minute) {
initializeSuggestedTime(); initializeSuggestedTime();
scheduleDateTime.set(Calendar.HOUR_OF_DAY, hourOfDay); scheduleDateTime.set(Calendar.HOUR_OF_DAY, hourOfDay);
scheduleDateTime.set(Calendar.MINUTE, minute); scheduleDateTime.set(Calendar.MINUTE, minute);
setScheduledDateTime(); setScheduledDateTime();
if (listener != null) {
listener.onTimeSet(getTime());
}
} }
public String getTime() { public String getTime() {

View File

@ -75,7 +75,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener); setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener);
setDisplayName(account.getDisplayName(), account.getEmojis()); setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername()); setUsername(account.getUsername());
setCreatedAt(status.getCreatedAt(), statusDisplayOptions); setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
setIsReply(status.getInReplyToId() != null); setIsReply(status.getInReplyToId() != null);
@ -83,7 +83,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
setBookmarked(status.getBookmarked()); setBookmarked(status.getBookmarked());
List<Attachment> attachments = status.getAttachments(); List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive(); boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && !hasAudioAttachment(attachments)) { if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(), setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(),
statusDisplayOptions.useBlurhash()); statusDisplayOptions.useBlurhash());

View File

@ -69,6 +69,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
cardViewMode = CardViewMode.NONE, cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmReblogs = preferences.getBoolean("confirmReblogs", true),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID
) )

View File

@ -0,0 +1,175 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.drafts
import android.content.Context
import android.net.Uri
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftAttachment
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.util.IOUtils
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject
class DraftHelper @Inject constructor(
val context: Context,
db: AppDatabase
) {
private val draftDao = db.draftDao()
fun saveDraft(
draftId: Int,
accountId: Long,
inReplyToId: String?,
content: String?,
contentWarning: String?,
sensitive: Boolean,
visibility: Status.Visibility,
mediaUris: List<String>,
mediaDescriptions: List<String?>,
poll: NewPoll?,
failedToSend: Boolean
): Completable {
return Single.fromCallable {
val externalFilesDir = context.getExternalFilesDir("Tusky")
if (externalFilesDir == null || !(externalFilesDir.exists())) {
Log.e("DraftHelper", "Error obtaining directory to save media.")
throw Exception()
}
val draftDirectory = File(externalFilesDir, "Drafts")
if (!draftDirectory.exists()) {
draftDirectory.mkdir()
}
val uris = mediaUris.map { uriString ->
uriString.toUri()
}.map { uri ->
if (uri.isNotInFolder(draftDirectory)) {
uri.copyToFolder(draftDirectory)
} else {
uri
}
}
val types = uris.map { uri ->
val mimeType = context.contentResolver.getType(uri)
when (mimeType?.substring(0, mimeType.indexOf('/'))) {
"video" -> DraftAttachment.Type.VIDEO
"image" -> DraftAttachment.Type.IMAGE
"audio" -> DraftAttachment.Type.AUDIO
else -> throw IllegalStateException("unknown media type")
}
}
val attachments: MutableList<DraftAttachment> = mutableListOf()
for (i in mediaUris.indices) {
attachments.add(
DraftAttachment(
uriString = uris[i].toString(),
description = mediaDescriptions[i],
type = types[i]
)
)
}
DraftEntity(
id = draftId,
accountId = accountId,
inReplyToId = inReplyToId,
content = content,
contentWarning = contentWarning,
sensitive = sensitive,
visibility = visibility,
attachments = attachments,
poll = poll,
failedToSend = failedToSend
)
}.flatMapCompletable { draft ->
draftDao.insertOrReplace(draft)
}.subscribeOn(Schedulers.io())
}
fun deleteDraftAndAttachments(draftId: Int): Completable {
return draftDao.find(draftId)
.flatMapCompletable { draft ->
deleteDraftAndAttachments(draft)
}
}
fun deleteDraftAndAttachments(draft: DraftEntity): Completable {
return deleteAttachments(draft)
.andThen(draftDao.delete(draft.id))
}
fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) {
draftDao.loadDraftsSingle(accountId)
.flatMapObservable { Observable.fromIterable(it) }
.flatMapCompletable { draft ->
deleteDraftAndAttachments(draft)
}.subscribeOn(Schedulers.io())
.subscribe()
}
fun deleteAttachments(draft: DraftEntity): Completable {
return Completable.fromCallable {
draft.attachments.forEach { attachment ->
if (context.contentResolver.delete(attachment.uri, null, null) == 0) {
Log.e("DraftHelper", "Did not delete file ${attachment.uriString}")
}
}
}.subscribeOn(Schedulers.io())
}
private fun Uri.isNotInFolder(folder: File): Boolean {
val filePath = path ?: return true
return File(filePath).parentFile == folder
}
private fun Uri.copyToFolder(folder: File): Uri {
val contentResolver = context.contentResolver
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val mimeType = contentResolver.getType(this)
val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType)
val filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension)
val file = File(folder, filename)
IOUtils.copyToFile(contentResolver, this, file)
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file)
}
}

View File

@ -0,0 +1,81 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.drafts
import android.view.ViewGroup
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatImageView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.DraftAttachment
class DraftMediaAdapter(
private val attachmentClick: () -> Unit
) : ListAdapter<DraftAttachment, DraftMediaAdapter.DraftMediaViewHolder>(
object: DiffUtil.ItemCallback<DraftAttachment>() {
override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean {
return oldItem == newItem
}
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder {
return DraftMediaViewHolder(AppCompatImageView(parent.context))
}
override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) {
getItem(position)?.let { attachment ->
if (attachment.type == DraftAttachment.Type.AUDIO) {
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp)
} else {
Glide.with(holder.itemView.context)
.load(attachment.uri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.dontAnimate()
.into(holder.imageView)
}
}
}
inner class DraftMediaViewHolder(val imageView: ImageView)
: RecyclerView.ViewHolder(imageView) {
init {
val thumbnailViewSize =
imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize)
val margin = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
val marginBottom = itemView.context.resources
.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
layoutParams.setMargins(margin, 0, margin, marginBottom)
imageView.layoutParams = layoutParams
imageView.scaleType = ImageView.ScaleType.CENTER_CROP
imageView.setOnClickListener {
attachmentClick()
}
}
}
}

View File

@ -0,0 +1,197 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.drafts
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.widget.LinearLayout
import android.widget.Toast
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SavedTootActivity
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.hide
import com.keylesspalace.tusky.util.show
import com.uber.autodispose.android.lifecycle.autoDispose
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import retrofit2.HttpException
import javax.inject.Inject
class DraftsActivity : BaseActivity(), DraftActionListener {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: DraftsViewModel by viewModels { viewModelFactory }
private lateinit var binding: ActivityDraftsBinding
private lateinit var bottomSheet: BottomSheetBehavior<LinearLayout>
private var oldDraftsButton: MenuItem? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDraftsBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.apply {
title = getString(R.string.title_drafts)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_saved_status)
val adapter = DraftsAdapter(this)
binding.draftsRecyclerView.adapter = adapter
binding.draftsRecyclerView.layoutManager = LinearLayoutManager(this)
binding.draftsRecyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root)
viewModel.drafts.observe(this) { draftList ->
if (draftList.isEmpty()) {
binding.draftsRecyclerView.hide()
binding.draftsErrorMessageView.show()
} else {
binding.draftsRecyclerView.show()
binding.draftsErrorMessageView.hide()
adapter.submitList(draftList)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.drafts, menu)
oldDraftsButton = menu.findItem(R.id.action_old_drafts)
viewModel.showOldDraftsButton()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe { showOldDraftsButton ->
oldDraftsButton?.isVisible = showOldDraftsButton
}
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
R.id.action_old_drafts -> {
val intent = Intent(this, SavedTootActivity::class.java)
startActivityWithSlideInAnimation(intent)
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onOpenDraft(draft: DraftEntity) {
if (draft.inReplyToId != null) {
bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.getToot(draft.inReplyToId)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this)
.subscribe({ status ->
val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id,
tootText = draft.content,
contentWarning = draft.contentWarning,
inReplyToId = draft.inReplyToId,
replyingStatusContent = status.content.toString(),
replyingStatusAuthor = status.account.localUsername,
draftAttachments = draft.attachments,
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility
)
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
startActivity(ComposeActivity.startIntent(this, composeOptions))
}, { throwable ->
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
Log.w(TAG, "failed loading reply information", throwable)
if (throwable is HttpException && throwable.code() == 404) {
// the original status to which a reply was drafted has been deleted
// let's open the ComposeActivity without reply information
Toast.makeText(this, getString(R.string.drafts_toot_reply_removed), Toast.LENGTH_LONG).show()
openDraftWithoutReply(draft)
} else {
Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT)
.show()
}
})
} else {
openDraftWithoutReply(draft)
}
}
private fun openDraftWithoutReply(draft: DraftEntity) {
val composeOptions = ComposeActivity.ComposeOptions(
draftId = draft.id,
tootText = draft.content,
contentWarning = draft.contentWarning,
draftAttachments = draft.attachments,
poll = draft.poll,
sensitive = draft.sensitive,
visibility = draft.visibility
)
startActivity(ComposeActivity.startIntent(this, composeOptions))
}
override fun onDeleteDraft(draft: DraftEntity) {
viewModel.deleteDraft(draft)
Snackbar.make(binding.root, getString(R.string.draft_deleted), Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
viewModel.restoreDraft(draft)
}
.show()
}
companion object {
const val TAG = "DraftsActivity"
fun newIntent(context: Context) = Intent(context, DraftsActivity::class.java)
}
}

View File

@ -0,0 +1,92 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.drafts
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.databinding.ItemDraftBinding
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.util.BindingViewHolder
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.visible
interface DraftActionListener {
fun onOpenDraft(draft: DraftEntity)
fun onDeleteDraft(draft: DraftEntity)
}
class DraftsAdapter(
private val listener: DraftActionListener
) : PagedListAdapter<DraftEntity, BindingViewHolder<ItemDraftBinding>>(
object : DiffUtil.ItemCallback<DraftEntity>() {
override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean {
return oldItem == newItem
}
}
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder<ItemDraftBinding> {
val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false)
val viewHolder = BindingViewHolder(binding)
binding.draftMediaPreview.layoutManager = LinearLayoutManager(binding.root.context, RecyclerView.HORIZONTAL, false)
binding.draftMediaPreview.adapter = DraftMediaAdapter {
getItem(viewHolder.adapterPosition)?.let { draft ->
listener.onOpenDraft(draft)
}
}
return viewHolder
}
override fun onBindViewHolder(holder: BindingViewHolder<ItemDraftBinding>, position: Int) {
getItem(position)?.let { draft ->
holder.binding.root.setOnClickListener {
listener.onOpenDraft(draft)
}
holder.binding.deleteButton.setOnClickListener {
listener.onDeleteDraft(draft)
}
holder.binding.draftSendingInfo.visible(draft.failedToSend)
holder.binding.contentWarning.visible(!draft.contentWarning.isNullOrEmpty())
holder.binding.contentWarning.text = draft.contentWarning
holder.binding.content.text = draft.content
holder.binding.draftMediaPreview.visible(draft.attachments.isNotEmpty())
(holder.binding.draftMediaPreview.adapter as DraftMediaAdapter).submitList(draft.attachments)
if (draft.poll != null) {
holder.binding.draftPoll.show()
holder.binding.draftPoll.setPoll(draft.poll)
} else {
holder.binding.draftPoll.hide()
}
}
}
}

View File

@ -0,0 +1,69 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.drafts
import androidx.lifecycle.ViewModel
import androidx.paging.toLiveData
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.DraftEntity
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import io.reactivex.Observable
import io.reactivex.Single
import javax.inject.Inject
class DraftsViewModel @Inject constructor(
val database: AppDatabase,
val accountManager: AccountManager,
val api: MastodonApi,
val draftHelper: DraftHelper
) : ViewModel() {
val drafts = database.draftDao().loadDrafts(accountManager.activeAccount?.id!!).toLiveData(pageSize = 20)
private val deletedDrafts: MutableList<DraftEntity> = mutableListOf()
fun showOldDraftsButton(): Observable<Boolean> {
return database.tootDao().savedTootCount()
.map { count -> count > 0 }
}
fun deleteDraft(draft: DraftEntity) {
// this does not immediately delete media files to avoid unnecessary file operations
// in case the user decides to restore the draft
database.draftDao().delete(draft.id)
.subscribe()
deletedDrafts.add(draft)
}
fun restoreDraft(draft: DraftEntity) {
database.draftDao().insertOrReplace(draft)
.subscribe()
deletedDrafts.remove(draft)
}
fun getToot(tootId: String): Single<Status> {
return api.status(tootId)
}
override fun onCleared() {
deletedDrafts.forEach {
draftHelper.deleteAttachments(it).subscribe()
}
}
}

View File

@ -1,7 +1,6 @@
package com.keylesspalace.tusky.components.instancemute package com.keylesspalace.tusky.components.instancemute
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
@ -32,16 +31,6 @@ class InstanceListActivity: BaseActivity(), HasAndroidInjector {
.commit() .commit()
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun androidInjector() = androidInjector override fun androidInjector() = androidInjector
} }

View File

@ -5,6 +5,7 @@ import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -14,7 +15,6 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter
import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.fragment.BaseFragment
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
@ -30,7 +30,7 @@ import retrofit2.Response
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class InstanceListFragment: BaseFragment(), Injectable, InstanceActionListener { class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
@Inject @Inject
lateinit var api: MastodonApi lateinit var api: MastodonApi
@ -39,10 +39,6 @@ class InstanceListFragment: BaseFragment(), Injectable, InstanceActionListener {
private var adapter = DomainMutesAdapter(this) private var adapter = DomainMutesAdapter(this)
private lateinit var scrollListener: EndlessOnScrollListener private lateinit var scrollListener: EndlessOnScrollListener
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.fragment_instance_list, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)

View File

@ -20,8 +20,8 @@ import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.MenuItem
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.MainActivity
@ -59,33 +59,36 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference
setDisplayShowHomeEnabled(true) setDisplayShowHomeEnabled(true)
} }
val fragment: Fragment = when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) { val fragmentTag = "preference_fragment_$EXTRA_PREFERENCE_TYPE"
GENERAL_PREFERENCES -> {
setTitle(R.string.action_view_preferences)
PreferencesFragment.newInstance()
}
ACCOUNT_PREFERENCES -> {
setTitle(R.string.action_view_account_preferences)
AccountPreferencesFragment.newInstance()
}
NOTIFICATION_PREFERENCES -> {
setTitle(R.string.pref_title_edit_notification_settings)
NotificationPreferencesFragment.newInstance()
}
TAB_FILTER_PREFERENCES -> {
setTitle(R.string.pref_title_status_tabs)
TabFilterPreferencesFragment.newInstance()
}
PROXY_PREFERENCES -> {
setTitle(R.string.pref_title_http_proxy_settings)
ProxyPreferencesFragment.newInstance()
}
else -> throw IllegalArgumentException("preferenceType not known")
}
supportFragmentManager.beginTransaction() val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag)
.replace(R.id.fragment_container, fragment) ?: when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) {
.commit() GENERAL_PREFERENCES -> {
setTitle(R.string.action_view_preferences)
PreferencesFragment.newInstance()
}
ACCOUNT_PREFERENCES -> {
setTitle(R.string.action_view_account_preferences)
AccountPreferencesFragment.newInstance()
}
NOTIFICATION_PREFERENCES -> {
setTitle(R.string.pref_title_edit_notification_settings)
NotificationPreferencesFragment.newInstance()
}
TAB_FILTER_PREFERENCES -> {
setTitle(R.string.pref_title_status_tabs)
TabFilterPreferencesFragment.newInstance()
}
PROXY_PREFERENCES -> {
setTitle(R.string.pref_title_http_proxy_settings)
ProxyPreferencesFragment.newInstance()
}
else -> throw IllegalArgumentException("preferenceType not known")
}
supportFragmentManager.commit {
replace(R.id.fragment_container, fragment, fragmentTag)
}
restartActivitiesOnExit = intent.getBooleanExtra("restart", false) restartActivitiesOnExit = intent.getBooleanExtra("restart", false)
@ -101,16 +104,6 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference
PreferenceManager.getDefaultSharedPreferences(this).unregisterOnSharedPreferenceChangeListener(this) PreferenceManager.getDefaultSharedPreferences(this).unregisterOnSharedPreferenceChangeListener(this)
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun saveInstanceState(outState: Bundle) { private fun saveInstanceState(outState: Bundle) {
outState.putBoolean("restart", restartActivitiesOnExit) outState.putBoolean("restart", restartActivitiesOnExit)
} }

View File

@ -171,6 +171,13 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
setTitle(R.string.pref_title_enable_swipe_for_tabs) setTitle(R.string.pref_title_enable_swipe_for_tabs)
isSingleLineTitle = false isSingleLineTitle = false
} }
switchPreference {
setDefaultValue(false)
key = PrefKeys.ANIMATE_CUSTOM_EMOJIS
setTitle(R.string.pref_title_animate_custom_emojis)
isSingleLineTitle = false
}
} }
preferenceCategory(R.string.pref_title_limited_bandwidth_settings) { preferenceCategory(R.string.pref_title_limited_bandwidth_settings) {

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky.components.report
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
@ -30,7 +29,6 @@ import kotlinx.android.synthetic.main.activity_report.*
import kotlinx.android.synthetic.main.toolbar_basic.* import kotlinx.android.synthetic.main.toolbar_basic.*
import javax.inject.Inject import javax.inject.Inject
class ReportActivity : BottomSheetActivity(), HasAndroidInjector { class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject @Inject
@ -120,16 +118,6 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
wizard.currentItem = 0 wizard.currentItem = 0
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
closeScreen()
return true
}
}
return super.onOptionsItemSelected(item)
}
companion object { companion object {
private const val ACCOUNT_ID = "account_id" private const val ACCOUNT_ID = "account_id"
private const val ACCOUNT_USERNAME = "account_username" private const val ACCOUNT_USERNAME = "account_username"

View File

@ -75,7 +75,7 @@ class StatusViewHolder(
sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive), sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive),
mediaViewHeight) mediaViewHeight)
statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions.useAbsoluteTime) statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions)
setCreatedAt(status.createdAt) setCreatedAt(status.createdAt)
} }
@ -90,7 +90,7 @@ class StatusViewHolder(
itemView.statusContentWarningButton.hide() itemView.statusContentWarningButton.hide()
itemView.statusContentWarningDescription.hide() itemView.statusContentWarningDescription.hide()
} else { } else {
val emojiSpoiler = status.spoilerText.emojify(status.emojis, itemView.statusContentWarningDescription) val emojiSpoiler = status.spoilerText.emojify(status.emojis, itemView.statusContentWarningDescription, statusDisplayOptions.animateEmojis)
itemView.statusContentWarningDescription.text = emojiSpoiler itemView.statusContentWarningDescription.text = emojiSpoiler
itemView.statusContentWarningDescription.show() itemView.statusContentWarningDescription.show()
itemView.statusContentWarningButton.show() itemView.statusContentWarningButton.show()
@ -126,7 +126,7 @@ class StatusViewHolder(
listener: LinkListener, listener: LinkListener,
removeQuote: Boolean) { removeQuote: Boolean) {
if (expanded) { if (expanded) {
val emojifiedText = content.emojify(emojis, itemView.statusContent) val emojifiedText = content.emojify(emojis, itemView.statusContent, statusDisplayOptions.animateEmojis)
LinkHelper.setClickableText(itemView.statusContent, emojifiedText, mentions, listener) LinkHelper.setClickableText(itemView.statusContent, emojifiedText, mentions, listener)
} else { } else {
LinkHelper.setClickableMentions(itemView.statusContent, mentions, listener) LinkHelper.setClickableMentions(itemView.statusContent, mentions, listener)

View File

@ -113,6 +113,7 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
cardViewMode = CardViewMode.NONE, cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmReblogs = preferences.getBoolean("confirmReblogs", true),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID quoteEnabled = accountManager.activeAccount?.domain in CAN_USE_QUOTE_ID
) )

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky.components.scheduled
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -104,23 +103,13 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
} }
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun refreshStatuses() { private fun refreshStatuses() {
viewModel.reload() viewModel.reload()
} }
override fun edit(item: ScheduledStatus) { override fun edit(item: ScheduledStatus) {
val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions( val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions(
scheduledTootUid = item.id, scheduledTootId = item.id,
tootText = item.params.text, tootText = item.params.text,
contentWarning = item.params.spoilerText, contentWarning = item.params.spoilerText,
mediaAttachments = item.mediaAttachments, mediaAttachments = item.mediaAttachments,

View File

@ -20,7 +20,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
@ -83,17 +82,7 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector {
return true return true
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { private fun getPageTitle(position: Int): CharSequence {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun getPageTitle(position: Int): CharSequence? {
return when (position) { return when (position) {
0 -> getString(R.string.title_statuses) 0 -> getString(R.string.title_statuses)
1 -> getString(R.string.title_accounts) 1 -> getString(R.string.title_accounts)

View File

@ -245,8 +245,8 @@ class SearchViewModel @Inject constructor(
return accountManager.getAllAccountsOrderedByActive() return accountManager.getAllAccountsOrderedByActive()
} }
fun muteAccount(accountId: String, notifications: Boolean) { fun muteAccount(accountId: String, notifications: Boolean, duration: Int) {
timelineCases.mute(accountId, notifications) timelineCases.mute(accountId, notifications, duration)
} }
fun pinAccount(status: Status, isPin: Boolean) { fun pinAccount(status: Status, isPin: Boolean) {

View File

@ -25,7 +25,7 @@ import com.keylesspalace.tusky.adapter.AccountViewHolder
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
class SearchAccountsAdapter(private val linkListener: LinkListener) class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean)
: PagedListAdapter<Account, RecyclerView.ViewHolder>(ACCOUNT_COMPARATOR) { : PagedListAdapter<Account, RecyclerView.ViewHolder>(ACCOUNT_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
@ -37,7 +37,7 @@ class SearchAccountsAdapter(private val linkListener: LinkListener)
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
getItem(position)?.let { item -> getItem(position)?.let { item ->
(holder as AccountViewHolder).apply { (holder as AccountViewHolder).apply {
setupWithAccount(item) setupWithAccount(item, animateAvatars, animateEmojis)
setupLinkListener(linkListener) setupLinkListener(linkListener)
} }
} }

View File

@ -18,12 +18,23 @@ package com.keylesspalace.tusky.components.search.fragments
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.paging.PagedList import androidx.paging.PagedList
import androidx.paging.PagedListAdapter import androidx.paging.PagedListAdapter
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.NetworkState
import kotlinx.android.synthetic.main.fragment_search.*
class SearchAccountsFragment : SearchFragment<Account>() { class SearchAccountsFragment : SearchFragment<Account>() {
override fun createAdapter(): PagedListAdapter<Account, *> = SearchAccountsAdapter(this) override fun createAdapter(): PagedListAdapter<Account, *> {
val preferences = PreferenceManager.getDefaultSharedPreferences(searchRecyclerView.context)
return SearchAccountsAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
}
override val networkStateRefresh: LiveData<NetworkState> override val networkStateRefresh: LiveData<NetworkState>
get() = viewModel.networkStateAccountRefresh get() = viewModel.networkStateAccountRefresh

View File

@ -72,6 +72,7 @@ class SearchNotestockFragment : SearchFragment<Pair<Status, StatusViewData.Concr
cardViewMode = CardViewMode.NONE, cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", false), confirmReblogs = preferences.getBoolean("confirmReblogs", false),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
quoteEnabled = viewModel.quoteEnabled quoteEnabled = viewModel.quoteEnabled
) )
@ -383,8 +384,9 @@ class SearchNotestockFragment : SearchFragment<Pair<Status, StatusViewData.Concr
showMuteAccountDialog( showMuteAccountDialog(
this.requireActivity(), this.requireActivity(),
accountUsername, accountUsername,
{ notifications -> viewModel.muteAccount(accountId, notifications) } ) { notifications, duration ->
) viewModel.muteAccount(accountId, notifications, duration)
}
} }
private fun accountIsInMentions(account: AccountEntity?, mentions: Array<Mention>): Boolean { private fun accountIsInMentions(account: AccountEntity?, mentions: Array<Mention>): Boolean {

View File

@ -54,6 +54,7 @@ import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.view.showMuteAccountDialog
@ -87,6 +88,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
cardViewMode = CardViewMode.NONE, cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true), confirmReblogs = preferences.getBoolean("confirmReblogs", true),
hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false),
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
quoteEnabled = viewModel.quoteEnabled quoteEnabled = viewModel.quoteEnabled
) )
@ -150,6 +152,7 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
} }
} }
Attachment.Type.UNKNOWN -> { Attachment.Type.UNKNOWN -> {
LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context)
} }
} }
@ -404,8 +407,8 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
showMuteAccountDialog( showMuteAccountDialog(
this.requireActivity(), this.requireActivity(),
accountUsername accountUsername
) { notifications -> ) { notifications, duration ->
viewModel.muteAccount(accountId, notifications) viewModel.muteAccount(accountId, notifications, duration)
} }
} }

View File

@ -28,9 +28,9 @@ import com.keylesspalace.tusky.components.conversation.ConversationEntity;
* DB version & declare DAO * DB version & declare DAO
*/ */
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, @Database(entities = { TootEntity.class, DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class TimelineAccountEntity.class, ConversationEntity.class
}, version = 24) }, version = 25)
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao(); public abstract TootDao tootDao();
@ -38,6 +38,7 @@ public abstract class AppDatabase extends RoomDatabase {
public abstract InstanceDao instanceDao(); public abstract InstanceDao instanceDao();
public abstract ConversationsDao conversationDao(); public abstract ConversationsDao conversationDao();
public abstract TimelineDao timelineDao(); public abstract TimelineDao timelineDao();
public abstract DraftDao draftDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) { public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override @Override
@ -46,7 +47,6 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("INSERT INTO TootEntity2 SELECT * FROM TootEntity;"); database.execSQL("INSERT INTO TootEntity2 SELECT * FROM TootEntity;");
database.execSQL("DROP TABLE TootEntity;"); database.execSQL("DROP TABLE TootEntity;");
database.execSQL("ALTER TABLE TootEntity2 RENAME TO TootEntity;"); database.execSQL("ALTER TABLE TootEntity2 RENAME TO TootEntity;");
} }
}; };
@ -347,4 +347,22 @@ public abstract class AppDatabase extends RoomDatabase {
} }
}; };
public static final Migration MIGRATION_24_25 = new Migration(24, 25) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
database.execSQL(
"CREATE TABLE IF NOT EXISTS `DraftEntity` (" +
"`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)"
);
}
};
} }

View File

@ -25,10 +25,7 @@ import com.keylesspalace.tusky.STREAMING
import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
import com.keylesspalace.tusky.createTabDataFromId import com.keylesspalace.tusky.createTabDataFromId
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.json.SpannedTypeAdapter import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.util.trimTrailingWhitespace import com.keylesspalace.tusky.util.trimTrailingWhitespace
import java.net.URLDecoder import java.net.URLDecoder
@ -155,4 +152,23 @@ class Converters {
return gson.fromJson(pollJson, Poll::class.java) return gson.fromJson(pollJson, Poll::class.java)
} }
} @TypeConverter
fun newPollToJson(newPoll: NewPoll?): String? {
return gson.toJson(newPoll)
}
@TypeConverter
fun jsonToNewPoll(newPollJson: String?): NewPoll? {
return gson.fromJson(newPollJson, NewPoll::class.java)
}
@TypeConverter
fun draftAttachmentListToJson(draftAttachments: List<DraftAttachment>?): String? {
return gson.toJson(draftAttachments)
}
@TypeConverter
fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List<DraftAttachment>? {
return gson.fromJson(draftAttachmentListJson, object : TypeToken<List<DraftAttachment>>() {}.type)
}
}

View File

@ -0,0 +1,44 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.db
import androidx.paging.DataSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.reactivex.Completable
import io.reactivex.Single
@Dao
interface DraftDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(draft: DraftEntity): Completable
@Query("SELECT * FROM DraftEntity WHERE accountId = :accountId ORDER BY id ASC")
fun loadDrafts(accountId: Long): DataSource.Factory<Int, DraftEntity>
@Query("SELECT * FROM DraftEntity WHERE accountId = :accountId")
fun loadDraftsSingle(accountId: Long): Single<List<DraftEntity>>
@Query("DELETE FROM DraftEntity WHERE id = :id")
fun delete(id: Int): Completable
@Query("SELECT * FROM DraftEntity WHERE id = :id")
fun find(id: Int): Single<DraftEntity?>
}

View File

@ -0,0 +1,55 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.db
import android.net.Uri
import android.os.Parcelable
import androidx.core.net.toUri
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import kotlinx.android.parcel.Parcelize
@Entity
@TypeConverters(Converters::class)
data class DraftEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val accountId: Long,
val inReplyToId: String?,
val content: String?,
val contentWarning: String?,
val sensitive: Boolean,
val visibility: Status.Visibility,
val attachments: List<DraftAttachment>,
val poll: NewPoll?,
val failedToSend: Boolean
)
@Parcelize
data class DraftAttachment(
val uriString: String,
val description: String?,
val type: Type
): Parcelable {
val uri: Uri
get() = uriString.toUri()
enum class Type {
IMAGE, VIDEO, AUDIO;
}
}

View File

@ -26,7 +26,7 @@ import com.keylesspalace.tusky.entity.Status
// Avoiding rescanning status table when accounts table changes. Recommended by Room(c). // Avoiding rescanning status table when accounts table changes. Recommended by Room(c).
indices = [Index("authorServerId", "timelineUserId")] indices = [Index("authorServerId", "timelineUserId")]
) )
@TypeConverters(TootEntity.Converters::class) @TypeConverters(Converters::class)
data class TimelineStatusEntity( data class TimelineStatusEntity(
val serverId: String, // id never flips: we need it for sorting so it's a real id val serverId: String, // id never flips: we need it for sorting so it's a real id
val url: String?, val url: String?,

View File

@ -16,12 +16,12 @@
package com.keylesspalace.tusky.db; package com.keylesspalace.tusky.db;
import androidx.room.Dao; import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query; import androidx.room.Query;
import java.util.List; import java.util.List;
import io.reactivex.Observable;
/** /**
* Created by cto3543 on 28/06/2017. * Created by cto3543 on 28/06/2017.
* *
@ -30,8 +30,6 @@ import java.util.List;
@Dao @Dao
public interface TootDao { public interface TootDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertOrReplace(TootEntity users);
@Query("SELECT * FROM TootEntity ORDER BY uid DESC") @Query("SELECT * FROM TootEntity ORDER BY uid DESC")
List<TootEntity> loadAll(); List<TootEntity> loadAll();
@ -41,4 +39,7 @@ public interface TootDao {
@Query("SELECT * FROM TootEntity WHERE uid = :uid") @Query("SELECT * FROM TootEntity WHERE uid = :uid")
TootEntity find(int uid); TootEntity find(int uid);
}
@Query("SELECT COUNT(*) FROM TootEntity")
Observable<Integer> savedTootCount();
}

View File

@ -18,6 +18,7 @@ package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.* import com.keylesspalace.tusky.*
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.report.ReportActivity
@ -109,6 +110,9 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity
@ContributesAndroidInjector
abstract fun contributesDraftActivity(): DraftsActivity
@ContributesAndroidInjector @ContributesAndroidInjector
abstract fun contributesAccessTokenLoginActivity(): AccessTokenLoginActivity abstract fun contributesAccessTokenLoginActivity(): AccessTokenLoginActivity
} }

View File

@ -80,7 +80,7 @@ class AppModule {
AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16, AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16,
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24) AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25)
.build() .build()
} }

View File

@ -16,6 +16,8 @@
package com.keylesspalace.tusky.di package com.keylesspalace.tusky.di
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.text.Spanned import android.text.Spanned
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
@ -25,16 +27,21 @@ import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.NotestockApi import com.keylesspalace.tusky.network.NotestockApi
import com.keylesspalace.tusky.util.okhttpClient import com.keylesspalace.tusky.util.getNonNullString
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import net.accelf.yuito.HttpToastInterceptor import net.accelf.yuito.HttpToastInterceptor
import okhttp3.Cache
import okhttp3.OkHttp
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create import retrofit2.create
import java.net.InetSocketAddress
import java.net.Proxy
import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
/** /**
@ -56,9 +63,37 @@ class NetworkModule {
@Singleton @Singleton
fun providesHttpClient( fun providesHttpClient(
accountManager: AccountManager, accountManager: AccountManager,
context: Context context: Context,
preferences: SharedPreferences
): OkHttpClient { ): OkHttpClient {
return okhttpClient(context) val httpProxyEnabled = preferences.getBoolean("httpProxyEnabled", false)
val httpServer = preferences.getNonNullString("httpProxyServer", "")
val httpPort = preferences.getNonNullString("httpProxyPort", "-1").toIntOrNull() ?: -1
val cacheSize = 25 * 1024 * 1024L // 25 MiB
val builder = OkHttpClient.Builder()
.addInterceptor { chain ->
/**
* Add a custom User-Agent that contains Tusky, Android and OkHttp Version to all requests
* Example:
* User-Agent: Tusky/1.1.2 Android/5.0.2 OkHttp/4.9.0
* */
val requestWithUserAgent = chain.request().newBuilder()
.header(
"User-Agent",
"Tusky/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.RELEASE} OkHttp/${OkHttp.VERSION}"
)
.build()
chain.proceed(requestWithUserAgent)
}
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.cache(Cache(context.cacheDir, cacheSize))
if (httpProxyEnabled && httpServer.isNotEmpty() && httpPort > 0 && httpPort < 65535) {
val address = InetSocketAddress.createUnresolved(httpServer, httpPort)
builder.proxy(Proxy(Proxy.Type.HTTP, address))
}
return builder
.apply { .apply {
addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
@ -89,18 +124,10 @@ class NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun providesNotestockApi(context: Context, fun providesNotestockApi(okHttpClient: OkHttpClient,
gson: Gson): NotestockApi { gson: Gson): NotestockApi {
val httpClient = okhttpClient(context)
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
addInterceptor(HttpToastInterceptor(context))
}
}
.build()
val retrofit = Retrofit.Builder().baseUrl("https://notestock.osa-p.net") val retrofit = Retrofit.Builder().baseUrl("https://notestock.osa-p.net")
.client(httpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson)) .addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava2CallAdapterFactory.createAsync()) .addCallAdapterFactory(RxJava2CallAdapterFactory.createAsync())
.build() .build()

View File

@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModelProvider
import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel
import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.components.drafts.DraftsViewModel
import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel
import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.components.search.SearchViewModel
@ -92,6 +93,11 @@ abstract class ViewModelModule {
@ViewModelKey(AnnouncementsViewModel::class) @ViewModelKey(AnnouncementsViewModel::class)
internal abstract fun announcementsViewModel(viewModel: AnnouncementsViewModel): ViewModel internal abstract fun announcementsViewModel(viewModel: AnnouncementsViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(DraftsViewModel::class)
internal abstract fun draftsViewModel(viewModel: DraftsViewModel): ViewModel
@Binds @Binds
@IntoMap @IntoMap
@ViewModelKey(QuickTootViewModel::class) @ViewModelKey(QuickTootViewModel::class)

View File

@ -17,10 +17,10 @@ package com.keylesspalace.tusky.fragment
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -36,6 +36,7 @@ import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
@ -45,14 +46,12 @@ import com.uber.autodispose.autoDispose
import io.reactivex.Single import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.fragment_account_list.* import kotlinx.android.synthetic.main.fragment_account_list.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
class AccountListFragment : BaseFragment(), AccountActionListener, Injectable { class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, Injectable {
@Inject @Inject
lateinit var api: MastodonApi lateinit var api: MastodonApi
@ -71,10 +70,6 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
id = arguments?.getString(ARG_ID) id = arguments?.getString(ARG_ID)
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.fragment_account_list, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@ -85,11 +80,15 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
val pm = PreferenceManager.getDefaultSharedPreferences(view.context)
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
adapter = when (type) { adapter = when (type) {
Type.BLOCKS -> BlocksAdapter(this) Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis)
Type.MUTES -> MutesAdapter(this) Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis)
Type.FOLLOW_REQUESTS -> FollowRequestsAdapter(this) Type.FOLLOW_REQUESTS -> FollowRequestsAdapter(this, animateAvatar, animateEmojis)
else -> FollowAdapter(this) else -> FollowAdapter(this, animateAvatar, animateEmojis)
} }
recyclerView.adapter = adapter recyclerView.adapter = adapter
@ -202,27 +201,23 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
override fun onRespondToFollowRequest(accept: Boolean, accountId: String, override fun onRespondToFollowRequest(accept: Boolean, accountId: String,
position: Int) { position: Int) {
val callback = object : Callback<Relationship> { if (accept) {
override fun onResponse(call: Call<Relationship>, response: Response<Relationship>) {
if (response.isSuccessful) {
onRespondToFollowRequestSuccess(position)
} else {
onRespondToFollowRequestFailure(accept, accountId)
}
}
override fun onFailure(call: Call<Relationship>, t: Throwable) {
onRespondToFollowRequestFailure(accept, accountId)
}
}
val call = if (accept) {
api.authorizeFollowRequest(accountId) api.authorizeFollowRequest(accountId)
} else { } else {
api.rejectFollowRequest(accountId) api.rejectFollowRequest(accountId)
} }.observeOn(AndroidSchedulers.mainThread())
callList.add(call) .autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
call.enqueue(callback) .subscribe({
onRespondToFollowRequestSuccess(position)
}, { throwable ->
val verb = if (accept) {
"accept"
} else {
"reject"
}
Log.e(TAG, "Failed to $verb account id $accountId.", throwable)
})
} }
private fun onRespondToFollowRequestSuccess(position: Int) { private fun onRespondToFollowRequestSuccess(position: Int) {
@ -230,15 +225,6 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
followRequestsAdapter.removeItem(position) followRequestsAdapter.removeItem(position)
} }
private fun onRespondToFollowRequestFailure(accept: Boolean, accountId: String) {
val verb = if (accept) {
"accept"
} else {
"reject"
}
Log.e(TAG, "Failed to $verb account id $accountId.")
}
private fun getFetchCallByListType(fromId: String?): Single<Response<List<Account>>> { private fun getFetchCallByListType(fromId: String?): Single<Response<List<Account>>> {
return when (type) { return when (type) {
Type.FOLLOWS -> { Type.FOLLOWS -> {

View File

@ -18,12 +18,13 @@ package com.keylesspalace.tusky.fragment
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@ -34,14 +35,17 @@ import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.view.SquareImageView import com.keylesspalace.tusky.view.SquareImageView
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.uber.autodispose.android.lifecycle.autoDispose
import io.reactivex.SingleObserver
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import kotlinx.android.synthetic.main.fragment_timeline.* import kotlinx.android.synthetic.main.fragment_timeline.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
@ -53,7 +57,7 @@ import javax.inject.Inject
* Fragment with multiple columns of media previews for the specified account. * Fragment with multiple columns of media previews for the specified account.
*/ */
class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable { class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFragment, Injectable {
companion object { companion object {
@JvmStatic @JvmStatic
fun newInstance(accountId: String, enableSwipeToRefresh:Boolean=true): AccountMediaFragment { fun newInstance(accountId: String, enableSwipeToRefresh:Boolean=true): AccountMediaFragment {
@ -77,14 +81,13 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
lateinit var api: MastodonApi lateinit var api: MastodonApi
private val adapter = MediaGridAdapter() private val adapter = MediaGridAdapter()
private var currentCall: Call<List<Status>>? = null
private val statuses = mutableListOf<Status>() private val statuses = mutableListOf<Status>()
private var fetchingStatus = FetchingStatus.NOT_FETCHING private var fetchingStatus = FetchingStatus.NOT_FETCHING
private lateinit var accountId: String private lateinit var accountId: String
private val callback = object : Callback<List<Status>> { private val callback = object : SingleObserver<Response<List<Status>>> {
override fun onFailure(call: Call<List<Status>>?, t: Throwable?) { override fun onError(t: Throwable) {
fetchingStatus = FetchingStatus.NOT_FETCHING fetchingStatus = FetchingStatus.NOT_FETCHING
if (isAdded) { if (isAdded) {
@ -106,7 +109,7 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
Log.d(TAG, "Failed to fetch account media", t) Log.d(TAG, "Failed to fetch account media", t)
} }
override fun onResponse(call: Call<List<Status>>, response: Response<List<Status>>) { override fun onSuccess(response: Response<List<Status>>) {
fetchingStatus = FetchingStatus.NOT_FETCHING fetchingStatus = FetchingStatus.NOT_FETCHING
if (isAdded) { if (isAdded) {
swipeRefreshLayout.isRefreshing = false swipeRefreshLayout.isRefreshing = false
@ -127,22 +130,23 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
if (statuses.isEmpty()) { if (statuses.isEmpty()) {
statusView.show() statusView.show()
statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
null)
} }
} }
} }
} }
override fun onSubscribe(d: Disposable) {}
} }
private val bottomCallback = object : Callback<List<Status>> { private val bottomCallback = object : SingleObserver<Response<List<Status>>> {
override fun onFailure(call: Call<List<Status>>?, t: Throwable?) { override fun onError(t: Throwable) {
fetchingStatus = FetchingStatus.NOT_FETCHING fetchingStatus = FetchingStatus.NOT_FETCHING
Log.d(TAG, "Failed to fetch account media", t) Log.d(TAG, "Failed to fetch account media", t)
} }
override fun onResponse(call: Call<List<Status>>, response: Response<List<Status>>) { override fun onSuccess(response: Response<List<Status>>) {
fetchingStatus = FetchingStatus.NOT_FETCHING fetchingStatus = FetchingStatus.NOT_FETCHING
val body = response.body() val body = response.body()
body?.let { fetched -> body?.let { fetched ->
@ -159,6 +163,7 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
} }
} }
override fun onSubscribe(d: Disposable) { }
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -166,10 +171,6 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH,true) == true isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH,true) == true
accountId = arguments?.getString(ACCOUNT_ID_ARG)!! accountId = arguments?.getString(ACCOUNT_ID_ARG)!!
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@ -201,8 +202,10 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
statuses.lastOrNull()?.let { (id) -> statuses.lastOrNull()?.let { (id) ->
Log.d(TAG, "Requesting statuses with max_id: ${id}, (bottom)") Log.d(TAG, "Requesting statuses with max_id: ${id}, (bottom)")
fetchingStatus = FetchingStatus.FETCHING_BOTTOM fetchingStatus = FetchingStatus.FETCHING_BOTTOM
currentCall = api.accountStatuses(accountId, id, null, null, null, true, null) api.accountStatuses(accountId, id, null, null, null, true, null)
currentCall?.enqueue(bottomCallback) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY)
.subscribe(bottomCallback)
} }
} }
} }
@ -215,14 +218,15 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
private fun refresh() { private fun refresh() {
statusView.hide() statusView.hide()
if (fetchingStatus != FetchingStatus.NOT_FETCHING) return if (fetchingStatus != FetchingStatus.NOT_FETCHING) return
currentCall = if (statuses.isEmpty()) { if (statuses.isEmpty()) {
fetchingStatus = FetchingStatus.INITIAL_FETCHING fetchingStatus = FetchingStatus.INITIAL_FETCHING
api.accountStatuses(accountId, null, null, null, null, true, null) api.accountStatuses(accountId, null, null, null, null, true, null)
} else { } else {
fetchingStatus = FetchingStatus.REFRESHING fetchingStatus = FetchingStatus.REFRESHING
api.accountStatuses(accountId, null, statuses[0].id, null, null, true, null) api.accountStatuses(accountId, null, statuses[0].id, null, null, true, null)
} }.observeOn(AndroidSchedulers.mainThread())
currentCall?.enqueue(callback) .autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(callback)
if (!isSwipeToRefreshEnabled) if (!isSwipeToRefreshEnabled)
topProgressBar?.show() topProgressBar?.show()
@ -234,8 +238,10 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
} }
if (fetchingStatus == FetchingStatus.NOT_FETCHING && statuses.isEmpty()) { if (fetchingStatus == FetchingStatus.NOT_FETCHING && statuses.isEmpty()) {
fetchingStatus = FetchingStatus.INITIAL_FETCHING fetchingStatus = FetchingStatus.INITIAL_FETCHING
currentCall = api.accountStatuses(accountId, null, null, null, null, true, null) api.accountStatuses(accountId, null, null, null, null, true, null)
currentCall?.enqueue(callback) .observeOn(AndroidSchedulers.mainThread())
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY)
.subscribe(callback)
} }
else if (needToRefresh) else if (needToRefresh)
refresh() refresh()
@ -260,10 +266,8 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
} }
} }
Attachment.Type.UNKNOWN -> { Attachment.Type.UNKNOWN -> {
}/* Intentionally do nothing. This case is here is to handle when new attachment LinkHelper.openLink(items[currentIndex].attachment.url, context)
* types are added to the API before code is added here to handle them. So, the }
* best fallback is to just show the preview and ignore requests to view them. */
} }
} }
@ -340,5 +344,4 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
needToRefresh = true needToRefresh = true
} }
} }

View File

@ -1,43 +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.fragment;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import java.util.ArrayList;
import java.util.List;
import retrofit2.Call;
public class BaseFragment extends Fragment {
protected List<Call> callList;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
callList = new ArrayList<>();
}
@Override
public void onDestroy() {
for (Call call : callList) {
call.cancel();
}
super.onDestroy();
}
}

View File

@ -105,13 +105,11 @@ import at.connyduck.sparkbutton.helpers.Utils;
import io.reactivex.Observable; import io.reactivex.Observable;
import io.reactivex.Single; import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import kotlin.Unit; import kotlin.Unit;
import kotlin.collections.CollectionsKt; import kotlin.collections.CollectionsKt;
import kotlin.jvm.functions.Function1; import kotlin.jvm.functions.Function1;
import okhttp3.ResponseBody;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import static com.keylesspalace.tusky.util.StringUtils.isLessThan; import static com.keylesspalace.tusky.util.StringUtils.isLessThan;
import static com.uber.autodispose.AutoDispose.autoDisposable; import static com.uber.autodispose.AutoDispose.autoDisposable;
@ -128,8 +126,9 @@ public class NotificationsFragment extends SFragment implements
private static final int LOAD_AT_ONCE = 30; private static final int LOAD_AT_ONCE = 30;
private int maxPlaceholderId = 0; private int maxPlaceholderId = 0;
private final Set<Notification.Type> notificationFilter = new HashSet<>();
private Set<Notification.Type> notificationFilter = new HashSet<>(); private final CompositeDisposable disposables = new CompositeDisposable();
private enum FetchEnd { private enum FetchEnd {
TOP, TOP,
@ -257,6 +256,7 @@ public class NotificationsFragment extends SFragment implements
CardViewMode.NONE, CardViewMode.NONE,
preferences.getBoolean("confirmReblogs", true), preferences.getBoolean("confirmReblogs", true),
preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, 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(ComposeViewModelKt.getCAN_USE_QUOTE_ID()).contains(accountManager.getActiveAccount().getDomain())
); );
@ -694,32 +694,21 @@ public class NotificationsFragment extends SFragment implements
updateAdapter(); updateAdapter();
//Execute clear notifications request //Execute clear notifications request
Call<ResponseBody> call = mastodonApi.clearNotifications(); mastodonApi.clearNotifications()
call.enqueue(new Callback<ResponseBody>() { .observeOn(AndroidSchedulers.mainThread())
@Override .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
public void onResponse(@NonNull Call<ResponseBody> call, @NonNull Response<ResponseBody> response) { .subscribe(
if (isAdded()) { response -> {
if (!response.isSuccessful()) { // nothing to do
//Reload notifications on failure },
fullyRefreshWithProgressBar(true); throwable -> {
} //Reload notifications on failure
} fullyRefreshWithProgressBar(true);
} });
@Override
public void onFailure(@NonNull Call<ResponseBody> call, @NonNull Throwable t) {
//Reload notifications on failure
fullyRefreshWithProgressBar(true);
}
});
callList.add(call);
} }
private void resetNotificationsLoad() { private void resetNotificationsLoad() {
for (Call callItem : callList) { disposables.clear();
callItem.cancel();
}
callList.clear();
bottomLoading = false; bottomLoading = false;
topLoading = false; topLoading = false;
@ -849,8 +838,8 @@ public class NotificationsFragment extends SFragment implements
@Override @Override
public void onRespondToFollowRequest(boolean accept, String id, int position) { public void onRespondToFollowRequest(boolean accept, String id, int position) {
Single<Relationship> request = accept ? Single<Relationship> request = accept ?
mastodonApi.authorizeFollowRequestObservable(id) : mastodonApi.authorizeFollowRequest(id) :
mastodonApi.rejectFollowRequestObservable(id); mastodonApi.rejectFollowRequest(id);
request.observeOn(AndroidSchedulers.mainThread()) request.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe( .subscribe(
@ -968,27 +957,20 @@ public class NotificationsFragment extends SFragment implements
bottomLoading = true; bottomLoading = true;
} }
Call<List<Notification>> call = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null); Disposable notificationCall = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null)
.observeOn(AndroidSchedulers.mainThread())
call.enqueue(new Callback<List<Notification>>() { .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
@Override .subscribe(
public void onResponse(@NonNull Call<List<Notification>> call, response -> {
@NonNull Response<List<Notification>> response) { if (response.isSuccessful()) {
if (response.isSuccessful()) { String linkHeader = response.headers().get("Link");
String linkHeader = response.headers().get("Link"); onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos);
onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); } else {
} else { onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos);
onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); }
} },
} throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos));
disposables.add(notificationCall);
@Override
public void onFailure(@NonNull Call<List<Notification>> call, @NonNull Throwable t) {
if (!call.isCanceled())
onFetchNotificationsFailure((Exception) t, fetchEnd, pos);
}
});
callList.add(call);
} }
private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader, private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader,
@ -1047,7 +1029,7 @@ public class NotificationsFragment extends SFragment implements
progressBar.setVisibility(View.GONE); progressBar.setVisibility(View.GONE);
} }
private void onFetchNotificationsFailure(Exception exception, FetchEnd fetchEnd, int position) { private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, int position) {
swipeRefreshLayout.setRefreshing(false); swipeRefreshLayout.setRefreshing(false);
if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) {
Placeholder placeholder = notifications.get(position).asLeft(); Placeholder placeholder = notifications.get(position).asLeft();
@ -1059,7 +1041,7 @@ public class NotificationsFragment extends SFragment implements
this.statusView.setVisibility(View.VISIBLE); this.statusView.setVisibility(View.VISIBLE);
swipeRefreshLayout.setEnabled(false); swipeRefreshLayout.setEnabled(false);
this.showingError = true; this.showingError = true;
if (exception instanceof IOException) { if (throwable instanceof IOException) {
this.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> { this.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> {
this.progressBar.setVisibility(View.VISIBLE); this.progressBar.setVisibility(View.VISIBLE);
this.onRefresh(); this.onRefresh();
@ -1074,7 +1056,7 @@ public class NotificationsFragment extends SFragment implements
} }
updateFilterVisibility(); updateFilterVisibility();
} }
Log.e(TAG, "Fetch failure: " + exception.getMessage()); Log.e(TAG, "Fetch failure: " + throwable.getMessage());
if (fetchEnd == FetchEnd.TOP) { if (fetchEnd == FetchEnd.TOP) {
topLoading = false; topLoading = false;

View File

@ -20,7 +20,6 @@ import android.app.DownloadManager;
import android.content.ClipData; import android.content.ClipData;
import android.content.ClipboardManager; import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri; import android.net.Uri;
@ -30,8 +29,6 @@ import android.util.Log;
import android.view.Menu; import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.widget.CheckBox;
import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@ -41,14 +38,14 @@ import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.PopupMenu; import androidx.appcompat.widget.PopupMenu;
import androidx.core.app.ActivityOptionsCompat; import androidx.core.app.ActivityOptionsCompat;
import androidx.core.view.ViewCompat; import androidx.core.view.ViewCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Lifecycle; import androidx.lifecycle.Lifecycle;
import androidx.preference.PreferenceManager;
import com.keylesspalace.tusky.BaseActivity; import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BottomSheetActivity; import com.keylesspalace.tusky.BottomSheetActivity;
import com.keylesspalace.tusky.MainActivity; import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.PostLookupFallbackBehavior; import com.keylesspalace.tusky.PostLookupFallbackBehavior;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.ViewTagActivity; import com.keylesspalace.tusky.ViewTagActivity;
import com.keylesspalace.tusky.components.compose.ComposeActivity; import com.keylesspalace.tusky.components.compose.ComposeActivity;
@ -63,6 +60,7 @@ import com.keylesspalace.tusky.entity.PollOption;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.network.TimelineCases; import com.keylesspalace.tusky.network.TimelineCases;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.view.MuteAccountDialog; import com.keylesspalace.tusky.view.MuteAccountDialog;
import com.keylesspalace.tusky.viewdata.AttachmentViewData; import com.keylesspalace.tusky.viewdata.AttachmentViewData;
@ -75,9 +73,8 @@ import java.util.regex.Pattern;
import javax.inject.Inject; import javax.inject.Inject;
import kotlin.Unit;
import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.android.schedulers.AndroidSchedulers;
import kotlin.Unit;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
import retrofit2.Response; import retrofit2.Response;
@ -91,7 +88,7 @@ import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvid
* adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also * adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also
* overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear * overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear
* up what needs to be where. */ * up what needs to be where. */
public abstract class SFragment extends BaseFragment implements Injectable { public abstract class SFragment extends Fragment implements Injectable {
protected abstract void removeItem(int position); protected abstract void removeItem(int position);
@ -102,7 +99,7 @@ public abstract class SFragment extends BaseFragment implements Injectable {
private static List<Filter> filters; private static List<Filter> filters;
private boolean filterRemoveRegex; private boolean filterRemoveRegex;
private Matcher filterRemoveRegexMatcher; private Matcher filterRemoveRegexMatcher;
private static Matcher alphanumeric = Pattern.compile("^\\w+$").matcher(""); private static final Matcher alphanumeric = Pattern.compile("^\\w+$").matcher("");
@Inject @Inject
public MastodonApi mastodonApi; public MastodonApi mastodonApi;
@ -367,8 +364,8 @@ public abstract class SFragment extends BaseFragment implements Injectable {
MuteAccountDialog.showMuteAccountDialog( MuteAccountDialog.showMuteAccountDialog(
this.getActivity(), this.getActivity(),
accountUsername, accountUsername,
(notifications) -> { (notifications, duration) -> {
timelineCases.mute(accountId, notifications); timelineCases.mute(accountId, notifications, duration);
return Unit.INSTANCE; return Unit.INSTANCE;
} }
); );
@ -422,10 +419,9 @@ public abstract class SFragment extends BaseFragment implements Injectable {
} }
break; break;
} }
default:
case UNKNOWN: { case UNKNOWN: {
/* Intentionally do nothing. This case is here is to handle when new attachment LinkHelper.openLink(active.getUrl(), getContext());
* types are added to the API before code is added here to handle them. So, the
* best fallback is to just show the preview and ignore requests to view them. */
break; break;
} }
} }

View File

@ -1,53 +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.fragment;
import android.app.Dialog;
import android.app.TimePickerDialog;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
import com.keylesspalace.tusky.components.compose.ComposeActivity;
import java.util.Calendar;
import java.util.TimeZone;
public class TimePickerFragment extends DialogFragment {
public static final String PICKER_TIME_HOUR = "picker_time_hour";
public static final String PICKER_TIME_MINUTE = "picker_time_minute";
@Override
@NonNull
public Dialog onCreateDialog(Bundle savedInstanceState) {
Bundle args = getArguments();
Calendar calendar = Calendar.getInstance(TimeZone.getDefault());
if (args != null) {
calendar.set(Calendar.HOUR_OF_DAY, args.getInt(PICKER_TIME_HOUR));
calendar.set(Calendar.MINUTE, args.getInt(PICKER_TIME_MINUTE));
}
return new TimePickerDialog(getContext(),
android.R.style.Theme_DeviceDefault_Dialog,
(ComposeActivity) getActivity(),
calendar.get(Calendar.HOUR_OF_DAY),
calendar.get(Calendar.MINUTE),
true);
}
}

View File

@ -27,11 +27,13 @@ import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.accessibility.AccessibilityManager;
import android.widget.ProgressBar; import android.widget.ProgressBar;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.arch.core.util.Function; import androidx.arch.core.util.Function;
import androidx.core.content.ContextCompat;
import androidx.core.util.Pair; import androidx.core.util.Pair;
import androidx.core.widget.ContentLoadingProgressBar; import androidx.core.widget.ContentLoadingProgressBar;
import androidx.lifecycle.Lifecycle; import androidx.lifecycle.Lifecycle;
@ -105,12 +107,14 @@ import java.util.Collections;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.ListIterator; import java.util.ListIterator;
import java.util.Objects;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import javax.inject.Inject; import javax.inject.Inject;
import at.connyduck.sparkbutton.helpers.Utils; import at.connyduck.sparkbutton.helpers.Utils;
import io.reactivex.Observable; import io.reactivex.Observable;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.android.schedulers.AndroidSchedulers;
import kotlin.Unit; import kotlin.Unit;
import kotlin.collections.CollectionsKt; import kotlin.collections.CollectionsKt;
@ -118,8 +122,6 @@ import kotlin.jvm.functions.Function1;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.WebSocket; import okhttp3.WebSocket;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response; import retrofit2.Response;
import static android.content.Context.CONNECTIVITY_SERVICE; import static android.content.Context.CONNECTIVITY_SERVICE;
@ -285,6 +287,7 @@ public class TimelineFragment extends SFragment implements
CardViewMode.NONE, CardViewMode.NONE,
preferences.getBoolean("confirmReblogs", true), preferences.getBoolean("confirmReblogs", true),
preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, 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(ComposeViewModelKt.getCAN_USE_QUOTE_ID()).contains(accountManager.getActiveAccount().getDomain())
); );
adapter = new TimelineAdapter(dataSource, statusDisplayOptions, this); adapter = new TimelineAdapter(dataSource, statusDisplayOptions, this);
@ -1150,7 +1153,7 @@ public class TimelineFragment extends SFragment implements
} }
} }
private Call<List<Status>> getFetchCallByTimelineType(String fromId, String uptoId) { private Single<Response<List<Status>>> getFetchCallByTimelineType(String fromId, String uptoId) {
MastodonApi api = mastodonApi; MastodonApi api = mastodonApi;
switch (kind) { switch (kind) {
default: default:
@ -1197,37 +1200,31 @@ public class TimelineFragment extends SFragment implements
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))) .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe( .subscribe(
(result) -> onFetchTimelineSuccess(result, fetchEnd, pos), result -> onFetchTimelineSuccess(result, fetchEnd, pos),
(err) -> onFetchTimelineFailure(new Exception(err), fetchEnd, pos) err -> onFetchTimelineFailure(err, fetchEnd, pos)
); );
} else { } else {
Callback<List<Status>> callback = new Callback<List<Status>>() { getFetchCallByTimelineType(maxId, sinceId)
@Override .observeOn(AndroidSchedulers.mainThread())
public void onResponse(@NonNull Call<List<Status>> call, @NonNull Response<List<Status>> response) { .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
if (response.isSuccessful()) { .subscribe(
@Nullable response -> {
String newNextId = extractNextId(response); if (response.isSuccessful()) {
if (newNextId != null) { @Nullable
// when we reach the bottom of the list, we won't have a new link. If String newNextId = extractNextId(response);
// we blindly write `null` here we will start loading from the top if (newNextId != null) {
// again. // when we reach the bottom of the list, we won't have a new link. If
nextId = newNextId; // we blindly write `null` here we will start loading from the top
} // again.
onFetchTimelineSuccess(liftStatusList(response.body()), fetchEnd, pos); nextId = newNextId;
} else { }
onFetchTimelineFailure(new Exception(response.message()), fetchEnd, pos); onFetchTimelineSuccess(liftStatusList(response.body()), fetchEnd, pos);
} } else {
} onFetchTimelineFailure(new Exception(response.message()), fetchEnd, pos);
}
@Override },
public void onFailure(@NonNull Call<List<Status>> call, @NonNull Throwable t) { err -> onFetchTimelineFailure(err, fetchEnd, pos)
onFetchTimelineFailure((Exception) t, fetchEnd, pos); );
}
};
Call<List<Status>> listCall = getFetchCallByTimelineType(maxId, sinceId);
callList.add(listCall);
listCall.enqueue(callback);
} }
} }
@ -1304,7 +1301,7 @@ public class TimelineFragment extends SFragment implements
} }
} }
private void onFetchTimelineFailure(Exception exception, FetchEnd fetchEnd, int position) { private void onFetchTimelineFailure(Throwable throwable, FetchEnd fetchEnd, int position) {
if (isAdded()) { if (isAdded()) {
swipeRefreshLayout.setRefreshing(false); swipeRefreshLayout.setRefreshing(false);
topProgressBar.hide(); topProgressBar.hide();
@ -1323,7 +1320,7 @@ public class TimelineFragment extends SFragment implements
} else if (this.statuses.isEmpty()) { } else if (this.statuses.isEmpty()) {
swipeRefreshLayout.setEnabled(false); swipeRefreshLayout.setEnabled(false);
this.statusView.setVisibility(View.VISIBLE); this.statusView.setVisibility(View.VISIBLE);
if (exception instanceof IOException) { if (throwable instanceof IOException) {
this.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> { this.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> {
this.progressBar.setVisibility(View.VISIBLE); this.progressBar.setVisibility(View.VISIBLE);
this.onRefresh(); this.onRefresh();
@ -1338,7 +1335,7 @@ public class TimelineFragment extends SFragment implements
} }
} }
Log.e(TAG, "Fetch Failure: " + exception.getMessage()); Log.e(TAG, "Fetch Failure: " + throwable.getMessage());
updateBottomLoadingState(fetchEnd); updateBottomLoadingState(fetchEnd);
progressBar.setVisibility(View.GONE); progressBar.setVisibility(View.GONE);
} }
@ -1681,9 +1678,21 @@ public class TimelineFragment extends SFragment implements
} }
}; };
AccessibilityManager a11yManager;
boolean talkBackWasEnabled;
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
a11yManager = Objects.requireNonNull(
ContextCompat.getSystemService(requireContext(), AccessibilityManager.class)
);
boolean wasEnabled = this.talkBackWasEnabled;
talkBackWasEnabled = a11yManager.isEnabled();
Log.d(TAG, "talkback was enabled: " + wasEnabled + ", now " + talkBackWasEnabled);
if (talkBackWasEnabled && !wasEnabled) {
this.adapter.notifyDataSetChanged();
}
startUpdateTimestamp(); startUpdateTimestamp();
} }

View File

@ -17,10 +17,11 @@ package com.keylesspalace.tusky.fragment
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Attachment
abstract class ViewMediaFragment : BaseFragment() { abstract class ViewMediaFragment : Fragment() {
private var toolbarVisibiltyDisposable: Function0<Boolean>? = null private var toolbarVisibiltyDisposable: Function0<Boolean>? = null
abstract fun setupMediaView( abstract fun setupMediaView(

View File

@ -56,7 +56,6 @@ import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Poll; import com.keylesspalace.tusky.entity.Poll;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext;
import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.MastodonApi; import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.settings.PrefKeys; import com.keylesspalace.tusky.settings.PrefKeys;
@ -77,9 +76,6 @@ import java.util.Locale;
import javax.inject.Inject; import javax.inject.Inject;
import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.android.schedulers.AndroidSchedulers;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import static com.uber.autodispose.AutoDispose.autoDisposable; import static com.uber.autodispose.AutoDispose.autoDisposable;
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from; import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
@ -141,6 +137,7 @@ public final class ViewThreadFragment extends SFragment implements
CardViewMode.NONE, CardViewMode.NONE,
preferences.getBoolean("confirmReblogs", true), preferences.getBoolean("confirmReblogs", true),
preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, 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(ComposeViewModelKt.getCAN_USE_QUOTE_ID()).contains(accountManager.getActiveAccount().getDomain())
); );
adapter = new ThreadAdapter(statusDisplayOptions, this); adapter = new ThreadAdapter(statusDisplayOptions, this);
@ -471,49 +468,32 @@ public final class ViewThreadFragment extends SFragment implements
} }
private void sendStatusRequest(final String id) { private void sendStatusRequest(final String id) {
Call<Status> call = mastodonApi.status(id); mastodonApi.status(id)
call.enqueue(new Callback<Status>() { .observeOn(AndroidSchedulers.mainThread())
@Override .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) { .subscribe(
if (response.isSuccessful()) { status -> {
int position = setStatus(response.body()); int position = setStatus(status);
recyclerView.scrollToPosition(position); recyclerView.scrollToPosition(position);
} else { },
onThreadRequestFailure(id); throwable -> onThreadRequestFailure(id, throwable)
} );
}
@Override
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
onThreadRequestFailure(id);
}
});
callList.add(call);
} }
private void sendThreadRequest(final String id) { private void sendThreadRequest(final String id) {
Call<StatusContext> call = mastodonApi.statusContext(id); mastodonApi.statusContext(id)
call.enqueue(new Callback<StatusContext>() { .observeOn(AndroidSchedulers.mainThread())
@Override .as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
public void onResponse(@NonNull Call<StatusContext> call, @NonNull Response<StatusContext> response) { .subscribe(
StatusContext context = response.body(); context -> {
if (response.isSuccessful() && context != null) { swipeRefreshLayout.setRefreshing(false);
swipeRefreshLayout.setRefreshing(false); setContext(context.getAncestors(), context.getDescendants());
setContext(context.getAncestors(), context.getDescendants()); },
} else { throwable -> onThreadRequestFailure(id, throwable)
onThreadRequestFailure(id); );
}
}
@Override
public void onFailure(@NonNull Call<StatusContext> call, @NonNull Throwable t) {
onThreadRequestFailure(id);
}
});
callList.add(call);
} }
private void onThreadRequestFailure(final String id) { private void onThreadRequestFailure(final String id, final Throwable throwable) {
View view = getView(); View view = getView();
swipeRefreshLayout.setRefreshing(false); swipeRefreshLayout.setRefreshing(false);
if (view != null) { if (view != null) {
@ -524,7 +504,7 @@ public final class ViewThreadFragment extends SFragment implements
}) })
.show(); .show();
} else { } else {
Log.e(TAG, "Couldn't display thread fetch error message"); Log.e(TAG, "Network request failed", throwable);
} }
} }

View File

@ -56,14 +56,7 @@ interface MastodonApi {
@Query("max_id") maxId: String?, @Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?, @Query("since_id") sinceId: String?,
@Query("limit") limit: Int? @Query("limit") limit: Int?
): Call<List<Status>> ): Single<Response<List<Status>>>
@GET("api/v1/timelines/home")
fun homeTimelineSingle(
@Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?,
@Query("limit") limit: Int?
): Single<List<Status>>
@GET("api/v1/timelines/public") @GET("api/v1/timelines/public")
fun publicTimeline( fun publicTimeline(
@ -71,7 +64,7 @@ interface MastodonApi {
@Query("max_id") maxId: String?, @Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?, @Query("since_id") sinceId: String?,
@Query("limit") limit: Int? @Query("limit") limit: Int?
): Call<List<Status>> ): Single<Response<List<Status>>>
@GET("api/v1/timelines/tag/{hashtag}") @GET("api/v1/timelines/tag/{hashtag}")
fun hashtagTimeline( fun hashtagTimeline(
@ -81,7 +74,7 @@ interface MastodonApi {
@Query("max_id") maxId: String?, @Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?, @Query("since_id") sinceId: String?,
@Query("limit") limit: Int? @Query("limit") limit: Int?
): Call<List<Status>> ): Single<Response<List<Status>>>
@GET("api/v1/timelines/list/{listId}") @GET("api/v1/timelines/list/{listId}")
fun listTimeline( fun listTimeline(
@ -89,7 +82,7 @@ interface MastodonApi {
@Query("max_id") maxId: String?, @Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?, @Query("since_id") sinceId: String?,
@Query("limit") limit: Int? @Query("limit") limit: Int?
): Call<List<Status>> ): Single<Response<List<Status>>>
@GET("api/v1/notifications") @GET("api/v1/notifications")
fun notifications( fun notifications(
@ -97,7 +90,7 @@ interface MastodonApi {
@Query("since_id") sinceId: String?, @Query("since_id") sinceId: String?,
@Query("limit") limit: Int?, @Query("limit") limit: Int?,
@Query("exclude_types[]") excludes: Set<Notification.Type>? @Query("exclude_types[]") excludes: Set<Notification.Type>?
): Call<List<Notification>> ): Single<Response<List<Notification>>>
@GET("api/v1/markers") @GET("api/v1/markers")
fun markersWithAuth( fun markersWithAuth(
@ -114,17 +107,13 @@ interface MastodonApi {
): Single<List<Notification>> ): Single<List<Notification>>
@POST("api/v1/notifications/clear") @POST("api/v1/notifications/clear")
fun clearNotifications(): Call<ResponseBody> fun clearNotifications(): Single<ResponseBody>
@GET("api/v1/notifications/{id}")
fun notification(
@Path("id") notificationId: String
): Call<Notification>
@Multipart @Multipart
@POST("api/v1/media") @POST("api/v1/media")
fun uploadMedia( fun uploadMedia(
@Part file: MultipartBody.Part @Part file: MultipartBody.Part,
@Part description: MultipartBody.Part? = null
): Single<Attachment> ): Single<Attachment>
@FormUrlEncoded @FormUrlEncoded
@ -145,12 +134,12 @@ interface MastodonApi {
@GET("api/v1/statuses/{id}") @GET("api/v1/statuses/{id}")
fun status( fun status(
@Path("id") statusId: String @Path("id") statusId: String
): Call<Status> ): Single<Status>
@GET("api/v1/statuses/{id}/context") @GET("api/v1/statuses/{id}/context")
fun statusContext( fun statusContext(
@Path("id") statusId: String @Path("id") statusId: String
): Call<StatusContext> ): Single<StatusContext>
@GET("api/v1/statuses/{id}/reblogged_by") @GET("api/v1/statuses/{id}/reblogged_by")
fun statusRebloggedBy( fun statusRebloggedBy(
@ -289,7 +278,7 @@ interface MastodonApi {
@Query("exclude_replies") excludeReplies: Boolean?, @Query("exclude_replies") excludeReplies: Boolean?,
@Query("only_media") onlyMedia: Boolean?, @Query("only_media") onlyMedia: Boolean?,
@Query("pinned") pinned: Boolean? @Query("pinned") pinned: Boolean?
): Call<List<Status>> ): Single<Response<List<Status>>>
@GET("api/v1/accounts/{id}/followers") @GET("api/v1/accounts/{id}/followers")
fun accountFollowers( fun accountFollowers(
@ -330,7 +319,8 @@ interface MastodonApi {
@POST("api/v1/accounts/{id}/mute") @POST("api/v1/accounts/{id}/mute")
fun muteAccount( fun muteAccount(
@Path("id") accountId: String, @Path("id") accountId: String,
@Field("notifications") notifications: Boolean? = null @Field("notifications") notifications: Boolean? = null,
@Field("duration") duration: Int? = null
): Single<Relationship> ): Single<Relationship>
@POST("api/v1/accounts/{id}/unmute") @POST("api/v1/accounts/{id}/unmute")
@ -391,14 +381,14 @@ interface MastodonApi {
@Query("max_id") maxId: String?, @Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?, @Query("since_id") sinceId: String?,
@Query("limit") limit: Int? @Query("limit") limit: Int?
): Call<List<Status>> ): Single<Response<List<Status>>>
@GET("api/v1/bookmarks") @GET("api/v1/bookmarks")
fun bookmarks( fun bookmarks(
@Query("max_id") maxId: String?, @Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?, @Query("since_id") sinceId: String?,
@Query("limit") limit: Int? @Query("limit") limit: Int?
): Call<List<Status>> ): Single<Response<List<Status>>>
@GET("api/v1/follow_requests") @GET("api/v1/follow_requests")
fun followRequests( fun followRequests(
@ -408,20 +398,10 @@ interface MastodonApi {
@POST("api/v1/follow_requests/{id}/authorize") @POST("api/v1/follow_requests/{id}/authorize")
fun authorizeFollowRequest( fun authorizeFollowRequest(
@Path("id") accountId: String @Path("id") accountId: String
): Call<Relationship>
@POST("api/v1/follow_requests/{id}/reject")
fun rejectFollowRequest(
@Path("id") accountId: String
): Call<Relationship>
@POST("api/v1/follow_requests/{id}/authorize")
fun authorizeFollowRequestObservable(
@Path("id") accountId: String
): Single<Relationship> ): Single<Relationship>
@POST("api/v1/follow_requests/{id}/reject") @POST("api/v1/follow_requests/{id}/reject")
fun rejectFollowRequestObservable( fun rejectFollowRequest(
@Path("id") accountId: String @Path("id") accountId: String
): Single<Relationship> ): Single<Relationship>

View File

@ -33,7 +33,7 @@ interface TimelineCases {
fun reblog(status: Status, reblog: Boolean): Single<Status> fun reblog(status: Status, reblog: Boolean): Single<Status>
fun favourite(status: Status, favourite: Boolean): Single<Status> fun favourite(status: Status, favourite: Boolean): Single<Status>
fun bookmark(status: Status, bookmark: Boolean): Single<Status> fun bookmark(status: Status, bookmark: Boolean): Single<Status>
fun mute(id: String, notifications: Boolean) fun mute(id: String, notifications: Boolean, duration: Int)
fun block(id: String) fun block(id: String)
fun delete(id: String): Single<DeletedStatus> fun delete(id: String): Single<DeletedStatus>
fun pin(status: Status, pin: Boolean) fun pin(status: Status, pin: Boolean)
@ -104,8 +104,8 @@ class TimelineCasesImpl(
} }
} }
override fun mute(id: String, notifications: Boolean) { override fun mute(id: String, notifications: Boolean, duration: Int) {
mastodonApi.muteAccount(id, notifications) mastodonApi.muteAccount(id, notifications, duration)
.subscribe({ .subscribe({
eventHub.dispatch(MuteEvent(id)) eventHub.dispatch(MuteEvent(id))
}, { t -> }, { t ->

View File

@ -60,7 +60,6 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
val notificationManager = NotificationManagerCompat.from(context) val notificationManager = NotificationManagerCompat.from(context)
if (intent.action == NotificationHelper.REPLY_ACTION) { if (intent.action == NotificationHelper.REPLY_ACTION) {
val message = getReplyMessage(intent) val message = getReplyMessage(intent)
@ -89,22 +88,24 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
val sendIntent = SendTootService.sendTootIntent( val sendIntent = SendTootService.sendTootIntent(
context, context,
TootToSend( TootToSend(
text, text = text,
spoiler, warningText = spoiler,
visibility.serverString(), visibility = visibility.serverString(),
false, sensitive = false,
emptyList(), mediaIds = emptyList(),
emptyList(), mediaUris = emptyList(),
emptyList(), mediaDescriptions = emptyList(),
null, scheduledAt = null,
citedStatusId, inReplyToId = citedStatusId,
null, poll = null,
null, replyingStatusContent = null,
null, replyingStatusAuthorUsername = null,
null, null, account.id, quoteId = null,
0, accountId = account.id,
randomAlphanumericString(16), savedTootUid = -1,
0 draftId = -1,
idempotencyKey = randomAlphanumericString(16),
retries = 0
) )
) )
@ -155,4 +156,4 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
return remoteInput.getCharSequence(NotificationHelper.KEY_REPLY, "") return remoteInput.getCharSequence(NotificationHelper.KEY_REPLY, "")
} }
} }

View File

@ -78,9 +78,9 @@ class TimelineRepositoryImpl(
sinceIdMinusOne: String?, limit: Int, sinceIdMinusOne: String?, limit: Int,
accountId: Long, requestMode: TimelineRequestMode accountId: Long, requestMode: TimelineRequestMode
): Single<out List<TimelineStatus>> { ): Single<out List<TimelineStatus>> {
return mastodonApi.homeTimelineSingle(maxId, sinceIdMinusOne, limit + 1) return mastodonApi.homeTimeline(maxId, sinceIdMinusOne, limit + 1)
.map { statuses -> .map { response ->
this.saveStatusesToDb(accountId, statuses, maxId, sinceId) this.saveStatusesToDb(accountId, response.body().orEmpty(), maxId, sinceId)
} }
.flatMap { statuses -> .flatMap { statuses ->
this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode) this.addFromDbIfNeeded(accountId, statuses, maxId, sinceId, limit, requestMode)
@ -97,7 +97,7 @@ class TimelineRepositoryImpl(
private fun addFromDbIfNeeded(accountId: Long, statuses: List<Either<Placeholder, Status>>, private fun addFromDbIfNeeded(accountId: Long, statuses: List<Either<Placeholder, Status>>,
maxId: String?, sinceId: String?, limit: Int, maxId: String?, sinceId: String?, limit: Int,
requestMode: TimelineRequestMode requestMode: TimelineRequestMode
): Single<List<TimelineStatus>>? { ): Single<List<TimelineStatus>> {
return if (requestMode != NETWORK && statuses.size < 2) { return if (requestMode != NETWORK && statuses.size < 2) {
val newMaxID = if (statuses.isEmpty()) { val newMaxID = if (statuses.isEmpty()) {
maxId maxId

View File

@ -18,6 +18,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent
import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.Injectable
@ -46,7 +47,8 @@ class SendTootService : Service(), Injectable {
lateinit var eventHub: EventHub lateinit var eventHub: EventHub
@Inject @Inject
lateinit var database: AppDatabase lateinit var database: AppDatabase
@Inject
lateinit var draftHelper: DraftHelper
@Inject @Inject
lateinit var saveTootHelper: SaveTootHelper lateinit var saveTootHelper: SaveTootHelper
@ -164,6 +166,10 @@ class SendTootService : Service(), Injectable {
if (tootToSend.savedTootUid != 0) { if (tootToSend.savedTootUid != 0) {
saveTootHelper.deleteDraft(tootToSend.savedTootUid) saveTootHelper.deleteDraft(tootToSend.savedTootUid)
} }
if (tootToSend.draftId != 0) {
draftHelper.deleteDraftAndAttachments(tootToSend.draftId)
.subscribe()
}
if (scheduled) { if (scheduled) {
response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch) response.body()?.let(::StatusScheduledEvent)?.let(eventHub::dispatch)
@ -246,17 +252,19 @@ class SendTootService : Service(), Injectable {
private fun saveTootToDrafts(toot: TootToSend) { private fun saveTootToDrafts(toot: TootToSend) {
saveTootHelper.saveToot(toot.text, draftHelper.saveDraft(
toot.warningText, draftId = toot.draftId,
toot.savedJsonUrls, accountId = toot.accountId,
toot.mediaUris, inReplyToId = toot.inReplyToId,
toot.mediaDescriptions, content = toot.text,
toot.savedTootUid, contentWarning = toot.warningText,
toot.inReplyToId, sensitive = toot.sensitive,
toot.replyingStatusContent, visibility = Status.Visibility.byString(toot.visibility),
toot.replyingStatusAuthorUsername, mediaUris = toot.mediaUris,
Status.Visibility.byString(toot.visibility), mediaDescriptions = toot.mediaDescriptions,
toot.poll) poll = toot.poll,
failedToSend = true
).subscribe()
} }
private fun cancelSendingIntent(tootId: Int): PendingIntent { private fun cancelSendingIntent(tootId: Int): PendingIntent {
@ -324,10 +332,10 @@ data class TootToSend(
val poll: NewPoll?, val poll: NewPoll?,
val replyingStatusContent: String?, val replyingStatusContent: String?,
val replyingStatusAuthorUsername: String?, val replyingStatusAuthorUsername: String?,
val savedJsonUrls: List<String>?,
val quoteId: String?, val quoteId: String?,
val accountId: Long, val accountId: Long,
val savedTootUid: Int, val savedTootUid: Int,
val draftId: Int,
val idempotencyKey: String, val idempotencyKey: String,
var retries: Int var retries: Int
) : Parcelable ) : Parcelable

View File

@ -31,6 +31,7 @@ object PrefKeys {
const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines" const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines"
const val CONFIRM_REBLOGS = "confirmReblogs" const val CONFIRM_REBLOGS = "confirmReblogs"
const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs" const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs"
const val ANIMATE_CUSTOM_EMOJIS = "animateCustomEmojis"
const val LIMITED_BANDWIDTH_ACTIVE = "limitedBandwidthActive" const val LIMITED_BANDWIDTH_ACTIVE = "limitedBandwidthActive"
const val LIMITED_BANDWIDTH_ONLY_MOBILE_NETWORK = "limitedBandwidthOnlyMobileNetwork" const val LIMITED_BANDWIDTH_ONLY_MOBILE_NETWORK = "limitedBandwidthOnlyMobileNetwork"

View File

@ -0,0 +1,8 @@
package com.keylesspalace.tusky.util
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
class BindingViewHolder<T : ViewBinding>(
val binding: T
) : RecyclerView.ViewHolder(binding.root)

View File

@ -21,20 +21,52 @@ import android.text.TextUtils
import android.widget.MultiAutoCompleteTextView import android.widget.MultiAutoCompleteTextView
class ComposeTokenizer : MultiAutoCompleteTextView.Tokenizer { class ComposeTokenizer : MultiAutoCompleteTextView.Tokenizer {
private fun isMentionOrHashtagAllowedCharacter(character: Char) : Boolean {
return Character.isLetterOrDigit(character) || character == '_' // simple usernames
|| character == '-' // extended usernames
|| character == '.' // domain dot
}
override fun findTokenStart(text: CharSequence, cursor: Int): Int { override fun findTokenStart(text: CharSequence, cursor: Int): Int {
if (cursor == 0) { if (cursor == 0) {
return cursor return cursor
} }
var i = cursor var i = cursor
var character = text[i - 1] var character = text[i - 1]
while (i > 0 && character != '@' && character != '#' && character != ':') {
// See SpanUtils.MENTION_REGEX // go up to first illegal character or character we're looking for (@, # or :)
if (!Character.isLetterOrDigit(character) && character != '_') { while(i > 0 && !(character == '@' || character == '#' || character == ':')) {
if(!isMentionOrHashtagAllowedCharacter(character)) {
return cursor return cursor
} }
i-- i--
character = if (i == 0) ' ' else text[i - 1] character = if (i == 0) ' ' else text[i - 1]
} }
// maybe caught domain name? try search username
if(i > 2 && character == '@') {
var j = i - 1
var character2 = text[i - 2]
// again go up to first illegal character or tag "@"
while(j > 0 && character2 != '@') {
if(!isMentionOrHashtagAllowedCharacter(character2)) {
break
}
j--
character2 = if (j == 0) ' ' else text[j - 1]
}
// found mention symbol, override cursor
if(character2 == '@') {
i = j
character = character2
}
}
if (i < 1 if (i < 1
|| (character != '@' && character != '#' && character != ':') || (character != '@' && character != '#' && character != ':')
|| i > 1 && !Character.isWhitespace(text[i - 2])) { || i > 1 && !Character.isWhitespace(text[i - 2])) {

View File

@ -16,11 +16,9 @@
@file:JvmName("CustomEmojiHelper") @file:JvmName("CustomEmojiHelper")
package com.keylesspalace.tusky.util package com.keylesspalace.tusky.util
import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.*
import android.graphics.drawable.Drawable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.style.ReplacementSpan import android.text.style.ReplacementSpan
import android.view.View import android.view.View
@ -33,6 +31,8 @@ import com.keylesspalace.tusky.entity.Emoji
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.regex.Pattern import java.util.regex.Pattern
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.settings.PrefKeys
/** /**
* replaces emoji shortcodes in a text with EmojiSpans * replaces emoji shortcodes in a text with EmojiSpans
@ -41,7 +41,7 @@ import java.util.regex.Pattern
* @param view a reference to the a view the emojis will be shown in (should be the TextView, but parents of the TextView are also acceptable) * @param view a reference to the a view the emojis will be shown in (should be the TextView, but parents of the TextView are also acceptable)
* @return the text with the shortcodes replaced by EmojiSpans * @return the text with the shortcodes replaced by EmojiSpans
*/ */
fun CharSequence.emojify(emojis: List<Emoji>?, view: View) : CharSequence { fun CharSequence.emojify(emojis: List<Emoji>?, view: View, animate: Boolean) : CharSequence {
if(emojis.isNullOrEmpty()) if(emojis.isNullOrEmpty())
return this return this
@ -56,9 +56,9 @@ fun CharSequence.emojify(emojis: List<Emoji>?, view: View) : CharSequence {
builder.setSpan(span, matcher.start(), matcher.end(), 0) builder.setSpan(span, matcher.start(), matcher.end(), 0)
Glide.with(view) Glide.with(view)
.asBitmap() .asDrawable()
.load(url) .load(url)
.into(span.getTarget()) .into(span.getTarget(animate))
} }
} }
return builder return builder
@ -97,11 +97,29 @@ class EmojiSpan(val viewWeakReference: WeakReference<View>) : ReplacementSpan()
} }
} }
fun getTarget(): Target<Bitmap> { fun getTarget(animate : Boolean): Target<Drawable> {
return object : CustomTarget<Bitmap>() { return object : CustomTarget<Drawable>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) { override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
viewWeakReference.get()?.let { view -> viewWeakReference.get()?.let { view ->
imageDrawable = BitmapDrawable(view.context.resources, resource) if(animate && resource is Animatable) {
val callback = resource.callback
resource.callback = object: Drawable.Callback {
override fun unscheduleDrawable(p0: Drawable, p1: Runnable) {
callback?.unscheduleDrawable(p0, p1)
}
override fun scheduleDrawable(p0: Drawable, p1: Runnable, p2: Long) {
callback?.scheduleDrawable(p0, p1, p2)
}
override fun invalidateDrawable(p0: Drawable) {
callback?.invalidateDrawable(p0)
view.invalidate()
}
}
resource.start()
}
imageDrawable = resource
view.invalidate() view.invalidate()
} }
} }

View File

@ -19,7 +19,6 @@ import android.content.ActivityNotFoundException;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.text.SpannableStringBuilder; import android.text.SpannableStringBuilder;
import android.text.Spanned; import android.text.Spanned;
import android.text.method.LinkMovementMethod; import android.text.method.LinkMovementMethod;
@ -31,6 +30,7 @@ import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.browser.customtabs.CustomTabColorSchemeParams;
import androidx.browser.customtabs.CustomTabsIntent; import androidx.browser.customtabs.CustomTabsIntent;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
@ -229,18 +229,20 @@ public class LinkHelper {
*/ */
public static void openLinkInCustomTab(Uri uri, Context context) { public static void openLinkInCustomTab(Uri uri, Context context) {
int toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface); int toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface);
int navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor);
int navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor);
CustomTabsIntent.Builder customTabsIntentBuilder = new CustomTabsIntent.Builder() CustomTabColorSchemeParams colorSchemeParams = new CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor) .setToolbarColor(toolbarColor)
.setShowTitle(true); .setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build();
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder()
customTabsIntentBuilder.setNavigationBarColor( .setDefaultColorSchemeParams(colorSchemeParams)
ThemeUtils.getColor(context, android.R.attr.navigationBarColor) .setShowTitle(true)
); .build();
}
CustomTabsIntent customTabsIntent = customTabsIntentBuilder.build();
try { try {
customTabsIntent.launchUrl(context, uri); customTabsIntent.launchUrl(context, uri);
} catch (ActivityNotFoundException e) { } catch (ActivityNotFoundException e) {

View File

@ -1,115 +0,0 @@
/* Copyright 2020 Tusky Contributors
*
* This file is part of Tusky.
*
* Tusky is free software: you can redistribute it and/or modify it under the terms of the GNU
* Lesser 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 Lesser
* General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along with Tusky. If
* not, see <http://www.gnu.org/licenses/>. */
package com.keylesspalace.tusky.util
import android.content.Context
import android.os.Build
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.BuildConfig
import okhttp3.Cache
import okhttp3.OkHttp
import okhttp3.OkHttpClient
import okhttp3.tls.HandshakeCertificates
import java.io.ByteArrayInputStream
import java.net.InetSocketAddress
import java.net.Proxy
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
fun okhttpClient(context: Context): OkHttpClient.Builder {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
val httpProxyEnabled = preferences.getBoolean("httpProxyEnabled", false)
val httpServer = preferences.getNonNullString("httpProxyServer", "")
val httpPort = preferences.getNonNullString("httpProxyPort", "-1").toIntOrNull() ?: -1
val cacheSize = 25 * 1024 * 1024 // 25 MiB
val builder = OkHttpClient.Builder()
.addInterceptor { chain ->
/**
* Add a custom User-Agent that contains Tusky, Android and Okhttp Version to all requests
* Example:
* User-Agent: Tusky/1.1.2 Android/5.0.2
* */
val requestWithUserAgent = chain.request().newBuilder()
.header(
"User-Agent",
"Tusky/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.RELEASE} OkHttp/${OkHttp.VERSION}"
)
.build()
chain.proceed(requestWithUserAgent)
}
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.cache(Cache(context.cacheDir, cacheSize.toLong()))
if (httpProxyEnabled && httpServer.isNotEmpty() && httpPort > 0 && httpPort < 65535) {
val address = InetSocketAddress.createUnresolved(httpServer, httpPort)
builder.proxy(Proxy(Proxy.Type.HTTP, address))
}
// trust the new Let's Encrypt root certificate that is not available on Android < 7.1.1
// new cert https://letsencrypt.org/certs/isrgrootx1.pem
// see https://letsencrypt.org/2020/11/06/own-two-feet.html
// see https://stackoverflow.com/questions/64844311/certpathvalidatorexception-connecting-to-a-lets-encrypt-host-on-android-m-or-ea
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
val isgCert = """
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
""".trimIndent()
val cf = CertificateFactory.getInstance("X.509")
val isgCertificate = cf.generateCertificate(ByteArrayInputStream(isgCert.toByteArray(charset("UTF-8"))))
val certificates = HandshakeCertificates.Builder()
.addTrustedCertificate(isgCertificate as X509Certificate)
.addPlatformTrustedCertificates()
.build()
builder.sslSocketFactory(
certificates.sslSocketFactory(),
certificates.trustManager
)
}
return builder
}

View File

@ -1,5 +1,6 @@
package com.keylesspalace.tusky.util package com.keylesspalace.tusky.util
import androidx.annotation.CallSuper
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable import io.reactivex.disposables.Disposable
@ -9,6 +10,7 @@ open class RxAwareViewModel : ViewModel() {
fun Disposable.autoDispose() = disposables.add(this) fun Disposable.autoDispose() = disposables.add(this)
@CallSuper
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
disposables.clear() disposables.clear()

View File

@ -1,33 +1,18 @@
package com.keylesspalace.tusky.util; package com.keylesspalace.tusky.util;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Context; import android.content.Context;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask;
import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.FileProvider;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken; import com.google.gson.reflect.TypeToken;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.db.AppDatabase; import com.keylesspalace.tusky.db.AppDatabase;
import com.keylesspalace.tusky.db.TootDao; import com.keylesspalace.tusky.db.TootDao;
import com.keylesspalace.tusky.db.TootEntity; import com.keylesspalace.tusky.db.TootEntity;
import com.keylesspalace.tusky.entity.NewPoll;
import com.keylesspalace.tusky.entity.Status;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import javax.inject.Inject; import javax.inject.Inject;
@ -45,61 +30,6 @@ public final class SaveTootHelper {
this.context = context; this.context = context;
} }
@SuppressLint("StaticFieldLeak")
public boolean saveToot(@NonNull String content,
@NonNull String contentWarning,
@Nullable List<String> savedJsonUrls,
@NonNull List<String> mediaUris,
@NonNull List<String> mediaDescriptions,
int savedTootUid,
@Nullable String inReplyToId,
@Nullable String replyingStatusContent,
@Nullable String replyingStatusAuthorUsername,
@NonNull Status.Visibility statusVisibility,
@Nullable NewPoll poll) {
if (TextUtils.isEmpty(content) && mediaUris.isEmpty() && poll == null) {
return false;
}
// Get any existing file's URIs.
String mediaUrlsSerialized = null;
String mediaDescriptionsSerialized = null;
if (!ListUtils.isEmpty(mediaUris)) {
List<String> savedList = saveMedia(mediaUris, savedJsonUrls);
if (!ListUtils.isEmpty(savedList)) {
mediaUrlsSerialized = gson.toJson(savedList);
if (!ListUtils.isEmpty(savedJsonUrls)) {
deleteMedia(setDifference(savedJsonUrls, savedList));
}
} else {
return false;
}
mediaDescriptionsSerialized = gson.toJson(mediaDescriptions);
} else if (!ListUtils.isEmpty(savedJsonUrls)) {
/* If there were URIs in the previous draft, but they've now been removed, those files
* can be deleted. */
deleteMedia(savedJsonUrls);
}
final TootEntity toot = new TootEntity(savedTootUid, content, mediaUrlsSerialized, mediaDescriptionsSerialized, contentWarning,
inReplyToId,
replyingStatusContent,
replyingStatusAuthorUsername,
statusVisibility,
poll);
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
tootDao.insertOrReplace(toot);
return null;
}
}.execute();
return true;
}
public void deleteDraft(int tootId) { public void deleteDraft(int tootId) {
TootEntity item = tootDao.find(tootId); TootEntity item = tootDao.find(tootId);
if (item != null) { if (item != null) {
@ -124,82 +54,4 @@ public final class SaveTootHelper {
tootDao.delete(item.getUid()); tootDao.delete(item.getUid());
} }
@Nullable }
private List<String> saveMedia(@NonNull List<String> mediaUris,
@Nullable List<String> existingUris) {
File directory = context.getExternalFilesDir("Tusky");
if (directory == null || !(directory.exists())) {
Log.e(TAG, "Error obtaining directory to save media.");
return null;
}
ContentResolver contentResolver = context.getContentResolver();
ArrayList<File> filesSoFar = new ArrayList<>();
ArrayList<String> results = new ArrayList<>();
for (String mediaUri : mediaUris) {
/* If the media was already saved in a previous draft, there's no need to save another
* copy, just add the existing URI to the results. */
if (existingUris != null) {
int index = existingUris.indexOf(mediaUri);
if (index != -1) {
results.add(mediaUri);
continue;
}
}
// Otherwise, save the media.
Uri uri = Uri.parse(mediaUri);
String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date());
String mimeType = contentResolver.getType(uri);
MimeTypeMap map = MimeTypeMap.getSingleton();
String fileExtension = map.getExtensionFromMimeType(mimeType);
String filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension);
File file = new File(directory, filename);
filesSoFar.add(file);
boolean copied = IOUtils.copyToFile(contentResolver, uri, file);
if (!copied) {
/* If any media files were created in prior iterations, delete those before
* returning. */
for (File earlierFile : filesSoFar) {
boolean deleted = earlierFile.delete();
if (!deleted) {
Log.i(TAG, "Could not delete the file " + earlierFile.toString());
}
}
return null;
}
Uri resultUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file);
results.add(resultUri.toString());
}
return results;
}
private void deleteMedia(List<String> mediaUris) {
for (String uriString : mediaUris) {
Uri uri = Uri.parse(uriString);
if (context.getContentResolver().delete(uri, null, null) == 0) {
Log.e(TAG, String.format("Did not delete file %s.", uriString));
}
}
}
/**
* AB={xA|xB}
*
* @return all elements of set A that are not in set B.
*/
private static List<String> setDifference(List<String> a, List<String> b) {
List<String> c = new ArrayList<>();
for (String s : a) {
if (!b.contains(s)) {
c.add(s);
}
}
return c;
}
}

View File

@ -18,7 +18,7 @@ private const val TAG_REGEX = "(?:^|[^/)A-Za-z0-9_])#([\\w_]*[\\p{Alpha}_][\\w_]
* @see <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/account.rb"> * @see <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/account.rb">
* Account#MENTION_RE</a> * Account#MENTION_RE</a>
*/ */
private const val MENTION_REGEX = "(?:^|[^/[:word:]])@([a-z0-9_]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)" private const val MENTION_REGEX = "(?:^|[^/[:word:]])@([a-z0-9_-]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)"
private const val HTTP_URL_REGEX = "(?:(^|\\b)http://[^\\s]+)" private const val HTTP_URL_REGEX = "(?:(^|\\b)http://[^\\s]+)"
private const val HTTPS_URL_REGEX = "(?:(^|\\b)https://[^\\s]+)" private const val HTTPS_URL_REGEX = "(?:(^|\\b)https://[^\\s]+)"

View File

@ -17,6 +17,8 @@ data class StatusDisplayOptions(
val confirmReblogs: Boolean, val confirmReblogs: Boolean,
@get:JvmName("hideStats") @get:JvmName("hideStats")
val hideStats: Boolean, val hideStats: Boolean,
@get:JvmName("animateEmojis")
val animateEmojis: Boolean,
@get:JvmName("quoteEnabled") @get:JvmName("quoteEnabled")
val quoteEnabled: Boolean val quoteEnabled: Boolean
) )

View File

@ -228,7 +228,8 @@ class StatusViewHelper(private val itemView: View) {
return when (type) { return when (type) {
Attachment.Type.IMAGE -> context.getString(R.string.status_media_images) Attachment.Type.IMAGE -> context.getString(R.string.status_media_images)
Attachment.Type.GIFV, Attachment.Type.VIDEO -> context.getString(R.string.status_media_video) Attachment.Type.GIFV, Attachment.Type.VIDEO -> context.getString(R.string.status_media_video)
else -> context.getString(R.string.status_media_images) Attachment.Type.AUDIO -> context.getString(R.string.status_media_audio)
else -> context.getString(R.string.status_media_attachments)
} }
} }
@ -237,11 +238,12 @@ class StatusViewHelper(private val itemView: View) {
return when (type) { return when (type) {
Attachment.Type.IMAGE -> R.drawable.ic_photo_24dp Attachment.Type.IMAGE -> R.drawable.ic_photo_24dp
Attachment.Type.GIFV, Attachment.Type.VIDEO -> R.drawable.ic_videocam_24dp Attachment.Type.GIFV, Attachment.Type.VIDEO -> R.drawable.ic_videocam_24dp
else -> R.drawable.ic_photo_24dp Attachment.Type.AUDIO -> R.drawable.ic_music_box_24dp
else -> R.drawable.ic_attach_file_24dp
} }
} }
fun setupPollReadonly(poll: PollViewData?, emojis: List<Emoji>, useAbsoluteTime: Boolean) { fun setupPollReadonly(poll: PollViewData?, emojis: List<Emoji>, statusDisplayOptions: StatusDisplayOptions) {
val pollResults = listOf<TextView>( val pollResults = listOf<TextView>(
itemView.findViewById(R.id.status_poll_option_result_0), itemView.findViewById(R.id.status_poll_option_result_0),
itemView.findViewById(R.id.status_poll_option_result_1), itemView.findViewById(R.id.status_poll_option_result_1),
@ -259,10 +261,10 @@ class StatusViewHelper(private val itemView: View) {
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
setupPollResult(poll, emojis, pollResults) setupPollResult(poll, emojis, pollResults, statusDisplayOptions.animateEmojis)
pollDescription.visibility = View.VISIBLE pollDescription.visibility = View.VISIBLE
pollDescription.text = getPollInfoText(timestamp, poll, pollDescription, useAbsoluteTime) pollDescription.text = getPollInfoText(timestamp, poll, pollDescription, statusDisplayOptions.useAbsoluteTime)
} }
} }
@ -290,7 +292,7 @@ class StatusViewHelper(private val itemView: View) {
} }
private fun setupPollResult(poll: PollViewData, emojis: List<Emoji>, pollResults: List<TextView>) { private fun setupPollResult(poll: PollViewData, emojis: List<Emoji>, pollResults: List<TextView>, animateEmojis: Boolean) {
val options = poll.options val options = poll.options
for (i in 0 until Status.MAX_POLL_OPTIONS) { for (i in 0 until Status.MAX_POLL_OPTIONS) {
@ -298,7 +300,7 @@ class StatusViewHelper(private val itemView: View) {
val percent = calculatePercent(options[i].votesCount, poll.votersCount, poll.votesCount) val percent = calculatePercent(options[i].votesCount, poll.votersCount, poll.votesCount)
val pollOptionText = buildDescription(options[i].title, percent, pollResults[i].context) val pollOptionText = buildDescription(options[i].title, percent, pollResults[i].context)
pollResults[i].text = pollOptionText.emojify(emojis, pollResults[i]) pollResults[i].text = pollOptionText.emojify(emojis, pollResults[i], animateEmojis)
pollResults[i].visibility = View.VISIBLE pollResults[i].visibility = View.VISIBLE
val level = percent * 100 val level = percent * 100

View File

@ -52,7 +52,7 @@ public final class ViewDataUtils {
.setSensitive(visibleStatus.getSensitive()) .setSensitive(visibleStatus.getSensitive())
.setIsShowingSensitiveContent(alwaysShowSensitiveMedia || !visibleStatus.getSensitive()) .setIsShowingSensitiveContent(alwaysShowSensitiveMedia || !visibleStatus.getSensitive())
.setSpoilerText(visibleStatus.getSpoilerText()) .setSpoilerText(visibleStatus.getSpoilerText())
.setRebloggedByUsername(status.getReblog() == null ? null : status.getAccount().getUsername()) .setRebloggedByUsername(status.getReblog() == null ? null : status.getAccount().getDisplayName())
.setUserFullName(visibleStatus.getAccount().getName()) .setUserFullName(visibleStatus.getAccount().getName())
.setVisibility(visibleStatus.getVisibility()) .setVisibility(visibleStatus.getVisibility())
.setSenderId(visibleStatus.getAccount().getId()) .setSenderId(visibleStatus.getAccount().getId())
@ -60,6 +60,7 @@ public final class ViewDataUtils {
.setApplication(visibleStatus.getApplication()) .setApplication(visibleStatus.getApplication())
.setStatusEmojis(visibleStatus.getEmojis()) .setStatusEmojis(visibleStatus.getEmojis())
.setAccountEmojis(visibleStatus.getAccount().getEmojis()) .setAccountEmojis(visibleStatus.getAccount().getEmojis())
.setRebloggedByEmojis(status.getReblog() == null ? null : status.getAccount().getEmojis())
.setCollapsible(SmartLengthInputFilterKt.shouldTrimStatus(visibleStatus.getContent())) .setCollapsible(SmartLengthInputFilterKt.shouldTrimStatus(visibleStatus.getContent()))
.setCollapsed(true) .setCollapsed(true)
.setPoll(visibleStatus.getPoll()) .setPoll(visibleStatus.getPoll())

View File

@ -4,6 +4,7 @@ package com.keylesspalace.tusky.view
import android.app.Activity import android.app.Activity
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
@ -11,7 +12,7 @@ import com.keylesspalace.tusky.R
fun showMuteAccountDialog( fun showMuteAccountDialog(
activity: Activity, activity: Activity,
accountUsername: String, accountUsername: String,
onOk: (notifications: Boolean) -> Unit onOk: (notifications: Boolean, duration: Int) -> Unit
) { ) {
val view = activity.layoutInflater.inflate(R.layout.dialog_mute_account, null) val view = activity.layoutInflater.inflate(R.layout.dialog_mute_account, null)
(view.findViewById(R.id.warning) as TextView).text = (view.findViewById(R.id.warning) as TextView).text =
@ -21,7 +22,11 @@ fun showMuteAccountDialog(
AlertDialog.Builder(activity) AlertDialog.Builder(activity)
.setView(view) .setView(view)
.setPositiveButton(android.R.string.ok) { _, _ -> onOk(checkbox.isChecked) } .setPositiveButton(android.R.string.ok) { _, _ ->
val spinner: Spinner = view.findViewById(R.id.duration)
val durationValues = activity.resources.getIntArray(R.array.mute_duration_values)
onOk(checkbox.isChecked, durationValues[spinner.selectedItemPosition])
}
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
} }

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