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 {
experimental = true
}
buildFeatures {
viewBinding true
}
testOptions {
unitTests {
returnDefaultValues = true
@ -144,7 +147,7 @@ dependencies {
implementation "androidx.room:room-rxjava2:$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:converter-gson:$retrofitVersion"

View File

@ -43,6 +43,10 @@
public *;
}
-keep enum com.keylesspalace.tusky.db.DraftAttachment$Type {
public *;
}
# preserve line numbers for crash reporting
-keepattributes SourceFile,LineNumberTable
-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.scheduled.ScheduledTootActivity" />
<activity android:name=".components.announcements.AnnouncementsActivity" />
<activity android:name=".components.drafts.DraftsActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
<receiver

View File

@ -7,7 +7,6 @@ import android.text.SpannableStringBuilder
import android.text.method.LinkMovementMethod
import android.text.style.URLSpan
import android.text.util.Linkify
import android.view.MenuItem
import android.widget.TextView
import androidx.annotation.StringRes
import com.keylesspalace.tusky.di.Injectable
@ -59,17 +58,6 @@ class AboutActivity : BottomSheetActivity(), Injectable {
private fun onEasterEggExecute() {
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) {

View File

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

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import com.keylesspalace.tusky.fragment.AccountListFragment
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
@ -68,16 +67,6 @@ class AccountListActivity : BaseActivity(), HasAndroidInjector {
.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
companion object {

View File

@ -31,6 +31,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
import com.keylesspalace.tusky.viewmodel.State
@ -71,7 +72,9 @@ class AccountsInListFragment : DialogFragment(), Injectable {
private val searchAdapter = SearchAdapter()
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?) {
super.onCreate(savedInstanceState)
@ -209,7 +212,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
}
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
loadAvatar(account.avatar, avatar, radius, animateAvatar)
}
@ -252,7 +255,7 @@ class AccountsInListFragment : DialogFragment(), Injectable {
override val containerView = itemView
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
loadAvatar(account.avatar, avatar, radius, animateAvatar)

View File

@ -24,6 +24,7 @@ import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
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);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
public void finish() {
super.finish();

View File

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

View File

@ -1,7 +1,6 @@
package com.keylesspalace.tusky
import android.os.Bundle
import android.view.MenuItem
import android.widget.AdapterView
import android.widget.ArrayAdapter
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 androidx.annotation.RawRes
import android.util.Log
import android.view.MenuItem
import android.widget.TextView
import com.keylesspalace.tusky.util.IOUtils
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) {
val sb = StringBuilder()

View File

@ -21,7 +21,6 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.*
@ -130,19 +129,27 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
else R.string.action_rename_list) { _, _ ->
onPickedDialogName(editText.text, list?.id)
}
.setNegativeButton(android.R.string.cancel) { d, _ ->
d.dismiss()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE)
editText.onTextChanged { s, _, _, _ ->
positiveButton.isEnabled = !s.isBlank()
positiveButton.isEnabled = s.isNotBlank()
}
editText.setText(list?.title)
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) {
adapter.submitList(state.lists)
@ -199,7 +206,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
when (item.itemId) {
R.id.list_edit -> openListSettings(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
}
true
@ -210,14 +217,6 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
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>() {
override fun areItemsTheSame(oldItem: MastoList, newItem: MastoList): Boolean {
return oldItem.id == newItem.id

View File

@ -20,14 +20,13 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import com.bumptech.glide.Glide
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
* 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 {
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)
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
customTabsIntentBuilder.setNavigationBarColor(
ThemeUtils.getColor(context, android.R.attr.navigationBarColor)
)
}
val customTabsIntent = CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(colorSchemeParams)
.build()
val customTabsIntent = customTabsIntentBuilder.build()
try {
customTabsIntent.launchUrl(context, uri)
} catch (e: ActivityNotFoundException) {

View File

@ -40,6 +40,7 @@ import androidx.appcompat.view.menu.MenuBuilder
import androidx.appcompat.widget.PopupMenu
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.emoji.text.EmojiCompat
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.Companion.canHandleMimeType
import com.keylesspalace.tusky.components.conversation.ConversationsRepository
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.fragment.NotificationsFragment
@ -115,6 +119,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
@Inject
lateinit var conversationRepository: ConversationsRepository
@Inject
lateinit var appDb: AppDatabase
@Inject
lateinit var draftHelper: DraftHelper
@Inject
lateinit var viewModelFactory: ViewModelFactory
@ -262,6 +272,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
// Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
}
draftWarning()
}
override fun onResume() {
@ -448,7 +459,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
nameRes = R.string.action_access_saved_toot
iconRes = R.drawable.ic_notebook
onClick = {
val intent = Intent(context, SavedTootActivity::class.java)
val intent = DraftsActivity.newIntent(context)
startActivityWithSlideInAnimation(intent)
}
},
@ -733,6 +744,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0
mainToolbar.title = tabs[activeTabPosition].title(this@MainActivity)
mainToolbar.setOnClickListener {
(adapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect()
}
keepScreenOn()
return popups
@ -783,6 +797,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
NotificationHelper.deleteNotificationChannelsForAccount(activeAccount, this)
cacheUpdater.clearForUser(activeAccount.id)
conversationRepository.deleteCacheForAccount(activeAccount.id)
draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id)
removeShortcut(this, activeAccount)
val newAccount = accountManager.logActiveAccountOut()
if (!NotificationHelper.areNotificationsEnabled(this, accountManager)) {
@ -861,16 +876,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
if(placeholder != null) {
if (placeholder != null) {
mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
mainToolbar.navigationIcon = resource
mainToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize)
}
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() {
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc ->
val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header))
val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header, animateEmojis))
ProfileDrawerItem().apply {
isSelected = acc.isActive
@ -920,6 +938,29 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
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 androidInjector() = androidInjector

View File

@ -3,7 +3,6 @@ package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.lifecycle.Lifecycle
import com.google.android.material.floatingactionbutton.FloatingActionButton
@ -80,14 +79,6 @@ class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasAn
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
}

View File

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

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
@ -85,14 +84,6 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
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
companion object {

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.widget.FrameLayout
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() {
super.onPause()
if (tabsChanged) {

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.MenuItem;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
@ -95,17 +94,6 @@ public class ViewTagActivity extends BottomSheetActivity implements HasAndroidIn
.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
public AndroidInjector<Object> androidInjector() {
return dispatchingAndroidInjector;

View File

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

View File

@ -33,10 +33,14 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
List<Account> accountList;
AccountActionListener accountActionListener;
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.accountActionListener = accountActionListener;
this.animateAvatar = animateAvatar;
this.animateEmojis = animateEmojis;
bottomLoading = false;
}

View File

@ -31,7 +31,7 @@ import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.emojify
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 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)
} else {
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
val emojifiedValue = field.value.emojify(emojis, viewHolder.valueTextView)
val emojifiedValue = field.value.emojify(emojis, viewHolder.valueTextView, animateEmojis)
LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener)
if(field.verifiedAt != null) {

View File

@ -23,6 +23,7 @@ import android.widget.ArrayAdapter
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
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 displayName = view.display_name
val avatar = view.avatar
val pm = PreferenceManager.getDefaultSharedPreferences(avatar.context)
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
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 animateAvatar = PreferenceManager.getDefaultSharedPreferences(avatar.context)
.getBoolean("animateGifAvatars", false)
val animateAvatar = pm.getBoolean("animateGifAvatars", false)
loadAvatar(account.profilePictureUrl, avatar, avatarRadius, animateAvatar)

View File

@ -22,7 +22,6 @@ public class AccountViewHolder extends RecyclerView.ViewHolder {
private ImageView avatarInset;
private String accountId;
private boolean showBotOverlay;
private boolean animateAvatar;
public AccountViewHolder(View itemView) {
super(itemView);
@ -32,15 +31,14 @@ public class AccountViewHolder extends RecyclerView.ViewHolder {
avatarInset = itemView.findViewById(R.id.account_avatar_inset);
SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(itemView.getContext());
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();
String format = username.getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.getUsername());
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);
int avatarRadius = avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_48dp);

View File

@ -34,8 +34,8 @@ import com.keylesspalace.tusky.util.ImageLoadingHelper;
public class BlocksAdapter extends AccountAdapter {
public BlocksAdapter(AccountActionListener accountActionListener) {
super(accountActionListener);
public BlocksAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener, animateAvatar, animateEmojis);
}
@NonNull
@ -60,7 +60,7 @@ public class BlocksAdapter extends AccountAdapter {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
BlockedUserViewHolder holder = (BlockedUserViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position));
holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis);
holder.setupActionListener(accountActionListener);
}
}
@ -71,7 +71,6 @@ public class BlocksAdapter extends AccountAdapter {
private TextView displayName;
private ImageButton unblock;
private String id;
private boolean animateAvatar;
BlockedUserViewHolder(View itemView) {
super(itemView);
@ -79,14 +78,12 @@ public class BlocksAdapter extends AccountAdapter {
username = itemView.findViewById(R.id.blocked_user_username);
displayName = itemView.findViewById(R.id.blocked_user_display_name);
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();
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName);
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(), account.getEmojis(), displayName, animateEmojis);
displayName.setText(emojifiedName);
String format = username.getContext().getString(R.string.status_username_format);
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. */
public class FollowAdapter extends AccountAdapter {
public FollowAdapter(AccountActionListener accountActionListener) {
super(accountActionListener);
public FollowAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener, animateAvatar, animateEmojis);
}
@NonNull
@ -53,7 +53,7 @@ public class FollowAdapter extends AccountAdapter {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
AccountViewHolder holder = (AccountViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position));
holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis);
holder.setupActionListener(accountActionListener);
}
}

View File

@ -10,27 +10,24 @@ import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.unicodeWrap
import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.util.*
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 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
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
if (showHeader) {
val wholeMessage: String = itemView.context.getString(R.string.notification_follow_request_format, wrappedName)
itemView.notificationTextView?.text = SpannableStringBuilder(wholeMessage).apply {
setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}.emojify(account.emojis, itemView)
}.emojify(account.emojis, itemView, animateEmojis)
}
itemView.notificationTextView?.visible(showHeader)
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 FollowRequestsAdapter(AccountActionListener accountActionListener) {
super(accountActionListener);
public FollowRequestsAdapter(AccountActionListener accountActionListener, boolean animateAvatar, boolean animateEmojis) {
super(accountActionListener, animateAvatar, animateEmojis);
}
@NonNull
@ -53,7 +53,7 @@ public class FollowRequestsAdapter extends AccountAdapter {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position));
holder.setupWithAccount(accountList.get(position), animateAvatar, animateEmojis);
holder.setupActionListener(accountActionListener);
}
}

View File

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

View File

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

View File

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

View File

@ -190,8 +190,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected abstract int getMediaPreviewHeight(Context context);
protected void setDisplayName(String name, List<Emoji> customEmojis) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(name, customEmojis, displayName);
protected void setDisplayName(String name, List<Emoji> customEmojis, StatusDisplayOptions statusDisplayOptions) {
CharSequence emojifiedName = CustomEmojiHelper.emojify(
name, customEmojis, displayName, statusDisplayOptions.animateEmojis()
);
displayName.setText(emojifiedName);
}
@ -215,7 +217,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
final StatusActionListener listener) {
boolean sensitive = !TextUtils.isEmpty(spoilerText);
if (sensitive) {
CharSequence emojiSpoiler = CustomEmojiHelper.emojify(spoilerText, emojis, contentWarningDescription);
CharSequence emojiSpoiler = CustomEmojiHelper.emojify(
spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis()
);
contentWarningDescription.setText(emojiSpoiler);
contentWarningDescription.setVisibility(View.VISIBLE);
contentWarningButton.setVisibility(View.VISIBLE);
@ -254,7 +258,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
StatusDisplayOptions statusDisplayOptions,
final StatusActionListener listener) {
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);
for (int i = 0; i < mediaLabels.length; ++i) {
updateMediaLabel(i, sensitive, expanded);
@ -613,7 +617,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
@DrawableRes
private static int getLabelIcon(Attachment.Type type) {
switch (type) {
default:
case IMAGE:
return R.drawable.ic_photo_24dp;
case GIFV:
@ -621,6 +624,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
return R.drawable.ic_videocam_24dp;
case AUDIO:
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,
@Nullable Object payloads) {
if (payloads == null) {
setDisplayName(status.getUserFullName(), status.getAccountEmojis());
setDisplayName(status.getUserFullName(), status.getAccountEmojis(), statusDisplayOptions);
setUsername(status.getNickname());
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
setStatusVisibility(status.getVisibility());
@ -826,7 +831,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setBookmarked(status.isBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.isSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && !hasAudioAttachment(attachments)) {
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
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) {
if (attachment.getType() == Attachment.Type.AUDIO) {
return true;
if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) {
return false;
}
}
return false;
return true;
}
private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status,
@ -1035,12 +1040,28 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
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);
} else {
// 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);

View File

@ -27,8 +27,10 @@ import androidx.recyclerview.widget.RecyclerView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.StatusDisplayOptions;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import at.connyduck.sparkbutton.helpers.Utils;
@ -64,7 +66,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
if (rebloggedByDisplayName == null) {
hideStatusInfo();
} else {
setRebloggedByDisplayName(rebloggedByDisplayName);
setRebloggedByDisplayName(rebloggedByDisplayName, status, statusDisplayOptions);
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();
String boostedText = context.getString(R.string.status_boosted_format, name);
statusInfo.setText(boostedText);
CharSequence wrappedName = StringUtils.unicodeWrap(name);
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);
}

View File

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

View File

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

View File

@ -19,7 +19,6 @@ import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.widget.PopupWindow
import androidx.activity.viewModels
@ -83,8 +82,9 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
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
@ -123,16 +123,6 @@ class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener,
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() {
viewModel.load()
swipeRefreshLayout.isRefreshing = true

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky.components.compose
import android.Manifest
import android.app.Activity
import android.app.ProgressDialog
import android.app.TimePickerDialog
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
@ -32,7 +31,6 @@ import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.provider.MediaStore
import android.text.TextUtils
import android.util.Log
import android.view.KeyEvent
import android.view.MenuItem
@ -60,7 +58,6 @@ import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
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.showAddPollDialog
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.DraftAttachment
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.*
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
@ -86,7 +86,6 @@ import java.io.File
import java.io.IOException
import java.util.*
import javax.inject.Inject
import kotlin.collections.ArrayList
import kotlin.math.max
import kotlin.math.min
@ -96,7 +95,7 @@ class ComposeActivity : BaseActivity(),
OnEmojiSelectedListener,
Injectable,
InputConnectionCompat.OnCommitContentListener,
TimePickerDialog.OnTimeSetListener {
ComposeScheduleView.OnTimeSetListener {
@Inject
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
private var finishingUploadDialog: ProgressDialog? = null
private var photoUploadUri: Uri? = null
@VisibleForTesting
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
private var composeOptions: ComposeOptions? = null
private val viewModel: ComposeViewModel by viewModels { viewModelFactory }
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
* based on what the intent from the reply request passes. */
if (intent != null) {
this.composeOptions = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor)
setupQuoteView(composeOptions?.quoteStatusAuthor)
val tootText = composeOptions?.tootText
if (!tootText.isNullOrEmpty()) {
composeEditField.setText(tootText)
}
val composeOptions: ComposeOptions? = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent)
setupQuoteView(composeOptions?.quoteStatusAuthor, composeOptions?.quoteStatusContent)
val tootText = composeOptions?.tootText
if (!tootText.isNullOrEmpty()) {
composeEditField.setText(tootText)
}
if (loadInstanceData(preferences)) {
if (loadInstanceData(preferences, composeOptions?.tootRightNow == true)) {
viewModel.loadInstanceDataFromNetwork()
} else {
viewModel.loadInstanceDataFromCache()
}
if (!TextUtils.isEmpty(composeOptions?.scheduledAt)) {
if (!composeOptions?.scheduledAt.isNullOrEmpty()) {
composeScheduleView.setDateTime(composeOptions?.scheduledAt)
}
setupComposeField(viewModel.startingText)
setupComposeField(preferences, viewModel.startingText)
setupDefaultTagViews(preferences)
setupContentWarningField(composeOptions?.contentWarning)
setupPollView()
@ -188,8 +187,8 @@ class ComposeActivity : BaseActivity(),
}
}
private fun loadInstanceData(preferences: SharedPreferences): Boolean {
if (composeOptions?.tootRightNow == true) {
private fun loadInstanceData(preferences: SharedPreferences, tootRightNow: Boolean): Boolean {
if (tootRightNow) {
return false // from Quick Toot
}
if (!preferences.getBoolean("limitedBandwidthActive", false)) {
@ -214,38 +213,24 @@ class ComposeActivity : BaseActivity(),
return false
}
private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) {
if (intent != null && savedInstanceState == null) {
private fun applyShareIntent(intent: Intent, savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
/* 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
* instance state will be re-queued. */
val type = intent.type
if (type != null) {
intent.type?.also { type ->
if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) {
val uriList = ArrayList<Uri>()
if (intent.action != null) {
when (intent.action) {
Intent.ACTION_SEND -> {
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
if (uri != null) {
uriList.add(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)
}
}
}
when (intent.action) {
Intent.ACTION_SEND -> {
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uri ->
pickMedia(uri)
}
}
Intent.ACTION_SEND_MULTIPLE -> {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.forEach { uri ->
pickMedia(uri)
}
}
}
for (uri in uriList) {
pickMedia(uri)
}
} else if (type == "text/plain" && intent.action == Intent.ACTION_SEND) {
@ -263,13 +248,16 @@ class ComposeActivity : BaseActivity(),
val left = min(start, end)
val right = max(start, end)
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) {
composeReplyView.show()
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) {
composeQuoteView.show()
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?) {
@ -330,13 +318,18 @@ class ComposeActivity : BaseActivity(),
composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
}
private fun setupComposeField(startingText: String?) {
private fun setupComposeField(preferences: SharedPreferences, startingText: String?) {
composeEditField.setOnCommitContentListener(this)
composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
composeEditField.setAdapter(
ComposeAutoCompleteAdapter(this))
ComposeAutoCompleteAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
)
composeEditField.setTokenizer(ComposeTokenizer())
composeEditField.setText(startingText)
@ -446,6 +439,7 @@ class ComposeActivity : BaseActivity(),
composeHideMediaButton.setOnClickListener { toggleHideMedia() }
composeScheduleButton.setOnClickListener { onScheduleClick() }
composeScheduleView.setResetOnClickListener { resetSchedule() }
composeScheduleView.setListener(this)
atButton.setOnClickListener { atButtonClicked() }
hashButton.setOnClickListener { hashButtonClicked() }
@ -743,7 +737,6 @@ class ComposeActivity : BaseActivity(),
}
}
private fun removePoll() {
viewModel.poll.value = null
pollPreview.hide()
@ -934,22 +927,22 @@ class ComposeActivity : BaseActivity(),
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent)
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.
pickMedia(intent.data!!)
}else if(intent.clipData != null){
} else if (intent.clipData != null) {
val clipData = intent.clipData!!
val count = clipData.itemCount
if(mediaCount + count > maxUploadMediaNumber){
if (mediaCount + count > maxUploadMediaNumber) {
// 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()
}else{
} else {
// if not grater then 4, upload all multiple media.
for (i in 0 until count) {
val imageUri = clipData.getItemAt(i).getUri()
pickMedia(imageUri)
}
val imageUri = clipData.getItemAt(i).getUri()
pickMedia(imageUri)
}
}
}
} else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
pickMedia(photoUploadUri!!)
@ -1099,9 +1092,8 @@ class ComposeActivity : BaseActivity(),
}
}
override fun onTimeSet(view: TimePicker, hourOfDay: Int, minute: Int) {
composeScheduleView.onTimeSet(hourOfDay, minute)
viewModel.updateScheduledAt(composeScheduleView.time)
override fun onTimeSet(time: String) {
viewModel.updateScheduledAt(time)
if (verifyScheduledTime()) {
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
} else {
@ -1117,8 +1109,9 @@ class ComposeActivity : BaseActivity(),
@Parcelize
data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin
var scheduledTootUid: String? = null,
var scheduledTootId: String? = null,
var savedTootUid: Int? = null,
var draftId: Int? = null,
var tootText: String? = null,
var mediaUrls: List<String>? = null,
var mediaDescriptions: List<String>? = null,
@ -1133,6 +1126,7 @@ class ComposeActivity : BaseActivity(),
var replyingStatusAuthor: String? = null,
var replyingStatusContent: String? = null,
var mediaAttachments: List<Attachment>? = null,
var draftAttachments: List<DraftAttachment>? = null,
var scheduledAt: String? = null,
var sensitive: Boolean? = null,
var poll: NewPoll? = null,
@ -1166,7 +1160,6 @@ class ComposeActivity : BaseActivity(),
}
}
@JvmStatic
fun canHandleMimeType(mimeType: String?): Boolean {
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,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter;
package com.keylesspalace.tusky.components.compose;
import android.content.Context;
import android.preference.PreferenceManager;
@ -53,11 +53,15 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
private final ArrayList<AutocompleteResult> resultList;
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();
resultList = new ArrayList<>();
this.autocompletionProvider = autocompletionProvider;
this.animateAvatar = animateAvatar;
this.animateEmojis = animateEmojis;
}
@Override
@ -147,15 +151,12 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter
);
accountViewHolder.username.setText(formattedUsername);
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(),
account.getEmojis(), accountViewHolder.displayName);
account.getEmojis(), accountViewHolder.displayName, animateEmojis);
accountViewHolder.displayName.setText(emojifiedName);
int avatarRadius = accountViewHolder.avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
boolean animateAvatar = PreferenceManager.getDefaultSharedPreferences(accountViewHolder.avatar.getContext())
.getBoolean("animateGifAvatars", false);
ImageLoadingHelper.loadAvatar(
account.getAvatar(),
accountViewHolder.avatar,

View File

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

View File

@ -17,7 +17,6 @@ package com.keylesspalace.tusky.components.compose.view;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.util.AttributeSet;
import android.widget.Button;
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.DateValidatorPointForward;
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.fragment.TimePickerFragment;
import java.text.DateFormat;
import java.text.ParseException;
@ -44,6 +44,12 @@ import java.util.TimeZone;
public class ComposeScheduleView extends ConstraintLayout {
public interface OnTimeSetListener {
void onTimeSet(String time);
}
private OnTimeSetListener listener;
private DateFormat dateFormat;
private DateFormat timeFormat;
private SimpleDateFormat iso8601;
@ -92,6 +98,10 @@ public class ComposeScheduleView extends ConstraintLayout {
setEditIcons();
}
public void setListener(OnTimeSetListener listener) {
this.listener = listener;
}
private void setScheduledDateTime() {
if (scheduleDateTime == null) {
scheduledDateTimeView.setText("");
@ -144,13 +154,20 @@ public class ComposeScheduleView extends ConstraintLayout {
}
private void openPickTimeDialog() {
TimePickerFragment picker = new TimePickerFragment();
MaterialTimePicker.Builder pickerBuilder = new MaterialTimePicker.Builder();
if (scheduleDateTime != null) {
Bundle args = new Bundle();
args.putInt(TimePickerFragment.PICKER_TIME_HOUR, scheduleDateTime.get(Calendar.HOUR_OF_DAY));
args.putInt(TimePickerFragment.PICKER_TIME_MINUTE, scheduleDateTime.get(Calendar.MINUTE));
picker.setArguments(args);
pickerBuilder.setHour(scheduleDateTime.get(Calendar.HOUR_OF_DAY))
.setMinute(scheduleDateTime.get(Calendar.MINUTE));
}
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");
}
@ -200,11 +217,14 @@ public class ComposeScheduleView extends ConstraintLayout {
openPickTimeDialog();
}
public void onTimeSet(int hourOfDay, int minute) {
private void onTimeSet(int hourOfDay, int minute) {
initializeSuggestedTime();
scheduleDateTime.set(Calendar.HOUR_OF_DAY, hourOfDay);
scheduleDateTime.set(Calendar.MINUTE, minute);
setScheduledDateTime();
if (listener != null) {
listener.onTimeSet(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);
setDisplayName(account.getDisplayName(), account.getEmojis());
setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions);
setUsername(account.getUsername());
setCreatedAt(status.getCreatedAt(), statusDisplayOptions);
setIsReply(status.getInReplyToId() != null);
@ -83,7 +83,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
setBookmarked(status.getBookmarked());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if (statusDisplayOptions.mediaPreviewEnabled() && !hasAudioAttachment(attachments)) {
if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) {
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(),
statusDisplayOptions.useBlurhash());

View File

@ -69,6 +69,7 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
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
)

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
import android.os.Bundle
import android.view.MenuItem
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
@ -32,16 +31,6 @@ class InstanceListActivity: BaseActivity(), HasAndroidInjector {
.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
}

View File

@ -5,6 +5,7 @@ import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.DividerItemDecoration
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.interfaces.InstanceActionListener
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.fragment.BaseFragment
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.hide
@ -30,7 +30,7 @@ import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class InstanceListFragment: BaseFragment(), Injectable, InstanceActionListener {
class InstanceListFragment: Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener {
@Inject
lateinit var api: MastodonApi
@ -39,10 +39,6 @@ class InstanceListFragment: BaseFragment(), Injectable, InstanceActionListener {
private var adapter = DomainMutesAdapter(this)
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?) {
super.onViewCreated(view, savedInstanceState)

View File

@ -20,8 +20,8 @@ import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import android.view.MenuItem
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.MainActivity
@ -59,33 +59,36 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference
setDisplayShowHomeEnabled(true)
}
val fragment: Fragment = when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) {
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")
}
val fragmentTag = "preference_fragment_$EXTRA_PREFERENCE_TYPE"
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, fragment)
.commit()
val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag)
?: when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) {
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)
@ -101,16 +104,6 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference
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) {
outState.putBoolean("restart", restartActivitiesOnExit)
}

View File

@ -171,6 +171,13 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
setTitle(R.string.pref_title_enable_swipe_for_tabs)
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) {

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky.components.report
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
@ -30,7 +29,6 @@ import kotlinx.android.synthetic.main.activity_report.*
import kotlinx.android.synthetic.main.toolbar_basic.*
import javax.inject.Inject
class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject
@ -120,16 +118,6 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
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 {
private const val ACCOUNT_ID = "account_id"
private const val ACCOUNT_USERNAME = "account_username"

View File

@ -75,7 +75,7 @@ class StatusViewHolder(
sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive),
mediaViewHeight)
statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions.useAbsoluteTime)
statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions)
setCreatedAt(status.createdAt)
}
@ -90,7 +90,7 @@ class StatusViewHolder(
itemView.statusContentWarningButton.hide()
itemView.statusContentWarningDescription.hide()
} 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.show()
itemView.statusContentWarningButton.show()
@ -126,7 +126,7 @@ class StatusViewHolder(
listener: LinkListener,
removeQuote: Boolean) {
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)
} else {
LinkHelper.setClickableMentions(itemView.statusContent, mentions, listener)

View File

@ -113,6 +113,7 @@ class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), Inje
cardViewMode = CardViewMode.NONE,
confirmReblogs = preferences.getBoolean("confirmReblogs", true),
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
)

View File

@ -18,7 +18,6 @@ package com.keylesspalace.tusky.components.scheduled
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.DividerItemDecoration
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() {
viewModel.reload()
}
override fun edit(item: ScheduledStatus) {
val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions(
scheduledTootUid = item.id,
scheduledTootId = item.id,
tootText = item.params.text,
contentWarning = item.params.spoilerText,
mediaAttachments = item.mediaAttachments,

View File

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

View File

@ -245,8 +245,8 @@ class SearchViewModel @Inject constructor(
return accountManager.getAllAccountsOrderedByActive()
}
fun muteAccount(accountId: String, notifications: Boolean) {
timelineCases.mute(accountId, notifications)
fun muteAccount(accountId: String, notifications: Boolean, duration: Int) {
timelineCases.mute(accountId, notifications, duration)
}
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.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) {
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) {
getItem(position)?.let { item ->
(holder as AccountViewHolder).apply {
setupWithAccount(item)
setupWithAccount(item, animateAvatars, animateEmojis)
setupLinkListener(linkListener)
}
}

View File

@ -18,12 +18,23 @@ package com.keylesspalace.tusky.components.search.fragments
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
import androidx.paging.PagedListAdapter
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.NetworkState
import kotlinx.android.synthetic.main.fragment_search.*
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>
get() = viewModel.networkStateAccountRefresh

View File

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

View File

@ -28,9 +28,9 @@ import com.keylesspalace.tusky.components.conversation.ConversationEntity;
* 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
}, version = 24)
}, version = 25)
public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao();
@ -38,6 +38,7 @@ public abstract class AppDatabase extends RoomDatabase {
public abstract InstanceDao instanceDao();
public abstract ConversationsDao conversationDao();
public abstract TimelineDao timelineDao();
public abstract DraftDao draftDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
@ -46,7 +47,6 @@ public abstract class AppDatabase extends RoomDatabase {
database.execSQL("INSERT INTO TootEntity2 SELECT * FROM TootEntity;");
database.execSQL("DROP TABLE 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.components.conversation.ConversationAccountEntity
import com.keylesspalace.tusky.createTabDataFromId
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.util.trimTrailingWhitespace
import java.net.URLDecoder
@ -155,4 +152,23 @@ class Converters {
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).
indices = [Index("authorServerId", "timelineUserId")]
)
@TypeConverters(TootEntity.Converters::class)
@TypeConverters(Converters::class)
data class TimelineStatusEntity(
val serverId: String, // id never flips: we need it for sorting so it's a real id
val url: String?,

View File

@ -16,12 +16,12 @@
package com.keylesspalace.tusky.db;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import java.util.List;
import io.reactivex.Observable;
/**
* Created by cto3543 on 28/06/2017.
*
@ -30,8 +30,6 @@ import java.util.List;
@Dao
public interface TootDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertOrReplace(TootEntity users);
@Query("SELECT * FROM TootEntity ORDER BY uid DESC")
List<TootEntity> loadAll();
@ -41,4 +39,7 @@ public interface TootDao {
@Query("SELECT * FROM TootEntity WHERE uid = :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.components.announcements.AnnouncementsActivity
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.preference.PreferencesActivity
import com.keylesspalace.tusky.components.report.ReportActivity
@ -109,6 +110,9 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity
@ContributesAndroidInjector
abstract fun contributesDraftActivity(): DraftsActivity
@ContributesAndroidInjector
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_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
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()
}

View File

@ -16,6 +16,8 @@
package com.keylesspalace.tusky.di
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.text.Spanned
import com.google.gson.Gson
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.MastodonApi
import com.keylesspalace.tusky.network.NotestockApi
import com.keylesspalace.tusky.util.okhttpClient
import com.keylesspalace.tusky.util.getNonNullString
import dagger.Module
import dagger.Provides
import net.accelf.yuito.HttpToastInterceptor
import okhttp3.Cache
import okhttp3.OkHttp
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import java.net.InetSocketAddress
import java.net.Proxy
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
/**
@ -56,9 +63,37 @@ class NetworkModule {
@Singleton
fun providesHttpClient(
accountManager: AccountManager,
context: Context
context: Context,
preferences: SharedPreferences
): 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 {
addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
if (BuildConfig.DEBUG) {
@ -89,18 +124,10 @@ class NetworkModule {
@Provides
@Singleton
fun providesNotestockApi(context: Context,
fun providesNotestockApi(okHttpClient: OkHttpClient,
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")
.client(httpClient)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava2CallAdapterFactory.createAsync())
.build()

View File

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

View File

@ -17,10 +17,10 @@ package com.keylesspalace.tusky.fragment
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
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.interfaces.AccountActionListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
@ -45,14 +46,12 @@ import com.uber.autodispose.autoDispose
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.fragment_account_list.*
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
import java.util.*
import javax.inject.Inject
class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, Injectable {
@Inject
lateinit var api: MastodonApi
@ -71,10 +70,6 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
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?) {
super.onViewCreated(view, savedInstanceState)
@ -85,11 +80,15 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
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) {
Type.BLOCKS -> BlocksAdapter(this)
Type.MUTES -> MutesAdapter(this)
Type.FOLLOW_REQUESTS -> FollowRequestsAdapter(this)
else -> FollowAdapter(this)
Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis)
Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis)
Type.FOLLOW_REQUESTS -> FollowRequestsAdapter(this, animateAvatar, animateEmojis)
else -> FollowAdapter(this, animateAvatar, animateEmojis)
}
recyclerView.adapter = adapter
@ -202,27 +201,23 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
override fun onRespondToFollowRequest(accept: Boolean, accountId: String,
position: Int) {
val callback = object : Callback<Relationship> {
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) {
if (accept) {
api.authorizeFollowRequest(accountId)
} else {
api.rejectFollowRequest(accountId)
}
callList.add(call)
call.enqueue(callback)
}.observeOn(AndroidSchedulers.mainThread())
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
.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) {
@ -230,15 +225,6 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
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>>> {
return when (type) {
Type.FOLLOWS -> {

View File

@ -18,12 +18,13 @@ package com.keylesspalace.tusky.fragment
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
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.interfaces.RefreshableFragment
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.view.SquareImageView
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 retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
import java.util.*
@ -53,7 +57,7 @@ import javax.inject.Inject
* 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 {
@JvmStatic
fun newInstance(accountId: String, enableSwipeToRefresh:Boolean=true): AccountMediaFragment {
@ -77,14 +81,13 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
lateinit var api: MastodonApi
private val adapter = MediaGridAdapter()
private var currentCall: Call<List<Status>>? = null
private val statuses = mutableListOf<Status>()
private var fetchingStatus = FetchingStatus.NOT_FETCHING
private lateinit var accountId: String
private val callback = object : Callback<List<Status>> {
override fun onFailure(call: Call<List<Status>>?, t: Throwable?) {
private val callback = object : SingleObserver<Response<List<Status>>> {
override fun onError(t: Throwable) {
fetchingStatus = FetchingStatus.NOT_FETCHING
if (isAdded) {
@ -106,7 +109,7 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
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
if (isAdded) {
swipeRefreshLayout.isRefreshing = false
@ -127,22 +130,23 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
if (statuses.isEmpty()) {
statusView.show()
statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty,
null)
statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty)
}
}
}
}
override fun onSubscribe(d: Disposable) {}
}
private val bottomCallback = object : Callback<List<Status>> {
override fun onFailure(call: Call<List<Status>>?, t: Throwable?) {
private val bottomCallback = object : SingleObserver<Response<List<Status>>> {
override fun onError(t: Throwable) {
fetchingStatus = FetchingStatus.NOT_FETCHING
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
val body = response.body()
body?.let { fetched ->
@ -159,6 +163,7 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
}
}
override fun onSubscribe(d: Disposable) { }
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -166,10 +171,6 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
isSwipeToRefreshEnabled = arguments?.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH,true) == true
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?) {
super.onViewCreated(view, savedInstanceState)
@ -201,8 +202,10 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
statuses.lastOrNull()?.let { (id) ->
Log.d(TAG, "Requesting statuses with max_id: ${id}, (bottom)")
fetchingStatus = FetchingStatus.FETCHING_BOTTOM
currentCall = api.accountStatuses(accountId, id, null, null, null, true, null)
currentCall?.enqueue(bottomCallback)
api.accountStatuses(accountId, id, null, null, null, true, null)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY)
.subscribe(bottomCallback)
}
}
}
@ -215,14 +218,15 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
private fun refresh() {
statusView.hide()
if (fetchingStatus != FetchingStatus.NOT_FETCHING) return
currentCall = if (statuses.isEmpty()) {
if (statuses.isEmpty()) {
fetchingStatus = FetchingStatus.INITIAL_FETCHING
api.accountStatuses(accountId, null, null, null, null, true, null)
} else {
fetchingStatus = FetchingStatus.REFRESHING
api.accountStatuses(accountId, null, statuses[0].id, null, null, true, null)
}
currentCall?.enqueue(callback)
}.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(callback)
if (!isSwipeToRefreshEnabled)
topProgressBar?.show()
@ -234,8 +238,10 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
}
if (fetchingStatus == FetchingStatus.NOT_FETCHING && statuses.isEmpty()) {
fetchingStatus = FetchingStatus.INITIAL_FETCHING
currentCall = api.accountStatuses(accountId, null, null, null, null, true, null)
currentCall?.enqueue(callback)
api.accountStatuses(accountId, null, null, null, null, true, null)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this@AccountMediaFragment, Lifecycle.Event.ON_DESTROY)
.subscribe(callback)
}
else if (needToRefresh)
refresh()
@ -260,10 +266,8 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
}
}
Attachment.Type.UNKNOWN -> {
}/* Intentionally do nothing. This case is here is to handle when new attachment
* 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. */
LinkHelper.openLink(items[currentIndex].attachment.url, context)
}
}
}
@ -340,5 +344,4 @@ class AccountMediaFragment : BaseFragment(), RefreshableFragment, Injectable {
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.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.disposables.Disposable;
import kotlin.Unit;
import kotlin.collections.CollectionsKt;
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.uber.autodispose.AutoDispose.autoDisposable;
@ -128,8 +126,9 @@ public class NotificationsFragment extends SFragment implements
private static final int LOAD_AT_ONCE = 30;
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 {
TOP,
@ -257,6 +256,7 @@ public class NotificationsFragment extends SFragment implements
CardViewMode.NONE,
preferences.getBoolean("confirmReblogs", true),
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())
);
@ -694,32 +694,21 @@ public class NotificationsFragment extends SFragment implements
updateAdapter();
//Execute clear notifications request
Call<ResponseBody> call = mastodonApi.clearNotifications();
call.enqueue(new Callback<ResponseBody>() {
@Override
public void onResponse(@NonNull Call<ResponseBody> call, @NonNull Response<ResponseBody> response) {
if (isAdded()) {
if (!response.isSuccessful()) {
//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);
mastodonApi.clearNotifications()
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
response -> {
// nothing to do
},
throwable -> {
//Reload notifications on failure
fullyRefreshWithProgressBar(true);
});
}
private void resetNotificationsLoad() {
for (Call callItem : callList) {
callItem.cancel();
}
callList.clear();
disposables.clear();
bottomLoading = false;
topLoading = false;
@ -849,8 +838,8 @@ public class NotificationsFragment extends SFragment implements
@Override
public void onRespondToFollowRequest(boolean accept, String id, int position) {
Single<Relationship> request = accept ?
mastodonApi.authorizeFollowRequestObservable(id) :
mastodonApi.rejectFollowRequestObservable(id);
mastodonApi.authorizeFollowRequest(id) :
mastodonApi.rejectFollowRequest(id);
request.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
@ -968,27 +957,20 @@ public class NotificationsFragment extends SFragment implements
bottomLoading = true;
}
Call<List<Notification>> call = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null);
call.enqueue(new Callback<List<Notification>>() {
@Override
public void onResponse(@NonNull Call<List<Notification>> call,
@NonNull Response<List<Notification>> response) {
if (response.isSuccessful()) {
String linkHeader = response.headers().get("Link");
onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos);
} else {
onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos);
}
}
@Override
public void onFailure(@NonNull Call<List<Notification>> call, @NonNull Throwable t) {
if (!call.isCanceled())
onFetchNotificationsFailure((Exception) t, fetchEnd, pos);
}
});
callList.add(call);
Disposable notificationCall = mastodonApi.notifications(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
response -> {
if (response.isSuccessful()) {
String linkHeader = response.headers().get("Link");
onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos);
} else {
onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos);
}
},
throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos));
disposables.add(notificationCall);
}
private void onFetchNotificationsSuccess(List<Notification> notifications, String linkHeader,
@ -1047,7 +1029,7 @@ public class NotificationsFragment extends SFragment implements
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);
if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) {
Placeholder placeholder = notifications.get(position).asLeft();
@ -1059,7 +1041,7 @@ public class NotificationsFragment extends SFragment implements
this.statusView.setVisibility(View.VISIBLE);
swipeRefreshLayout.setEnabled(false);
this.showingError = true;
if (exception instanceof IOException) {
if (throwable instanceof IOException) {
this.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> {
this.progressBar.setVisibility(View.VISIBLE);
this.onRefresh();
@ -1074,7 +1056,7 @@ public class NotificationsFragment extends SFragment implements
}
updateFilterVisibility();
}
Log.e(TAG, "Fetch failure: " + exception.getMessage());
Log.e(TAG, "Fetch failure: " + throwable.getMessage());
if (fetchEnd == FetchEnd.TOP) {
topLoading = false;

View File

@ -20,7 +20,6 @@ import android.app.DownloadManager;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
@ -30,8 +29,6 @@ import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.CheckBox;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
@ -41,14 +38,14 @@ import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.PopupMenu;
import androidx.core.app.ActivityOptionsCompat;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Lifecycle;
import androidx.preference.PreferenceManager;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BottomSheetActivity;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.PostLookupFallbackBehavior;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.ViewTagActivity;
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.network.MastodonApi;
import com.keylesspalace.tusky.network.TimelineCases;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.view.MuteAccountDialog;
import com.keylesspalace.tusky.viewdata.AttachmentViewData;
@ -75,9 +73,8 @@ import java.util.regex.Pattern;
import javax.inject.Inject;
import kotlin.Unit;
import io.reactivex.android.schedulers.AndroidSchedulers;
import kotlin.Unit;
import retrofit2.Call;
import retrofit2.Callback;
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
* overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear
* 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);
@ -102,7 +99,7 @@ public abstract class SFragment extends BaseFragment implements Injectable {
private static List<Filter> filters;
private boolean filterRemoveRegex;
private Matcher filterRemoveRegexMatcher;
private static Matcher alphanumeric = Pattern.compile("^\\w+$").matcher("");
private static final Matcher alphanumeric = Pattern.compile("^\\w+$").matcher("");
@Inject
public MastodonApi mastodonApi;
@ -367,8 +364,8 @@ public abstract class SFragment extends BaseFragment implements Injectable {
MuteAccountDialog.showMuteAccountDialog(
this.getActivity(),
accountUsername,
(notifications) -> {
timelineCases.mute(accountId, notifications);
(notifications, duration) -> {
timelineCases.mute(accountId, notifications, duration);
return Unit.INSTANCE;
}
);
@ -422,10 +419,9 @@ public abstract class SFragment extends BaseFragment implements Injectable {
}
break;
}
default:
case UNKNOWN: {
/* Intentionally do nothing. This case is here is to handle when new attachment
* 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. */
LinkHelper.openLink(active.getUrl(), getContext());
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.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityManager;
import android.widget.ProgressBar;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.arch.core.util.Function;
import androidx.core.content.ContextCompat;
import androidx.core.util.Pair;
import androidx.core.widget.ContentLoadingProgressBar;
import androidx.lifecycle.Lifecycle;
@ -105,12 +107,14 @@ import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import at.connyduck.sparkbutton.helpers.Utils;
import io.reactivex.Observable;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
import kotlin.Unit;
import kotlin.collections.CollectionsKt;
@ -118,8 +122,6 @@ import kotlin.jvm.functions.Function1;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.WebSocket;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import static android.content.Context.CONNECTIVITY_SERVICE;
@ -285,6 +287,7 @@ public class TimelineFragment extends SFragment implements
CardViewMode.NONE,
preferences.getBoolean("confirmReblogs", true),
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())
);
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;
switch (kind) {
default:
@ -1197,37 +1200,31 @@ public class TimelineFragment extends SFragment implements
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
(result) -> onFetchTimelineSuccess(result, fetchEnd, pos),
(err) -> onFetchTimelineFailure(new Exception(err), fetchEnd, pos)
result -> onFetchTimelineSuccess(result, fetchEnd, pos),
err -> onFetchTimelineFailure(err, fetchEnd, pos)
);
} else {
Callback<List<Status>> callback = new Callback<List<Status>>() {
@Override
public void onResponse(@NonNull Call<List<Status>> call, @NonNull Response<List<Status>> response) {
if (response.isSuccessful()) {
@Nullable
String newNextId = extractNextId(response);
if (newNextId != null) {
// when we reach the bottom of the list, we won't have a new link. If
// we blindly write `null` here we will start loading from the top
// again.
nextId = newNextId;
}
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) {
onFetchTimelineFailure((Exception) t, fetchEnd, pos);
}
};
Call<List<Status>> listCall = getFetchCallByTimelineType(maxId, sinceId);
callList.add(listCall);
listCall.enqueue(callback);
getFetchCallByTimelineType(maxId, sinceId)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
response -> {
if (response.isSuccessful()) {
@Nullable
String newNextId = extractNextId(response);
if (newNextId != null) {
// when we reach the bottom of the list, we won't have a new link. If
// we blindly write `null` here we will start loading from the top
// again.
nextId = newNextId;
}
onFetchTimelineSuccess(liftStatusList(response.body()), fetchEnd, pos);
} else {
onFetchTimelineFailure(new Exception(response.message()), fetchEnd, pos);
}
},
err -> onFetchTimelineFailure(err, fetchEnd, pos)
);
}
}
@ -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()) {
swipeRefreshLayout.setRefreshing(false);
topProgressBar.hide();
@ -1323,7 +1320,7 @@ public class TimelineFragment extends SFragment implements
} else if (this.statuses.isEmpty()) {
swipeRefreshLayout.setEnabled(false);
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.progressBar.setVisibility(View.VISIBLE);
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);
progressBar.setVisibility(View.GONE);
}
@ -1681,9 +1678,21 @@ public class TimelineFragment extends SFragment implements
}
};
AccessibilityManager a11yManager;
boolean talkBackWasEnabled;
@Override
public void 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();
}

View File

@ -17,10 +17,11 @@ package com.keylesspalace.tusky.fragment
import android.os.Bundle
import android.text.TextUtils
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.ViewMediaActivity
import com.keylesspalace.tusky.entity.Attachment
abstract class ViewMediaFragment : BaseFragment() {
abstract class ViewMediaFragment : Fragment() {
private var toolbarVisibiltyDisposable: Function0<Boolean>? = null
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.Poll;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.entity.StatusContext;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.settings.PrefKeys;
@ -77,9 +76,6 @@ import java.util.Locale;
import javax.inject.Inject;
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.android.lifecycle.AndroidLifecycleScopeProvider.from;
@ -141,6 +137,7 @@ public final class ViewThreadFragment extends SFragment implements
CardViewMode.NONE,
preferences.getBoolean("confirmReblogs", true),
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())
);
adapter = new ThreadAdapter(statusDisplayOptions, this);
@ -471,49 +468,32 @@ public final class ViewThreadFragment extends SFragment implements
}
private void sendStatusRequest(final String id) {
Call<Status> call = mastodonApi.status(id);
call.enqueue(new Callback<Status>() {
@Override
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) {
int position = setStatus(response.body());
recyclerView.scrollToPosition(position);
} else {
onThreadRequestFailure(id);
}
}
@Override
public void onFailure(@NonNull Call<Status> call, @NonNull Throwable t) {
onThreadRequestFailure(id);
}
});
callList.add(call);
mastodonApi.status(id)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
status -> {
int position = setStatus(status);
recyclerView.scrollToPosition(position);
},
throwable -> onThreadRequestFailure(id, throwable)
);
}
private void sendThreadRequest(final String id) {
Call<StatusContext> call = mastodonApi.statusContext(id);
call.enqueue(new Callback<StatusContext>() {
@Override
public void onResponse(@NonNull Call<StatusContext> call, @NonNull Response<StatusContext> response) {
StatusContext context = response.body();
if (response.isSuccessful() && context != null) {
swipeRefreshLayout.setRefreshing(false);
setContext(context.getAncestors(), context.getDescendants());
} else {
onThreadRequestFailure(id);
}
}
@Override
public void onFailure(@NonNull Call<StatusContext> call, @NonNull Throwable t) {
onThreadRequestFailure(id);
}
});
callList.add(call);
mastodonApi.statusContext(id)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
context -> {
swipeRefreshLayout.setRefreshing(false);
setContext(context.getAncestors(), context.getDescendants());
},
throwable -> onThreadRequestFailure(id, throwable)
);
}
private void onThreadRequestFailure(final String id) {
private void onThreadRequestFailure(final String id, final Throwable throwable) {
View view = getView();
swipeRefreshLayout.setRefreshing(false);
if (view != null) {
@ -524,7 +504,7 @@ public final class ViewThreadFragment extends SFragment implements
})
.show();
} 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("since_id") sinceId: String?,
@Query("limit") limit: Int?
): Call<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>>
): Single<Response<List<Status>>>
@GET("api/v1/timelines/public")
fun publicTimeline(
@ -71,7 +64,7 @@ interface MastodonApi {
@Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?,
@Query("limit") limit: Int?
): Call<List<Status>>
): Single<Response<List<Status>>>
@GET("api/v1/timelines/tag/{hashtag}")
fun hashtagTimeline(
@ -81,7 +74,7 @@ interface MastodonApi {
@Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?,
@Query("limit") limit: Int?
): Call<List<Status>>
): Single<Response<List<Status>>>
@GET("api/v1/timelines/list/{listId}")
fun listTimeline(
@ -89,7 +82,7 @@ interface MastodonApi {
@Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?,
@Query("limit") limit: Int?
): Call<List<Status>>
): Single<Response<List<Status>>>
@GET("api/v1/notifications")
fun notifications(
@ -97,7 +90,7 @@ interface MastodonApi {
@Query("since_id") sinceId: String?,
@Query("limit") limit: Int?,
@Query("exclude_types[]") excludes: Set<Notification.Type>?
): Call<List<Notification>>
): Single<Response<List<Notification>>>
@GET("api/v1/markers")
fun markersWithAuth(
@ -114,17 +107,13 @@ interface MastodonApi {
): Single<List<Notification>>
@POST("api/v1/notifications/clear")
fun clearNotifications(): Call<ResponseBody>
@GET("api/v1/notifications/{id}")
fun notification(
@Path("id") notificationId: String
): Call<Notification>
fun clearNotifications(): Single<ResponseBody>
@Multipart
@POST("api/v1/media")
fun uploadMedia(
@Part file: MultipartBody.Part
@Part file: MultipartBody.Part,
@Part description: MultipartBody.Part? = null
): Single<Attachment>
@FormUrlEncoded
@ -145,12 +134,12 @@ interface MastodonApi {
@GET("api/v1/statuses/{id}")
fun status(
@Path("id") statusId: String
): Call<Status>
): Single<Status>
@GET("api/v1/statuses/{id}/context")
fun statusContext(
@Path("id") statusId: String
): Call<StatusContext>
): Single<StatusContext>
@GET("api/v1/statuses/{id}/reblogged_by")
fun statusRebloggedBy(
@ -289,7 +278,7 @@ interface MastodonApi {
@Query("exclude_replies") excludeReplies: Boolean?,
@Query("only_media") onlyMedia: Boolean?,
@Query("pinned") pinned: Boolean?
): Call<List<Status>>
): Single<Response<List<Status>>>
@GET("api/v1/accounts/{id}/followers")
fun accountFollowers(
@ -330,7 +319,8 @@ interface MastodonApi {
@POST("api/v1/accounts/{id}/mute")
fun muteAccount(
@Path("id") accountId: String,
@Field("notifications") notifications: Boolean? = null
@Field("notifications") notifications: Boolean? = null,
@Field("duration") duration: Int? = null
): Single<Relationship>
@POST("api/v1/accounts/{id}/unmute")
@ -391,14 +381,14 @@ interface MastodonApi {
@Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?,
@Query("limit") limit: Int?
): Call<List<Status>>
): Single<Response<List<Status>>>
@GET("api/v1/bookmarks")
fun bookmarks(
@Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?,
@Query("limit") limit: Int?
): Call<List<Status>>
): Single<Response<List<Status>>>
@GET("api/v1/follow_requests")
fun followRequests(
@ -408,20 +398,10 @@ interface MastodonApi {
@POST("api/v1/follow_requests/{id}/authorize")
fun authorizeFollowRequest(
@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>
@POST("api/v1/follow_requests/{id}/reject")
fun rejectFollowRequestObservable(
fun rejectFollowRequest(
@Path("id") accountId: String
): Single<Relationship>

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,7 @@ object PrefKeys {
const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines"
const val CONFIRM_REBLOGS = "confirmReblogs"
const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs"
const val ANIMATE_CUSTOM_EMOJIS = "animateCustomEmojis"
const val LIMITED_BANDWIDTH_ACTIVE = "limitedBandwidthActive"
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
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 {
if (cursor == 0) {
return cursor
}
var i = cursor
var character = text[i - 1]
while (i > 0 && character != '@' && character != '#' && character != ':') {
// See SpanUtils.MENTION_REGEX
if (!Character.isLetterOrDigit(character) && character != '_') {
// go up to first illegal character or character we're looking for (@, # or :)
while(i > 0 && !(character == '@' || character == '#' || character == ':')) {
if(!isMentionOrHashtagAllowedCharacter(character)) {
return cursor
}
i--
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
|| (character != '@' && character != '#' && character != ':')
|| i > 1 && !Character.isWhitespace(text[i - 2])) {

View File

@ -16,11 +16,9 @@
@file:JvmName("CustomEmojiHelper")
package com.keylesspalace.tusky.util
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.*
import android.text.SpannableStringBuilder
import android.text.style.ReplacementSpan
import android.view.View
@ -33,6 +31,8 @@ import com.keylesspalace.tusky.entity.Emoji
import java.lang.ref.WeakReference
import java.util.regex.Pattern
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.settings.PrefKeys
/**
* 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)
* @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())
return this
@ -56,9 +56,9 @@ fun CharSequence.emojify(emojis: List<Emoji>?, view: View) : CharSequence {
builder.setSpan(span, matcher.start(), matcher.end(), 0)
Glide.with(view)
.asBitmap()
.asDrawable()
.load(url)
.into(span.getTarget())
.into(span.getTarget(animate))
}
}
return builder
@ -97,11 +97,29 @@ class EmojiSpan(val viewWeakReference: WeakReference<View>) : ReplacementSpan()
}
}
fun getTarget(): Target<Bitmap> {
return object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
fun getTarget(animate : Boolean): Target<Drawable> {
return object : CustomTarget<Drawable>() {
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
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 File

@ -19,7 +19,6 @@ import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
@ -31,6 +30,7 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.browser.customtabs.CustomTabColorSchemeParams;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.preference.PreferenceManager;
@ -229,18 +229,20 @@ public class LinkHelper {
*/
public static void openLinkInCustomTab(Uri uri, Context context) {
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)
.setShowTitle(true);
.setNavigationBarColor(navigationbarColor)
.setNavigationBarDividerColor(navigationbarDividerColor)
.build();
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
customTabsIntentBuilder.setNavigationBarColor(
ThemeUtils.getColor(context, android.R.attr.navigationBarColor)
);
}
CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder()
.setDefaultColorSchemeParams(colorSchemeParams)
.setShowTitle(true)
.build();
CustomTabsIntent customTabsIntent = customTabsIntentBuilder.build();
try {
customTabsIntent.launchUrl(context, uri);
} 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
import androidx.annotation.CallSuper
import androidx.lifecycle.ViewModel
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
@ -9,6 +10,7 @@ open class RxAwareViewModel : ViewModel() {
fun Disposable.autoDispose() = disposables.add(this)
@CallSuper
override fun onCleared() {
super.onCleared()
disposables.clear()

View File

@ -1,33 +1,18 @@
package com.keylesspalace.tusky.util;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
import android.os.AsyncTask;
import android.text.TextUtils;
import android.util.Log;
import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.FileProvider;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.db.AppDatabase;
import com.keylesspalace.tusky.db.TootDao;
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.Date;
import java.util.List;
import java.util.Locale;
import javax.inject.Inject;
@ -45,61 +30,6 @@ public final class SaveTootHelper {
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) {
TootEntity item = tootDao.find(tootId);
if (item != null) {
@ -124,82 +54,4 @@ public final class SaveTootHelper {
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">
* 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 HTTPS_URL_REGEX = "(?:(^|\\b)https://[^\\s]+)"

View File

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

View File

@ -228,7 +228,8 @@ class StatusViewHelper(private val itemView: View) {
return when (type) {
Attachment.Type.IMAGE -> context.getString(R.string.status_media_images)
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) {
Attachment.Type.IMAGE -> R.drawable.ic_photo_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>(
itemView.findViewById(R.id.status_poll_option_result_0),
itemView.findViewById(R.id.status_poll_option_result_1),
@ -259,10 +261,10 @@ class StatusViewHelper(private val itemView: View) {
val timestamp = System.currentTimeMillis()
setupPollResult(poll, emojis, pollResults)
setupPollResult(poll, emojis, pollResults, statusDisplayOptions.animateEmojis)
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
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 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
val level = percent * 100

View File

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

View File

@ -4,6 +4,7 @@ package com.keylesspalace.tusky.view
import android.app.Activity
import android.widget.CheckBox
import android.widget.Spinner
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import com.keylesspalace.tusky.R
@ -11,7 +12,7 @@ import com.keylesspalace.tusky.R
fun showMuteAccountDialog(
activity: Activity,
accountUsername: String,
onOk: (notifications: Boolean) -> Unit
onOk: (notifications: Boolean, duration: Int) -> Unit
) {
val view = activity.layoutInflater.inflate(R.layout.dialog_mute_account, null)
(view.findViewById(R.id.warning) as TextView).text =
@ -21,7 +22,11 @@ fun showMuteAccountDialog(
AlertDialog.Builder(activity)
.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)
.show()
}

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