Merge upstream develop
This commit is contained in:
commit
0e5e0bea39
|
@ -93,37 +93,40 @@ project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
|
|||
}
|
||||
}
|
||||
|
||||
ext.roomVersion = '2.2.1'
|
||||
ext.lifecycleVersion = "2.1.0"
|
||||
ext.roomVersion = '2.2.3'
|
||||
ext.retrofitVersion = '2.6.0'
|
||||
ext.okhttpVersion = '4.2.2'
|
||||
ext.glideVersion = '4.10.0'
|
||||
ext.daggerVersion = '2.25.2'
|
||||
ext.daggerVersion = '2.25.3'
|
||||
|
||||
// if libraries are changed here, they should also be changed in LicenseActivity
|
||||
dependencies {
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
|
||||
implementation "androidx.core:core-ktx:1.2.0-beta01"
|
||||
implementation "androidx.core:core-ktx:1.2.0-rc01"
|
||||
implementation "androidx.appcompat:appcompat:1.1.0"
|
||||
implementation "androidx.fragment:fragment-ktx:1.1.0"
|
||||
implementation "androidx.browser:browser:1.0.0"
|
||||
implementation "androidx.recyclerview:recyclerview:1.0.0"
|
||||
implementation "androidx.exifinterface:exifinterface:1.0.0"
|
||||
implementation "androidx.browser:browser:1.2.0"
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
|
||||
implementation "androidx.recyclerview:recyclerview:1.1.0"
|
||||
implementation "androidx.exifinterface:exifinterface:1.1.0"
|
||||
implementation "androidx.cardview:cardview:1.0.0"
|
||||
implementation "androidx.preference:preference:1.1.0"
|
||||
implementation "androidx.sharetarget:sharetarget:1.0.0-beta01"
|
||||
implementation "androidx.sharetarget:sharetarget:1.0.0-rc01"
|
||||
implementation "androidx.emoji:emoji:1.0.0"
|
||||
implementation "androidx.emoji:emoji-appcompat:1.0.0"
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:2.1.0"
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-reactivestreams:$lifecycleVersion"
|
||||
implementation "androidx.constraintlayout:constraintlayout:1.1.3"
|
||||
implementation "androidx.paging:paging-runtime-ktx:2.1.0"
|
||||
implementation "androidx.viewpager2:viewpager2:1.0.0-rc01"
|
||||
implementation "androidx.paging:paging-runtime-ktx:2.1.1"
|
||||
implementation "androidx.viewpager2:viewpager2:1.0.0"
|
||||
implementation "androidx.room:room-runtime:$roomVersion"
|
||||
implementation "androidx.room:room-rxjava2:$roomVersion"
|
||||
kapt "androidx.room:room-compiler:$roomVersion"
|
||||
|
||||
implementation "com.google.android.material:material:1.1.0-beta01"
|
||||
implementation "com.google.android.material:material:1.1.0-rc01"
|
||||
|
||||
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
|
||||
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
|
||||
|
@ -137,7 +140,7 @@ dependencies {
|
|||
implementation "com.github.bumptech.glide:glide:$glideVersion"
|
||||
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion"
|
||||
|
||||
implementation "io.reactivex.rxjava2:rxjava:2.2.13"
|
||||
implementation "io.reactivex.rxjava2:rxjava:2.2.16"
|
||||
implementation "io.reactivex.rxjava2:rxandroid:2.1.1"
|
||||
implementation "io.reactivex.rxjava2:rxkotlin:2.4.0"
|
||||
|
||||
|
@ -150,7 +153,7 @@ dependencies {
|
|||
implementation "com.google.dagger:dagger-android-support:$daggerVersion"
|
||||
kapt "com.google.dagger:dagger-android-processor:$daggerVersion"
|
||||
|
||||
implementation "com.github.connyduck:sparkbutton:2.0.1"
|
||||
implementation "com.github.connyduck:sparkbutton:3.0.0"
|
||||
|
||||
implementation "com.github.chrisbanes:PhotoView:2.3.0"
|
||||
|
||||
|
@ -171,7 +174,7 @@ dependencies {
|
|||
|
||||
testImplementation "androidx.test.ext:junit:1.1.1"
|
||||
testImplementation "org.robolectric:robolectric:4.3.1"
|
||||
testImplementation "org.mockito:mockito-inline:3.1.0"
|
||||
testImplementation "org.mockito:mockito-inline:3.2.4"
|
||||
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
|
||||
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.1.1", {
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 21,
|
||||
"identityHash": "4645108ec2ec8d4e8cec2db02753ecb8",
|
||||
"identityHash": "7570c84ffeb4f90521f91dc7ef3e7da1",
|
||||
"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, `markdownMode` INTEGER)",
|
||||
"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",
|
||||
|
@ -67,12 +67,6 @@
|
|||
"columnName": "poll",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "markdownMode",
|
||||
"columnName": "markdownMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
|
@ -272,7 +266,7 @@
|
|||
},
|
||||
{
|
||||
"tableName": "InstanceEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, PRIMARY KEY(`instance`))",
|
||||
"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",
|
||||
|
@ -303,6 +297,12 @@
|
|||
"columnName": "maxPollOptionLength",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
|
@ -723,7 +723,7 @@
|
|||
"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, '4645108ec2ec8d4e8cec2db02753ecb8')"
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7570c84ffeb4f90521f91dc7ef3e7da1')"
|
||||
]
|
||||
}
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
Before Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
Before Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
Before Width: | Height: | Size: 10 KiB |
Binary file not shown.
Before Width: | Height: | Size: 13 KiB |
|
@ -96,7 +96,7 @@
|
|||
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ComposeActivity"
|
||||
android:name=".components.compose.ComposeActivity"
|
||||
android:theme="@style/TuskyDialogActivityTheme"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize"/>
|
||||
<activity
|
||||
|
@ -137,7 +137,7 @@
|
|||
android:name=".components.report.ReportActivity"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
|
||||
<activity android:name=".components.instancemute.InstanceListActivity" />
|
||||
<activity android:name=".ScheduledTootActivity" />
|
||||
<activity android:name=".components.scheduled.ScheduledTootActivity" />
|
||||
|
||||
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
|
||||
<receiver
|
||||
|
|
|
@ -19,8 +19,7 @@ import android.animation.ArgbEvaluator
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.*
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
|
@ -48,9 +47,12 @@ import com.google.android.material.snackbar.Snackbar
|
|||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.keylesspalace.tusky.adapter.AccountFieldAdapter
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Field
|
||||
import com.keylesspalace.tusky.entity.IdentityProof
|
||||
import com.keylesspalace.tusky.entity.Relationship
|
||||
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
|
@ -117,7 +119,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
viewModel = ViewModelProviders.of(this, viewModelFactory)[AccountViewModel::class.java]
|
||||
|
||||
// Obtain information to fill out the profile.
|
||||
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID))
|
||||
viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!)
|
||||
|
||||
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false)
|
||||
|
@ -265,7 +267,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
|
||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
||||
|
||||
if(verticalOffset == oldOffset) {
|
||||
if (verticalOffset == oldOffset) {
|
||||
return
|
||||
}
|
||||
oldOffset = verticalOffset
|
||||
|
@ -349,6 +351,11 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
}
|
||||
|
||||
})
|
||||
viewModel.accountFieldData.observe(this, Observer<List<Either<IdentityProof, Field>>> {
|
||||
accountFieldAdapter.fields = it
|
||||
accountFieldAdapter.notifyDataSetChanged()
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -377,7 +384,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
val emojifiedNote = CustomEmojiHelper.emojifyText(account.note, account.emojis, accountNoteTextView)
|
||||
LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this)
|
||||
|
||||
accountFieldAdapter.fields = account.fields ?: emptyList()
|
||||
// accountFieldAdapter.fields = account.fields ?: emptyList()
|
||||
accountFieldAdapter.emojis = account.emojis ?: emptyList()
|
||||
accountFieldAdapter.notifyDataSetChanged()
|
||||
|
||||
|
@ -471,7 +478,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
// this is necessary because API 19 can't handle vector compound drawables
|
||||
val movedIcon = ContextCompat.getDrawable(this, R.drawable.ic_briefcase)?.mutate()
|
||||
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
movedIcon?.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||
movedIcon?.colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||
|
||||
accountMovedText.setCompoundDrawablesRelativeWithIntrinsicBounds(movedIcon, null, null, null)
|
||||
}
|
||||
|
@ -693,9 +700,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
|
||||
private fun mention() {
|
||||
loadedAccount?.let {
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.mentionedUsernames(setOf(it.username))
|
||||
.build(this)
|
||||
val intent = ComposeActivity.startIntent(this,
|
||||
ComposeActivity.ComposeOptions(mentionedUsernames = setOf(it.username)))
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
@ -754,7 +760,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
|
|||
return true
|
||||
}
|
||||
R.id.action_report -> {
|
||||
if(loadedAccount != null) {
|
||||
if (loadedAccount != null) {
|
||||
startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount!!.username))
|
||||
}
|
||||
return true
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -20,14 +20,15 @@ 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 androidx.browser.customtabs.CustomTabsIntent
|
||||
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.CustomTabsIntent
|
||||
import com.bumptech.glide.Glide
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.AccessToken
|
||||
|
@ -362,9 +363,16 @@ class LoginActivity : BaseActivity(), Injectable {
|
|||
private fun openInCustomTab(uri: Uri, context: Context): Boolean {
|
||||
|
||||
val toolbarColor = ThemeUtils.getColor(context, R.attr.custom_tab_toolbar)
|
||||
val customTabsIntent = CustomTabsIntent.Builder()
|
||||
val customTabsIntentBuilder = CustomTabsIntent.Builder()
|
||||
.setToolbarColor(toolbarColor)
|
||||
.build()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
customTabsIntentBuilder.setNavigationBarColor(
|
||||
ThemeUtils.getColor(context, android.R.attr.navigationBarColor)
|
||||
)
|
||||
}
|
||||
|
||||
val customTabsIntent = customTabsIntentBuilder.build()
|
||||
try {
|
||||
customTabsIntent.launchUrl(context, uri)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
|
|
|
@ -44,7 +44,9 @@ import com.keylesspalace.tusky.appstore.CacheUpdater;
|
|||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent;
|
||||
import com.keylesspalace.tusky.appstore.ProfileEditedEvent;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity;
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsRepository;
|
||||
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity;
|
||||
import com.keylesspalace.tusky.components.search.SearchActivity;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.entity.Account;
|
||||
|
|
|
@ -56,7 +56,7 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference
|
|||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
||||
val fragment: Fragment = when(intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) {
|
||||
val fragment: Fragment = when (intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0)) {
|
||||
GENERAL_PREFERENCES -> {
|
||||
setTitle(R.string.action_view_preferences)
|
||||
PreferencesFragment.newInstance()
|
||||
|
@ -128,7 +128,8 @@ class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreference
|
|||
this.restartCurrentActivity()
|
||||
|
||||
}
|
||||
"statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars" -> {
|
||||
"statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars",
|
||||
"useBlurhash" -> {
|
||||
restartActivitiesOnExit = true
|
||||
}
|
||||
"language" -> {
|
||||
|
|
|
@ -22,21 +22,6 @@ import android.view.MenuItem;
|
|||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import com.keylesspalace.tusky.adapter.SavedTootAdapter;
|
||||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
|
||||
import com.keylesspalace.tusky.db.AppDatabase;
|
||||
import com.keylesspalace.tusky.db.TootDao;
|
||||
import com.keylesspalace.tusky.db.TootEntity;
|
||||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.util.SaveTootHelper;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
|
@ -44,16 +29,35 @@ import androidx.lifecycle.Lifecycle;
|
|||
import androidx.recyclerview.widget.DividerItemDecoration;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.keylesspalace.tusky.adapter.SavedTootAdapter;
|
||||
import com.keylesspalace.tusky.appstore.EventHub;
|
||||
import com.keylesspalace.tusky.appstore.StatusComposedEvent;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity;
|
||||
import com.keylesspalace.tusky.db.AppDatabase;
|
||||
import com.keylesspalace.tusky.db.TootDao;
|
||||
import com.keylesspalace.tusky.db.TootEntity;
|
||||
import com.keylesspalace.tusky.di.Injectable;
|
||||
import com.keylesspalace.tusky.util.SaveTootHelper;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
|
||||
import static com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions;
|
||||
import static com.uber.autodispose.AutoDispose.autoDisposable;
|
||||
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
|
||||
|
||||
public final class SavedTootActivity extends BaseActivity implements SavedTootAdapter.SavedTootAction,
|
||||
Injectable {
|
||||
|
||||
private SaveTootHelper saveTootHelper;
|
||||
|
||||
// ui
|
||||
private SavedTootAdapter adapter;
|
||||
private TextView noContent;
|
||||
|
@ -66,13 +70,13 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
|
|||
EventHub eventHub;
|
||||
@Inject
|
||||
AppDatabase database;
|
||||
@Inject
|
||||
SaveTootHelper saveTootHelper;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
saveTootHelper = new SaveTootHelper(database.tootDao(), this);
|
||||
|
||||
eventHub.getEvents()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.ofType(StatusComposedEvent.class)
|
||||
|
@ -153,19 +157,30 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
|
|||
|
||||
@Override
|
||||
public void click(int position, TootEntity item) {
|
||||
Intent intent = new ComposeActivity.IntentBuilder()
|
||||
.savedTootUid(item.getUid())
|
||||
.tootText(item.getText())
|
||||
.contentWarning(item.getContentWarning())
|
||||
.savedJsonUrls(item.getUrls())
|
||||
.savedJsonDescriptions(item.getDescriptions())
|
||||
.inReplyToId(item.getInReplyToId())
|
||||
.replyingStatusAuthor(item.getInReplyToUsername())
|
||||
.replyingStatusContent(item.getInReplyToText())
|
||||
.visibility(item.getVisibility())
|
||||
.poll(item.getPoll())
|
||||
.markdownMode(item.getMarkdownMode())
|
||||
.build(this);
|
||||
Gson gson = new Gson();
|
||||
Type stringListType = new TypeToken<List<String>>() {}.getType();
|
||||
List<String> jsonUrls = gson.fromJson(item.getUrls(), stringListType);
|
||||
List<String> descriptions = gson.fromJson(item.getDescriptions(), stringListType);
|
||||
|
||||
ComposeOptions composeOptions = new ComposeOptions(
|
||||
item.getUid(),
|
||||
item.getText(),
|
||||
jsonUrls,
|
||||
descriptions,
|
||||
/*mentionedUsernames*/null,
|
||||
item.getInReplyToId(),
|
||||
/*replyVisibility*/null,
|
||||
item.getVisibility(),
|
||||
item.getContentWarning(),
|
||||
item.getInReplyToUsername(),
|
||||
item.getInReplyToText(),
|
||||
/*mediaAttachments*/null,
|
||||
/*scheduledAt*/null,
|
||||
/*sensitive*/null,
|
||||
/*poll*/null,
|
||||
item.getMarkdownMode()
|
||||
);
|
||||
Intent intent = ComposeActivity.startIntent(this, composeOptions);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,166 +0,0 @@
|
|||
package com.keylesspalace.tusky
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.keylesspalace.tusky.adapter.ScheduledTootAdapter
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.StatusScheduledEvent
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import com.uber.autodispose.AutoDispose.autoDisposable
|
||||
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import kotlinx.android.synthetic.main.activity_scheduled_toot.*
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
class ScheduledTootActivity : BaseActivity(), ScheduledTootAdapter.ScheduledTootAction, Injectable {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun newIntent(context: Context): Intent {
|
||||
return Intent(context, ScheduledTootActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
lateinit var adapter: ScheduledTootAdapter
|
||||
|
||||
@Inject
|
||||
lateinit var mastodonApi: MastodonApi
|
||||
@Inject
|
||||
lateinit var eventHub: EventHub
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_scheduled_toot)
|
||||
|
||||
val toolbar = findViewById<Toolbar>(R.id.toolbar)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
val bar = supportActionBar
|
||||
if (bar != null) {
|
||||
bar.title = getString(R.string.title_scheduled_toot)
|
||||
bar.setDisplayHomeAsUpEnabled(true)
|
||||
bar.setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
||||
swipe_refresh_layout.setOnRefreshListener(this::refreshStatuses)
|
||||
|
||||
scheduled_toot_list.setHasFixedSize(true)
|
||||
val layoutManager = LinearLayoutManager(this)
|
||||
scheduled_toot_list.layoutManager = layoutManager
|
||||
val divider = DividerItemDecoration(this, layoutManager.orientation)
|
||||
scheduled_toot_list.addItemDecoration(divider)
|
||||
adapter = ScheduledTootAdapter(this)
|
||||
scheduled_toot_list.adapter = adapter
|
||||
|
||||
loadStatuses()
|
||||
|
||||
eventHub.events
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.`as`(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||
.subscribe { event ->
|
||||
if (event is StatusScheduledEvent) {
|
||||
refreshStatuses()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadStatuses() {
|
||||
progress_bar.visibility = View.VISIBLE
|
||||
mastodonApi.scheduledStatuses()
|
||||
.enqueue(object : Callback<List<ScheduledStatus>> {
|
||||
override fun onResponse(call: Call<List<ScheduledStatus>>, response: Response<List<ScheduledStatus>>) {
|
||||
progress_bar.visibility = View.GONE
|
||||
if (response.body().isNullOrEmpty()) {
|
||||
errorMessageView.show()
|
||||
errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty,
|
||||
null)
|
||||
} else {
|
||||
show(response.body()!!)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<List<ScheduledStatus>>, t: Throwable) {
|
||||
progress_bar.visibility = View.GONE
|
||||
errorMessageView.show()
|
||||
errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
errorMessageView.hide()
|
||||
loadStatuses()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun refreshStatuses() {
|
||||
swipe_refresh_layout.isRefreshing = true
|
||||
mastodonApi.scheduledStatuses()
|
||||
.enqueue(object : Callback<List<ScheduledStatus>> {
|
||||
override fun onResponse(call: Call<List<ScheduledStatus>>, response: Response<List<ScheduledStatus>>) {
|
||||
swipe_refresh_layout.isRefreshing = false
|
||||
if (response.body().isNullOrEmpty()) {
|
||||
errorMessageView.show()
|
||||
errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty,
|
||||
null)
|
||||
} else {
|
||||
show(response.body()!!)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<List<ScheduledStatus>>, t: Throwable) {
|
||||
swipe_refresh_layout.isRefreshing = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun show(statuses: List<ScheduledStatus>) {
|
||||
adapter.setItems(statuses)
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun edit(position: Int, item: ScheduledStatus?) {
|
||||
if (item == null) {
|
||||
return
|
||||
}
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.tootText(item.params.text)
|
||||
.contentWarning(item.params.spoilerText)
|
||||
.mediaAttachments(item.mediaAttachments)
|
||||
.inReplyToId(item.params.inReplyToId)
|
||||
.visibility(item.params.visibility)
|
||||
.scheduledAt(item.scheduledAt)
|
||||
.sensitive(item.params.sensitive)
|
||||
.build(this)
|
||||
startActivity(intent)
|
||||
delete(position, item)
|
||||
}
|
||||
|
||||
override fun delete(position: Int, item: ScheduledStatus?) {
|
||||
if (item == null) {
|
||||
return
|
||||
}
|
||||
mastodonApi.deleteScheduledStatus(item.id)
|
||||
.enqueue(object : Callback<ResponseBody> {
|
||||
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
|
||||
adapter.removeItem(position)
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -41,6 +41,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers
|
|||
import io.reactivex.schedulers.Schedulers
|
||||
import kotlinx.android.synthetic.main.activity_tab_preference.*
|
||||
import kotlinx.android.synthetic.main.toolbar_basic.*
|
||||
import kotlinx.android.synthetic.main.item_tab_preference.view.removeButton
|
||||
import java.util.regex.Pattern
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -76,7 +77,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
}
|
||||
|
||||
currentTabs = (accountManager.activeAccount?.tabPreferences ?: emptyList()).toMutableList()
|
||||
currentTabsAdapter = TabAdapter(currentTabs, false, this)
|
||||
currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT)
|
||||
currentTabsRecyclerView.adapter = currentTabsAdapter
|
||||
currentTabsRecyclerView.layoutManager = LinearLayoutManager(this)
|
||||
currentTabsRecyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
|
||||
|
@ -109,10 +110,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
currentTabs.removeAt(viewHolder.adapterPosition)
|
||||
currentTabsAdapter.notifyItemRemoved(viewHolder.adapterPosition)
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
onTabRemoved(viewHolder.adapterPosition)
|
||||
}
|
||||
|
||||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
||||
|
@ -168,6 +166,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
saveTabs()
|
||||
}
|
||||
|
||||
override fun onTabRemoved(position: Int) {
|
||||
currentTabs.removeAt(position)
|
||||
currentTabsAdapter.notifyItemRemoved(position)
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
}
|
||||
|
||||
override fun onActionChipClicked(tab: TabData) {
|
||||
showEditHashtagDialog(tab)
|
||||
}
|
||||
|
@ -273,7 +278,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
|
|||
addTabAdapter.updateData(addableTabs)
|
||||
|
||||
maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT)
|
||||
|
||||
currentTabsAdapter.setRemoveButtonVisible(currentTabs.size > MIN_TAB_COUNT);
|
||||
}
|
||||
|
||||
override fun onStartDelete(viewHolder: RecyclerView.ViewHolder) {
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package com.keylesspalace.tusky.adapter
|
||||
|
||||
import android.text.method.LinkMovementMethod
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
|
@ -23,15 +24,17 @@ import android.widget.TextView
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.entity.Emoji
|
||||
import com.keylesspalace.tusky.entity.Field
|
||||
import com.keylesspalace.tusky.entity.IdentityProof
|
||||
import com.keylesspalace.tusky.interfaces.LinkListener
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.LinkHelper
|
||||
import kotlinx.android.synthetic.main.item_account_field.view.*
|
||||
|
||||
class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView.Adapter<AccountFieldAdapter.ViewHolder>() {
|
||||
|
||||
var emojis: List<Emoji> = emptyList()
|
||||
var fields: List<Field> = emptyList()
|
||||
var fields: List<Either<IdentityProof, Field>> = emptyList()
|
||||
|
||||
override fun getItemCount() = fields.size
|
||||
|
||||
|
@ -41,18 +44,30 @@ class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView
|
|||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
|
||||
val field = fields[position]
|
||||
val proofOrField = fields[position]
|
||||
|
||||
val emojifiedName = CustomEmojiHelper.emojifyString(field.name, emojis, viewHolder.nameTextView)
|
||||
viewHolder.nameTextView.text = emojifiedName
|
||||
if(proofOrField.isLeft()) {
|
||||
val identityProof = proofOrField.asLeft()
|
||||
|
||||
val emojifiedValue = CustomEmojiHelper.emojifyText(field.value, emojis, viewHolder.valueTextView)
|
||||
LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener)
|
||||
viewHolder.nameTextView.text = identityProof.provider
|
||||
viewHolder.valueTextView.text = LinkHelper.createClickableText(identityProof.username, identityProof.profileUrl)
|
||||
|
||||
viewHolder.valueTextView.movementMethod = LinkMovementMethod.getInstance()
|
||||
|
||||
if(field.verifiedAt != null) {
|
||||
viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
|
||||
} else {
|
||||
viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0 )
|
||||
val field = proofOrField.asRight()
|
||||
val emojifiedName = CustomEmojiHelper.emojifyString(field.name, emojis, viewHolder.nameTextView)
|
||||
viewHolder.nameTextView.text = emojifiedName
|
||||
|
||||
val emojifiedValue = CustomEmojiHelper.emojifyText(field.value, emojis, viewHolder.valueTextView)
|
||||
LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener)
|
||||
|
||||
if(field.verifiedAt != null) {
|
||||
viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)
|
||||
} else {
|
||||
viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0 )
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -33,6 +33,12 @@ import android.widget.ImageView;
|
|||
import android.widget.TextView;
|
||||
import android.widget.ToggleButton;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.text.BidiFormatter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.entity.Account;
|
||||
import com.keylesspalace.tusky.entity.Emoji;
|
||||
|
@ -40,10 +46,11 @@ import com.keylesspalace.tusky.entity.Notification;
|
|||
import com.keylesspalace.tusky.interfaces.LinkListener;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||
import com.keylesspalace.tusky.viewdata.NotificationViewData;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
import com.mikepenz.iconics.utils.Utils;
|
||||
|
@ -53,12 +60,6 @@ import java.util.Date;
|
|||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.text.BidiFormatter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
public class NotificationsAdapter extends RecyclerView.Adapter {
|
||||
|
||||
public interface AdapterDataSource<T> {
|
||||
|
@ -78,28 +79,23 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
|
||||
|
||||
private String accountId;
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
private StatusActionListener statusListener;
|
||||
private NotificationActionListener notificationActionListener;
|
||||
private boolean mediaPreviewEnabled;
|
||||
private boolean useAbsoluteTime;
|
||||
private boolean showBotOverlay;
|
||||
private boolean animateAvatar;
|
||||
private BidiFormatter bidiFormatter;
|
||||
private AdapterDataSource<NotificationViewData> dataSource;
|
||||
|
||||
public NotificationsAdapter(String accountId,
|
||||
AdapterDataSource<NotificationViewData> dataSource,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
StatusActionListener statusListener,
|
||||
NotificationActionListener notificationActionListener) {
|
||||
|
||||
this.accountId = accountId;
|
||||
this.dataSource = dataSource;
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
this.statusListener = statusListener;
|
||||
this.notificationActionListener = notificationActionListener;
|
||||
mediaPreviewEnabled = true;
|
||||
useAbsoluteTime = false;
|
||||
showBotOverlay = true;
|
||||
animateAvatar = false;
|
||||
bidiFormatter = BidiFormatter.getInstance();
|
||||
}
|
||||
|
||||
|
@ -108,20 +104,20 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||
switch (viewType) {
|
||||
case VIEW_TYPE_STATUS: {
|
||||
case VIEW_TYPE_STATUS: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_status, parent, false);
|
||||
return new StatusViewHolder(view, useAbsoluteTime);
|
||||
return new StatusViewHolder(view);
|
||||
}
|
||||
case VIEW_TYPE_STATUS_NOTIFICATION: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_status_notification, parent, false);
|
||||
return new StatusNotificationViewHolder(view, useAbsoluteTime, animateAvatar);
|
||||
return new StatusNotificationViewHolder(view, statusDisplayOptions);
|
||||
}
|
||||
case VIEW_TYPE_FOLLOW: {
|
||||
View view = inflater
|
||||
.inflate(R.layout.item_follow, parent, false);
|
||||
return new FollowViewHolder(view, animateAvatar);
|
||||
return new FollowViewHolder(view, statusDisplayOptions);
|
||||
}
|
||||
case VIEW_TYPE_PLACEHOLDER: {
|
||||
View view = inflater
|
||||
|
@ -137,7 +133,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
Utils.convertDpToPx(parent.getContext(), 24)
|
||||
)
|
||||
);
|
||||
return new RecyclerView.ViewHolder(view) {};
|
||||
return new RecyclerView.ViewHolder(view) {
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -171,8 +168,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
StatusViewHolder holder = (StatusViewHolder) viewHolder;
|
||||
StatusViewData.Concrete status = concreteNotificaton.getStatusViewData();
|
||||
holder.setupWithStatus(status,
|
||||
statusListener, mediaPreviewEnabled, showBotOverlay, animateAvatar, payloadForHolder);
|
||||
if(concreteNotificaton.getType() == Notification.Type.POLL) {
|
||||
statusListener, statusDisplayOptions, payloadForHolder);
|
||||
if (concreteNotificaton.getType() == Notification.Type.POLL) {
|
||||
holder.setPollInfo(accountId.equals(concreteNotificaton.getAccount().getId()));
|
||||
} else {
|
||||
holder.hideStatusInfo();
|
||||
|
@ -202,7 +199,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
concreteNotificaton.getId());
|
||||
} else {
|
||||
if (payloadForHolder instanceof List)
|
||||
for (Object item : (List)payloadForHolder) {
|
||||
for (Object item : (List) payloadForHolder) {
|
||||
if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item)) {
|
||||
holder.setCreatedAt(statusViewData.getCreatedAt());
|
||||
}
|
||||
|
@ -221,7 +218,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
default:
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -229,6 +225,20 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
return dataSource.getItemCount();
|
||||
}
|
||||
|
||||
public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
|
||||
this.statusDisplayOptions = statusDisplayOptions.copy(
|
||||
statusDisplayOptions.animateAvatars(),
|
||||
mediaPreviewEnabled,
|
||||
statusDisplayOptions.useAbsoluteTime(),
|
||||
statusDisplayOptions.showBotOverlay(),
|
||||
statusDisplayOptions.useBlurhash()
|
||||
);
|
||||
}
|
||||
|
||||
public boolean isMediaPreviewEnabled() {
|
||||
return this.statusDisplayOptions.mediaPreviewEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
NotificationViewData notification = dataSource.getItemAt(position);
|
||||
|
@ -256,26 +266,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
throw new AssertionError("Unknown notification type");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void setMediaPreviewEnabled(boolean enabled) {
|
||||
mediaPreviewEnabled = enabled;
|
||||
}
|
||||
|
||||
public boolean isMediaPreviewEnabled() {
|
||||
return mediaPreviewEnabled;
|
||||
}
|
||||
|
||||
public void setUseAbsoluteTime(boolean useAbsoluteTime) {
|
||||
this.useAbsoluteTime = useAbsoluteTime;
|
||||
}
|
||||
|
||||
public void setShowBotOverlay(boolean showBotOverlay) {
|
||||
this.showBotOverlay = showBotOverlay;
|
||||
}
|
||||
|
||||
public void setAnimateAvatar(boolean animateAvatar) {
|
||||
this.animateAvatar = animateAvatar;
|
||||
}
|
||||
|
||||
public interface NotificationActionListener {
|
||||
|
@ -300,15 +291,15 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
private TextView usernameView;
|
||||
private TextView displayNameView;
|
||||
private ImageView avatar;
|
||||
private boolean animateAvatar;
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
|
||||
FollowViewHolder(View itemView, boolean animateAvatar) {
|
||||
FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) {
|
||||
super(itemView);
|
||||
message = itemView.findViewById(R.id.notification_text);
|
||||
usernameView = itemView.findViewById(R.id.notification_username);
|
||||
displayNameView = itemView.findViewById(R.id.notification_display_name);
|
||||
avatar = itemView.findViewById(R.id.notification_avatar);
|
||||
this.animateAvatar = animateAvatar;
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
}
|
||||
|
||||
void setMessage(Account account, BidiFormatter bidiFormatter) {
|
||||
|
@ -330,7 +321,8 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
int avatarRadius = avatar.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, animateAvatar);
|
||||
ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius,
|
||||
statusDisplayOptions.animateAvatars());
|
||||
|
||||
}
|
||||
|
||||
|
@ -352,18 +344,16 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
private final TextView contentWarningDescriptionTextView;
|
||||
private final ToggleButton contentWarningButton;
|
||||
private final ToggleButton contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
|
||||
private String accountId;
|
||||
private String notificationId;
|
||||
private NotificationActionListener notificationActionListener;
|
||||
private StatusViewData.Concrete statusViewData;
|
||||
|
||||
private boolean useAbsoluteTime;
|
||||
private boolean animateAvatar;
|
||||
private SimpleDateFormat shortSdf;
|
||||
private SimpleDateFormat longSdf;
|
||||
|
||||
StatusNotificationViewHolder(View itemView, boolean useAbsoluteTime, boolean animateAvatar) {
|
||||
StatusNotificationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) {
|
||||
super(itemView);
|
||||
message = itemView.findViewById(R.id.notification_top_text);
|
||||
statusNameBar = itemView.findViewById(R.id.status_name_bar);
|
||||
|
@ -376,6 +366,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description);
|
||||
contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button);
|
||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content);
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
|
||||
int darkerFilter = Color.rgb(123, 123, 123);
|
||||
statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY);
|
||||
|
@ -385,9 +376,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
message.setOnClickListener(this);
|
||||
statusContent.setOnClickListener(this);
|
||||
contentWarningButton.setOnCheckedChangeListener(this);
|
||||
|
||||
this.useAbsoluteTime = useAbsoluteTime;
|
||||
this.animateAvatar = animateAvatar;
|
||||
shortSdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
|
||||
longSdf = new SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault());
|
||||
}
|
||||
|
@ -414,7 +402,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
}
|
||||
|
||||
protected void setCreatedAt(@Nullable Date createdAt) {
|
||||
if (useAbsoluteTime) {
|
||||
if (statusDisplayOptions.useAbsoluteTime()) {
|
||||
String time;
|
||||
if (createdAt != null) {
|
||||
if (System.currentTimeMillis() - createdAt.getTime() > 86400000L) {
|
||||
|
@ -511,13 +499,13 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
|
|||
.getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(statusAvatarUrl,
|
||||
statusAvatar, statusAvatarRadius, animateAvatar);
|
||||
statusAvatar, statusAvatarRadius, statusDisplayOptions.animateAvatars());
|
||||
|
||||
int notificationAvatarRadius = statusAvatar.getContext().getResources()
|
||||
.getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
||||
|
||||
ImageLoadingHelper.loadAvatar(notificationAvatarUrl,
|
||||
notificationAvatar, notificationAvatarRadius, animateAvatar);
|
||||
ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar,
|
||||
notificationAvatarRadius, statusDisplayOptions.animateAvatars());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -1,125 +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.adapter;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ScheduledTootAdapter extends RecyclerView.Adapter {
|
||||
private List<ScheduledStatus> list;
|
||||
private ScheduledTootAction handler;
|
||||
|
||||
public ScheduledTootAdapter(Context context) {
|
||||
super();
|
||||
list = new ArrayList<>();
|
||||
handler = (ScheduledTootAction) context;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_scheduled_toot, parent, false);
|
||||
return new TootViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
||||
TootViewHolder holder = (TootViewHolder) viewHolder;
|
||||
holder.bind(getItem(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return list.size();
|
||||
}
|
||||
|
||||
public void setItems(List<ScheduledStatus> newToot) {
|
||||
list = new ArrayList<>();
|
||||
list.addAll(newToot);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public ScheduledStatus removeItem(int position) {
|
||||
if (position < 0 || position >= list.size()) {
|
||||
return null;
|
||||
}
|
||||
ScheduledStatus toot = list.remove(position);
|
||||
notifyItemRemoved(position);
|
||||
return toot;
|
||||
}
|
||||
|
||||
private ScheduledStatus getItem(int position) {
|
||||
if (position >= 0 && position < list.size()) {
|
||||
return list.get(position);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public interface ScheduledTootAction {
|
||||
void edit(int position, ScheduledStatus item);
|
||||
|
||||
void delete(int position, ScheduledStatus item);
|
||||
}
|
||||
|
||||
private class TootViewHolder extends RecyclerView.ViewHolder {
|
||||
View view;
|
||||
TextView text;
|
||||
ImageButton edit;
|
||||
ImageButton delete;
|
||||
|
||||
TootViewHolder(View view) {
|
||||
super(view);
|
||||
this.view = view;
|
||||
this.text = view.findViewById(R.id.text);
|
||||
this.edit = view.findViewById(R.id.edit);
|
||||
this.delete = view.findViewById(R.id.delete);
|
||||
}
|
||||
|
||||
void bind(final ScheduledStatus item) {
|
||||
edit.setEnabled(true);
|
||||
delete.setEnabled(true);
|
||||
|
||||
if (item != null) {
|
||||
text.setText(item.getParams().getText());
|
||||
|
||||
edit.setOnClickListener(v -> {
|
||||
v.setEnabled(false);
|
||||
handler.edit(getAdapterPosition(), item);
|
||||
});
|
||||
|
||||
delete.setOnClickListener(v -> {
|
||||
v.setEnabled(false);
|
||||
handler.delete(getAdapterPosition(), item);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
package com.keylesspalace.tusky.adapter;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
|
@ -32,6 +34,7 @@ import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
|||
import com.keylesspalace.tusky.util.HtmlUtils;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
import com.keylesspalace.tusky.util.TimestampUtils;
|
||||
import com.keylesspalace.tusky.view.MediaPreviewImageView;
|
||||
|
@ -49,7 +52,6 @@ import java.util.Locale;
|
|||
import java.util.Objects;
|
||||
|
||||
import at.connyduck.sparkbutton.SparkButton;
|
||||
import at.connyduck.sparkbutton.SparkEventListener;
|
||||
import kotlin.collections.CollectionsKt;
|
||||
|
||||
import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription;
|
||||
|
@ -95,10 +97,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private int avatarRadius36dp;
|
||||
private int avatarRadius24dp;
|
||||
|
||||
private final int mediaPreviewUnloadedId;
|
||||
private final Drawable mediaPreviewUnloaded;
|
||||
|
||||
protected StatusBaseViewHolder(View itemView,
|
||||
boolean useAbsoluteTime) {
|
||||
protected StatusBaseViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
displayName = itemView.findViewById(R.id.status_display_name);
|
||||
username = itemView.findViewById(R.id.status_username);
|
||||
|
@ -152,8 +153,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp);
|
||||
this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp);
|
||||
|
||||
mediaPreviewUnloadedId = ThemeUtils.getDrawableId(itemView.getContext(),
|
||||
R.attr.media_preview_unloaded_drawable, android.R.color.black);
|
||||
mediaPreviewUnloaded = itemView.getContext().getDrawable(
|
||||
ThemeUtils.getDrawableId(itemView.getContext(),
|
||||
R.attr.media_preview_unloaded_drawable, android.R.color.black)
|
||||
);
|
||||
}
|
||||
|
||||
protected abstract int getMediaPreviewHeight(Context context);
|
||||
|
@ -212,8 +215,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener);
|
||||
if (poll != null) {
|
||||
setupPoll(poll, emojis, listener);
|
||||
} else {
|
||||
hidePoll();
|
||||
}
|
||||
} else {
|
||||
hidePoll();
|
||||
LinkHelper.setClickableMentions(this.content, mentions, listener);
|
||||
}
|
||||
if (TextUtils.isEmpty(this.content.getText())) {
|
||||
|
@ -221,27 +227,24 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
} else {
|
||||
this.content.setVisibility(View.VISIBLE);
|
||||
}
|
||||
setPollVisible(poll != null && expanded);
|
||||
}
|
||||
|
||||
private void setPollVisible(boolean visible) {
|
||||
int visibility = visible ? View.VISIBLE : View.GONE;
|
||||
pollButton.setVisibility(visibility);
|
||||
pollDescription.setVisibility(visibility);
|
||||
pollOptions.setVisibility(visibility);
|
||||
private void hidePoll() {
|
||||
pollButton.setVisibility(View.GONE);
|
||||
pollDescription.setVisibility(View.GONE);
|
||||
pollOptions.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void setAvatar(String url,
|
||||
@Nullable String rebloggedUrl,
|
||||
boolean isBot,
|
||||
boolean showBotOverlay,
|
||||
boolean animateAvatar) {
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
|
||||
int avatarRadius;
|
||||
if (TextUtils.isEmpty(rebloggedUrl)) {
|
||||
avatar.setPaddingRelative(0, 0, 0, 0);
|
||||
|
||||
if (showBotOverlay && isBot) {
|
||||
if (statusDisplayOptions.showBotOverlay() && isBot) {
|
||||
avatarInset.setVisibility(View.VISIBLE);
|
||||
avatarInset.setBackgroundColor(0x50ffffff);
|
||||
Glide.with(avatarInset)
|
||||
|
@ -260,12 +263,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
avatarInset.setVisibility(View.VISIBLE);
|
||||
avatarInset.setBackground(null);
|
||||
ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp, animateAvatar);
|
||||
ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp,
|
||||
statusDisplayOptions.animateAvatars());
|
||||
|
||||
avatarRadius = avatarRadius36dp;
|
||||
}
|
||||
|
||||
ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius, animateAvatar);
|
||||
ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius,
|
||||
statusDisplayOptions.animateAvatars());
|
||||
|
||||
}
|
||||
|
||||
|
@ -273,7 +278,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
if (useAbsoluteTime) {
|
||||
timestampInfo.setText(getAbsoluteTime(createdAt));
|
||||
} else {
|
||||
if(createdAt == null) {
|
||||
if (createdAt == null) {
|
||||
timestampInfo.setText("?m");
|
||||
} else {
|
||||
long then = createdAt.getTime();
|
||||
|
@ -285,7 +290,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
private String getAbsoluteTime(Date createdAt) {
|
||||
if(createdAt == null) {
|
||||
if (createdAt == null) {
|
||||
return "??:??:??";
|
||||
}
|
||||
if (DateUtils.isToday(createdAt.getTime())) {
|
||||
|
@ -302,7 +307,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
/* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m"
|
||||
* as 17 meters instead of minutes. */
|
||||
|
||||
if(createdAt == null) {
|
||||
if (createdAt == null) {
|
||||
return "? minutes";
|
||||
} else {
|
||||
long then = createdAt.getTime();
|
||||
|
@ -367,12 +372,22 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
bookmarkButton.setChecked(bookmarked);
|
||||
}
|
||||
|
||||
private void loadImage(MediaPreviewImageView imageView, String previewUrl, MetaData meta) {
|
||||
private BitmapDrawable decodeBlurHash(String blurhash) {
|
||||
return ImageLoadingHelper.decodeBlurHash(this.avatar.getContext(), blurhash);
|
||||
}
|
||||
|
||||
private void loadImage(MediaPreviewImageView imageView, String previewUrl, MetaData meta,
|
||||
@Nullable String blurhash) {
|
||||
Drawable placeholder = blurhash != null ? decodeBlurHash(blurhash) : mediaPreviewUnloaded;
|
||||
if (TextUtils.isEmpty(previewUrl)) {
|
||||
Glide.with(imageView)
|
||||
.load(mediaPreviewUnloadedId)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
if (blurhash != null) {
|
||||
imageView.setImageDrawable(decodeBlurHash(blurhash));
|
||||
} else {
|
||||
Glide.with(imageView)
|
||||
.load(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
}
|
||||
} else {
|
||||
Focus focus = meta != null ? meta.getFocus() : null;
|
||||
|
||||
|
@ -381,7 +396,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
Glide.with(imageView)
|
||||
.load(previewUrl)
|
||||
.placeholder(mediaPreviewUnloadedId)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.addListener(imageView)
|
||||
.into(imageView);
|
||||
|
@ -390,7 +405,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
Glide.with(imageView)
|
||||
.load(previewUrl)
|
||||
.placeholder(mediaPreviewUnloadedId)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.into(imageView);
|
||||
}
|
||||
|
@ -398,39 +413,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
protected void setMediaPreviews(final List<Attachment> attachments, boolean sensitive,
|
||||
final StatusActionListener listener, boolean showingContent) {
|
||||
final StatusActionListener listener, boolean showingContent,
|
||||
boolean useBlurhash) {
|
||||
Context context = itemView.getContext();
|
||||
final int n = Math.min(attachments.size(), Status.MAX_MEDIA_ATTACHMENTS);
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
String previewUrl = attachments.get(i).getPreviewUrl();
|
||||
String description = attachments.get(i).getDescription();
|
||||
MediaPreviewImageView imageView = mediaPreviews[i];
|
||||
|
||||
imageView.setVisibility(View.VISIBLE);
|
||||
|
||||
if (TextUtils.isEmpty(description)) {
|
||||
imageView.setContentDescription(imageView.getContext()
|
||||
.getString(R.string.action_view_media));
|
||||
} else {
|
||||
imageView.setContentDescription(description);
|
||||
}
|
||||
|
||||
if (!sensitive || showingContent) {
|
||||
loadImage(imageView, previewUrl, attachments.get(i).getMeta());
|
||||
} else {
|
||||
imageView.setImageResource(mediaPreviewUnloadedId);
|
||||
}
|
||||
|
||||
final Attachment.Type type = attachments.get(i).getType();
|
||||
if (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV) {
|
||||
mediaOverlays[i].setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
mediaOverlays[i].setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
setAttachmentClickListener(imageView, listener, i, attachments.get(i), true);
|
||||
}
|
||||
|
||||
final int mediaPreviewHeight = getMediaPreviewHeight(context);
|
||||
|
||||
|
@ -444,15 +431,51 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
mediaPreviews[3].getLayoutParams().height = mediaPreviewHeight;
|
||||
}
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
Attachment attachment = attachments.get(i);
|
||||
String previewUrl = attachment.getPreviewUrl();
|
||||
String description = attachment.getDescription();
|
||||
MediaPreviewImageView imageView = mediaPreviews[i];
|
||||
|
||||
imageView.setVisibility(View.VISIBLE);
|
||||
|
||||
if (TextUtils.isEmpty(description)) {
|
||||
imageView.setContentDescription(imageView.getContext()
|
||||
.getString(R.string.action_view_media));
|
||||
} else {
|
||||
imageView.setContentDescription(description);
|
||||
}
|
||||
|
||||
if (showingContent) {
|
||||
loadImage(imageView, previewUrl, attachment.getMeta(), attachment.getBlurhash());
|
||||
} else {
|
||||
imageView.setFocalPoint(null);
|
||||
imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
|
||||
if (useBlurhash && attachment.getBlurhash() != null) {
|
||||
BitmapDrawable blurhashBitmap = decodeBlurHash(attachment.getBlurhash());
|
||||
imageView.setImageDrawable(blurhashBitmap);
|
||||
} else {
|
||||
imageView.setImageDrawable(new ColorDrawable(ThemeUtils.getColor(
|
||||
context, R.attr.sensitive_media_warning_background_color)));
|
||||
}
|
||||
}
|
||||
|
||||
final Attachment.Type type = attachment.getType();
|
||||
if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) {
|
||||
mediaOverlays[i].setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
mediaOverlays[i].setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
setAttachmentClickListener(imageView, listener, i, attachment, true);
|
||||
}
|
||||
|
||||
|
||||
final String hiddenContentText;
|
||||
if (sensitive) {
|
||||
hiddenContentText = context.getString(R.string.status_sensitive_media_template,
|
||||
context.getString(R.string.status_sensitive_media_title),
|
||||
context.getString(R.string.status_sensitive_media_directions));
|
||||
hiddenContentText = context.getString(R.string.status_sensitive_media_title);
|
||||
} else {
|
||||
hiddenContentText = context.getString(R.string.status_sensitive_media_template,
|
||||
context.getString(R.string.status_media_hidden_title),
|
||||
context.getString(R.string.status_sensitive_media_directions));
|
||||
hiddenContentText = context.getString(R.string.status_media_hidden_title);
|
||||
}
|
||||
|
||||
sensitiveMediaWarning.setText(HtmlUtils.fromHtml(hiddenContentText));
|
||||
|
@ -528,7 +551,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
view.setOnClickListener(v -> {
|
||||
int position = getAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
listener.onViewMedia(position, index, animateTransition ? v : null);
|
||||
if (sensitiveMediaWarning.getVisibility() == View.VISIBLE) {
|
||||
listener.onContentHiddenChange(true, getAdapterPosition());
|
||||
} else {
|
||||
listener.onViewMedia(position, index, animateTransition ? v : null);
|
||||
}
|
||||
}
|
||||
});
|
||||
view.setOnLongClickListener(v -> {
|
||||
|
@ -540,7 +567,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
private static CharSequence getAttachmentDescription(Context context, Attachment attachment) {
|
||||
String duration = "";
|
||||
if(attachment.getMeta() != null && attachment.getMeta().getDuration() != null && attachment.getMeta().getDuration() > 0) {
|
||||
if (attachment.getMeta() != null && attachment.getMeta().getDuration() != null && attachment.getMeta().getDuration() > 0) {
|
||||
duration = formatDuration(attachment.getMeta().getDuration()) + " ";
|
||||
}
|
||||
if (TextUtils.isEmpty(attachment.getDescription())) {
|
||||
|
@ -565,60 +592,25 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
});
|
||||
if (reblogButton != null) {
|
||||
reblogButton.setEventListener(new SparkEventListener() {
|
||||
@Override
|
||||
public void onEvent(ImageView button, boolean buttonState) {
|
||||
int position = getAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
listener.onReblog(buttonState, position);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEventAnimationEnd(ImageView button, boolean buttonState) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEventAnimationStart(ImageView button, boolean buttonState) {
|
||||
reblogButton.setEventListener((button, buttonState) -> {
|
||||
int position = getAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
listener.onReblog(buttonState, position);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
favouriteButton.setEventListener(new SparkEventListener() {
|
||||
@Override
|
||||
public void onEvent(ImageView button, boolean buttonState) {
|
||||
int position = getAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
listener.onFavourite(buttonState, position);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEventAnimationEnd(ImageView button, boolean buttonState) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEventAnimationStart(ImageView button, boolean buttonState) {
|
||||
favouriteButton.setEventListener((button, buttonState) -> {
|
||||
int position = getAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
listener.onFavourite(buttonState, position);
|
||||
}
|
||||
});
|
||||
|
||||
bookmarkButton.setEventListener(new SparkEventListener() {
|
||||
@Override
|
||||
public void onEvent(ImageView button, boolean buttonState) {
|
||||
int position = getAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
listener.onBookmark(buttonState, position);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEventAnimationEnd(ImageView button, boolean buttonState) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEventAnimationStart(ImageView button, boolean buttonState) {
|
||||
|
||||
bookmarkButton.setEventListener((button, buttonState) -> {
|
||||
int position = getAdapterPosition();
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
listener.onBookmark(buttonState, position);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -643,29 +635,27 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
|
||||
boolean mediaPreviewEnabled, boolean showBotOverlay, boolean animateAvatar) {
|
||||
this.setupWithStatus(status, listener, mediaPreviewEnabled, showBotOverlay, animateAvatar, null);
|
||||
StatusDisplayOptions statusDisplayOptions) {
|
||||
this.setupWithStatus(status, listener, statusDisplayOptions, null);
|
||||
}
|
||||
|
||||
protected void setupWithStatus(StatusViewData.Concrete status,
|
||||
final StatusActionListener listener,
|
||||
boolean mediaPreviewEnabled,
|
||||
boolean showBotOverlay,
|
||||
boolean animateAvatar,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
@Nullable Object payloads) {
|
||||
if (payloads == null) {
|
||||
setDisplayName(status.getUserFullName(), status.getAccountEmojis());
|
||||
setUsername(status.getNickname());
|
||||
setCreatedAt(status.getCreatedAt());
|
||||
setIsReply(status.getInReplyToId() != null);
|
||||
setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), showBotOverlay, animateAvatar);
|
||||
setAvatar(status.getAvatar(), status.getRebloggedAvatar(), status.isBot(), statusDisplayOptions);
|
||||
setReblogged(status.isReblogged());
|
||||
setFavourited(status.isFavourited());
|
||||
setBookmarked(status.isBookmarked());
|
||||
List<Attachment> attachments = status.getAttachments();
|
||||
boolean sensitive = status.isSensitive();
|
||||
if (mediaPreviewEnabled && !hasAudioAttachment(attachments)) {
|
||||
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent());
|
||||
if (statusDisplayOptions.mediaPreviewEnabled() && !hasAudioAttachment(attachments)) {
|
||||
setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash());
|
||||
|
||||
if (attachments.size() == 0) {
|
||||
hideSensitiveMediaWarning();
|
||||
|
@ -709,7 +699,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
}
|
||||
|
||||
protected static boolean hasAudioAttachment(List<Attachment> attachments) {
|
||||
for(Attachment attachment: attachments) {
|
||||
for (Attachment attachment : attachments) {
|
||||
if (attachment.getType() == Attachment.Type.AUDIO) {
|
||||
return true;
|
||||
}
|
||||
|
@ -782,7 +772,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
|
||||
private static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) {
|
||||
|
||||
if(visibility == null) {
|
||||
if (visibility == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,9 @@ import android.widget.LinearLayout;
|
|||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
|
||||
import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners;
|
||||
|
@ -25,14 +28,12 @@ import com.keylesspalace.tusky.entity.Status;
|
|||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.CustomURLSpan;
|
||||
import com.keylesspalace.tusky.util.LinkHelper;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.util.Date;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
||||
private TextView reblogs;
|
||||
private TextView favourites;
|
||||
|
@ -44,8 +45,8 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
private TextView cardUrl;
|
||||
private View infoDivider;
|
||||
|
||||
StatusDetailedViewHolder(View view, boolean useAbsoluteTime) {
|
||||
super(view, useAbsoluteTime);
|
||||
StatusDetailedViewHolder(View view) {
|
||||
super(view);
|
||||
reblogs = view.findViewById(R.id.status_reblogs);
|
||||
favourites = view.findViewById(R.id.status_favourites);
|
||||
cardView = view.findViewById(R.id.card_view);
|
||||
|
@ -115,10 +116,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
timestampInfo.append(" • ");
|
||||
|
||||
if (app.getWebsite() != null) {
|
||||
URLSpan span = new CustomURLSpan(app.getWebsite());
|
||||
|
||||
SpannableStringBuilder text = new SpannableStringBuilder(app.getName());
|
||||
text.setSpan(span, 0, app.getName().length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
|
||||
CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite());
|
||||
timestampInfo.append(text);
|
||||
timestampInfo.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
} else {
|
||||
|
@ -128,10 +126,11 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected void setupWithStatus(final StatusViewData.Concrete status, final StatusActionListener listener,
|
||||
boolean mediaPreviewEnabled, boolean showBotOverlay, boolean animateAvatar,
|
||||
protected void setupWithStatus(final StatusViewData.Concrete status,
|
||||
final StatusActionListener listener,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
@Nullable Object payloads) {
|
||||
super.setupWithStatus(status, listener, mediaPreviewEnabled, showBotOverlay, animateAvatar, payloads);
|
||||
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
|
||||
if (payloads == null) {
|
||||
setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener);
|
||||
|
||||
|
|
|
@ -22,14 +22,15 @@ import android.view.View;
|
|||
import android.widget.TextView;
|
||||
import android.widget.ToggleButton;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import at.connyduck.sparkbutton.helpers.Utils;
|
||||
|
||||
public class StatusViewHolder extends StatusBaseViewHolder {
|
||||
|
@ -39,8 +40,8 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
private TextView statusInfo;
|
||||
private ToggleButton contentCollapseButton;
|
||||
|
||||
public StatusViewHolder(View itemView, boolean useAbsoluteTime) {
|
||||
super(itemView, useAbsoluteTime);
|
||||
public StatusViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
statusInfo = itemView.findViewById(R.id.status_info);
|
||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
|
||||
}
|
||||
|
@ -51,8 +52,9 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
|
||||
boolean mediaPreviewEnabled, boolean showBotOverlay, boolean animateAvatar,
|
||||
protected void setupWithStatus(StatusViewData.Concrete status,
|
||||
final StatusActionListener listener,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
@Nullable Object payloads) {
|
||||
if (payloads == null) {
|
||||
|
||||
|
@ -67,7 +69,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
}
|
||||
super.setupWithStatus(status, listener, mediaPreviewEnabled, showBotOverlay, animateAvatar, payloads);
|
||||
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -32,14 +32,16 @@ import kotlinx.android.synthetic.main.item_tab_preference.view.*
|
|||
|
||||
interface ItemInteractionListener {
|
||||
fun onTabAdded(tab: TabData)
|
||||
fun onTabRemoved(position: Int)
|
||||
fun onStartDelete(viewHolder: RecyclerView.ViewHolder)
|
||||
fun onStartDrag(viewHolder: RecyclerView.ViewHolder)
|
||||
fun onActionChipClicked(tab: TabData)
|
||||
}
|
||||
|
||||
class TabAdapter(private var data: List<TabData>,
|
||||
private val small: Boolean = false,
|
||||
private val listener: ItemInteractionListener? = null) : RecyclerView.Adapter<TabAdapter.ViewHolder>() {
|
||||
private val small: Boolean,
|
||||
private val listener: ItemInteractionListener,
|
||||
private var removeButtonEnabled: Boolean = false) : RecyclerView.Adapter<TabAdapter.ViewHolder>() {
|
||||
|
||||
fun updateData(newData: List<TabData>) {
|
||||
this.data = newData
|
||||
|
@ -67,17 +69,28 @@ class TabAdapter(private var data: List<TabData>,
|
|||
holder.itemView.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconDrawable, null, null, null)
|
||||
if (small) {
|
||||
holder.itemView.textView.setOnClickListener {
|
||||
listener?.onTabAdded(data[position])
|
||||
listener.onTabAdded(data[position])
|
||||
}
|
||||
}
|
||||
holder.itemView.imageView?.setOnTouchListener { _, event ->
|
||||
if (event.action == MotionEvent.ACTION_DOWN) {
|
||||
listener?.onStartDrag(holder)
|
||||
listener.onStartDrag(holder)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
holder.itemView.removeButton?.setOnClickListener {
|
||||
listener.onTabRemoved(holder.adapterPosition)
|
||||
}
|
||||
if (holder.itemView.removeButton != null) {
|
||||
holder.itemView.removeButton.isEnabled = removeButtonEnabled
|
||||
ThemeUtils.setDrawableTint(
|
||||
holder.itemView.context,
|
||||
holder.itemView.removeButton.drawable,
|
||||
(if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.image_button_disabled_tint)
|
||||
)
|
||||
}
|
||||
|
||||
if (!small) {
|
||||
|
||||
|
@ -89,7 +102,7 @@ class TabAdapter(private var data: List<TabData>,
|
|||
|
||||
holder.itemView.actionChip.chipIcon = context.getDrawable(R.drawable.ic_edit_chip)
|
||||
holder.itemView.actionChip.setOnClickListener {
|
||||
listener?.onActionChipClicked(data[position])
|
||||
listener.onActionChipClicked(data[position])
|
||||
}
|
||||
|
||||
} else {
|
||||
|
@ -102,5 +115,12 @@ class TabAdapter(private var data: List<TabData>,
|
|||
return data.size
|
||||
}
|
||||
|
||||
fun setRemoveButtonVisible(enabled: Boolean) {
|
||||
if (removeButtonEnabled != enabled) {
|
||||
removeButtonEnabled = enabled
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
|
||||
}
|
||||
|
|
|
@ -15,15 +15,17 @@
|
|||
|
||||
package com.keylesspalace.tusky.adapter;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
@ -34,20 +36,14 @@ public class ThreadAdapter extends RecyclerView.Adapter {
|
|||
private static final int VIEW_TYPE_STATUS_DETAILED = 1;
|
||||
|
||||
private List<StatusViewData.Concrete> statuses;
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
private StatusActionListener statusActionListener;
|
||||
private boolean mediaPreviewEnabled;
|
||||
private boolean useAbsoluteTime;
|
||||
private boolean showBotOverlay;
|
||||
private boolean animateAvatar;
|
||||
private int detailedStatusPosition;
|
||||
|
||||
public ThreadAdapter(StatusActionListener listener) {
|
||||
public ThreadAdapter(StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
this.statusActionListener = listener;
|
||||
this.statuses = new ArrayList<>();
|
||||
mediaPreviewEnabled = true;
|
||||
useAbsoluteTime = false;
|
||||
showBotOverlay = true;
|
||||
animateAvatar = false;
|
||||
detailedStatusPosition = RecyclerView.NO_POSITION;
|
||||
}
|
||||
|
||||
|
@ -59,12 +55,12 @@ public class ThreadAdapter extends RecyclerView.Adapter {
|
|||
case VIEW_TYPE_STATUS: {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_status, parent, false);
|
||||
return new StatusViewHolder(view, useAbsoluteTime);
|
||||
return new StatusViewHolder(view);
|
||||
}
|
||||
case VIEW_TYPE_STATUS_DETAILED: {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_status_detailed, parent, false);
|
||||
return new StatusDetailedViewHolder(view, useAbsoluteTime);
|
||||
return new StatusDetailedViewHolder(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -74,10 +70,10 @@ public class ThreadAdapter extends RecyclerView.Adapter {
|
|||
StatusViewData.Concrete status = statuses.get(position);
|
||||
if (position == detailedStatusPosition) {
|
||||
StatusDetailedViewHolder holder = (StatusDetailedViewHolder) viewHolder;
|
||||
holder.setupWithStatus(status, statusActionListener, mediaPreviewEnabled, showBotOverlay, animateAvatar);
|
||||
holder.setupWithStatus(status, statusActionListener, statusDisplayOptions);
|
||||
} else {
|
||||
StatusViewHolder holder = (StatusViewHolder) viewHolder;
|
||||
holder.setupWithStatus(status, statusActionListener, mediaPreviewEnabled, showBotOverlay, animateAvatar);
|
||||
holder.setupWithStatus(status, statusActionListener, statusDisplayOptions);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -151,22 +147,6 @@ public class ThreadAdapter extends RecyclerView.Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
public void setMediaPreviewEnabled(boolean enabled) {
|
||||
mediaPreviewEnabled = enabled;
|
||||
}
|
||||
|
||||
public void setUseAbsoluteTime(boolean useAbsoluteTime) {
|
||||
this.useAbsoluteTime = useAbsoluteTime;
|
||||
}
|
||||
|
||||
public void setShowBotOverlay(boolean showBotOverlay) {
|
||||
this.showBotOverlay = showBotOverlay;
|
||||
}
|
||||
|
||||
public void setAnimateAvatar(boolean animateAvatar) {
|
||||
this.animateAvatar = animateAvatar;
|
||||
}
|
||||
|
||||
public void setDetailedStatusPosition(int position) {
|
||||
if (position != detailedStatusPosition
|
||||
&& detailedStatusPosition != RecyclerView.NO_POSITION) {
|
||||
|
|
|
@ -15,19 +15,21 @@
|
|||
|
||||
package com.keylesspalace.tusky.adapter;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import java.util.List;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class TimelineAdapter extends RecyclerView.Adapter {
|
||||
|
||||
public interface AdapterDataSource<T> {
|
||||
|
@ -40,20 +42,29 @@ public final class TimelineAdapter extends RecyclerView.Adapter {
|
|||
private static final int VIEW_TYPE_PLACEHOLDER = 2;
|
||||
|
||||
private final AdapterDataSource<StatusViewData> dataSource;
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
private final StatusActionListener statusListener;
|
||||
private boolean mediaPreviewEnabled;
|
||||
private boolean useAbsoluteTime;
|
||||
private boolean showBotOverlay;
|
||||
private boolean animateAvatar;
|
||||
|
||||
public TimelineAdapter(AdapterDataSource<StatusViewData> dataSource,
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
StatusActionListener statusListener) {
|
||||
this.dataSource = dataSource;
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
this.statusListener = statusListener;
|
||||
mediaPreviewEnabled = true;
|
||||
useAbsoluteTime = false;
|
||||
showBotOverlay = true;
|
||||
animateAvatar = false;
|
||||
}
|
||||
|
||||
public boolean getMediaPreviewEnabled() {
|
||||
return statusDisplayOptions.mediaPreviewEnabled();
|
||||
}
|
||||
|
||||
public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) {
|
||||
this.statusDisplayOptions = statusDisplayOptions.copy(
|
||||
statusDisplayOptions.animateAvatars(),
|
||||
mediaPreviewEnabled,
|
||||
statusDisplayOptions.useAbsoluteTime(),
|
||||
statusDisplayOptions.showBotOverlay(),
|
||||
statusDisplayOptions.useBlurhash()
|
||||
);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
|
@ -64,7 +75,7 @@ public final class TimelineAdapter extends RecyclerView.Adapter {
|
|||
case VIEW_TYPE_STATUS: {
|
||||
View view = LayoutInflater.from(viewGroup.getContext())
|
||||
.inflate(R.layout.item_status, viewGroup, false);
|
||||
return new StatusViewHolder(view, useAbsoluteTime);
|
||||
return new StatusViewHolder(view);
|
||||
}
|
||||
case VIEW_TYPE_PLACEHOLDER: {
|
||||
View view = LayoutInflater.from(viewGroup.getContext())
|
||||
|
@ -76,16 +87,16 @@ public final class TimelineAdapter extends RecyclerView.Adapter {
|
|||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
|
||||
bindViewHolder(viewHolder,position,null);
|
||||
bindViewHolder(viewHolder, position, null);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) {
|
||||
bindViewHolder(viewHolder,position,payloads);
|
||||
bindViewHolder(viewHolder, position, payloads);
|
||||
}
|
||||
|
||||
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads){
|
||||
private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) {
|
||||
StatusViewData status = dataSource.getItemAt(position);
|
||||
if (status instanceof StatusViewData.Placeholder) {
|
||||
PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder;
|
||||
|
@ -94,12 +105,11 @@ public final class TimelineAdapter extends RecyclerView.Adapter {
|
|||
StatusViewHolder holder = (StatusViewHolder) viewHolder;
|
||||
holder.setupWithStatus((StatusViewData.Concrete) status,
|
||||
statusListener,
|
||||
mediaPreviewEnabled,
|
||||
showBotOverlay,
|
||||
animateAvatar,
|
||||
statusDisplayOptions,
|
||||
payloads != null && !payloads.isEmpty() ? payloads.get(0) : null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return dataSource.getItemCount();
|
||||
|
@ -114,26 +124,6 @@ public final class TimelineAdapter extends RecyclerView.Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
public void setMediaPreviewEnabled(boolean enabled) {
|
||||
mediaPreviewEnabled = enabled;
|
||||
}
|
||||
|
||||
public void setUseAbsoluteTime(boolean useAbsoluteTime){
|
||||
this.useAbsoluteTime = useAbsoluteTime;
|
||||
}
|
||||
|
||||
public boolean getMediaPreviewEnabled() {
|
||||
return mediaPreviewEnabled;
|
||||
}
|
||||
|
||||
public void setShowBotOverlay(boolean showBotOverlay) {
|
||||
this.showBotOverlay = showBotOverlay;
|
||||
}
|
||||
|
||||
public void setAnimateAvatar(boolean animateAvatar) {
|
||||
this.animateAvatar = animateAvatar;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return dataSource.getItemAt(position).getViewDataId();
|
||||
|
|
|
@ -0,0 +1,994 @@
|
|||
/* Copyright 2019 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.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
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.preference.PreferenceManager
|
||||
import android.provider.MediaStore
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||
import androidx.core.view.inputmethod.InputContentInfoCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.transition.TransitionManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
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.components.compose.dialog.makeCaptionDialog
|
||||
import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
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.util.*
|
||||
import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog
|
||||
import com.mikepenz.google_material_typeface_library.GoogleMaterial
|
||||
import com.mikepenz.iconics.IconicsDrawable
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.android.synthetic.main.activity_compose.*
|
||||
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
|
||||
|
||||
class ComposeActivity : BaseActivity(),
|
||||
ComposeOptionsListener,
|
||||
ComposeAutoCompleteAdapter.AutocompletionProvider,
|
||||
OnEmojiSelectedListener,
|
||||
Injectable,
|
||||
InputConnectionCompat.OnCommitContentListener,
|
||||
TimePickerDialog.OnTimeSetListener {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
private lateinit var composeOptionsBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var addMediaBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var emojiBehavior: BottomSheetBehavior<*>
|
||||
private lateinit var scheduleBehavior: BottomSheetBehavior<*>
|
||||
|
||||
// this only exists when a status is trying to be sent, but uploads are still occurring
|
||||
private var finishingUploadDialog: ProgressDialog? = null
|
||||
private var currentInputContentInfo: InputContentInfoCompat? = null
|
||||
private var currentFlags: Int = 0
|
||||
private var photoUploadUri: Uri? = null
|
||||
@VisibleForTesting
|
||||
var maximumTootCharacters = DEFAULT_CHARACTER_LIMIT
|
||||
|
||||
private var composeOptions: ComposeOptions? = null
|
||||
private lateinit var viewModel: ComposeViewModel
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
|
||||
if (theme == "black") {
|
||||
setTheme(R.style.TuskyDialogActivityBlackTheme)
|
||||
}
|
||||
setContentView(R.layout.activity_compose)
|
||||
|
||||
setupActionBar()
|
||||
// do not do anything when not logged in, activity will be finished in super.onCreate() anyway
|
||||
val activeAccount = accountManager.activeAccount ?: return
|
||||
|
||||
setupAvatar(preferences, activeAccount)
|
||||
val mediaAdapter = MediaPreviewAdapter(
|
||||
this,
|
||||
onAddCaption = { item ->
|
||||
makeCaptionDialog(item.description, item.uri) { newDescription ->
|
||||
viewModel.updateDescription(item.localId, newDescription)
|
||||
}
|
||||
},
|
||||
onRemove = this::removeMediaFromQueue
|
||||
)
|
||||
composeMediaPreviewBar.layoutManager =
|
||||
LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
|
||||
composeMediaPreviewBar.adapter = mediaAdapter
|
||||
composeMediaPreviewBar.itemAnimator = null
|
||||
|
||||
viewModel = ViewModelProviders.of(this, viewModelFactory)[ComposeViewModel::class.java]
|
||||
|
||||
subscribeToUpdates(mediaAdapter)
|
||||
setupButtons()
|
||||
|
||||
/* 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<ComposeOptions?>(COMPOSE_OPTIONS_EXTRA)
|
||||
viewModel.setup(composeOptions)
|
||||
setupReplyViews(composeOptions?.replyingStatusAuthor)
|
||||
val tootText = composeOptions?.tootText
|
||||
if (!tootText.isNullOrEmpty()) {
|
||||
composeEditField.setText(tootText)
|
||||
}
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(composeOptions?.scheduledAt)) {
|
||||
composeScheduleView.setDateTime(composeOptions?.scheduledAt)
|
||||
}
|
||||
|
||||
setupComposeField(viewModel.startingText)
|
||||
setupContentWarningField(composeOptions?.contentWarning)
|
||||
setupPollView()
|
||||
applyShareIntent(intent, savedInstanceState)
|
||||
|
||||
composeEditField.requestFocus()
|
||||
}
|
||||
|
||||
private fun applyShareIntent(intent: Intent?, savedInstanceState: Bundle?) {
|
||||
if (intent != null && 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) {
|
||||
if (type.startsWith("image/") || type.startsWith("video/")) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (uri in uriList) {
|
||||
pickMedia(uri)
|
||||
}
|
||||
} else if (type == "text/plain") {
|
||||
val action = intent.action
|
||||
if (action != null && action == Intent.ACTION_SEND) {
|
||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
val shareBody = if (subject != null && text != null) {
|
||||
if (subject != text && !text.contains(subject)) {
|
||||
String.format("%s\n%s", subject, text)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
} else text ?: subject
|
||||
|
||||
if (shareBody != null) {
|
||||
val start = composeEditField.selectionStart.coerceAtLeast(0)
|
||||
val end = composeEditField.selectionEnd.coerceAtLeast(0)
|
||||
val left = min(start, end)
|
||||
val right = max(start, end)
|
||||
composeEditField.text.replace(left, right, shareBody, 0, shareBody.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupReplyViews(replyingStatusAuthor: String?) {
|
||||
if (replyingStatusAuthor != null) {
|
||||
composeReplyView.show()
|
||||
composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor)
|
||||
val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).sizeDp(12)
|
||||
|
||||
ThemeUtils.setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary)
|
||||
composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
|
||||
|
||||
composeReplyView.setOnClickListener {
|
||||
TransitionManager.beginDelayedTransition(composeReplyContentView.parent as ViewGroup)
|
||||
|
||||
if (composeReplyContentView.isVisible) {
|
||||
composeReplyContentView.hide()
|
||||
composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null)
|
||||
} else {
|
||||
composeReplyContentView.show()
|
||||
val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).sizeDp(12)
|
||||
|
||||
ThemeUtils.setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary)
|
||||
composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
composeOptions?.replyingStatusContent?.let { composeReplyContentView.text = it }
|
||||
}
|
||||
|
||||
private fun setupContentWarningField(startingContentWarning: String?) {
|
||||
if (startingContentWarning != null) {
|
||||
composeContentWarningField.setText(startingContentWarning)
|
||||
}
|
||||
composeContentWarningField.onTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() }
|
||||
}
|
||||
|
||||
private fun setupComposeField(startingText: String?) {
|
||||
composeEditField.setOnCommitContentListener(this)
|
||||
|
||||
composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) }
|
||||
|
||||
composeEditField.setAdapter(
|
||||
ComposeAutoCompleteAdapter(this))
|
||||
composeEditField.setTokenizer(ComposeTokenizer())
|
||||
|
||||
composeEditField.setText(startingText)
|
||||
composeEditField.setSelection(composeEditField.length())
|
||||
|
||||
val mentionColour = composeEditField.linkTextColors.defaultColor
|
||||
highlightSpans(composeEditField.text, mentionColour)
|
||||
composeEditField.afterTextChanged { editable ->
|
||||
highlightSpans(editable, mentionColour)
|
||||
updateVisibleCharactersLeft()
|
||||
}
|
||||
|
||||
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O
|
||||
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1) {
|
||||
composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) {
|
||||
withLifecycleContext {
|
||||
viewModel.instanceParams.observe { instanceData ->
|
||||
maximumTootCharacters = instanceData.maxChars
|
||||
updateVisibleCharactersLeft()
|
||||
composeScheduleButton.visible(instanceData.supportsScheduled)
|
||||
}
|
||||
viewModel.emoji.observe { emoji -> setEmojiList(emoji) }
|
||||
combineLiveData(viewModel.markMediaAsSensitive, viewModel.showContentWarning) { markSensitive, showContentWarning ->
|
||||
updateSensitiveMediaToggle(markSensitive, showContentWarning)
|
||||
showContentWarning(showContentWarning)
|
||||
}.subscribe()
|
||||
viewModel.statusVisibility.observe { visibility ->
|
||||
setStatusVisibility(visibility)
|
||||
}
|
||||
viewModel.media.observe { media ->
|
||||
composeMediaPreviewBar.visible(media.isNotEmpty())
|
||||
mediaAdapter.submitList(media)
|
||||
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false)
|
||||
}
|
||||
viewModel.poll.observe { poll ->
|
||||
pollPreview.visible(poll != null)
|
||||
poll?.let(pollPreview::setPoll)
|
||||
}
|
||||
viewModel.scheduledAt.observe {scheduledAt ->
|
||||
if(scheduledAt == null) {
|
||||
composeScheduleView.resetSchedule()
|
||||
} else {
|
||||
composeScheduleView.setDateTime(scheduledAt)
|
||||
}
|
||||
updateScheduleButton()
|
||||
}
|
||||
combineOptionalLiveData(viewModel.media, viewModel.poll) { media, poll ->
|
||||
val active = poll == null
|
||||
&& media!!.size != 4
|
||||
&& media.firstOrNull()?.type != QueuedMedia.Type.VIDEO
|
||||
enableButton(composeAddMediaButton, active, active)
|
||||
enablePollButton(media.isNullOrEmpty())
|
||||
}.subscribe()
|
||||
viewModel.uploadError.observe {
|
||||
displayTransientError(R.string.error_media_upload_sending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupButtons() {
|
||||
composeOptionsBottomSheet.listener = this
|
||||
|
||||
composeOptionsBehavior = BottomSheetBehavior.from(composeOptionsBottomSheet)
|
||||
addMediaBehavior = BottomSheetBehavior.from(addMediaBottomSheet)
|
||||
scheduleBehavior = BottomSheetBehavior.from(composeScheduleView)
|
||||
emojiBehavior = BottomSheetBehavior.from(emojiView)
|
||||
|
||||
emojiView.layoutManager = GridLayoutManager(this, 3, GridLayoutManager.HORIZONTAL, false)
|
||||
enableButton(composeEmojiButton, clickable = false, colorActive = false)
|
||||
|
||||
// Setup the interface buttons.
|
||||
composeTootButton.setOnClickListener { onSendClicked() }
|
||||
composeAddMediaButton.setOnClickListener { openPickDialog() }
|
||||
composeToggleVisibilityButton.setOnClickListener { showComposeOptions() }
|
||||
composeContentWarningButton.setOnClickListener { onContentWarningChanged() }
|
||||
composeEmojiButton.setOnClickListener { showEmojis() }
|
||||
composeHideMediaButton.setOnClickListener { toggleHideMedia() }
|
||||
composeScheduleButton.setOnClickListener { onScheduleClick() }
|
||||
composeScheduleView.setResetOnClickListener { resetSchedule() }
|
||||
atButton.setOnClickListener { atButtonClicked() }
|
||||
hashButton.setOnClickListener { hashButtonClicked() }
|
||||
|
||||
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
|
||||
val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).color(textColor).sizeDp(18)
|
||||
actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null)
|
||||
|
||||
val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).color(textColor).sizeDp(18)
|
||||
actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null)
|
||||
|
||||
val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).color(textColor).sizeDp(18)
|
||||
addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null)
|
||||
|
||||
actionPhotoTake.setOnClickListener { initiateCameraApp() }
|
||||
actionPhotoPick.setOnClickListener { onMediaPick() }
|
||||
addPollTextActionTextView.setOnClickListener { openPollDialog() }
|
||||
}
|
||||
|
||||
private fun setupActionBar() {
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.run {
|
||||
title = null
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
val closeIcon = AppCompatResources.getDrawable(this@ComposeActivity, R.drawable.ic_close_24dp)
|
||||
ThemeUtils.setDrawableTint(this@ComposeActivity, closeIcon!!, R.attr.compose_close_button_tint)
|
||||
setHomeAsUpIndicator(closeIcon)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun setupAvatar(preferences: SharedPreferences, activeAccount: AccountEntity) {
|
||||
val actionBarSizeAttr = intArrayOf(R.attr.actionBarSize)
|
||||
val a = obtainStyledAttributes(null, actionBarSizeAttr)
|
||||
val avatarSize = a.getDimensionPixelSize(0, 1)
|
||||
a.recycle()
|
||||
|
||||
val animateAvatars = preferences.getBoolean("animateGifAvatars", false)
|
||||
loadAvatar(
|
||||
activeAccount.profilePictureUrl,
|
||||
composeAvatar,
|
||||
avatarSize / 8,
|
||||
animateAvatars
|
||||
)
|
||||
composeAvatar.contentDescription = getString(R.string.compose_active_account_description,
|
||||
activeAccount.fullName)
|
||||
}
|
||||
|
||||
private fun replaceTextAtCaret(text: CharSequence) {
|
||||
// If you select "backward" in an editable, you get SelectionStart > SelectionEnd
|
||||
val start = composeEditField.selectionStart.coerceAtMost(composeEditField.selectionEnd)
|
||||
val end = composeEditField.selectionStart.coerceAtLeast(composeEditField.selectionEnd)
|
||||
composeEditField.text.replace(start, end, text)
|
||||
|
||||
// Set the cursor after the inserted text
|
||||
composeEditField.setSelection(start + text.length)
|
||||
}
|
||||
|
||||
private fun atButtonClicked() {
|
||||
replaceTextAtCaret("@")
|
||||
}
|
||||
|
||||
private fun hashButtonClicked() {
|
||||
replaceTextAtCaret("#")
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
if (currentInputContentInfo != null) {
|
||||
outState.putParcelable("commitContentInputContentInfo",
|
||||
currentInputContentInfo!!.unwrap() as Parcelable?)
|
||||
outState.putInt("commitContentFlags", currentFlags)
|
||||
}
|
||||
currentInputContentInfo = null
|
||||
currentFlags = 0
|
||||
outState.putParcelable("photoUploadUri", photoUploadUri)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun displayTransientError(@StringRes stringId: Int) {
|
||||
val bar = Snackbar.make(activityCompose, stringId, Snackbar.LENGTH_LONG)
|
||||
//necessary so snackbar is shown over everything
|
||||
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
|
||||
bar.show()
|
||||
}
|
||||
|
||||
private fun toggleHideMedia() {
|
||||
this.viewModel.toggleMarkSensitive()
|
||||
}
|
||||
|
||||
private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) {
|
||||
TransitionManager.beginDelayedTransition(composeHideMediaButton.parent as ViewGroup)
|
||||
|
||||
if (viewModel.media.value.isNullOrEmpty()) {
|
||||
composeHideMediaButton.hide()
|
||||
} else {
|
||||
composeHideMediaButton.show()
|
||||
@ColorInt val color = if (contentWarningShown) {
|
||||
composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
|
||||
composeHideMediaButton.isClickable = false
|
||||
ContextCompat.getColor(this, R.color.compose_media_visible_button_disabled_blue)
|
||||
|
||||
} else {
|
||||
composeHideMediaButton.isClickable = true
|
||||
if (markMediaSensitive) {
|
||||
composeHideMediaButton.setImageResource(R.drawable.ic_hide_media_24dp)
|
||||
ContextCompat.getColor(this, R.color.tusky_blue)
|
||||
} else {
|
||||
composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp)
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
}
|
||||
}
|
||||
composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateScheduleButton() {
|
||||
@ColorInt val color = if (composeScheduleView.time == null) {
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
} else {
|
||||
ContextCompat.getColor(this, R.color.tusky_blue)
|
||||
}
|
||||
composeScheduleButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
private fun enableButtons(enable: Boolean) {
|
||||
composeAddMediaButton.isClickable = enable
|
||||
composeToggleVisibilityButton.isClickable = enable
|
||||
composeEmojiButton.isClickable = enable
|
||||
composeHideMediaButton.isClickable = enable
|
||||
composeScheduleButton.isClickable = enable
|
||||
composeTootButton.isEnabled = enable
|
||||
}
|
||||
|
||||
private fun setStatusVisibility(visibility: Status.Visibility) {
|
||||
composeOptionsBottomSheet.setStatusVisibility(visibility)
|
||||
composeTootButton.setStatusVisibility(visibility)
|
||||
|
||||
val iconRes = when (visibility) {
|
||||
Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp
|
||||
Status.Visibility.PRIVATE -> R.drawable.ic_lock_outline_24dp
|
||||
Status.Visibility.DIRECT -> R.drawable.ic_email_24dp
|
||||
Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp
|
||||
else -> R.drawable.ic_lock_open_24dp
|
||||
}
|
||||
val drawable = ThemeUtils.getTintedDrawable(this, iconRes, android.R.attr.textColorTertiary)
|
||||
composeToggleVisibilityButton.setImageDrawable(drawable)
|
||||
}
|
||||
|
||||
private fun showComposeOptions() {
|
||||
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_HIDDEN || composeOptionsBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScheduleClick() {
|
||||
if(viewModel.scheduledAt.value == null) {
|
||||
composeScheduleView.openPickDateDialog()
|
||||
} else {
|
||||
showScheduleView()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showScheduleView() {
|
||||
if (scheduleBehavior.state == BottomSheetBehavior.STATE_HIDDEN || scheduleBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showEmojis() {
|
||||
emojiView.adapter?.let {
|
||||
if (it.itemCount == 0) {
|
||||
val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain)
|
||||
Toast.makeText(this, errorMessage, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPickDialog() {
|
||||
if (addMediaBehavior.state == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
} else {
|
||||
addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMediaPick() {
|
||||
addMediaBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
//Wait until bottom sheet is not collapsed and show next screen after
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
addMediaBehavior.removeBottomSheetCallback(this)
|
||||
if (ContextCompat.checkSelfPermission(this@ComposeActivity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this@ComposeActivity,
|
||||
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE),
|
||||
PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
initiateMediaPicking()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
||||
}
|
||||
)
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
||||
private fun openPollDialog() {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
val instanceParams = viewModel.instanceParams.value!!
|
||||
showAddPollDialog(this, viewModel.poll.value, instanceParams.pollMaxOptions,
|
||||
instanceParams.pollMaxLength, viewModel::updatePoll)
|
||||
}
|
||||
|
||||
private fun setupPollView() {
|
||||
val margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin)
|
||||
val marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom)
|
||||
|
||||
val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
layoutParams.setMargins(margin, margin, margin, marginBottom)
|
||||
pollPreview.layoutParams = layoutParams
|
||||
|
||||
pollPreview.setOnClickListener {
|
||||
val popup = PopupMenu(this, pollPreview)
|
||||
val editId = 1
|
||||
val removeId = 2
|
||||
popup.menu.add(0, editId, 0, R.string.edit_poll)
|
||||
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
editId -> openPollDialog()
|
||||
removeId -> removePoll()
|
||||
}
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun removePoll() {
|
||||
viewModel.poll.value = null
|
||||
pollPreview.hide()
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(visibility: Status.Visibility) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
viewModel.statusVisibility.value = visibility
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun calculateTextLength(): Int {
|
||||
var offset = 0
|
||||
val urlSpans = composeEditField.urls
|
||||
if (urlSpans != null) {
|
||||
for (span in urlSpans) {
|
||||
offset += max(0, span.url.length - MAXIMUM_URL_LENGTH)
|
||||
}
|
||||
}
|
||||
var length = composeEditField.length() - offset
|
||||
if (viewModel.showContentWarning.value!!) {
|
||||
length += composeContentWarningField.length()
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
private fun updateVisibleCharactersLeft() {
|
||||
composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", maximumTootCharacters - calculateTextLength())
|
||||
}
|
||||
|
||||
private fun onContentWarningChanged() {
|
||||
val showWarning = composeContentWarningBar.isGone
|
||||
viewModel.showContentWarning.value = showWarning
|
||||
updateVisibleCharactersLeft()
|
||||
}
|
||||
|
||||
private fun onSendClicked() {
|
||||
enableButtons(false)
|
||||
sendStatus()
|
||||
}
|
||||
|
||||
/** This is for the fancy keyboards which can insert images and stuff. */
|
||||
override fun onCommitContent(inputContentInfo: InputContentInfoCompat, flags: Int, opts: Bundle): Boolean {
|
||||
try {
|
||||
currentInputContentInfo?.releasePermission()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "InputContentInfoCompat#releasePermission() failed." + e.message)
|
||||
} finally {
|
||||
currentInputContentInfo = null
|
||||
}
|
||||
|
||||
// Verify the returned content's type is of the correct MIME type
|
||||
val supported = inputContentInfo.description.hasMimeType("image/*")
|
||||
|
||||
return supported && onCommitContentInternal(inputContentInfo, flags)
|
||||
}
|
||||
|
||||
private fun onCommitContentInternal(inputContentInfo: InputContentInfoCompat, flags: Int): Boolean {
|
||||
if (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION != 0) {
|
||||
try {
|
||||
inputContentInfo.requestPermission()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "InputContentInfoCompat#requestPermission() failed." + e.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the file size before putting handing it off to be put in the queue.
|
||||
pickMedia(inputContentInfo.contentUri)
|
||||
|
||||
currentInputContentInfo = inputContentInfo
|
||||
currentFlags = flags
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun sendStatus() {
|
||||
val contentText = composeEditField.text.toString()
|
||||
var spoilerText = ""
|
||||
if (viewModel.showContentWarning.value!!) {
|
||||
spoilerText = composeContentWarningField.text.toString()
|
||||
}
|
||||
val characterCount = calculateTextLength()
|
||||
if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value!!.isEmpty()) {
|
||||
composeEditField.error = getString(R.string.error_empty)
|
||||
enableButtons(true)
|
||||
} else if (characterCount <= maximumTootCharacters) {
|
||||
finishingUploadDialog = ProgressDialog.show(
|
||||
this, getString(R.string.dialog_title_finishing_media_upload),
|
||||
getString(R.string.dialog_message_uploading_media), true, true)
|
||||
|
||||
viewModel.sendStatus(contentText, spoilerText).observe(this, Observer {
|
||||
finishingUploadDialog?.dismiss()
|
||||
finishWithoutSlideOutAnimation()
|
||||
})
|
||||
|
||||
} else {
|
||||
composeEditField.error = getString(R.string.error_compose_character_limit)
|
||||
enableButtons(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>,
|
||||
grantResults: IntArray) {
|
||||
if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
initiateMediaPicking()
|
||||
} else {
|
||||
val bar = Snackbar.make(activityCompose, R.string.error_media_upload_permission,
|
||||
Snackbar.LENGTH_SHORT).apply {
|
||||
|
||||
}
|
||||
bar.setAction(R.string.action_retry) { onMediaPick()}
|
||||
//necessary so snackbar is shown over everything
|
||||
bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation)
|
||||
bar.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initiateCameraApp() {
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
|
||||
|
||||
// We don't need to ask for permission in this case, because the used calls require
|
||||
// android.permission.WRITE_EXTERNAL_STORAGE only on SDKs *older* than Kitkat, which was
|
||||
// way before permission dialogues have been introduced.
|
||||
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
|
||||
if (intent.resolveActivity(packageManager) != null) {
|
||||
val photoFile: File = try {
|
||||
createNewImageFile(this)
|
||||
} catch (ex: IOException) {
|
||||
displayTransientError(R.string.error_media_upload_opening)
|
||||
return
|
||||
}
|
||||
|
||||
// Continue only if the File was successfully created
|
||||
photoUploadUri = FileProvider.getUriForFile(this,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
photoFile)
|
||||
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUploadUri)
|
||||
startActivityForResult(intent, MEDIA_TAKE_PHOTO_RESULT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initiateMediaPicking() {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
|
||||
val mimeTypes = arrayOf("image/*", "video/*")
|
||||
intent.type = "*/*"
|
||||
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
|
||||
startActivityForResult(intent, MEDIA_PICK_RESULT)
|
||||
}
|
||||
|
||||
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
|
||||
button.isEnabled = clickable
|
||||
ThemeUtils.setDrawableTint(this, button.drawable,
|
||||
if (colorActive) android.R.attr.textColorTertiary
|
||||
else R.attr.image_button_disabled_tint)
|
||||
}
|
||||
|
||||
private fun enablePollButton(enable: Boolean) {
|
||||
addPollTextActionTextView.isEnabled = enable
|
||||
val textColor = ThemeUtils.getColor(this,
|
||||
if (enable) android.R.attr.textColorTertiary
|
||||
else R.attr.image_button_disabled_tint)
|
||||
addPollTextActionTextView.setTextColor(textColor)
|
||||
addPollTextActionTextView.compoundDrawablesRelative[0].colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
private fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
viewModel.removeMediaFromQueue(item)
|
||||
}
|
||||
|
||||
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) {
|
||||
pickMedia(intent.data!!)
|
||||
} else if (resultCode == Activity.RESULT_OK && requestCode == MEDIA_TAKE_PHOTO_RESULT) {
|
||||
pickMedia(photoUploadUri!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickMedia(uri: Uri) {
|
||||
withLifecycleContext {
|
||||
viewModel.pickMedia(uri).observe { exceptionOrItem ->
|
||||
exceptionOrItem.asLeftOrNull()?.let {
|
||||
val errorId = when (it) {
|
||||
is VideoSizeException -> {
|
||||
R.string.error_video_upload_size
|
||||
}
|
||||
is VideoOrImageException -> {
|
||||
R.string.error_media_upload_image_or_video
|
||||
}
|
||||
else -> {
|
||||
R.string.error_media_upload_opening
|
||||
}
|
||||
}
|
||||
displayTransientError(errorId)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showContentWarning(show: Boolean) {
|
||||
TransitionManager.beginDelayedTransition(composeContentWarningBar.parent as ViewGroup)
|
||||
@ColorInt val color = if (show) {
|
||||
composeContentWarningBar.show()
|
||||
composeContentWarningField.setSelection(composeContentWarningField.text.length)
|
||||
composeContentWarningField.requestFocus()
|
||||
ContextCompat.getColor(this, R.color.tusky_blue)
|
||||
} else {
|
||||
composeContentWarningBar.hide()
|
||||
composeEditField.requestFocus()
|
||||
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
|
||||
}
|
||||
composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
|
||||
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
handleCloseButton()
|
||||
return true
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
// Acting like a teen: deliberately ignoring parent.
|
||||
if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED ||
|
||||
scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
return
|
||||
}
|
||||
|
||||
handleCloseButton()
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
Log.d(TAG, event.toString())
|
||||
if (event.isCtrlPressed) {
|
||||
if (keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
// send toot by pressing CTRL + ENTER
|
||||
this.onSendClicked()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
onBackPressed()
|
||||
return true
|
||||
}
|
||||
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
private fun handleCloseButton() {
|
||||
val contentText = composeEditField.text.toString()
|
||||
val contentWarning = composeContentWarningField.text.toString()
|
||||
if (viewModel.didChange(contentText, contentWarning)) {
|
||||
AlertDialog.Builder(this)
|
||||
.setMessage(R.string.compose_save_draft)
|
||||
.setPositiveButton(R.string.action_save) { _, _ ->
|
||||
saveDraftAndFinish(contentText, contentWarning)
|
||||
}
|
||||
.setNegativeButton(R.string.action_delete) { _, _ -> deleteDraftAndFinish() }
|
||||
.show()
|
||||
} else {
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteDraftAndFinish() {
|
||||
viewModel.deleteDraft()
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
|
||||
private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
|
||||
viewModel.saveDraft(contentText, contentWarning)
|
||||
finishWithoutSlideOutAnimation()
|
||||
}
|
||||
|
||||
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
return viewModel.searchAutocompleteSuggestions(token)
|
||||
}
|
||||
|
||||
override fun onEmojiSelected(shortcode: String) {
|
||||
replaceTextAtCaret(":$shortcode: ")
|
||||
}
|
||||
|
||||
private fun setEmojiList(emojiList: List<Emoji>?) {
|
||||
if (emojiList != null) {
|
||||
emojiView.adapter = EmojiAdapter(emojiList, this@ComposeActivity)
|
||||
enableButton(composeEmojiButton, true, emojiList.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
data class QueuedMedia(
|
||||
val localId: Long,
|
||||
val uri: Uri,
|
||||
val type: Type,
|
||||
val mediaSize: Long,
|
||||
val uploadPercent: Int = 0,
|
||||
val id: String? = null,
|
||||
val description: String? = null
|
||||
) {
|
||||
enum class Type {
|
||||
IMAGE, VIDEO;
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTimeSet(view: TimePicker, hourOfDay: Int, minute: Int) {
|
||||
composeScheduleView.onTimeSet(hourOfDay, minute)
|
||||
viewModel.updateScheduledAt(composeScheduleView.time)
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
private fun resetSchedule() {
|
||||
viewModel.updateScheduledAt(null)
|
||||
scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ComposeOptions(
|
||||
// Let's keep fields var until all consumers are Kotlin
|
||||
var savedTootUid: Int? = null,
|
||||
var tootText: String? = null,
|
||||
var mediaUrls: List<String>? = null,
|
||||
var mediaDescriptions: List<String>? = null,
|
||||
var mentionedUsernames: Set<String>? = null,
|
||||
var inReplyToId: String? = null,
|
||||
var replyVisibility: Status.Visibility? = null,
|
||||
var visibility: Status.Visibility? = null,
|
||||
var contentWarning: String? = null,
|
||||
var replyingStatusAuthor: String? = null,
|
||||
var replyingStatusContent: String? = null,
|
||||
var mediaAttachments: List<Attachment>? = null,
|
||||
var scheduledAt: String? = null,
|
||||
var sensitive: Boolean? = null,
|
||||
var poll: NewPoll? = null
|
||||
) : Parcelable
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ComposeActivity" // logging tag
|
||||
private const val MEDIA_PICK_RESULT = 1
|
||||
private const val MEDIA_TAKE_PHOTO_RESULT = 2
|
||||
private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1
|
||||
|
||||
private const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
|
||||
|
||||
// Mastodon only counts URLs as this long in terms of status character limits
|
||||
@VisibleForTesting
|
||||
const val MAXIMUM_URL_LENGTH = 23
|
||||
|
||||
@JvmStatic
|
||||
fun startIntent(context: Context, options: ComposeOptions): Intent {
|
||||
return Intent(context, ComposeActivity::class.java).apply {
|
||||
putExtra(COMPOSE_OPTIONS_EXTRA, options)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun canHandleMimeType(mimeType: String?): Boolean {
|
||||
return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType == "text/plain")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,453 @@
|
|||
/* Copyright 2019 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
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.search.SearchType
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.db.InstanceEntity
|
||||
import com.keylesspalace.tusky.entity.*
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.service.TootToSend
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import io.reactivex.disposables.Disposable
|
||||
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(
|
||||
private val api: MastodonApi,
|
||||
private val accountManager: AccountManager,
|
||||
private val mediaUploader: MediaUploader,
|
||||
private val serviceClient: ServiceClient,
|
||||
private val saveTootHelper: SaveTootHelper,
|
||||
private val db: AppDatabase
|
||||
) : RxAwareViewModel() {
|
||||
|
||||
private var replyingStatusAuthor: String? = null
|
||||
private var replyingStatusContent: String? = null
|
||||
internal var startingText: String? = null
|
||||
private var savedTootUid: Int = 0
|
||||
private var startingContentWarning: String? = null
|
||||
private var inReplyToId: String? = null
|
||||
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
|
||||
|
||||
private val instance: MutableLiveData<InstanceEntity?> = MutableLiveData()
|
||||
|
||||
val instanceParams: LiveData<ComposeInstanceParams> = instance.map { instance ->
|
||||
ComposeInstanceParams(
|
||||
maxChars = instance?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT,
|
||||
pollMaxOptions = instance?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT,
|
||||
pollMaxLength = instance?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
supportsScheduled = instance?.version?.let { VersionUtils(it).supportsScheduledToots() } ?: false
|
||||
)
|
||||
}
|
||||
val emoji: MutableLiveData<List<Emoji>?> = MutableLiveData()
|
||||
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 poll: MutableLiveData<NewPoll?> = mutableLiveData(null)
|
||||
val scheduledAt: MutableLiveData<String?> = mutableLiveData(null)
|
||||
|
||||
val media = mutableLiveData<List<QueuedMedia>>(listOf())
|
||||
val uploadError = MutableLiveData<Throwable>()
|
||||
|
||||
private val mediaToDisposable = mutableMapOf<Long, Disposable>()
|
||||
|
||||
|
||||
init {
|
||||
|
||||
Singles.zip(api.getCustomEmojis(), api.getInstance()) { emojis, instance ->
|
||||
InstanceEntity(
|
||||
instance = accountManager.activeAccount?.domain!!,
|
||||
emojiList = emojis,
|
||||
maximumTootCharacters = instance.maxTootChars,
|
||||
maxPollOptions = instance.pollLimits?.maxOptions,
|
||||
maxPollOptionLength = instance.pollLimits?.maxOptionChars,
|
||||
version = instance.version
|
||||
)
|
||||
}
|
||||
.doOnSuccess {
|
||||
db.instanceDao().insertOrReplace(it)
|
||||
}
|
||||
.onErrorResumeNext(
|
||||
db.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
|
||||
)
|
||||
.subscribe ({ instanceEntity ->
|
||||
emoji.postValue(instanceEntity.emojiList)
|
||||
instance.postValue(instanceEntity)
|
||||
}, { throwable ->
|
||||
// this can happen on network error when no cached data is available
|
||||
Log.w(TAG, "error loading instance data", throwable)
|
||||
})
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun pickMedia(uri: Uri): LiveData<Either<Throwable, QueuedMedia>> {
|
||||
// We are not calling .toLiveData() here because we don't want to stop the process when
|
||||
// the Activity goes away temporarily (like on screen rotation).
|
||||
val liveData = MutableLiveData<Either<Throwable, QueuedMedia>>()
|
||||
mediaUploader.prepareMedia(uri)
|
||||
.map { (type, uri, size) ->
|
||||
val mediaItems = media.value!!
|
||||
if (type == QueuedMedia.Type.VIDEO
|
||||
&& mediaItems.isNotEmpty()
|
||||
&& mediaItems[0].type == QueuedMedia.Type.IMAGE) {
|
||||
throw VideoOrImageException()
|
||||
} else {
|
||||
addMediaToQueue(type, uri, size)
|
||||
}
|
||||
}
|
||||
.subscribe({ queuedMedia ->
|
||||
liveData.postValue(Either.Right(queuedMedia))
|
||||
}, { error ->
|
||||
liveData.postValue(Either.Left(error))
|
||||
})
|
||||
.autoDispose()
|
||||
return liveData
|
||||
}
|
||||
|
||||
private fun addMediaToQueue(type: QueuedMedia.Type, uri: Uri, mediaSize: Long): QueuedMedia {
|
||||
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, mediaSize)
|
||||
media.value = media.value!! + mediaItem
|
||||
mediaToDisposable[mediaItem.localId] = mediaUploader
|
||||
.uploadMedia(mediaItem)
|
||||
.subscribe ({ event ->
|
||||
val item = media.value?.find { it.localId == mediaItem.localId }
|
||||
?: return@subscribe
|
||||
val newMediaItem = when (event) {
|
||||
is UploadEvent.ProgressEvent ->
|
||||
item.copy(uploadPercent = event.percentage)
|
||||
is UploadEvent.FinishedEvent ->
|
||||
item.copy(id = event.attachment.id, uploadPercent = -1)
|
||||
}
|
||||
synchronized(media) {
|
||||
val mediaValue = media.value!!
|
||||
val index = mediaValue.indexOfFirst { it.localId == newMediaItem.localId }
|
||||
media.postValue(if (index == -1) {
|
||||
mediaValue + newMediaItem
|
||||
} else {
|
||||
mediaValue.toMutableList().also { it[index] = newMediaItem }
|
||||
})
|
||||
}
|
||||
}, { error ->
|
||||
media.postValue(media.value?.filter { it.localId != mediaItem.localId } ?: emptyList())
|
||||
uploadError.postValue(error)
|
||||
})
|
||||
return mediaItem
|
||||
}
|
||||
|
||||
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?) {
|
||||
val mediaItem = QueuedMedia(System.currentTimeMillis(), uri, type, 0, -1, id, description)
|
||||
media.value = media.value!! + mediaItem
|
||||
}
|
||||
|
||||
fun removeMediaFromQueue(item: QueuedMedia) {
|
||||
mediaToDisposable[item.localId]?.dispose()
|
||||
media.value = media.value!!.withoutFirstWhich { it.localId == item.localId }
|
||||
}
|
||||
|
||||
fun didChange(content: String?, contentWarning: String?): Boolean {
|
||||
|
||||
val textChanged = !(content.isNullOrEmpty()
|
||||
|| startingText?.startsWith(content.toString()) ?: false)
|
||||
|
||||
val contentWarningChanged = showContentWarning.value!!
|
||||
&& !contentWarning.isNullOrEmpty()
|
||||
&& !startingContentWarning!!.startsWith(contentWarning.toString())
|
||||
val mediaChanged = media.value!!.isNotEmpty()
|
||||
val pollChanged = poll.value != null
|
||||
|
||||
return textChanged || contentWarningChanged || mediaChanged || pollChanged
|
||||
}
|
||||
|
||||
fun deleteDraft() {
|
||||
saveTootHelper.deleteDraft(this.savedTootUid)
|
||||
}
|
||||
|
||||
fun saveDraft(content: String, contentWarning: String) {
|
||||
val mediaUris = mutableListOf<String>()
|
||||
val mediaDescriptions = mutableListOf<String?>()
|
||||
for (item in media.value!!) {
|
||||
mediaUris.add(item.uri.toString())
|
||||
mediaDescriptions.add(item.description)
|
||||
}
|
||||
saveTootHelper.saveToot(
|
||||
content,
|
||||
contentWarning,
|
||||
null,
|
||||
mediaUris,
|
||||
mediaDescriptions,
|
||||
savedTootUid,
|
||||
inReplyToId,
|
||||
replyingStatusContent,
|
||||
replyingStatusAuthor,
|
||||
statusVisibility.value!!,
|
||||
poll.value
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send status to the server.
|
||||
* Uses current state plus provided arguments.
|
||||
* @return LiveData which will signal once the screen can be closed or null if there are errors
|
||||
*/
|
||||
fun sendStatus(
|
||||
content: String,
|
||||
spoilerText: String
|
||||
): LiveData<Unit> {
|
||||
return media
|
||||
.filter { items -> items.all { it.uploadPercent == -1 } }
|
||||
.map {
|
||||
val mediaIds = ArrayList<String>()
|
||||
val mediaUris = ArrayList<Uri>()
|
||||
val mediaDescriptions = ArrayList<String>()
|
||||
for (item in media.value!!) {
|
||||
mediaIds.add(item.id!!)
|
||||
mediaUris.add(item.uri)
|
||||
mediaDescriptions.add(item.description ?: "")
|
||||
}
|
||||
|
||||
val tootToSend = TootToSend(
|
||||
content,
|
||||
spoilerText,
|
||||
statusVisibility.value!!.serverString(),
|
||||
mediaUris.isNotEmpty() && markMediaAsSensitive.value!!,
|
||||
mediaIds,
|
||||
mediaUris.map { it.toString() },
|
||||
mediaDescriptions,
|
||||
scheduledAt = scheduledAt.value,
|
||||
inReplyToId = inReplyToId,
|
||||
poll = poll.value,
|
||||
replyingStatusContent = null,
|
||||
replyingStatusAuthorUsername = null,
|
||||
savedJsonUrls = null,
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
savedTootUid = 0,
|
||||
idempotencyKey = randomAlphanumericString(16),
|
||||
retries = 0
|
||||
)
|
||||
serviceClient.sendToot(tootToSend)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDescription(localId: Long, description: String): LiveData<Boolean> {
|
||||
val newList = media.value!!.toMutableList()
|
||||
val index = newList.indexOfFirst { it.localId == localId }
|
||||
if (index != -1) {
|
||||
newList[index] = newList[index].copy(description = description)
|
||||
}
|
||||
media.value = newList
|
||||
val completedCaptioningLiveData = MutableLiveData<Boolean>()
|
||||
media.observeForever(object : Observer<List<QueuedMedia>> {
|
||||
override fun onChanged(mediaItems: List<QueuedMedia>) {
|
||||
val updatedItem = mediaItems.find { it.localId == localId }
|
||||
if (updatedItem == null) {
|
||||
media.removeObserver(this)
|
||||
} else if (updatedItem.id != null) {
|
||||
api.updateMedia(updatedItem.id, description)
|
||||
.subscribe({
|
||||
completedCaptioningLiveData.postValue(true)
|
||||
}, {
|
||||
completedCaptioningLiveData.postValue(false)
|
||||
})
|
||||
.autoDispose()
|
||||
media.removeObserver(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
return completedCaptioningLiveData
|
||||
}
|
||||
|
||||
|
||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
when (token[0]) {
|
||||
'@' -> {
|
||||
return try {
|
||||
api.searchAccounts(query = token.substring(1), limit = 10)
|
||||
.blockingGet()
|
||||
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
'#' -> {
|
||||
return try {
|
||||
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.blockingGet()
|
||||
.hashtags
|
||||
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
':' -> {
|
||||
val emojiList = emoji.value ?: return emptyList()
|
||||
|
||||
val incomplete = token.substring(1).toLowerCase(Locale.ROOT)
|
||||
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
||||
for (emoji in emojiList) {
|
||||
val shortcode = emoji.shortcode.toLowerCase(Locale.ROOT)
|
||||
if (shortcode.startsWith(incomplete)) {
|
||||
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
} else if (shortcode.indexOf(incomplete, 1) != -1) {
|
||||
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
||||
}
|
||||
}
|
||||
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
|
||||
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
|
||||
}
|
||||
results.addAll(resultsInside)
|
||||
return results
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unexpected autocompletion token: $token")
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
for (uploadDisposable in mediaToDisposable.values) {
|
||||
uploadDisposable.dispose()
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun setup(composeOptions: ComposeActivity.ComposeOptions?) {
|
||||
val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy
|
||||
|
||||
val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN
|
||||
startingVisibility = Status.Visibility.byNum(
|
||||
preferredVisibility.num.coerceAtLeast(replyVisibility.num))
|
||||
|
||||
inReplyToId = composeOptions?.inReplyToId
|
||||
|
||||
|
||||
val contentWarning = composeOptions?.contentWarning
|
||||
if (contentWarning != null) {
|
||||
startingContentWarning = contentWarning
|
||||
}
|
||||
|
||||
// recreate media list
|
||||
// when coming from SavedTootActivity
|
||||
val loadedDraftMediaUris = composeOptions?.mediaUrls
|
||||
val loadedDraftMediaDescriptions: List<String?>? = composeOptions?.mediaDescriptions
|
||||
if (loadedDraftMediaUris != null && loadedDraftMediaDescriptions != null) {
|
||||
loadedDraftMediaUris.zip(loadedDraftMediaDescriptions)
|
||||
.forEach { (uri, description) ->
|
||||
pickMedia(uri.toUri()).observeForever { errorOrItem ->
|
||||
if (errorOrItem.isRight() && description != null) {
|
||||
updateDescription(errorOrItem.asRight().localId, description)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else composeOptions?.mediaAttachments?.forEach { a ->
|
||||
// when coming from redraft
|
||||
val mediaType = when (a.type) {
|
||||
Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO
|
||||
Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE
|
||||
else -> QueuedMedia.Type.IMAGE
|
||||
}
|
||||
addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description)
|
||||
}
|
||||
|
||||
|
||||
savedTootUid = composeOptions?.savedTootUid ?: 0
|
||||
startingText = composeOptions?.tootText
|
||||
|
||||
|
||||
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
|
||||
if (tootVisibility.num != Status.Visibility.UNKNOWN.num) {
|
||||
startingVisibility = tootVisibility
|
||||
}
|
||||
statusVisibility.value = startingVisibility
|
||||
val mentionedUsernames = composeOptions?.mentionedUsernames
|
||||
if (mentionedUsernames != null) {
|
||||
val builder = StringBuilder()
|
||||
for (name in mentionedUsernames) {
|
||||
builder.append('@')
|
||||
builder.append(name)
|
||||
builder.append(' ')
|
||||
}
|
||||
startingText = builder.toString()
|
||||
}
|
||||
|
||||
|
||||
scheduledAt.value = composeOptions?.scheduledAt
|
||||
|
||||
composeOptions?.sensitive?.let { markMediaAsSensitive.value = it }
|
||||
|
||||
val poll = composeOptions?.poll
|
||||
if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) {
|
||||
this.poll.value = poll
|
||||
}
|
||||
replyingStatusContent = composeOptions?.replyingStatusContent
|
||||
replyingStatusAuthor = composeOptions?.replyingStatusAuthor
|
||||
}
|
||||
|
||||
fun updatePoll(newPoll: NewPoll) {
|
||||
poll.value = newPoll
|
||||
}
|
||||
|
||||
fun updateScheduledAt(newScheduledAt: String?) {
|
||||
scheduledAt.value = newScheduledAt
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ComposeViewModel"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun <T> mutableLiveData(default: T) = MutableLiveData<T>().apply { value = default }
|
||||
|
||||
const val DEFAULT_CHARACTER_LIMIT = 500
|
||||
private const val DEFAULT_MAX_OPTION_COUNT = 4
|
||||
private const val DEFAULT_MAX_OPTION_LENGTH = 25
|
||||
|
||||
data class ComposeInstanceParams(
|
||||
val maxChars: Int,
|
||||
val pollMaxOptions: Int,
|
||||
val pollMaxLength: Int,
|
||||
val supportsScheduled: Boolean
|
||||
)
|
|
@ -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.util;
|
||||
package com.keylesspalace.tusky.components.compose;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.graphics.Bitmap;
|
||||
|
@ -21,6 +21,8 @@ import android.graphics.BitmapFactory;
|
|||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
|
||||
import com.keylesspalace.tusky.util.IOUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
|
@ -42,10 +44,10 @@ public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
|
|||
private File tempFile;
|
||||
|
||||
/**
|
||||
* @param sizeLimit the maximum number of bytes each image can take
|
||||
* @param sizeLimit the maximum number of bytes each image can take
|
||||
* @param contentResolver to resolve the specified images' URIs
|
||||
* @param tempFile the file where the result will be stored
|
||||
* @param listener to whom the results are given
|
||||
* @param tempFile the file where the result will be stored
|
||||
* @param listener to whom the results are given
|
||||
*/
|
||||
public DownsizeImageTask(int sizeLimit, ContentResolver contentResolver, File tempFile, Listener listener) {
|
||||
this.sizeLimit = sizeLimit;
|
||||
|
@ -56,6 +58,25 @@ public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
|
|||
|
||||
@Override
|
||||
protected Boolean doInBackground(Uri... uris) {
|
||||
boolean result = DownsizeImageTask.resize(uris, sizeLimit, contentResolver, tempFile);
|
||||
if (isCancelled()) {
|
||||
return false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Boolean successful) {
|
||||
if (successful) {
|
||||
listener.onSuccess(tempFile);
|
||||
} else {
|
||||
listener.onFailure();
|
||||
}
|
||||
super.onPostExecute(successful);
|
||||
}
|
||||
|
||||
public static boolean resize(Uri[] uris, int sizeLimit, ContentResolver contentResolver,
|
||||
File tempFile) {
|
||||
for (Uri uri : uris) {
|
||||
InputStream inputStream;
|
||||
try {
|
||||
|
@ -118,27 +139,16 @@ public class DownsizeImageTask extends AsyncTask<Uri, Void, Boolean> {
|
|||
reorientedBitmap.recycle();
|
||||
scaledImageSize /= 2;
|
||||
} while (tempFile.length() > sizeLimit);
|
||||
|
||||
if (isCancelled()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Boolean successful) {
|
||||
if (successful) {
|
||||
listener.onSuccess(tempFile);
|
||||
} else {
|
||||
listener.onFailure();
|
||||
}
|
||||
super.onPostExecute(successful);
|
||||
}
|
||||
|
||||
/** Used to communicate the results of the task. */
|
||||
/**
|
||||
* Used to communicate the results of the task.
|
||||
*/
|
||||
public interface Listener {
|
||||
void onSuccess(File file);
|
||||
|
||||
void onFailure();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
/* Copyright 2019 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.PopupMenu
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.recyclerview.widget.AsyncListDiffer
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
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.components.compose.view.ProgressImageView
|
||||
|
||||
class MediaPreviewAdapter(
|
||||
context: Context,
|
||||
private val onAddCaption: (ComposeActivity.QueuedMedia) -> Unit,
|
||||
private val onRemove: (ComposeActivity.QueuedMedia) -> Unit
|
||||
) : RecyclerView.Adapter<MediaPreviewAdapter.PreviewViewHolder>() {
|
||||
|
||||
fun submitList(list: List<ComposeActivity.QueuedMedia>) {
|
||||
this.differ.submitList(list)
|
||||
}
|
||||
|
||||
private fun onMediaClick(position: Int, view: View) {
|
||||
val item = differ.currentList[position]
|
||||
val popup = PopupMenu(view.context, view)
|
||||
val addCaptionId = 1
|
||||
val removeId = 2
|
||||
popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption)
|
||||
popup.menu.add(0, removeId, 0, R.string.action_remove)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
addCaptionId -> onAddCaption(item)
|
||||
removeId -> onRemove(item)
|
||||
}
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
|
||||
private val thumbnailViewSize =
|
||||
context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size)
|
||||
|
||||
override fun getItemCount(): Int = differ.currentList.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder {
|
||||
return PreviewViewHolder(ProgressImageView(parent.context))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) {
|
||||
val item = differ.currentList[position]
|
||||
holder.progressImageView.setChecked(!item.description.isNullOrEmpty())
|
||||
holder.progressImageView.setProgress(item.uploadPercent)
|
||||
Glide.with(holder.itemView.context)
|
||||
.load(item.uri)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.dontAnimate()
|
||||
.into(holder.progressImageView)
|
||||
}
|
||||
|
||||
private val differ = AsyncListDiffer(this, object : DiffUtil.ItemCallback<ComposeActivity.QueuedMedia>() {
|
||||
override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
|
||||
return oldItem.localId == newItem.localId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
})
|
||||
|
||||
inner class PreviewViewHolder(val progressImageView: ProgressImageView)
|
||||
: RecyclerView.ViewHolder(progressImageView) {
|
||||
init {
|
||||
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)
|
||||
progressImageView.layoutParams = layoutParams
|
||||
progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
progressImageView.setOnClickListener {
|
||||
onMediaClick(adapterPosition, progressImageView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,203 @@
|
|||
/* Copyright 2019 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
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.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.network.ProgressRequestBody
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
sealed class UploadEvent {
|
||||
data class ProgressEvent(val percentage: Int) : UploadEvent()
|
||||
data class FinishedEvent(val attachment: Attachment) : UploadEvent()
|
||||
}
|
||||
|
||||
fun createNewImageFile(context: Context): File {
|
||||
// Create an image file name
|
||||
val randomId = randomAlphanumericString(12)
|
||||
val imageFileName = "Tusky_${randomId}_"
|
||||
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
return File.createTempFile(
|
||||
imageFileName, /* prefix */
|
||||
".jpg", /* suffix */
|
||||
storageDir /* directory */
|
||||
)
|
||||
}
|
||||
|
||||
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
|
||||
|
||||
interface MediaUploader {
|
||||
fun prepareMedia(inUri: Uri): Single<PreparedMedia>
|
||||
fun uploadMedia(media: QueuedMedia): Observable<UploadEvent>
|
||||
}
|
||||
|
||||
class VideoSizeException : Exception()
|
||||
class MediaTypeException : Exception()
|
||||
class CouldNotOpenFileException : Exception()
|
||||
|
||||
class MediaUploaderImpl(
|
||||
private val context: Context,
|
||||
private val mastodonApi: MastodonApi
|
||||
) : MediaUploader {
|
||||
override fun uploadMedia(media: QueuedMedia): Observable<UploadEvent> {
|
||||
return Observable
|
||||
.fromCallable {
|
||||
if (shouldResizeMedia(media)) {
|
||||
downsize(media)
|
||||
}
|
||||
media
|
||||
}
|
||||
.switchMap { upload(it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
override fun prepareMedia(inUri: Uri): Single<PreparedMedia> {
|
||||
return Single.fromCallable {
|
||||
var mediaSize = getMediaSize(contentResolver, inUri)
|
||||
var uri = inUri
|
||||
val mimeType = contentResolver.getType(uri)
|
||||
|
||||
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
||||
|
||||
try {
|
||||
contentResolver.openInputStream(inUri).use { input ->
|
||||
if (input == null) {
|
||||
Log.w(TAG, "Media input is null")
|
||||
uri = inUri
|
||||
return@use
|
||||
}
|
||||
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
|
||||
FileOutputStream(file.absoluteFile).use { out ->
|
||||
input.copyTo(out)
|
||||
uri = FileProvider.getUriForFile(context,
|
||||
BuildConfig.APPLICATION_ID + ".fileprovider",
|
||||
file)
|
||||
mediaSize = getMediaSize(contentResolver, uri)
|
||||
}
|
||||
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
uri = inUri
|
||||
}
|
||||
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
|
||||
throw CouldNotOpenFileException()
|
||||
}
|
||||
|
||||
if (mimeType != null) {
|
||||
val topLevelType = mimeType.substring(0, mimeType.indexOf('/'))
|
||||
when (topLevelType) {
|
||||
"video" -> {
|
||||
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
|
||||
throw VideoSizeException()
|
||||
}
|
||||
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
|
||||
}
|
||||
"image" -> {
|
||||
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
|
||||
}
|
||||
else -> {
|
||||
throw MediaTypeException()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw MediaTypeException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
|
||||
private fun upload(media: QueuedMedia): Observable<UploadEvent> {
|
||||
return Observable.create { emitter ->
|
||||
var mimeType = contentResolver.getType(media.uri)
|
||||
val map = MimeTypeMap.getSingleton()
|
||||
val fileExtension = map.getExtensionFromMimeType(mimeType)
|
||||
val filename = String.format("%s_%s_%s.%s",
|
||||
context.getString(R.string.app_name),
|
||||
Date().time.toString(),
|
||||
randomAlphanumericString(10),
|
||||
fileExtension)
|
||||
|
||||
val stream = contentResolver.openInputStream(media.uri)
|
||||
|
||||
if (mimeType == null) mimeType = "multipart/form-data"
|
||||
|
||||
|
||||
var lastProgress = -1
|
||||
val fileBody = ProgressRequestBody(stream, media.mediaSize,
|
||||
mimeType.toMediaTypeOrNull()) { percentage ->
|
||||
if (percentage != lastProgress) {
|
||||
emitter.onNext(UploadEvent.ProgressEvent(percentage))
|
||||
}
|
||||
lastProgress = percentage
|
||||
}
|
||||
|
||||
val body = MultipartBody.Part.createFormData("file", filename, fileBody)
|
||||
|
||||
val uploadDisposable = mastodonApi.uploadMedia(body)
|
||||
.subscribe({ attachment ->
|
||||
emitter.onNext(UploadEvent.FinishedEvent(attachment))
|
||||
emitter.onComplete()
|
||||
}, { e ->
|
||||
emitter.onError(e)
|
||||
})
|
||||
|
||||
// Cancel the request when our observable is cancelled
|
||||
emitter.setDisposable(uploadDisposable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downsize(media: QueuedMedia): QueuedMedia {
|
||||
val file = createNewImageFile(context)
|
||||
DownsizeImageTask.resize(arrayOf(media.uri),
|
||||
STATUS_IMAGE_SIZE_LIMIT, context.contentResolver, file)
|
||||
return media.copy(uri = file.toUri(), mediaSize = file.length())
|
||||
}
|
||||
|
||||
private fun shouldResizeMedia(media: QueuedMedia): Boolean {
|
||||
return media.type == QueuedMedia.Type.IMAGE
|
||||
&& (media.mediaSize > STATUS_IMAGE_SIZE_LIMIT
|
||||
|| getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "MediaUploaderImpl"
|
||||
private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
|
||||
private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB
|
||||
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
|
||||
|
||||
}
|
||||
}
|
|
@ -15,29 +15,28 @@
|
|||
|
||||
@file:JvmName("AddPollDialog")
|
||||
|
||||
package com.keylesspalace.tusky.view
|
||||
package com.keylesspalace.tusky.components.compose.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.WindowManager
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.keylesspalace.tusky.ComposeActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.adapter.AddPollOptionsAdapter
|
||||
import com.keylesspalace.tusky.entity.NewPoll
|
||||
import kotlinx.android.synthetic.main.dialog_add_poll.view.*
|
||||
import android.view.WindowManager
|
||||
import com.keylesspalace.tusky.R
|
||||
|
||||
private const val DEFAULT_MAX_OPTION_COUNT = 4
|
||||
private const val DEFAULT_MAX_OPTION_LENGTH = 25
|
||||
|
||||
fun showAddPollDialog(
|
||||
activity: ComposeActivity,
|
||||
context: Context,
|
||||
poll: NewPoll?,
|
||||
maxOptionCount: Int?,
|
||||
maxOptionLength: Int?
|
||||
maxOptionCount: Int,
|
||||
maxOptionLength: Int,
|
||||
onUpdatePoll: (NewPoll) -> Unit
|
||||
) {
|
||||
|
||||
val view = activity.layoutInflater.inflate(R.layout.dialog_add_poll, null)
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_add_poll, null)
|
||||
|
||||
val dialog = AlertDialog.Builder(activity)
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
.setIcon(R.drawable.ic_poll_24dp)
|
||||
.setTitle(R.string.create_poll_title)
|
||||
.setView(view)
|
||||
|
@ -47,7 +46,7 @@ fun showAddPollDialog(
|
|||
|
||||
val adapter = AddPollOptionsAdapter(
|
||||
options = poll?.options?.toMutableList() ?: mutableListOf("", ""),
|
||||
maxOptionLength = maxOptionLength ?: DEFAULT_MAX_OPTION_LENGTH,
|
||||
maxOptionLength = maxOptionLength,
|
||||
onOptionRemoved = { valid ->
|
||||
view.addChoiceButton.isEnabled = true
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid
|
||||
|
@ -60,15 +59,15 @@ fun showAddPollDialog(
|
|||
view.pollChoices.adapter = adapter
|
||||
|
||||
view.addChoiceButton.setOnClickListener {
|
||||
if (adapter.itemCount < maxOptionCount ?: DEFAULT_MAX_OPTION_COUNT) {
|
||||
if (adapter.itemCount < maxOptionCount) {
|
||||
adapter.addChoice()
|
||||
}
|
||||
if (adapter.itemCount >= maxOptionCount ?: DEFAULT_MAX_OPTION_COUNT) {
|
||||
if (adapter.itemCount >= maxOptionCount) {
|
||||
it.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
val pollDurationId = activity.resources.getIntArray(R.array.poll_duration_values).indexOfLast {
|
||||
val pollDurationId = context.resources.getIntArray(R.array.poll_duration_values).indexOfLast {
|
||||
it <= poll?.expiresIn ?: 0
|
||||
}
|
||||
|
||||
|
@ -81,15 +80,14 @@ fun showAddPollDialog(
|
|||
button.setOnClickListener {
|
||||
val selectedPollDurationId = view.pollDurationSpinner.selectedItemPosition
|
||||
|
||||
val pollDuration = activity.resources.getIntArray(R.array.poll_duration_values)[selectedPollDurationId]
|
||||
val pollDuration = context.resources
|
||||
.getIntArray(R.array.poll_duration_values)[selectedPollDurationId]
|
||||
|
||||
activity.updatePoll(
|
||||
NewPoll(
|
||||
options = adapter.pollOptions,
|
||||
expiresIn = pollDuration,
|
||||
multiple = view.multipleChoicesCheckBox.isChecked
|
||||
)
|
||||
)
|
||||
onUpdatePoll(NewPoll(
|
||||
options = adapter.pollOptions,
|
||||
expiresIn = pollDuration,
|
||||
multiple = view.multipleChoicesCheckBox.isChecked
|
||||
))
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
/* Copyright 2019 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.components.compose.dialog
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.text.InputFilter
|
||||
import android.text.InputType
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import at.connyduck.sparkbutton.helpers.Utils
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.util.withLifecycleContext
|
||||
|
||||
// https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94
|
||||
private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 420
|
||||
|
||||
|
||||
fun <T> T.makeCaptionDialog(existingDescription: String?,
|
||||
previewUri: Uri,
|
||||
onUpdateDescription: (String) -> LiveData<Boolean>
|
||||
) where T : Activity, T : LifecycleOwner {
|
||||
val dialogLayout = LinearLayout(this)
|
||||
val padding = Utils.dpToPx(this, 8)
|
||||
dialogLayout.setPadding(padding, padding, padding, padding)
|
||||
|
||||
dialogLayout.orientation = LinearLayout.VERTICAL
|
||||
val imageView = ImageView(this)
|
||||
|
||||
val displayMetrics = DisplayMetrics()
|
||||
windowManager.defaultDisplay.getMetrics(displayMetrics)
|
||||
|
||||
val margin = Utils.dpToPx(this, 4)
|
||||
dialogLayout.addView(imageView)
|
||||
(imageView.layoutParams as LinearLayout.LayoutParams).weight = 1f
|
||||
imageView.layoutParams.height = 0
|
||||
(imageView.layoutParams as LinearLayout.LayoutParams).setMargins(0, margin, 0, 0)
|
||||
|
||||
val input = EditText(this)
|
||||
input.hint = getString(R.string.hint_describe_for_visually_impaired,
|
||||
MEDIA_DESCRIPTION_CHARACTER_LIMIT)
|
||||
dialogLayout.addView(input)
|
||||
(input.layoutParams as LinearLayout.LayoutParams).setMargins(margin, margin, margin, margin)
|
||||
input.setLines(2)
|
||||
input.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
||||
input.setText(existingDescription)
|
||||
input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT))
|
||||
|
||||
val okListener = { dialog: DialogInterface, _: Int ->
|
||||
onUpdateDescription(input.text.toString())
|
||||
withLifecycleContext {
|
||||
onUpdateDescription(input.text.toString())
|
||||
.observe { success -> if (!success) showFailedCaptionMessage() }
|
||||
|
||||
}
|
||||
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setView(dialogLayout)
|
||||
.setPositiveButton(android.R.string.ok, okListener)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
|
||||
val window = dialog.window
|
||||
window?.setSoftInputMode(
|
||||
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
|
||||
dialog.show()
|
||||
|
||||
// Load the image and manually set it into the ImageView because it doesn't have a fixed
|
||||
// size. Maybe we should limit the size of CustomTarget
|
||||
Glide.with(this)
|
||||
.load(previewUri)
|
||||
.into(object : CustomTarget<Drawable>() {
|
||||
override fun onLoadCleared(placeholder: Drawable?) {}
|
||||
|
||||
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
|
||||
imageView.setImageDrawable(resource)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private fun Activity.showFailedCaptionMessage() {
|
||||
Toast.makeText(this, R.string.error_failed_set_caption, Toast.LENGTH_SHORT).show()
|
||||
}
|
|
@ -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.view
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
|
@ -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.view;
|
||||
package com.keylesspalace.tusky.components.compose.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
@ -30,6 +30,7 @@ import com.google.android.material.datepicker.DateValidatorPointForward;
|
|||
import com.google.android.material.datepicker.MaterialDatePicker;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.fragment.TimePickerFragment;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.text.ParseException;
|
||||
|
@ -87,7 +88,7 @@ public class ComposeScheduleView extends ConstraintLayout {
|
|||
|
||||
private void setScheduledDateTime() {
|
||||
if (scheduleDateTime == null) {
|
||||
scheduledDateTimeView.setText(R.string.hint_configure_scheduled_toot);
|
||||
scheduledDateTimeView.setText("");
|
||||
} else {
|
||||
scheduledDateTimeView.setText(String.format("%s %s",
|
||||
dateFormat.format(scheduleDateTime.getTime()),
|
||||
|
@ -96,13 +97,13 @@ public class ComposeScheduleView extends ConstraintLayout {
|
|||
}
|
||||
|
||||
private void setEditIcons() {
|
||||
final int size = scheduledDateTimeView.getLineHeight();
|
||||
|
||||
Drawable icon = getContext().getDrawable(R.drawable.ic_create_24dp);
|
||||
Drawable icon = ThemeUtils.getTintedDrawable(getContext(), R.drawable.ic_create_24dp, android.R.attr.textColorTertiary);
|
||||
if (icon == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final int size = scheduledDateTimeView.getLineHeight();
|
||||
|
||||
icon.setBounds(0, 0, size, size);
|
||||
|
||||
scheduledDateTimeView.setCompoundDrawables(null, null, icon, null);
|
||||
|
@ -117,7 +118,7 @@ public class ComposeScheduleView extends ConstraintLayout {
|
|||
setScheduledDateTime();
|
||||
}
|
||||
|
||||
private void openPickDateDialog() {
|
||||
public void openPickDateDialog() {
|
||||
long yesterday = Calendar.getInstance().getTimeInMillis() - 24 * 60 * 60 * 1000;
|
||||
CalendarConstraints calendarConstraints = new CalendarConstraints.Builder()
|
||||
.setValidator(
|
|
@ -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.view
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import androidx.emoji.widget.EmojiEditTextHelper
|
|
@ -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.view;
|
||||
package com.keylesspalace.tusky.components.compose.view;
|
||||
|
||||
public interface IProgressView {
|
||||
public void setProgress(int progress);
|
|
@ -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.view
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
|
@ -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.view;
|
||||
package com.keylesspalace.tusky.components.compose.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
|
@ -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.view;
|
||||
package com.keylesspalace.tusky.components.compose.view;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
|
@ -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.view
|
||||
package com.keylesspalace.tusky.components.compose.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
|
@ -12,20 +12,21 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
|
||||
class ConversationAdapter(private val useAbsoluteTime: Boolean,
|
||||
private val mediaPreviewEnabled: Boolean,
|
||||
private val listener: StatusActionListener,
|
||||
private val topLoadedCallback: () -> Unit,
|
||||
private val retryCallback: () -> Unit)
|
||||
: RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
class ConversationAdapter(
|
||||
private val statusDisplayOptions: StatusDisplayOptions,
|
||||
private val listener: StatusActionListener,
|
||||
private val topLoadedCallback: () -> Unit,
|
||||
private val retryCallback: () -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private var networkState: NetworkState? = null
|
||||
|
||||
private val differ: AsyncPagedListDiffer<ConversationEntity> = AsyncPagedListDiffer(object: ListUpdateCallback {
|
||||
private val differ: AsyncPagedListDiffer<ConversationEntity> = AsyncPagedListDiffer(object : ListUpdateCallback {
|
||||
override fun onInserted(position: Int, count: Int) {
|
||||
notifyItemRangeInserted(position, count)
|
||||
if(position == 0) {
|
||||
if (position == 0) {
|
||||
topLoadedCallback()
|
||||
}
|
||||
}
|
||||
|
@ -51,7 +52,8 @@ class ConversationAdapter(private val useAbsoluteTime: Boolean,
|
|||
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
|
||||
return when (viewType) {
|
||||
R.layout.item_network_state -> NetworkStateViewHolder(view, retryCallback)
|
||||
R.layout.item_conversation -> ConversationViewHolder(view, listener, useAbsoluteTime, mediaPreviewEnabled)
|
||||
R.layout.item_conversation -> ConversationViewHolder(view, statusDisplayOptions,
|
||||
listener)
|
||||
else -> throw IllegalArgumentException("unknown view type $viewType")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,6 @@ import android.widget.ImageView;
|
|||
import android.widget.TextView;
|
||||
import android.widget.ToggleButton;
|
||||
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.keylesspalace.tusky.R;
|
||||
|
@ -32,6 +31,7 @@ import com.keylesspalace.tusky.entity.Attachment;
|
|||
import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
||||
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
|
||||
|
||||
import java.util.List;
|
||||
|
@ -44,15 +44,13 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
private ToggleButton contentCollapseButton;
|
||||
private ImageView[] avatars;
|
||||
|
||||
private StatusDisplayOptions statusDisplayOptions;
|
||||
private StatusActionListener listener;
|
||||
private boolean mediaPreviewEnabled;
|
||||
private boolean animateAvatars;
|
||||
|
||||
ConversationViewHolder(View itemView,
|
||||
StatusActionListener listener,
|
||||
boolean useAbsoluteTime,
|
||||
boolean mediaPreviewEnabled) {
|
||||
super(itemView, useAbsoluteTime);
|
||||
StatusDisplayOptions statusDisplayOptions,
|
||||
StatusActionListener listener) {
|
||||
super(itemView);
|
||||
conversationNameTextView = itemView.findViewById(R.id.conversation_name);
|
||||
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
|
||||
avatars = new ImageView[]{
|
||||
|
@ -60,11 +58,10 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
itemView.findViewById(R.id.status_avatar_1),
|
||||
itemView.findViewById(R.id.status_avatar_2)
|
||||
};
|
||||
this.statusDisplayOptions = statusDisplayOptions;
|
||||
|
||||
this.listener = listener;
|
||||
this.mediaPreviewEnabled = mediaPreviewEnabled;
|
||||
|
||||
this.animateAvatars = PreferenceManager.getDefaultSharedPreferences(itemView.getContext()).getBoolean("animateGifAvatars", false);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -86,8 +83,9 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
setBookmarked(status.getBookmarked());
|
||||
List<Attachment> attachments = status.getAttachments();
|
||||
boolean sensitive = status.getSensitive();
|
||||
if(mediaPreviewEnabled && !hasAudioAttachment(attachments)) {
|
||||
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent());
|
||||
if (statusDisplayOptions.mediaPreviewEnabled() && !hasAudioAttachment(attachments)) {
|
||||
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent(),
|
||||
statusDisplayOptions.useBlurhash());
|
||||
|
||||
if (attachments.size() == 0) {
|
||||
hideSensitiveMediaWarning();
|
||||
|
@ -118,11 +116,11 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
private void setConversationName(List<ConversationAccountEntity> accounts) {
|
||||
Context context = conversationNameTextView.getContext();
|
||||
String conversationName = "";
|
||||
if(accounts.size() == 1) {
|
||||
if (accounts.size() == 1) {
|
||||
conversationName = context.getString(R.string.conversation_1_recipients, accounts.get(0).getUsername());
|
||||
} else if(accounts.size() == 2) {
|
||||
} else if (accounts.size() == 2) {
|
||||
conversationName = context.getString(R.string.conversation_2_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername());
|
||||
} else if (accounts.size() > 2){
|
||||
} else if (accounts.size() > 2) {
|
||||
conversationName = context.getString(R.string.conversation_more_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername(), accounts.size() - 2);
|
||||
}
|
||||
|
||||
|
@ -130,10 +128,11 @@ public class ConversationViewHolder extends StatusBaseViewHolder {
|
|||
}
|
||||
|
||||
private void setAvatars(List<ConversationAccountEntity> accounts) {
|
||||
for(int i=0; i < avatars.length; i++) {
|
||||
for (int i = 0; i < avatars.length; i++) {
|
||||
ImageView avatarView = avatars[i];
|
||||
if(i < accounts.size()) {
|
||||
ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView, avatarRadius48dp, animateAvatars);
|
||||
if (i < accounts.size()) {
|
||||
ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView,
|
||||
avatarRadius48dp, statusDisplayOptions.animateAvatars());
|
||||
avatarView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
avatarView.setVisibility(View.GONE);
|
||||
|
|
|
@ -37,6 +37,7 @@ import com.keylesspalace.tusky.fragment.SFragment
|
|||
import com.keylesspalace.tusky.interfaces.ReselectableFragment
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import kotlinx.android.synthetic.main.fragment_timeline.*
|
||||
|
@ -62,15 +63,18 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
|
|||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
|
||||
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
|
||||
|
||||
val account = accountManager.activeAccount
|
||||
val mediaPreviewEnabled = account?.mediaPreviewEnabled ?: true
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
||||
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
|
||||
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
|
||||
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
|
||||
useBlurhash = preferences.getBoolean("useBlurhash", true)
|
||||
)
|
||||
|
||||
|
||||
adapter = ConversationAdapter(useAbsoluteTime, mediaPreviewEnabled, this, ::onTopLoaded, viewModel::retry)
|
||||
adapter = ConversationAdapter(statusDisplayOptions, this, ::onTopLoaded, viewModel::retry)
|
||||
|
||||
recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
|
||||
layoutManager = LinearLayoutManager(view.context)
|
||||
|
|
|
@ -4,15 +4,13 @@ import android.util.Log
|
|||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.paging.PagedList
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.util.Listing
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.rxkotlin.addTo
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -21,7 +19,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
private val timelineCases: TimelineCases,
|
||||
private val database: AppDatabase,
|
||||
private val accountManager: AccountManager
|
||||
) : ViewModel() {
|
||||
) : RxAwareViewModel() {
|
||||
|
||||
private val repoResult = MutableLiveData<Listing<ConversationEntity>>()
|
||||
|
||||
|
@ -29,8 +27,6 @@ class ConversationsViewModel @Inject constructor(
|
|||
val networkState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.networkState }
|
||||
val refreshState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.refreshState }
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
fun load() {
|
||||
val accountId = accountManager.activeAccount?.id ?: return
|
||||
if (repoResult.value == null) {
|
||||
|
@ -61,7 +57,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
|
||||
.onErrorReturnItem(0)
|
||||
.subscribe()
|
||||
.addTo(disposables)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -79,7 +75,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
.subscribeOn(Schedulers.io())
|
||||
.doOnError { t -> Log.w("ConversationViewModel", "Failed to bookmark conversation", t) }
|
||||
.subscribe()
|
||||
.addTo(disposables)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -98,7 +94,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
.doOnError { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) }
|
||||
.onErrorReturnItem(0)
|
||||
.subscribe()
|
||||
.addTo(disposables)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -150,8 +146,4 @@ class ConversationsViewModel @Inject constructor(
|
|||
.subscribe()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
}
|
|
@ -18,7 +18,6 @@ package com.keylesspalace.tusky.components.report
|
|||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.paging.PagedList
|
||||
import com.keylesspalace.tusky.components.report.adapter.StatusesRepository
|
||||
import com.keylesspalace.tusky.components.report.model.StatusViewState
|
||||
|
@ -26,16 +25,13 @@ import com.keylesspalace.tusky.entity.Relationship
|
|||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import javax.inject.Inject
|
||||
|
||||
class ReportViewModel @Inject constructor(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val statusesRepository: StatusesRepository) : ViewModel() {
|
||||
private val disposables = CompositeDisposable()
|
||||
private val statusesRepository: StatusesRepository) : RxAwareViewModel() {
|
||||
|
||||
private val navigationMutable = MutableLiveData<Screen>()
|
||||
val navigation: LiveData<Screen> = navigationMutable
|
||||
|
@ -87,11 +83,6 @@ class ReportViewModel @Inject constructor(
|
|||
repoResult.value = statusesRepository.getStatuses(accountId, statusId, disposables)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun navigateTo(screen: Screen) {
|
||||
navigationMutable.value = screen
|
||||
}
|
||||
|
@ -105,19 +96,19 @@ class ReportViewModel @Inject constructor(
|
|||
val ids = listOf(accountId)
|
||||
muteStateMutable.value = Loading()
|
||||
blockStateMutable.value = Loading()
|
||||
disposables.add(
|
||||
mastodonApi.relationshipsObservable(ids)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ data ->
|
||||
updateRelationship(data.getOrNull(0))
|
||||
mastodonApi.relationshipsObservable(ids)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ data ->
|
||||
updateRelationship(data.getOrNull(0))
|
||||
|
||||
},
|
||||
{
|
||||
updateRelationship(null)
|
||||
}
|
||||
))
|
||||
},
|
||||
{
|
||||
updateRelationship(null)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
|
||||
|
@ -132,62 +123,61 @@ class ReportViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun toggleMute() {
|
||||
val single: Single<Relationship> = if (muteStateMutable.value?.data == true) {
|
||||
if (muteStateMutable.value?.data == true) {
|
||||
mastodonApi.unmuteAccountObservable(accountId)
|
||||
} else {
|
||||
mastodonApi.muteAccountObservable(accountId)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ relationship ->
|
||||
muteStateMutable.value = Success(relationship?.muting == true)
|
||||
},
|
||||
{ error ->
|
||||
muteStateMutable.value = Error(false, error.message)
|
||||
}
|
||||
).autoDispose()
|
||||
|
||||
muteStateMutable.value = Loading()
|
||||
disposables.add(
|
||||
single
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ relationship ->
|
||||
muteStateMutable.value = Success(relationship?.muting == true)
|
||||
},
|
||||
{ error ->
|
||||
muteStateMutable.value = Error(false, error.message)
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
fun toggleBlock() {
|
||||
val single: Single<Relationship> = if (blockStateMutable.value?.data == true) {
|
||||
if (blockStateMutable.value?.data == true) {
|
||||
mastodonApi.unblockAccountObservable(accountId)
|
||||
} else {
|
||||
mastodonApi.blockAccountObservable(accountId)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ relationship ->
|
||||
blockStateMutable.value = Success(relationship?.blocking == true)
|
||||
},
|
||||
{ error ->
|
||||
blockStateMutable.value = Error(false, error.message)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
|
||||
blockStateMutable.value = Loading()
|
||||
disposables.add(
|
||||
single
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ relationship ->
|
||||
blockStateMutable.value = Success(relationship?.blocking == true)
|
||||
},
|
||||
{ error ->
|
||||
blockStateMutable.value = Error(false, error.message)
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
fun doReport() {
|
||||
reportingStateMutable.value = Loading()
|
||||
disposables.add(
|
||||
mastodonApi.reportObservable(accountId, selectedIds.toList(), reportNote, if (isRemoteAccount) isRemoteNotify else null)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
reportingStateMutable.value = Success(true)
|
||||
},
|
||||
{ error ->
|
||||
reportingStateMutable.value = Error(cause = error)
|
||||
}
|
||||
)
|
||||
)
|
||||
mastodonApi.reportObservable(accountId, selectedIds.toList(), reportNote, if (isRemoteAccount) isRemoteNotify else null)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
reportingStateMutable.value = Success(true)
|
||||
},
|
||||
{ error ->
|
||||
reportingStateMutable.value = Error(cause = error)
|
||||
}
|
||||
)
|
||||
.autoDispose()
|
||||
|
||||
}
|
||||
|
||||
fun retryStatusLoad() {
|
||||
|
|
|
@ -31,12 +31,13 @@ import com.keylesspalace.tusky.viewdata.toViewData
|
|||
import kotlinx.android.synthetic.main.item_report_status.view.*
|
||||
import java.util.*
|
||||
|
||||
class StatusViewHolder(itemView: View,
|
||||
private val useAbsoluteTime: Boolean,
|
||||
private val mediaPreviewEnabled: Boolean,
|
||||
private val viewState: StatusViewState,
|
||||
private val adapterHandler: AdapterHandler,
|
||||
private val getStatusForPosition: (Int) -> Status?) : RecyclerView.ViewHolder(itemView) {
|
||||
class StatusViewHolder(
|
||||
itemView: View,
|
||||
private val statusDisplayOptions: StatusDisplayOptions,
|
||||
private val viewState: StatusViewState,
|
||||
private val adapterHandler: AdapterHandler,
|
||||
private val getStatusForPosition: (Int) -> Status?
|
||||
) : RecyclerView.ViewHolder(itemView) {
|
||||
private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height)
|
||||
private val statusViewHelper = StatusViewHelper(itemView)
|
||||
|
||||
|
@ -69,11 +70,11 @@ class StatusViewHolder(itemView: View,
|
|||
|
||||
val sensitive = status.sensitive
|
||||
|
||||
statusViewHelper.setMediasPreview(mediaPreviewEnabled, status.attachments, sensitive, previewListener,
|
||||
viewState.isMediaShow(status.id, status.sensitive),
|
||||
statusViewHelper.setMediasPreview(statusDisplayOptions, status.attachments,
|
||||
sensitive, previewListener, viewState.isMediaShow(status.id, status.sensitive),
|
||||
mediaViewHeight)
|
||||
|
||||
statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, useAbsoluteTime)
|
||||
statusViewHelper.setupPollReadonly(status.poll.toViewData(), status.emojis, statusDisplayOptions.useAbsoluteTime)
|
||||
setCreatedAt(status.createdAt)
|
||||
}
|
||||
|
||||
|
@ -124,7 +125,7 @@ class StatusViewHolder(itemView: View,
|
|||
}
|
||||
|
||||
private fun setCreatedAt(createdAt: Date?) {
|
||||
if (useAbsoluteTime) {
|
||||
if (statusDisplayOptions.useAbsoluteTime) {
|
||||
itemView.timestampInfo.text = statusViewHelper.getAbsoluteTime(createdAt)
|
||||
} else {
|
||||
itemView.timestampInfo.text = if (createdAt != null) {
|
||||
|
|
|
@ -23,12 +23,13 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.report.model.StatusViewState
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
|
||||
class StatusesAdapter(private val useAbsoluteTime: Boolean,
|
||||
private val mediaPreviewEnabled: Boolean,
|
||||
private val statusViewState: StatusViewState,
|
||||
private val adapterHandler: AdapterHandler)
|
||||
: PagedListAdapter<Status, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
||||
class StatusesAdapter(
|
||||
private val statusDisplayOptions: StatusDisplayOptions,
|
||||
private val statusViewState: StatusViewState,
|
||||
private val adapterHandler: AdapterHandler
|
||||
) : PagedListAdapter<Status, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
||||
|
||||
private val statusForPosition: (Int) -> Status? = { position: Int ->
|
||||
if (position != RecyclerView.NO_POSITION) getItem(position) else null
|
||||
|
@ -36,8 +37,10 @@ class StatusesAdapter(private val useAbsoluteTime: Boolean,
|
|||
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return StatusViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_report_status, parent, false),
|
||||
useAbsoluteTime, mediaPreviewEnabled, statusViewState, adapterHandler, statusForPosition)
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_report_status, parent, false)
|
||||
return StatusViewHolder(view, statusDisplayOptions, statusViewState, adapterHandler,
|
||||
statusForPosition)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
|
|
|
@ -43,6 +43,7 @@ import com.keylesspalace.tusky.di.Injectable
|
|||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.Attachment
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
|
@ -119,14 +120,16 @@ class ReportStatusesFragment : Fragment(), Injectable, AdapterHandler {
|
|||
|
||||
private fun initStatusesView() {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = false,
|
||||
mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true,
|
||||
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
|
||||
showBotOverlay = false,
|
||||
useBlurhash = preferences.getBoolean("useBlurhash", true)
|
||||
)
|
||||
|
||||
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
|
||||
|
||||
val account = accountManager.activeAccount
|
||||
val mediaPreviewEnabled = account?.mediaPreviewEnabled ?: true
|
||||
|
||||
|
||||
adapter = StatusesAdapter(useAbsoluteTime, mediaPreviewEnabled, viewModel.statusViewState, this)
|
||||
adapter = StatusesAdapter(statusDisplayOptions,
|
||||
viewModel.statusViewState, this)
|
||||
|
||||
recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL))
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
|
|
|
@ -0,0 +1,150 @@
|
|||
/* Copyright 2019 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.scheduled
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
import com.keylesspalace.tusky.di.ViewModelFactory
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
import com.keylesspalace.tusky.util.Status
|
||||
import com.keylesspalace.tusky.util.ThemeUtils
|
||||
import com.keylesspalace.tusky.util.hide
|
||||
import com.keylesspalace.tusky.util.show
|
||||
import kotlinx.android.synthetic.main.activity_scheduled_toot.*
|
||||
import kotlinx.android.synthetic.main.toolbar_basic.*
|
||||
import javax.inject.Inject
|
||||
|
||||
class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injectable {
|
||||
|
||||
@Inject
|
||||
lateinit var viewModelFactory: ViewModelFactory
|
||||
|
||||
lateinit var viewModel: ScheduledTootViewModel
|
||||
|
||||
private val adapter = ScheduledTootAdapter(this)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_scheduled_toot)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.run {
|
||||
title = getString(R.string.title_scheduled_toot)
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowHomeEnabled(true)
|
||||
}
|
||||
|
||||
swipeRefreshLayout.setOnRefreshListener(this::refreshStatuses)
|
||||
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
|
||||
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(
|
||||
ThemeUtils.getColor(this, android.R.attr.colorBackground))
|
||||
|
||||
scheduledTootList.setHasFixedSize(true)
|
||||
scheduledTootList.layoutManager = LinearLayoutManager(this)
|
||||
val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
|
||||
scheduledTootList.addItemDecoration(divider)
|
||||
scheduledTootList.adapter = adapter
|
||||
|
||||
viewModel = ViewModelProvider(this, viewModelFactory)[ScheduledTootViewModel::class.java]
|
||||
|
||||
viewModel.data.observe(this, Observer {
|
||||
adapter.submitList(it)
|
||||
})
|
||||
|
||||
viewModel.networkState.observe(this, Observer { (status) ->
|
||||
when(status) {
|
||||
Status.SUCCESS -> {
|
||||
progressBar.hide()
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
if(viewModel.data.value?.loadedCount == 0) {
|
||||
errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_scheduled_status)
|
||||
errorMessageView.show()
|
||||
} else {
|
||||
errorMessageView.hide()
|
||||
}
|
||||
}
|
||||
Status.RUNNING -> {
|
||||
errorMessageView.hide()
|
||||
if(viewModel.data.value?.loadedCount ?: 0 > 0) {
|
||||
swipeRefreshLayout.isRefreshing = true
|
||||
} else {
|
||||
progressBar.show()
|
||||
}
|
||||
}
|
||||
Status.FAILED -> {
|
||||
if(viewModel.data.value?.loadedCount ?: 0 >= 0) {
|
||||
progressBar.hide()
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
|
||||
refreshStatuses()
|
||||
}
|
||||
errorMessageView.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
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(
|
||||
tootText = item.params.text,
|
||||
contentWarning = item.params.spoilerText,
|
||||
mediaAttachments = item.mediaAttachments,
|
||||
inReplyToId = item.params.inReplyToId,
|
||||
visibility = item.params.visibility,
|
||||
scheduledAt = item.scheduledAt,
|
||||
sensitive = item.params.sensitive
|
||||
))
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun delete(item: ScheduledStatus) {
|
||||
viewModel.deleteScheduledStatus(item)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun newIntent(context: Context): Intent {
|
||||
return Intent(context, ScheduledTootActivity::class.java)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
/* Copyright 2019 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.scheduled
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
import android.widget.TextView
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
|
||||
interface ScheduledTootActionListener {
|
||||
fun edit(item: ScheduledStatus)
|
||||
fun delete(item: ScheduledStatus)
|
||||
}
|
||||
|
||||
class ScheduledTootAdapter(
|
||||
val listener: ScheduledTootActionListener
|
||||
) : PagedListAdapter<ScheduledStatus, ScheduledTootAdapter.TootViewHolder>(
|
||||
object: DiffUtil.ItemCallback<ScheduledStatus>(){
|
||||
override fun areItemsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
|
||||
}
|
||||
) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TootViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_scheduled_toot, parent, false)
|
||||
return TootViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: TootViewHolder, position: Int) {
|
||||
getItem(position)?.let{
|
||||
viewHolder.bind(it)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
inner class TootViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
|
||||
private val text: TextView = view.findViewById(R.id.text)
|
||||
private val edit: ImageButton = view.findViewById(R.id.edit)
|
||||
private val delete: ImageButton = view.findViewById(R.id.delete)
|
||||
|
||||
fun bind(item: ScheduledStatus) {
|
||||
edit.isEnabled = true
|
||||
delete.isEnabled = true
|
||||
text.text = item.params.text
|
||||
edit.setOnClickListener { v: View ->
|
||||
v.isEnabled = false
|
||||
listener.edit(item)
|
||||
}
|
||||
delete.setOnClickListener { v: View ->
|
||||
v.isEnabled = false
|
||||
listener.delete(item)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
/* Copyright 2019 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.scheduled
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.paging.DataSource
|
||||
import androidx.paging.ItemKeyedDataSource
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.rxkotlin.addTo
|
||||
|
||||
class ScheduledTootDataSourceFactory(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val disposables: CompositeDisposable
|
||||
): DataSource.Factory<String, ScheduledStatus>() {
|
||||
|
||||
private val scheduledTootsCache = mutableListOf<ScheduledStatus>()
|
||||
|
||||
private var dataSource: ScheduledTootDataSource? = null
|
||||
|
||||
val networkState = MutableLiveData<NetworkState>()
|
||||
|
||||
override fun create(): DataSource<String, ScheduledStatus> {
|
||||
return ScheduledTootDataSource(mastodonApi, disposables, scheduledTootsCache, networkState).also {
|
||||
dataSource = it
|
||||
}
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
scheduledTootsCache.clear()
|
||||
dataSource?.invalidate()
|
||||
}
|
||||
|
||||
fun remove(status: ScheduledStatus) {
|
||||
scheduledTootsCache.remove(status)
|
||||
dataSource?.invalidate()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class ScheduledTootDataSource(
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val disposables: CompositeDisposable,
|
||||
private val scheduledTootsCache: MutableList<ScheduledStatus>,
|
||||
private val networkState: MutableLiveData<NetworkState>
|
||||
): ItemKeyedDataSource<String, ScheduledStatus>() {
|
||||
override fun loadInitial(params: LoadInitialParams<String>, callback: LoadInitialCallback<ScheduledStatus>) {
|
||||
if(scheduledTootsCache.isNotEmpty()) {
|
||||
callback.onResult(scheduledTootsCache.toList())
|
||||
} else {
|
||||
networkState.postValue(NetworkState.LOADING)
|
||||
mastodonApi.scheduledStatuses(limit = params.requestedLoadSize)
|
||||
.subscribe({ newData ->
|
||||
scheduledTootsCache.addAll(newData)
|
||||
callback.onResult(newData)
|
||||
networkState.postValue(NetworkState.LOADED)
|
||||
}, { throwable ->
|
||||
Log.w("ScheduledTootDataSource", "Error loading scheduled statuses", throwable)
|
||||
networkState.postValue(NetworkState.error(throwable.message))
|
||||
})
|
||||
.addTo(disposables)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<ScheduledStatus>) {
|
||||
mastodonApi.scheduledStatuses(limit = params.requestedLoadSize, maxId = params.key)
|
||||
.subscribe({ newData ->
|
||||
scheduledTootsCache.addAll(newData)
|
||||
callback.onResult(newData)
|
||||
}, { throwable ->
|
||||
Log.w("ScheduledTootDataSource", "Error loading scheduled statuses", throwable)
|
||||
networkState.postValue(NetworkState.error(throwable.message))
|
||||
})
|
||||
.addTo(disposables)
|
||||
}
|
||||
|
||||
override fun loadBefore(params: LoadParams<String>, callback: LoadCallback<ScheduledStatus>) {
|
||||
// we are always loading from beginning to end
|
||||
}
|
||||
|
||||
override fun getKey(item: ScheduledStatus): String {
|
||||
return item.id
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
/* Copyright 2019 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.scheduled
|
||||
|
||||
import android.util.Log
|
||||
import androidx.paging.Config
|
||||
import androidx.paging.toLiveData
|
||||
import com.keylesspalace.tusky.appstore.EventHub
|
||||
import com.keylesspalace.tusky.appstore.StatusScheduledEvent
|
||||
import com.keylesspalace.tusky.entity.ScheduledStatus
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import javax.inject.Inject
|
||||
|
||||
class ScheduledTootViewModel @Inject constructor(
|
||||
val mastodonApi: MastodonApi,
|
||||
val eventHub: EventHub
|
||||
): RxAwareViewModel() {
|
||||
|
||||
private val dataSourceFactory = ScheduledTootDataSourceFactory(mastodonApi, disposables)
|
||||
|
||||
val data = dataSourceFactory.toLiveData(
|
||||
config = Config(pageSize = 20, initialLoadSizeHint = 20, enablePlaceholders = false)
|
||||
)
|
||||
|
||||
val networkState = dataSourceFactory.networkState
|
||||
|
||||
init {
|
||||
eventHub.events
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { event ->
|
||||
if (event is StatusScheduledEvent) {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
dataSourceFactory.reload()
|
||||
}
|
||||
|
||||
fun deleteScheduledStatus(status: ScheduledStatus) {
|
||||
mastodonApi.deleteScheduledStatus(status.id)
|
||||
.subscribe({
|
||||
dataSourceFactory.remove(status)
|
||||
},{ throwable ->
|
||||
Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable)
|
||||
})
|
||||
.autoDispose()
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -4,7 +4,6 @@ import android.util.Log
|
|||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.paging.PagedList
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchRepository
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
|
@ -14,18 +13,17 @@ import com.keylesspalace.tusky.network.MastodonApi
|
|||
import com.keylesspalace.tusky.network.TimelineCases
|
||||
import com.keylesspalace.tusky.util.Listing
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.ViewDataUtils
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.rxkotlin.addTo
|
||||
import javax.inject.Inject
|
||||
|
||||
class SearchViewModel @Inject constructor(
|
||||
mastodonApi: MastodonApi,
|
||||
private val timelineCases: TimelineCases,
|
||||
private val accountManager: AccountManager) : ViewModel() {
|
||||
private val accountManager: AccountManager) : RxAwareViewModel() {
|
||||
|
||||
var currentQuery: String = ""
|
||||
|
||||
|
@ -37,7 +35,6 @@ class SearchViewModel @Inject constructor(
|
|||
|
||||
val mediaPreviewEnabled: Boolean
|
||||
get() = activeAccount?.mediaPreviewEnabled ?: false
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val statusesRepository = SearchRepository<Pair<Status, StatusViewData.Concrete>>(mastodonApi)
|
||||
private val accountsRepository = SearchRepository<Account>(mastodonApi)
|
||||
|
@ -83,11 +80,6 @@ class SearchViewModel @Inject constructor(
|
|||
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
|
||||
timelineCases.delete(status.first.id)
|
||||
.subscribe({
|
||||
|
@ -96,7 +88,7 @@ class SearchViewModel @Inject constructor(
|
|||
}, {
|
||||
err -> Log.d(TAG, "Failed to delete status", err)
|
||||
})
|
||||
.addTo(disposables)
|
||||
.autoDispose()
|
||||
|
||||
}
|
||||
|
||||
|
@ -110,13 +102,13 @@ class SearchViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun reblog(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
|
||||
disposables.add(timelineCases.reblog(status.first, reblog)
|
||||
timelineCases.reblog(status.first, reblog)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ setRebloggedForStatus(status, reblog) },
|
||||
{ err -> Log.d(TAG, "Failed to reblog status ${status.first.id}", err) }
|
||||
)
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
private fun setRebloggedForStatus(status: Pair<Status, StatusViewData.Concrete>, reblog: Boolean) {
|
||||
|
@ -152,7 +144,7 @@ class SearchViewModel @Inject constructor(
|
|||
fun voteInPoll(status: Pair<Status, StatusViewData.Concrete>, choices: MutableList<Int>) {
|
||||
val votedPoll = status.first.actionableStatus.poll!!.votedCopy(choices)
|
||||
updateStatus(status, votedPoll)
|
||||
disposables.add(timelineCases.voteInPoll(status.first, choices)
|
||||
timelineCases.voteInPoll(status.first, choices)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ newPoll -> updateStatus(status, newPoll) },
|
||||
|
@ -160,7 +152,8 @@ class SearchViewModel @Inject constructor(
|
|||
Log.d(TAG,
|
||||
"Failed to vote in poll: ${status.first.id}", t)
|
||||
}
|
||||
))
|
||||
)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
private fun updateStatus(status: Pair<Status, StatusViewData.Concrete>, newPoll: Poll) {
|
||||
|
@ -182,9 +175,10 @@ class SearchViewModel @Inject constructor(
|
|||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
}
|
||||
disposables.add(timelineCases.favourite(status.first, isFavorited)
|
||||
timelineCases.favourite(status.first, isFavorited)
|
||||
.onErrorReturnItem(status.first)
|
||||
.subscribe())
|
||||
.subscribe()
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun bookmark(status: Pair<Status, StatusViewData.Concrete>, isBookmarked: Boolean) {
|
||||
|
@ -194,9 +188,10 @@ class SearchViewModel @Inject constructor(
|
|||
loadedStatuses[idx] = newPair
|
||||
repoResultStatus.value?.refresh?.invoke()
|
||||
}
|
||||
disposables.add(timelineCases.favourite(status.first, isBookmarked)
|
||||
timelineCases.favourite(status.first, isBookmarked)
|
||||
.onErrorReturnItem(status.first)
|
||||
.subscribe())
|
||||
.subscribe()
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun getAllAccountsOrderedByActive(): List<AccountEntity> {
|
||||
|
|
|
@ -24,28 +24,26 @@ import com.keylesspalace.tusky.R
|
|||
import com.keylesspalace.tusky.adapter.StatusViewHolder
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
|
||||
class SearchStatusesAdapter(private val useAbsoluteTime: Boolean,
|
||||
private val mediaPreviewEnabled: Boolean,
|
||||
private val showBotOverlay: Boolean,
|
||||
private val animateAvatar: Boolean,
|
||||
private val statusListener: StatusActionListener)
|
||||
: PagedListAdapter<Pair<Status, StatusViewData.Concrete>, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
||||
class SearchStatusesAdapter(
|
||||
private val statusDisplayOptions: StatusDisplayOptions,
|
||||
private val statusListener: StatusActionListener
|
||||
) : PagedListAdapter<Pair<Status, StatusViewData.Concrete>, RecyclerView.ViewHolder>(STATUS_COMPARATOR) {
|
||||
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_status, parent, false)
|
||||
return StatusViewHolder(view, useAbsoluteTime)
|
||||
return StatusViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
getItem(position)?.let { item ->
|
||||
(holder as? StatusViewHolder)?.setupWithStatus(item.second, statusListener,
|
||||
mediaPreviewEnabled, showBotOverlay, animateAvatar)
|
||||
statusDisplayOptions)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public override fun getItem(position: Int): Pair<Status, StatusViewData.Concrete>? {
|
||||
|
|
|
@ -38,7 +38,12 @@ import androidx.paging.PagedListAdapter
|
|||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.keylesspalace.tusky.*
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.MainActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.ViewMediaActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter
|
||||
import com.keylesspalace.tusky.db.AccountEntity
|
||||
|
@ -47,6 +52,7 @@ import com.keylesspalace.tusky.entity.Status
|
|||
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
|
||||
import com.keylesspalace.tusky.interfaces.StatusActionListener
|
||||
import com.keylesspalace.tusky.util.NetworkState
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions
|
||||
import com.keylesspalace.tusky.viewdata.AttachmentViewData
|
||||
import com.keylesspalace.tusky.viewdata.StatusViewData
|
||||
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
|
||||
|
@ -66,13 +72,17 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
|
||||
override fun createAdapter(): PagedListAdapter<Pair<Status, StatusViewData.Concrete>, *> {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(searchRecyclerView.context)
|
||||
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
|
||||
val showBotOverlay = preferences.getBoolean("showBotOverlay", true)
|
||||
val animateAvatar = preferences.getBoolean("animateGifAvatars", false)
|
||||
val statusDisplayOptions = StatusDisplayOptions(
|
||||
animateAvatars = preferences.getBoolean("animateGifAvatars", false),
|
||||
mediaPreviewEnabled = viewModel.mediaPreviewEnabled,
|
||||
useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false),
|
||||
showBotOverlay = preferences.getBoolean("showBotOverlay", true),
|
||||
useBlurhash = preferences.getBoolean("useBlurhash", true)
|
||||
)
|
||||
|
||||
searchRecyclerView.addItemDecoration(DividerItemDecoration(searchRecyclerView.context, DividerItemDecoration.VERTICAL))
|
||||
searchRecyclerView.layoutManager = LinearLayoutManager(searchRecyclerView.context)
|
||||
return SearchStatusesAdapter(useAbsoluteTime, viewModel.mediaPreviewEnabled, showBotOverlay, animateAvatar, this)
|
||||
return SearchStatusesAdapter(statusDisplayOptions, this)
|
||||
}
|
||||
|
||||
|
||||
|
@ -195,14 +205,14 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
mentionedUsernames.add(username)
|
||||
}
|
||||
mentionedUsernames.remove(loggedInUsername)
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.inReplyToId(inReplyToId)
|
||||
.replyVisibility(replyVisibility)
|
||||
.contentWarning(contentWarning)
|
||||
.mentionedUsernames(mentionedUsernames)
|
||||
.replyingStatusAuthor(actionableStatus.account.localUsername)
|
||||
.replyingStatusContent(actionableStatus.content.toString())
|
||||
.build(context)
|
||||
val intent = ComposeActivity.startIntent(context!!, ComposeOptions(
|
||||
inReplyToId = inReplyToId,
|
||||
replyVisibility = replyVisibility,
|
||||
contentWarning = contentWarning,
|
||||
mentionedUsernames = mentionedUsernames,
|
||||
replyingStatusAuthor = actionableStatus.account.localUsername,
|
||||
replyingStatusContent = actionableStatus.content.toString()
|
||||
))
|
||||
requireActivity().startActivity(intent)
|
||||
}
|
||||
|
||||
|
@ -398,24 +408,24 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
|
|||
viewModel.deleteStatus(id)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.autoDispose(from(this, Lifecycle.Event.ON_DESTROY))
|
||||
.subscribe ({ deletedStatus ->
|
||||
.subscribe({ deletedStatus ->
|
||||
removeItem(position)
|
||||
|
||||
val redraftStatus = if(deletedStatus.isEmpty()) {
|
||||
val redraftStatus = if (deletedStatus.isEmpty()) {
|
||||
status.toDeletedStatus()
|
||||
} else {
|
||||
deletedStatus
|
||||
}
|
||||
|
||||
val intent = ComposeActivity.IntentBuilder()
|
||||
.tootText(redraftStatus.text)
|
||||
.inReplyToId(redraftStatus.inReplyToId)
|
||||
.visibility(redraftStatus.visibility)
|
||||
.contentWarning(redraftStatus.spoilerText)
|
||||
.mediaAttachments(redraftStatus.attachments)
|
||||
.sensitive(redraftStatus.sensitive)
|
||||
.poll(redraftStatus.poll?.toNewPoll(status.createdAt))
|
||||
.build(context)
|
||||
val intent = ComposeActivity.startIntent(context!!, ComposeOptions(
|
||||
tootText = redraftStatus.text ?: "",
|
||||
inReplyToId = redraftStatus.inReplyToId,
|
||||
visibility = redraftStatus.visibility,
|
||||
contentWarning = redraftStatus.spoilerText,
|
||||
mediaAttachments = redraftStatus.attachments,
|
||||
sensitive = redraftStatus.sensitive,
|
||||
poll = redraftStatus.poll?.toNewPoll(status.createdAt)
|
||||
))
|
||||
startActivity(intent)
|
||||
}, { error ->
|
||||
Log.w("SearchStatusesFragment", "error deleting status", error)
|
||||
|
|
|
@ -316,6 +316,14 @@ public abstract class AppDatabase extends RoomDatabase {
|
|||
database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `bookmarked` INTEGER NOT NULL DEFAULT 0");
|
||||
database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_bookmarked` INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_20_21 = new Migration(20, 21) {
|
||||
@Override
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `version` TEXT");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_20_21 = new Migration(20, 21) {
|
||||
|
|
|
@ -19,6 +19,7 @@ import androidx.room.Dao
|
|||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import io.reactivex.Single
|
||||
|
||||
@Dao
|
||||
interface InstanceDao {
|
||||
|
@ -26,5 +27,5 @@ interface InstanceDao {
|
|||
fun insertOrReplace(instance: InstanceEntity)
|
||||
|
||||
@Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1")
|
||||
fun loadMetadataForInstance(instance: String): InstanceEntity?
|
||||
fun loadMetadataForInstance(instance: String): Single<InstanceEntity>
|
||||
}
|
||||
|
|
|
@ -27,5 +27,6 @@ data class InstanceEntity(
|
|||
val emojiList: List<Emoji>?,
|
||||
val maximumTootCharacters: Int?,
|
||||
val maxPollOptions: Int?,
|
||||
val maxPollOptionLength: Int?
|
||||
val maxPollOptionLength: Int?,
|
||||
val version: String?
|
||||
)
|
||||
|
|
|
@ -16,8 +16,10 @@
|
|||
package com.keylesspalace.tusky.di
|
||||
|
||||
import com.keylesspalace.tusky.*
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity
|
||||
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity
|
||||
import com.keylesspalace.tusky.components.search.SearchActivity
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
|
|
@ -35,7 +35,8 @@ import javax.inject.Singleton
|
|||
ServicesModule::class,
|
||||
BroadcastReceiverModule::class,
|
||||
ViewModelModule::class,
|
||||
RepositoryModule::class
|
||||
RepositoryModule::class,
|
||||
MediaUploaderModule::class
|
||||
])
|
||||
interface AppComponent {
|
||||
@Component.Builder
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
/* Copyright 2019 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.di
|
||||
|
||||
import android.content.Context
|
||||
import com.keylesspalace.tusky.components.compose.MediaUploader
|
||||
import com.keylesspalace.tusky.components.compose.MediaUploaderImpl
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
||||
@Module
|
||||
class MediaUploaderModule {
|
||||
@Provides
|
||||
fun providesMediaUploder(context: Context, mastodonApi: MastodonApi): MediaUploader =
|
||||
MediaUploaderImpl(context, mastodonApi)
|
||||
}
|
|
@ -15,12 +15,25 @@
|
|||
|
||||
package com.keylesspalace.tusky.di
|
||||
|
||||
import android.content.Context
|
||||
import com.keylesspalace.tusky.service.SendTootService
|
||||
import com.keylesspalace.tusky.service.ServiceClient
|
||||
import com.keylesspalace.tusky.service.ServiceClientImpl
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
|
||||
@Module
|
||||
abstract class ServicesModule {
|
||||
@ContributesAndroidInjector
|
||||
abstract fun contributesSendTootService(): SendTootService
|
||||
|
||||
@Module
|
||||
companion object {
|
||||
@Provides
|
||||
@JvmStatic
|
||||
fun providesServiceClient(context: Context): ServiceClient {
|
||||
return ServiceClientImpl(context)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,10 +4,14 @@ package com.keylesspalace.tusky.di
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.keylesspalace.tusky.components.compose.ComposeViewModel
|
||||
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
|
||||
import com.keylesspalace.tusky.components.report.ReportViewModel
|
||||
import com.keylesspalace.tusky.components.scheduled.ScheduledTootViewModel
|
||||
import com.keylesspalace.tusky.components.search.SearchViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.*
|
||||
import com.keylesspalace.tusky.viewmodel.AccountViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
|
||||
import com.keylesspalace.tusky.viewmodel.ListsViewModel
|
||||
import dagger.Binds
|
||||
import dagger.MapKey
|
||||
|
@ -71,5 +75,15 @@ abstract class ViewModelModule {
|
|||
@ViewModelKey(SearchViewModel::class)
|
||||
internal abstract fun searchViewModel(viewModel: SearchViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ComposeViewModel::class)
|
||||
internal abstract fun composeViewModel(viewModel: ComposeViewModel): ViewModel
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ViewModelKey(ScheduledTootViewModel::class)
|
||||
internal abstract fun scheduledTootViewModel(viewModel: ScheduledTootViewModel): ViewModel
|
||||
|
||||
//Add more ViewModels here
|
||||
}
|
|
@ -31,7 +31,8 @@ data class Attachment(
|
|||
@SerializedName("preview_url") val previewUrl: String,
|
||||
val meta: MetaData?,
|
||||
val type: Type,
|
||||
val description: String?
|
||||
val description: String?,
|
||||
val blurhash: String?
|
||||
) : Parcelable {
|
||||
|
||||
@JsonAdapter(MediaTypeDeserializer::class)
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
package com.keylesspalace.tusky.entity
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class IdentityProof(
|
||||
val provider: String,
|
||||
@SerializedName("provider_username") val username: String,
|
||||
@SerializedName("profile_url") val profileUrl: String
|
||||
)
|
|
@ -75,6 +75,7 @@ import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
|
|||
import com.keylesspalace.tusky.util.ListUtils;
|
||||
import com.keylesspalace.tusky.util.NotificationTypeConverterKt;
|
||||
import com.keylesspalace.tusky.util.PairedList;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
import com.keylesspalace.tusky.util.ViewDataUtils;
|
||||
import com.keylesspalace.tusky.view.BackgroundMessageView;
|
||||
|
@ -237,18 +238,18 @@ public class NotificationsFragment extends SFragment implements
|
|||
|
||||
recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL));
|
||||
|
||||
StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions(
|
||||
preferences.getBoolean("animateGifAvatars", false),
|
||||
accountManager.getActiveAccount().getMediaPreviewEnabled(),
|
||||
preferences.getBoolean("absoluteTimeView", false),
|
||||
preferences.getBoolean("showBotOverlay", true),
|
||||
preferences.getBoolean("useBlurhash", true)
|
||||
);
|
||||
|
||||
adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(),
|
||||
dataSource, this, this);
|
||||
dataSource, statusDisplayOptions, this, this);
|
||||
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
|
||||
alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler();
|
||||
boolean mediaPreviewEnabled = accountManager.getActiveAccount().getMediaPreviewEnabled();
|
||||
adapter.setMediaPreviewEnabled(mediaPreviewEnabled);
|
||||
boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false);
|
||||
adapter.setUseAbsoluteTime(useAbsoluteTime);
|
||||
boolean showBotOverlay = preferences.getBoolean("showBotOverlay", true);
|
||||
adapter.setShowBotOverlay(showBotOverlay);
|
||||
boolean animateAvatar = preferences.getBoolean("animateGifAvatars", false);
|
||||
adapter.setAnimateAvatar(animateAvatar);
|
||||
recyclerView.setAdapter(adapter);
|
||||
|
||||
topLoading = false;
|
||||
|
|
|
@ -42,12 +42,13 @@ import androidx.lifecycle.Lifecycle;
|
|||
|
||||
import com.keylesspalace.tusky.BaseActivity;
|
||||
import com.keylesspalace.tusky.BottomSheetActivity;
|
||||
import com.keylesspalace.tusky.ComposeActivity;
|
||||
import com.keylesspalace.tusky.MainActivity;
|
||||
import com.keylesspalace.tusky.R;
|
||||
import com.keylesspalace.tusky.PostLookupFallbackBehavior;
|
||||
import com.keylesspalace.tusky.ViewMediaActivity;
|
||||
import com.keylesspalace.tusky.ViewTagActivity;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions;
|
||||
import com.keylesspalace.tusky.components.report.ReportActivity;
|
||||
import com.keylesspalace.tusky.db.AccountEntity;
|
||||
import com.keylesspalace.tusky.db.AccountManager;
|
||||
|
@ -152,21 +153,22 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
mentionedUsernames.add(actionableStatus.getAccount().getUsername());
|
||||
String loggedInUsername = null;
|
||||
AccountEntity activeAccount = accountManager.getActiveAccount();
|
||||
if(activeAccount != null) {
|
||||
if (activeAccount != null) {
|
||||
loggedInUsername = activeAccount.getUsername();
|
||||
}
|
||||
for (Status.Mention mention : mentions) {
|
||||
mentionedUsernames.add(mention.getUsername());
|
||||
}
|
||||
mentionedUsernames.remove(loggedInUsername);
|
||||
Intent intent = new ComposeActivity.IntentBuilder()
|
||||
.inReplyToId(inReplyToId)
|
||||
.replyVisibility(replyVisibility)
|
||||
.contentWarning(contentWarning)
|
||||
.mentionedUsernames(mentionedUsernames)
|
||||
.replyingStatusAuthor(actionableStatus.getAccount().getLocalUsername())
|
||||
.replyingStatusContent(actionableStatus.getContent().toString())
|
||||
.build(getContext());
|
||||
ComposeOptions composeOptions = new ComposeOptions();
|
||||
composeOptions.setInReplyToId(inReplyToId);
|
||||
composeOptions.setReplyVisibility(replyVisibility);
|
||||
composeOptions.setContentWarning(contentWarning);
|
||||
composeOptions.setMentionedUsernames(mentionedUsernames);
|
||||
composeOptions.setReplyingStatusAuthor(actionableStatus.getAccount().getLocalUsername());
|
||||
composeOptions.setReplyingStatusContent(actionableStatus.getContent().toString());
|
||||
|
||||
Intent intent = ComposeActivity.startIntent(getContext(), composeOptions);
|
||||
getActivity().startActivity(intent);
|
||||
}
|
||||
|
||||
|
@ -180,7 +182,7 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
|
||||
String loggedInAccountId = null;
|
||||
AccountEntity activeAccount = accountManager.getActiveAccount();
|
||||
if(activeAccount != null) {
|
||||
if (activeAccount != null) {
|
||||
loggedInAccountId = activeAccount.getAccountId();
|
||||
}
|
||||
|
||||
|
@ -213,7 +215,7 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
|
||||
Menu menu = popup.getMenu();
|
||||
MenuItem openAsItem = menu.findItem(R.id.status_open_as);
|
||||
switch(accounts.size()) {
|
||||
switch (accounts.size()) {
|
||||
case 0:
|
||||
case 1:
|
||||
openAsItem.setVisible(false);
|
||||
|
@ -242,7 +244,8 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
switch (item.getItemId()) {
|
||||
case R.id.status_share_content: {
|
||||
Status statusToShare = status;
|
||||
if(statusToShare.getReblog() != null) statusToShare = statusToShare.getReblog();
|
||||
if (statusToShare.getReblog() != null)
|
||||
statusToShare = statusToShare.getReblog();
|
||||
|
||||
Intent sendIntent = new Intent();
|
||||
sendIntent.setAction(Intent.ACTION_SEND);
|
||||
|
@ -371,7 +374,8 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
|
||||
.subscribe(
|
||||
deletedStatus -> {},
|
||||
deletedStatus -> {
|
||||
},
|
||||
error -> {
|
||||
Log.w("SFragment", "error deleting status", error);
|
||||
Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show();
|
||||
|
@ -395,22 +399,22 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
.subscribe(deletedStatus -> {
|
||||
removeItem(position);
|
||||
|
||||
if(deletedStatus.isEmpty()) {
|
||||
if (deletedStatus.isEmpty()) {
|
||||
deletedStatus = status.toDeletedStatus();
|
||||
}
|
||||
|
||||
ComposeActivity.IntentBuilder intentBuilder = new ComposeActivity.IntentBuilder()
|
||||
.tootText(deletedStatus.getText())
|
||||
.inReplyToId(deletedStatus.getInReplyToId())
|
||||
.visibility(deletedStatus.getVisibility())
|
||||
.contentWarning(deletedStatus.getSpoilerText())
|
||||
.mediaAttachments(deletedStatus.getAttachments())
|
||||
.sensitive(deletedStatus.getSensitive());
|
||||
if(deletedStatus.getPoll() != null) {
|
||||
intentBuilder.poll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt()));
|
||||
ComposeOptions composeOptions = new ComposeOptions();
|
||||
composeOptions.setTootText(deletedStatus.getText());
|
||||
composeOptions.setInReplyToId(deletedStatus.getInReplyToId());
|
||||
composeOptions.setVisibility(deletedStatus.getVisibility());
|
||||
composeOptions.setContentWarning(deletedStatus.getSpoilerText());
|
||||
composeOptions.setMediaAttachments(deletedStatus.getAttachments());
|
||||
composeOptions.setSensitive(deletedStatus.getSensitive());
|
||||
if (deletedStatus.getPoll() != null) {
|
||||
composeOptions.setPoll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt()));
|
||||
}
|
||||
|
||||
Intent intent = intentBuilder.build(getContext());
|
||||
Intent intent = ComposeActivity
|
||||
.startIntent(getContext(), composeOptions);
|
||||
startActivity(intent);
|
||||
},
|
||||
error -> {
|
||||
|
@ -429,22 +433,22 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
intent.putExtra(MainActivity.STATUS_URL, statusUrl);
|
||||
startActivity(intent);
|
||||
((BaseActivity)getActivity()).finishWithoutSlideOutAnimation();
|
||||
((BaseActivity) getActivity()).finishWithoutSlideOutAnimation();
|
||||
}
|
||||
|
||||
private void showOpenAsDialog(String statusUrl, CharSequence dialogTitle) {
|
||||
BaseActivity activity = (BaseActivity)getActivity();
|
||||
BaseActivity activity = (BaseActivity) getActivity();
|
||||
activity.showAccountChooserDialog(dialogTitle, false, account -> openAsAccount(statusUrl, account));
|
||||
}
|
||||
|
||||
private void downloadAllMedia(Status status) {
|
||||
Toast.makeText(getContext(), R.string.downloading_media, Toast.LENGTH_SHORT).show();
|
||||
for(Attachment attachment: status.getAttachments()) {
|
||||
for (Attachment attachment : status.getAttachments()) {
|
||||
String url = attachment.getUrl();
|
||||
Uri uri = Uri.parse(url);
|
||||
String filename = uri.getLastPathSegment();
|
||||
|
||||
DownloadManager downloadManager = (DownloadManager)getActivity().getSystemService(Context.DOWNLOAD_SERVICE);
|
||||
DownloadManager downloadManager = (DownloadManager) getActivity().getSystemService(Context.DOWNLOAD_SERVICE);
|
||||
DownloadManager.Request request = new DownloadManager.Request(uri);
|
||||
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename);
|
||||
downloadManager.enqueue(request);
|
||||
|
@ -452,8 +456,8 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
}
|
||||
|
||||
private void requestDownloadAllMedia(Status status) {
|
||||
String[] permissions = new String[]{ Manifest.permission.WRITE_EXTERNAL_STORAGE };
|
||||
((BaseActivity)getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> {
|
||||
String[] permissions = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE};
|
||||
((BaseActivity) getActivity()).requestPermissions(permissions, (permissions1, grantResults) -> {
|
||||
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
downloadAllMedia(status);
|
||||
} else {
|
||||
|
@ -501,9 +505,9 @@ public abstract class SFragment extends BaseFragment implements Injectable {
|
|||
@VisibleForTesting
|
||||
public boolean shouldFilterStatus(Status status) {
|
||||
|
||||
if(filterRemoveRegex && status.getPoll() != null) {
|
||||
for(PollOption option: status.getPoll().getOptions()) {
|
||||
if(filterRemoveRegexMatcher.reset(option.getTitle()).find()) {
|
||||
if (filterRemoveRegex && status.getPoll() != null) {
|
||||
for (PollOption option : status.getPoll().getOptions()) {
|
||||
if (filterRemoveRegexMatcher.reset(option.getTitle()).find()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ import android.os.Bundle;
|
|||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
|
||||
import com.keylesspalace.tusky.ComposeActivity;
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.util.TimeZone;
|
||||
|
|
|
@ -77,6 +77,7 @@ import com.keylesspalace.tusky.util.LinkHelper;
|
|||
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
|
||||
import com.keylesspalace.tusky.util.ListUtils;
|
||||
import com.keylesspalace.tusky.util.PairedList;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.StringUtils;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
import com.keylesspalace.tusky.util.ViewDataUtils;
|
||||
|
@ -219,7 +220,15 @@ public class TimelineFragment extends SFragment implements
|
|||
hashtagOrId = arguments.getString(HASHTAG_OR_ID_ARG);
|
||||
}
|
||||
|
||||
adapter = new TimelineAdapter(dataSource, this);
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
|
||||
StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions(
|
||||
preferences.getBoolean("animateGifAvatars", false),
|
||||
accountManager.getActiveAccount().getMediaPreviewEnabled(),
|
||||
preferences.getBoolean("absoluteTimeView", false),
|
||||
preferences.getBoolean("showBotOverlay", true),
|
||||
preferences.getBoolean("useBlurhash", true)
|
||||
);
|
||||
adapter = new TimelineAdapter(dataSource, statusDisplayOptions, this);
|
||||
|
||||
isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true);
|
||||
|
||||
|
@ -341,18 +350,10 @@ public class TimelineFragment extends SFragment implements
|
|||
}
|
||||
|
||||
private void setupTimelinePreferences() {
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
|
||||
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
|
||||
alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler();
|
||||
boolean mediaPreviewEnabled = accountManager.getActiveAccount().getMediaPreviewEnabled();
|
||||
adapter.setMediaPreviewEnabled(mediaPreviewEnabled);
|
||||
boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false);
|
||||
adapter.setUseAbsoluteTime(useAbsoluteTime);
|
||||
boolean showBotOverlay = preferences.getBoolean("showBotOverlay", true);
|
||||
adapter.setShowBotOverlay(showBotOverlay);
|
||||
boolean animateAvatar = preferences.getBoolean("animateGifAvatars", false);
|
||||
adapter.setAnimateAvatar(animateAvatar);
|
||||
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getContext());
|
||||
boolean filter = preferences.getBoolean("tabFilterHomeReplies", true);
|
||||
filterRemoveReplies = kind == Kind.HOME && !filter;
|
||||
|
||||
|
|
|
@ -60,6 +60,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener;
|
|||
import com.keylesspalace.tusky.network.MastodonApi;
|
||||
import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate;
|
||||
import com.keylesspalace.tusky.util.PairedList;
|
||||
import com.keylesspalace.tusky.util.StatusDisplayOptions;
|
||||
import com.keylesspalace.tusky.util.ThemeUtils;
|
||||
import com.keylesspalace.tusky.util.ViewDataUtils;
|
||||
import com.keylesspalace.tusky.view.ConversationLineItemDecoration;
|
||||
|
@ -123,8 +124,16 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
super.onCreate(savedInstanceState);
|
||||
|
||||
thisThreadsStatusId = getArguments().getString("id");
|
||||
|
||||
adapter = new ThreadAdapter(this);
|
||||
SharedPreferences preferences =
|
||||
PreferenceManager.getDefaultSharedPreferences(getActivity());
|
||||
StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions(
|
||||
preferences.getBoolean("animateGifAvatars", false),
|
||||
accountManager.getActiveAccount().getMediaPreviewEnabled(),
|
||||
preferences.getBoolean("absoluteTimeView", false),
|
||||
preferences.getBoolean("showBotOverlay", true),
|
||||
preferences.getBoolean("useBlurhash", true)
|
||||
);
|
||||
adapter = new ThreadAdapter(statusDisplayOptions, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -150,18 +159,8 @@ public final class ViewThreadFragment extends SFragment implements
|
|||
recyclerView.addItemDecoration(divider);
|
||||
|
||||
recyclerView.addItemDecoration(new ConversationLineItemDecoration(context));
|
||||
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(
|
||||
getActivity());
|
||||
alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia();
|
||||
alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler();
|
||||
boolean mediaPreviewEnabled = accountManager.getActiveAccount().getMediaPreviewEnabled();
|
||||
adapter.setMediaPreviewEnabled(mediaPreviewEnabled);
|
||||
boolean useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false);
|
||||
adapter.setUseAbsoluteTime(useAbsoluteTime);
|
||||
boolean animateAvatars = preferences.getBoolean("animateGifAvatars", false);
|
||||
adapter.setAnimateAvatar(animateAvatars);
|
||||
boolean showBotIndicator = preferences.getBoolean("showBotOverlay", true);
|
||||
adapter.setShowBotOverlay(showBotIndicator);
|
||||
reloadFilters(false);
|
||||
|
||||
recyclerView.setAdapter(adapter);
|
||||
|
|
|
@ -43,7 +43,7 @@ interface MastodonApi {
|
|||
fun getLists(): Single<List<MastoList>>
|
||||
|
||||
@GET("/api/v1/custom_emojis")
|
||||
fun getCustomEmojis(): Call<List<Emoji>>
|
||||
fun getCustomEmojis(): Single<List<Emoji>>
|
||||
|
||||
@GET("api/v1/instance")
|
||||
fun getInstance(): Single<Instance>
|
||||
|
@ -116,14 +116,14 @@ interface MastodonApi {
|
|||
@POST("api/v1/media")
|
||||
fun uploadMedia(
|
||||
@Part file: MultipartBody.Part
|
||||
): Call<Attachment>
|
||||
): Single<Attachment>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("api/v1/media/{mediaId}")
|
||||
fun updateMedia(
|
||||
@Path("mediaId") mediaId: String,
|
||||
@Field("description") description: String
|
||||
): Call<Attachment>
|
||||
): Single<Attachment>
|
||||
|
||||
@POST("api/v1/statuses")
|
||||
fun createStatus(
|
||||
|
@ -201,12 +201,15 @@ interface MastodonApi {
|
|||
): Single<Status>
|
||||
|
||||
@GET("api/v1/scheduled_statuses")
|
||||
fun scheduledStatuses(): Call<List<ScheduledStatus>>
|
||||
fun scheduledStatuses(
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("max_id") maxId: String? = null
|
||||
): Single<List<ScheduledStatus>>
|
||||
|
||||
@DELETE("api/v1/scheduled_statuses/{id}")
|
||||
fun deleteScheduledStatus(
|
||||
@Path("id") scheduledStatusId: String
|
||||
): Call<ResponseBody>
|
||||
): Single<ResponseBody>
|
||||
|
||||
@GET("api/v1/accounts/verify_credentials")
|
||||
fun accountVerifyCredentials(): Single<Account>
|
||||
|
@ -238,10 +241,10 @@ interface MastodonApi {
|
|||
|
||||
@GET("api/v1/accounts/search")
|
||||
fun searchAccounts(
|
||||
@Query("q") q: String,
|
||||
@Query("resolve") resolve: Boolean?,
|
||||
@Query("limit") limit: Int?,
|
||||
@Query("following") following: Boolean?
|
||||
@Query("q") query: String,
|
||||
@Query("resolve") resolve: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("following") following: Boolean? = null
|
||||
): Single<List<Account>>
|
||||
|
||||
@GET("api/v1/accounts/{id}")
|
||||
|
@ -318,6 +321,11 @@ interface MastodonApi {
|
|||
@Query("id[]") accountIds: List<String>
|
||||
): Call<List<Relationship>>
|
||||
|
||||
@GET("api/v1/accounts/{id}/identity_proofs")
|
||||
fun identityProofs(
|
||||
@Path("id") accountId: String
|
||||
): Call<List<IdentityProof>>
|
||||
|
||||
@GET("api/v1/blocks")
|
||||
fun blocks(
|
||||
@Query("max_id") maxId: String?
|
||||
|
|
|
@ -23,12 +23,15 @@ import androidx.core.app.NotificationCompat
|
|||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.keylesspalace.tusky.ComposeActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.service.SendTootService
|
||||
import com.keylesspalace.tusky.service.TootToSend
|
||||
import com.keylesspalace.tusky.util.NotificationHelper
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import dagger.android.AndroidInjection
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -85,19 +88,27 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
|||
|
||||
val sendIntent = SendTootService.sendTootIntent(
|
||||
context,
|
||||
text,
|
||||
spoiler,
|
||||
visibility,
|
||||
false,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
null,
|
||||
citedStatusId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null, account, 0, null)
|
||||
TootToSend(
|
||||
text,
|
||||
spoiler,
|
||||
visibility.serverString(),
|
||||
false,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
null,
|
||||
citedStatusId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
account.id,
|
||||
0,
|
||||
randomAlphanumericString(16),
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
context.startService(sendIntent)
|
||||
|
||||
|
@ -125,14 +136,14 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
|
|||
|
||||
accountManager.setActiveAccount(senderId)
|
||||
|
||||
val composeIntent = ComposeActivity.IntentBuilder()
|
||||
.inReplyToId(citedStatusId)
|
||||
.replyVisibility(visibility)
|
||||
.contentWarning(spoiler)
|
||||
.mentionedUsernames(mentions.toList())
|
||||
.replyingStatusAuthor(localAuthorId)
|
||||
.replyingStatusContent(citedText)
|
||||
.build(context)
|
||||
val composeIntent = ComposeActivity.startIntent(context, ComposeOptions(
|
||||
inReplyToId = citedStatusId,
|
||||
replyVisibility = visibility,
|
||||
contentWarning = spoiler,
|
||||
mentionedUsernames = mentions.toSet(),
|
||||
replyingStatusAuthor = localAuthorId,
|
||||
replyingStatusContent = citedText
|
||||
))
|
||||
|
||||
composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@ import android.content.ClipData
|
|||
import android.content.ClipDescription
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Parcelable
|
||||
|
@ -19,7 +18,6 @@ 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.db.AccountEntity
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.db.AppDatabase
|
||||
import com.keylesspalace.tusky.di.Injectable
|
||||
|
@ -28,7 +26,6 @@ import com.keylesspalace.tusky.entity.NewStatus
|
|||
import com.keylesspalace.tusky.entity.Status
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.SaveTootHelper
|
||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||
import dagger.android.AndroidInjection
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import retrofit2.Call
|
||||
|
@ -50,7 +47,8 @@ class SendTootService : Service(), Injectable {
|
|||
@Inject
|
||||
lateinit var database: AppDatabase
|
||||
|
||||
private lateinit var saveTootHelper: SaveTootHelper
|
||||
@Inject
|
||||
lateinit var saveTootHelper: SaveTootHelper
|
||||
|
||||
private val tootsToSend = ConcurrentHashMap<Int, TootToSend>()
|
||||
private val sendCalls = ConcurrentHashMap<Int, Call<Status>>()
|
||||
|
@ -61,7 +59,6 @@ class SendTootService : Service(), Injectable {
|
|||
|
||||
override fun onCreate() {
|
||||
AndroidInjection.inject(this)
|
||||
saveTootHelper = SaveTootHelper(database.tootDao(), this)
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
|
@ -288,56 +285,19 @@ class SendTootService : Service(), Injectable {
|
|||
|
||||
@JvmStatic
|
||||
fun sendTootIntent(context: Context,
|
||||
text: String,
|
||||
warningText: String,
|
||||
visibility: Status.Visibility,
|
||||
sensitive: Boolean,
|
||||
mediaIds: List<String>,
|
||||
mediaUris: List<Uri>,
|
||||
mediaDescriptions: List<String>,
|
||||
scheduledAt: String?,
|
||||
inReplyToId: String?,
|
||||
poll: NewPoll?,
|
||||
replyingStatusContent: String?,
|
||||
replyingStatusAuthorUsername: String?,
|
||||
savedJsonUrls: String?,
|
||||
account: AccountEntity,
|
||||
savedTootUid: Int,
|
||||
markdownMode: Boolean?
|
||||
tootToSend: TootToSend
|
||||
): Intent {
|
||||
val intent = Intent(context, SendTootService::class.java)
|
||||
|
||||
val idempotencyKey = randomAlphanumericString(16)
|
||||
|
||||
val tootToSend = TootToSend(text,
|
||||
warningText,
|
||||
visibility.serverString(),
|
||||
sensitive,
|
||||
mediaIds,
|
||||
mediaUris.map { it.toString() },
|
||||
mediaDescriptions,
|
||||
scheduledAt,
|
||||
inReplyToId,
|
||||
poll,
|
||||
replyingStatusContent,
|
||||
replyingStatusAuthorUsername,
|
||||
savedJsonUrls,
|
||||
markdownMode,
|
||||
account.id,
|
||||
savedTootUid,
|
||||
idempotencyKey,
|
||||
0)
|
||||
|
||||
intent.putExtra(KEY_TOOT, tootToSend)
|
||||
|
||||
if(mediaUris.isNotEmpty()) {
|
||||
if (tootToSend.mediaUris.isNotEmpty()) {
|
||||
// forward uri permissions
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val uriClip = ClipData(
|
||||
ClipDescription("Toot Media", arrayOf("image/*", "video/*")),
|
||||
ClipData.Item(mediaUris[0])
|
||||
ClipData.Item(tootToSend.mediaUris[0])
|
||||
)
|
||||
mediaUris
|
||||
tootToSend.mediaUris
|
||||
.drop(1)
|
||||
.forEach { mediaUri ->
|
||||
uriClip.addItem(ClipData.Item(mediaUri))
|
||||
|
@ -354,21 +314,23 @@ class SendTootService : Service(), Injectable {
|
|||
}
|
||||
|
||||
@Parcelize
|
||||
data class TootToSend(val text: String,
|
||||
val warningText: String,
|
||||
val visibility: String,
|
||||
val sensitive: Boolean,
|
||||
val mediaIds: List<String>,
|
||||
val mediaUris: List<String>,
|
||||
val mediaDescriptions: List<String>,
|
||||
val scheduledAt: String?,
|
||||
val inReplyToId: String?,
|
||||
val poll: NewPoll?,
|
||||
val replyingStatusContent: String?,
|
||||
val replyingStatusAuthorUsername: String?,
|
||||
val savedJsonUrls: String?,
|
||||
var markdownMode: Boolean?,
|
||||
val accountId: Long,
|
||||
val savedTootUid: Int,
|
||||
val idempotencyKey: String,
|
||||
var retries: Int) : Parcelable
|
||||
data class TootToSend(
|
||||
val text: String,
|
||||
val warningText: String,
|
||||
val visibility: String,
|
||||
val sensitive: Boolean,
|
||||
val mediaIds: List<String>,
|
||||
val mediaUris: List<String>,
|
||||
val mediaDescriptions: List<String>,
|
||||
val scheduledAt: String?,
|
||||
val inReplyToId: String?,
|
||||
val poll: NewPoll?,
|
||||
val replyingStatusContent: String?,
|
||||
val replyingStatusAuthorUsername: String?,
|
||||
val savedJsonUrls: List<String>?,
|
||||
val markdownMode: Boolean?,
|
||||
val accountId: Long,
|
||||
val savedTootUid: Int,
|
||||
val idempotencyKey: String,
|
||||
var retries: Int
|
||||
) : Parcelable
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/* Copyright 2019 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.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
|
||||
interface ServiceClient {
|
||||
fun sendToot(tootToSend: TootToSend)
|
||||
}
|
||||
|
||||
class ServiceClientImpl(private val context: Context) : ServiceClient {
|
||||
override fun sendToot(tootToSend: TootToSend) {
|
||||
val intent = SendTootService.sendTootIntent(context, tootToSend)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -18,7 +18,6 @@ package com.keylesspalace.tusky.service
|
|||
import android.annotation.TargetApi
|
||||
import android.content.Intent
|
||||
import android.service.quicksettings.TileService
|
||||
|
||||
import com.keylesspalace.tusky.MainActivity
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Blurhash implementation from blurhash project:
|
||||
* https://github.com/woltapp/blurhash
|
||||
* Minor modifications by charlag
|
||||
*/
|
||||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.withSign
|
||||
|
||||
object BlurHashDecoder {
|
||||
|
||||
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? {
|
||||
require(width > 0) { "Width must be greater than zero" }
|
||||
require(height > 0) { "height must be greater than zero" }
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
val numCompEnc = decode83(blurHash, 0, 1)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||
return null
|
||||
}
|
||||
val maxAcEnc = decode83(blurHash, 1, 2)
|
||||
val maxAc = (maxAcEnc + 1) / 166f
|
||||
val colors = Array(numCompX * numCompY) { i ->
|
||||
if (i == 0) {
|
||||
val colorEnc = decode83(blurHash, 2, 6)
|
||||
decodeDc(colorEnc)
|
||||
} else {
|
||||
val from = 4 + i * 2
|
||||
val colorEnc = decode83(blurHash, from, from + 2)
|
||||
decodeAc(colorEnc, maxAc * punch)
|
||||
}
|
||||
}
|
||||
return composeBitmap(width, height, numCompX, numCompY, colors)
|
||||
}
|
||||
|
||||
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
|
||||
var result = 0
|
||||
for (i in from until to) {
|
||||
val index = charMap[str[i]] ?: -1
|
||||
if (index != -1) {
|
||||
result = result * 83 + index
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun decodeDc(colorEnc: Int): FloatArray {
|
||||
val r = colorEnc shr 16
|
||||
val g = (colorEnc shr 8) and 255
|
||||
val b = colorEnc and 255
|
||||
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
|
||||
}
|
||||
|
||||
private fun srgbToLinear(colorEnc: Int): Float {
|
||||
val v = colorEnc / 255f
|
||||
return if (v <= 0.04045f) {
|
||||
(v / 12.92f)
|
||||
} else {
|
||||
((v + 0.055f) / 1.055f).pow(2.4f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
|
||||
val r = value / (19 * 19)
|
||||
val g = (value / 19) % 19
|
||||
val b = value % 19
|
||||
return floatArrayOf(
|
||||
signedPow2((r - 9) / 9.0f) * maxAc,
|
||||
signedPow2((g - 9) / 9.0f) * maxAc,
|
||||
signedPow2((b - 9) / 9.0f) * maxAc
|
||||
)
|
||||
}
|
||||
|
||||
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
|
||||
|
||||
private fun composeBitmap(
|
||||
width: Int, height: Int,
|
||||
numCompX: Int, numCompY: Int,
|
||||
colors: Array<FloatArray>
|
||||
): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
var r = 0f
|
||||
var g = 0f
|
||||
var b = 0f
|
||||
for (j in 0 until numCompY) {
|
||||
for (i in 0 until numCompX) {
|
||||
val basis = (cos(PI * x * i / width) * cos(PI * y * j / height)).toFloat()
|
||||
val color = colors[j * numCompX + i]
|
||||
r += color[0] * basis
|
||||
g += color[1] * basis
|
||||
b += color[2] * basis
|
||||
}
|
||||
}
|
||||
bitmap.setPixel(x, y, Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)))
|
||||
}
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun linearToSrgb(value: Float): Int {
|
||||
val v = value.coerceIn(0f, 1f)
|
||||
return if (v <= 0.0031308f) {
|
||||
(v * 12.92f * 255f + 0.5f).toInt()
|
||||
} else {
|
||||
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private val charMap = listOf(
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
|
||||
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
|
||||
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
|
||||
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
|
||||
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
|
||||
)
|
||||
.mapIndexed { i, c -> c to i }
|
||||
.toMap()
|
||||
|
||||
}
|
|
@ -1,51 +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.util;
|
||||
|
||||
/**
|
||||
* This is a synchronization primitive related to {@link java.util.concurrent.CountDownLatch}
|
||||
* except that it starts at zero and can count upward.
|
||||
* <p>
|
||||
* The intended use case is for waiting for all tasks to be finished when the number of tasks isn't
|
||||
* known ahead of time, or may change while waiting.
|
||||
*/
|
||||
public class CountUpDownLatch {
|
||||
private int count;
|
||||
|
||||
public CountUpDownLatch() {
|
||||
this.count = 0;
|
||||
}
|
||||
|
||||
public synchronized void countDown() {
|
||||
count--;
|
||||
notifyAll();
|
||||
}
|
||||
|
||||
public synchronized void countUp() {
|
||||
count++;
|
||||
notifyAll();
|
||||
}
|
||||
|
||||
public synchronized void await() throws InterruptedException {
|
||||
while (count != 0) {
|
||||
wait();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean isEmpty() {
|
||||
return count == 0;
|
||||
}
|
||||
}
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.Px
|
||||
import com.bumptech.glide.Glide
|
||||
|
@ -14,7 +16,7 @@ private val centerCropTransformation = CenterCrop()
|
|||
|
||||
fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) {
|
||||
|
||||
if(url.isNullOrBlank()) {
|
||||
if (url.isNullOrBlank()) {
|
||||
Glide.with(imageView)
|
||||
.load(R.drawable.avatar_default)
|
||||
.into(imageView)
|
||||
|
@ -42,4 +44,8 @@ fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boo
|
|||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeBlurHash(context: Context, blurhash: String): BitmapDrawable {
|
||||
return BitmapDrawable(context.resources, BlurHashDecoder.decode(blurhash, 32, 32, 1f))
|
||||
}
|
|
@ -19,6 +19,7 @@ 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;
|
||||
|
@ -68,7 +69,7 @@ public class LinkHelper {
|
|||
* @param listener to notify about particular spans that are clicked
|
||||
*/
|
||||
public static void setClickableText(TextView view, Spanned content,
|
||||
@Nullable Status.Mention[] mentions, final LinkListener listener) {
|
||||
@Nullable Status.Mention[] mentions, final LinkListener listener) {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder(content);
|
||||
URLSpan[] urlSpans = content.getSpans(0, content.length(), URLSpan.class);
|
||||
for (URLSpan span : urlSpans) {
|
||||
|
@ -178,6 +179,14 @@ public class LinkHelper {
|
|||
view.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
}
|
||||
|
||||
public static CharSequence createClickableText(String text, String link) {
|
||||
URLSpan span = new CustomURLSpan(link);
|
||||
|
||||
SpannableStringBuilder clickableText = new SpannableStringBuilder(text);
|
||||
clickableText.setSpan(span, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
|
||||
return clickableText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a link, depending on the settings, either in the browser or in a custom tab
|
||||
*
|
||||
|
@ -221,10 +230,17 @@ public class LinkHelper {
|
|||
public static void openLinkInCustomTab(Uri uri, Context context) {
|
||||
int toolbarColor = ThemeUtils.getColor(context, R.attr.custom_tab_toolbar);
|
||||
|
||||
CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder()
|
||||
CustomTabsIntent.Builder customTabsIntentBuilder = new CustomTabsIntent.Builder()
|
||||
.setToolbarColor(toolbarColor)
|
||||
.setShowTitle(true)
|
||||
.build();
|
||||
.setShowTitle(true);
|
||||
|
||||
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
customTabsIntentBuilder.setNavigationBarColor(
|
||||
ThemeUtils.getColor(context, android.R.attr.navigationBarColor)
|
||||
);
|
||||
}
|
||||
|
||||
CustomTabsIntent customTabsIntent = customTabsIntentBuilder.build();
|
||||
try {
|
||||
customTabsIntent.launchUrl(context, uri);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
/* Copyright 2019 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
package com.keylesspalace.tusky.util
|
||||
|
||||
import androidx.lifecycle.*
|
||||
import io.reactivex.BackpressureStrategy
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.Single
|
||||
|
||||
inline fun <X, Y> LiveData<X>.map(crossinline mapFunction: (X) -> Y): LiveData<Y> =
|
||||
Transformations.map(this) { input -> mapFunction(input) }
|
||||
|
||||
inline fun <X, Y> LiveData<X>.switchMap(
|
||||
crossinline switchMapFunction: (X) -> LiveData<Y>
|
||||
): LiveData<Y> = Transformations.switchMap(this) { input -> switchMapFunction(input) }
|
||||
|
||||
inline fun <X> LiveData<X>.filter(crossinline predicate: (X) -> Boolean): LiveData<X> {
|
||||
val liveData = MediatorLiveData<X>()
|
||||
liveData.addSource(this) { value ->
|
||||
if (predicate(value)) {
|
||||
liveData.value = value
|
||||
}
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
fun LifecycleOwner.withLifecycleContext(body: LifecycleContext.() -> Unit) =
|
||||
LifecycleContext(this).apply(body)
|
||||
|
||||
class LifecycleContext(val lifecycleOwner: LifecycleOwner) {
|
||||
inline fun <T> LiveData<T>.observe(crossinline observer: (T) -> Unit) =
|
||||
this.observe(lifecycleOwner, Observer { observer(it) })
|
||||
|
||||
/**
|
||||
* Just hold a subscription,
|
||||
*/
|
||||
fun <T> LiveData<T>.subscribe() =
|
||||
this.observe(lifecycleOwner, Observer { })
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes @param [combiner] when value of both @param [a] and @param [b] are not null. Returns
|
||||
* [LiveData] with value set to the result of calling [combiner] with value of both.
|
||||
* Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked.
|
||||
*/
|
||||
fun <A, B, R> combineLiveData(a: LiveData<A>, b: LiveData<B>, combiner: (A, B) -> R): LiveData<R> {
|
||||
val liveData = MediatorLiveData<R>()
|
||||
liveData.addSource(a) {
|
||||
if (a.value != null && b.value != null) {
|
||||
liveData.value = combiner(a.value!!, b.value!!)
|
||||
}
|
||||
}
|
||||
liveData.addSource(b) {
|
||||
if (a.value != null && b.value != null) {
|
||||
liveData.value = combiner(a.value!!, b.value!!)
|
||||
}
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [LiveData] with value set to the result of calling [combiner] with value of [a] and [b]
|
||||
* after either changes. Doesn't check if either has value.
|
||||
* Important! You still need to observe to the returned [LiveData] for [combiner] to be invoked.
|
||||
*/
|
||||
fun <A, B, R> combineOptionalLiveData(a: LiveData<A>, b: LiveData<B>, combiner: (A?, B?) -> R): LiveData<R> {
|
||||
val liveData = MediatorLiveData<R>()
|
||||
liveData.addSource(a) {
|
||||
liveData.value = combiner(a.value, b.value)
|
||||
}
|
||||
liveData.addSource(b) {
|
||||
liveData.value = combiner(a.value, b.value)
|
||||
}
|
||||
return liveData
|
||||
}
|
||||
|
||||
fun <T> Single<T>.toLiveData() = LiveDataReactiveStreams.fromPublisher(this.toFlowable())
|
||||
fun <T> Observable<T>.toLiveData(
|
||||
backpressureStrategy: BackpressureStrategy = BackpressureStrategy.LATEST
|
||||
) = LiveDataReactiveStreams.fromPublisher(this.toFlowable(BackpressureStrategy.LATEST))
|
|
@ -16,13 +16,10 @@
|
|||
package com.keylesspalace.tusky.util
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.ThumbnailUtils
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.annotation.Px
|
||||
|
@ -106,26 +103,6 @@ fun getSampledBitmap(contentResolver: ContentResolver, uri: Uri, @Px reqWidth: I
|
|||
}
|
||||
}
|
||||
|
||||
fun getImageThumbnail(contentResolver: ContentResolver, uri: Uri, @Px thumbnailSize: Int): Bitmap? {
|
||||
val source = getSampledBitmap(contentResolver, uri, thumbnailSize, thumbnailSize) ?: return null
|
||||
return ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize, ThumbnailUtils.OPTIONS_RECYCLE_INPUT)
|
||||
}
|
||||
|
||||
fun getVideoThumbnail(context: Context, uri: Uri, @Px thumbnailSize: Int): Bitmap? {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(context, uri)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.w(TAG, e)
|
||||
return null
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, e)
|
||||
return null
|
||||
}
|
||||
val source = retriever.frameAtTime ?: return null
|
||||
return ThumbnailUtils.extractThumbnail(source, thumbnailSize, thumbnailSize, ThumbnailUtils.OPTIONS_RECYCLE_INPUT)
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class)
|
||||
fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Long {
|
||||
val input = contentResolver.openInputStream(uri)
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
package com.keylesspalace.tusky.util
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.disposables.Disposable
|
||||
|
||||
open class RxAwareViewModel : ViewModel() {
|
||||
val disposables = CompositeDisposable()
|
||||
|
||||
fun Disposable.autoDispose() = disposables.add(this)
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.clear()
|
||||
}
|
||||
}
|
|
@ -5,16 +5,18 @@ import android.content.ContentResolver;
|
|||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.FileProvider;
|
||||
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;
|
||||
|
@ -27,6 +29,8 @@ import java.util.Date;
|
|||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
public final class SaveTootHelper {
|
||||
|
||||
private static final String TAG = "SaveTootHelper";
|
||||
|
@ -35,15 +39,16 @@ public final class SaveTootHelper {
|
|||
private Context context;
|
||||
private Gson gson = new Gson();
|
||||
|
||||
public SaveTootHelper(@NonNull TootDao tootDao, @NonNull Context context) {
|
||||
this.tootDao = tootDao;
|
||||
@Inject
|
||||
public SaveTootHelper(@NonNull AppDatabase appDatabase, @NonNull Context context) {
|
||||
this.tootDao = appDatabase.tootDao();
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
public boolean saveToot(@NonNull String content,
|
||||
@NonNull String contentWarning,
|
||||
@Nullable String savedJsonUrls,
|
||||
@Nullable List<String> savedJsonUrls,
|
||||
@NonNull List<String> mediaUris,
|
||||
@NonNull List<String> mediaDescriptions,
|
||||
int savedTootUid,
|
||||
|
@ -59,31 +64,25 @@ public final class SaveTootHelper {
|
|||
}
|
||||
|
||||
// Get any existing file's URIs.
|
||||
ArrayList<String> existingUris = null;
|
||||
if (!TextUtils.isEmpty(savedJsonUrls)) {
|
||||
existingUris = gson.fromJson(savedJsonUrls,
|
||||
new TypeToken<ArrayList<String>>() {
|
||||
}.getType());
|
||||
}
|
||||
|
||||
String mediaUrlsSerialized = null;
|
||||
String mediaDescriptionsSerialized = null;
|
||||
|
||||
if (!ListUtils.isEmpty(mediaUris)) {
|
||||
List<String> savedList = saveMedia(mediaUris, existingUris);
|
||||
List<String> savedList = saveMedia(mediaUris, savedJsonUrls);
|
||||
if (!ListUtils.isEmpty(savedList)) {
|
||||
mediaUrlsSerialized = gson.toJson(savedList);
|
||||
if (!ListUtils.isEmpty(existingUris)) {
|
||||
deleteMedia(setDifference(existingUris, savedList));
|
||||
if (!ListUtils.isEmpty(savedJsonUrls)) {
|
||||
deleteMedia(setDifference(savedJsonUrls, savedList));
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
mediaDescriptionsSerialized = gson.toJson(mediaDescriptions);
|
||||
} else if (!ListUtils.isEmpty(existingUris)) {
|
||||
} 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(existingUris);
|
||||
deleteMedia(savedJsonUrls);
|
||||
}
|
||||
final TootEntity toot = new TootEntity(savedTootUid, content, mediaUrlsSerialized, mediaDescriptionsSerialized, contentWarning,
|
||||
inReplyToId,
|
||||
|
@ -104,15 +103,16 @@ public final class SaveTootHelper {
|
|||
|
||||
public void deleteDraft(int tootId) {
|
||||
TootEntity item = tootDao.find(tootId);
|
||||
if(item != null) {
|
||||
if (item != null) {
|
||||
deleteDraft(item);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteDraft(@NonNull TootEntity item){
|
||||
public void deleteDraft(@NonNull TootEntity item) {
|
||||
// Delete any media files associated with the status.
|
||||
ArrayList<String> uris = gson.fromJson(item.getUrls(),
|
||||
new TypeToken<ArrayList<String>>() {}.getType());
|
||||
new TypeToken<ArrayList<String>>() {
|
||||
}.getType());
|
||||
if (uris != null) {
|
||||
for (String uriString : uris) {
|
||||
Uri uri = Uri.parse(uriString);
|
||||
|
@ -173,7 +173,7 @@ public final class SaveTootHelper {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
Uri resultUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID+".fileprovider", file);
|
||||
Uri resultUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file);
|
||||
results.add(resultUri.toString());
|
||||
}
|
||||
return results;
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
package com.keylesspalace.tusky.util
|
||||
|
||||
data class StatusDisplayOptions(
|
||||
@get:JvmName("animateAvatars")
|
||||
val animateAvatars: Boolean,
|
||||
@get:JvmName("mediaPreviewEnabled")
|
||||
val mediaPreviewEnabled: Boolean,
|
||||
@get:JvmName("useAbsoluteTime")
|
||||
val useAbsoluteTime: Boolean,
|
||||
@get:JvmName("showBotOverlay")
|
||||
val showBotOverlay: Boolean,
|
||||
@get:JvmName("useBlurhash")
|
||||
val useBlurhash: Boolean
|
||||
)
|
|
@ -16,6 +16,7 @@
|
|||
package com.keylesspalace.tusky.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.text.InputFilter
|
||||
import android.text.TextUtils
|
||||
import android.view.View
|
||||
|
@ -47,7 +48,7 @@ class StatusViewHelper(private val itemView: View) {
|
|||
private val longSdf = SimpleDateFormat("MM/dd HH:mm:ss", Locale.getDefault())
|
||||
|
||||
fun setMediasPreview(
|
||||
mediaPreviewEnabled: Boolean,
|
||||
statusDisplayOptions: StatusDisplayOptions,
|
||||
attachments: List<Attachment>,
|
||||
sensitive: Boolean,
|
||||
previewListener: MediaPreviewListener,
|
||||
|
@ -70,7 +71,7 @@ class StatusViewHelper(private val itemView: View) {
|
|||
val sensitiveMediaWarning = itemView.findViewById<TextView>(R.id.status_sensitive_media_warning)
|
||||
val sensitiveMediaShow = itemView.findViewById<View>(R.id.status_sensitive_media_button)
|
||||
val mediaLabel = itemView.findViewById<TextView>(R.id.status_media_label)
|
||||
if (mediaPreviewEnabled) {
|
||||
if (statusDisplayOptions.mediaPreviewEnabled) {
|
||||
// Hide the unused label.
|
||||
mediaLabel.visibility = View.GONE
|
||||
} else {
|
||||
|
@ -86,13 +87,15 @@ class StatusViewHelper(private val itemView: View) {
|
|||
}
|
||||
|
||||
|
||||
val mediaPreviewUnloadedId = ThemeUtils.getDrawableId(context, R.attr.media_preview_unloaded_drawable, android.R.color.black)
|
||||
val mediaPreviewUnloaded = ThemeUtils.getDrawable(context,
|
||||
R.attr.media_preview_unloaded_drawable, android.R.color.black)
|
||||
|
||||
val n = min(attachments.size, Status.MAX_MEDIA_ATTACHMENTS)
|
||||
|
||||
for (i in 0 until n) {
|
||||
val previewUrl = attachments[i].previewUrl
|
||||
val description = attachments[i].description
|
||||
val attachment = attachments[i]
|
||||
val previewUrl = attachment.previewUrl
|
||||
val description = attachment.description
|
||||
|
||||
if (TextUtils.isEmpty(description)) {
|
||||
mediaPreviews[i].contentDescription = context.getString(R.string.action_view_media)
|
||||
|
@ -104,35 +107,49 @@ class StatusViewHelper(private val itemView: View) {
|
|||
|
||||
if (TextUtils.isEmpty(previewUrl)) {
|
||||
Glide.with(mediaPreviews[i])
|
||||
.load(mediaPreviewUnloadedId)
|
||||
.load(mediaPreviewUnloaded)
|
||||
.centerInside()
|
||||
.into(mediaPreviews[i])
|
||||
} else {
|
||||
val meta = attachments[i].meta
|
||||
val placeholder = if (attachment.blurhash != null)
|
||||
decodeBlurHash(context, attachment.blurhash)
|
||||
else mediaPreviewUnloaded
|
||||
val meta = attachment.meta
|
||||
val focus = meta?.focus
|
||||
if (showingContent) {
|
||||
if (focus != null) { // If there is a focal point for this attachment:
|
||||
mediaPreviews[i].setFocalPoint(focus)
|
||||
|
||||
if (focus != null) { // If there is a focal point for this attachment:
|
||||
mediaPreviews[i].setFocalPoint(focus)
|
||||
Glide.with(mediaPreviews[i])
|
||||
.load(previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.addListener(mediaPreviews[i])
|
||||
.into(mediaPreviews[i])
|
||||
} else {
|
||||
mediaPreviews[i].removeFocalPoint()
|
||||
|
||||
Glide.with(mediaPreviews[i])
|
||||
.load(previewUrl)
|
||||
.placeholder(mediaPreviewUnloadedId)
|
||||
.centerInside()
|
||||
.addListener(mediaPreviews[i])
|
||||
.into(mediaPreviews[i])
|
||||
Glide.with(mediaPreviews[i])
|
||||
.load(previewUrl)
|
||||
.placeholder(placeholder)
|
||||
.centerInside()
|
||||
.into(mediaPreviews[i])
|
||||
}
|
||||
} else {
|
||||
mediaPreviews[i].removeFocalPoint()
|
||||
|
||||
Glide.with(mediaPreviews[i])
|
||||
.load(previewUrl)
|
||||
.placeholder(mediaPreviewUnloadedId)
|
||||
.centerInside()
|
||||
.into(mediaPreviews[i])
|
||||
if (statusDisplayOptions.useBlurhash && attachment.blurhash != null) {
|
||||
val blurhashBitmap = decodeBlurHash(context, attachment.blurhash)
|
||||
mediaPreviews[i].setImageDrawable(blurhashBitmap)
|
||||
} else {
|
||||
mediaPreviews[i].setImageDrawable(ColorDrawable(ThemeUtils.getColor(
|
||||
context, R.attr.sensitive_media_warning_background_color)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val type = attachments[i].type
|
||||
if ((type === Attachment.Type.VIDEO) or (type === Attachment.Type.GIFV)) {
|
||||
val type = attachment.type
|
||||
if (showingContent
|
||||
&& (type === Attachment.Type.VIDEO) or (type === Attachment.Type.GIFV)) {
|
||||
mediaOverlays[i].visibility = View.VISIBLE
|
||||
} else {
|
||||
mediaOverlays[i].visibility = View.GONE
|
||||
|
@ -158,13 +175,9 @@ class StatusViewHelper(private val itemView: View) {
|
|||
} else {
|
||||
|
||||
val hiddenContentText: String = if (sensitive) {
|
||||
context.getString(R.string.status_sensitive_media_template,
|
||||
context.getString(R.string.status_sensitive_media_title),
|
||||
context.getString(R.string.status_sensitive_media_directions))
|
||||
context.getString(R.string.status_sensitive_media_title)
|
||||
} else {
|
||||
context.getString(R.string.status_sensitive_media_template,
|
||||
context.getString(R.string.status_media_hidden_title),
|
||||
context.getString(R.string.status_sensitive_media_directions))
|
||||
context.getString(R.string.status_media_hidden_title)
|
||||
}
|
||||
|
||||
sensitiveMediaWarning.text = HtmlUtils.fromHtml(hiddenContentText)
|
||||
|
@ -175,11 +188,15 @@ class StatusViewHelper(private val itemView: View) {
|
|||
previewListener.onContentHiddenChange(false)
|
||||
v.visibility = View.GONE
|
||||
sensitiveMediaWarning.visibility = View.VISIBLE
|
||||
setMediasPreview(statusDisplayOptions, attachments, sensitive, previewListener,
|
||||
false, mediaPreviewHeight)
|
||||
}
|
||||
sensitiveMediaWarning.setOnClickListener { v ->
|
||||
previewListener.onContentHiddenChange(true)
|
||||
v.visibility = View.GONE
|
||||
sensitiveMediaShow.visibility = View.VISIBLE
|
||||
setMediasPreview(statusDisplayOptions, attachments, sensitive, previewListener,
|
||||
true, mediaPreviewHeight)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,8 @@
|
|||
|
||||
package com.keylesspalace.tusky.util;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
@ -24,7 +26,7 @@ public class VersionUtils {
|
|||
private int minor;
|
||||
private int patch;
|
||||
|
||||
public VersionUtils(String versionString) {
|
||||
public VersionUtils(@NonNull String versionString) {
|
||||
String regex = "([0-9]+)\\.([0-9]+)\\.([0-9]+).*";
|
||||
Pattern pattern = Pattern.compile(regex);
|
||||
Matcher matcher = pattern.matcher(versionString);
|
||||
|
|
|
@ -51,4 +51,13 @@ inline fun EditText.onTextChanged(
|
|||
callback(s, start, before, count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
inline fun EditText.afterTextChanged(
|
||||
crossinline callback: (s: Editable) -> Unit) {
|
||||
addTextChangedListener(object : DefaultTextWatcher() {
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
callback(s)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -37,7 +37,7 @@ class BackgroundMessageView @JvmOverloads constructor(
|
|||
* If [clickListener] is `null` then the button will be hidden.
|
||||
*/
|
||||
fun setup(@DrawableRes imageRes: Int, @StringRes messageRes: Int,
|
||||
clickListener: ((v: View) -> Unit)?) {
|
||||
clickListener: ((v: View) -> Unit)? = null) {
|
||||
messageTextView.setText(messageRes)
|
||||
messageTextView.setCompoundDrawablesWithIntrinsicBounds(0, imageRes, 0, 0)
|
||||
button.setOnClickListener(clickListener)
|
||||
|
|
|
@ -50,7 +50,7 @@ defStyleAttr: Int = 0
|
|||
/**
|
||||
* Set the focal point for this view.
|
||||
*/
|
||||
fun setFocalPoint(focus: Attachment.Focus) {
|
||||
fun setFocalPoint(focus: Attachment.Focus?) {
|
||||
this.focus = focus
|
||||
super.setScaleType(ScaleType.MATRIX)
|
||||
|
||||
|
|
|
@ -6,12 +6,11 @@ import androidx.lifecycle.ViewModel
|
|||
import com.keylesspalace.tusky.appstore.*
|
||||
import com.keylesspalace.tusky.db.AccountManager
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.entity.Field
|
||||
import com.keylesspalace.tusky.entity.IdentityProof
|
||||
import com.keylesspalace.tusky.entity.Relationship
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Error
|
||||
import com.keylesspalace.tusky.util.Loading
|
||||
import com.keylesspalace.tusky.util.Resource
|
||||
import com.keylesspalace.tusky.util.Success
|
||||
import com.keylesspalace.tusky.util.*
|
||||
import io.reactivex.disposables.Disposable
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
|
@ -27,6 +26,14 @@ class AccountViewModel @Inject constructor(
|
|||
val accountData = MutableLiveData<Resource<Account>>()
|
||||
val relationshipData = MutableLiveData<Resource<Relationship>>()
|
||||
|
||||
private val identityProofData = MutableLiveData<List<IdentityProof>>()
|
||||
|
||||
val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs ->
|
||||
identityProofs.orEmpty().map { Either.Left<IdentityProof, Field>(it) }
|
||||
.plus(accountRes?.data?.fields.orEmpty().map { Either.Right<IdentityProof, Field>(it) })
|
||||
}
|
||||
|
||||
|
||||
private val callList: MutableList<Call<*>> = mutableListOf()
|
||||
private val disposable: Disposable = eventHub.events
|
||||
.subscribe { event ->
|
||||
|
@ -60,6 +67,7 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
override fun onFailure(call: Call<Account>, t: Throwable) {
|
||||
Log.w(TAG, "failed obtaining account", t)
|
||||
accountData.postValue(Error())
|
||||
isDataLoading = false
|
||||
isRefreshing.postValue(false)
|
||||
|
@ -90,6 +98,7 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
override fun onFailure(call: Call<List<Relationship>>, t: Throwable) {
|
||||
Log.w(TAG, "failed obtaining relationships", t)
|
||||
relationshipData.postValue(Error())
|
||||
}
|
||||
})
|
||||
|
@ -98,6 +107,30 @@ class AccountViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun obtainIdentityProof(reload: Boolean = false) {
|
||||
if (identityProofData.value == null || reload) {
|
||||
|
||||
val call = mastodonApi.identityProofs(accountId)
|
||||
call.enqueue(object : Callback<List<IdentityProof>> {
|
||||
override fun onResponse(call: Call<List<IdentityProof>>,
|
||||
response: Response<List<IdentityProof>>) {
|
||||
val proofs = response.body()
|
||||
if (response.isSuccessful && proofs != null ) {
|
||||
identityProofData.postValue(proofs)
|
||||
} else {
|
||||
identityProofData.postValue(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<List<IdentityProof>>, t: Throwable) {
|
||||
Log.w(TAG, "failed obtaining identity proofs", t)
|
||||
}
|
||||
})
|
||||
|
||||
callList.add(call)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeFollowState() {
|
||||
val relationship = relationshipData.value?.data
|
||||
if (relationship?.following == true || relationship?.requested == true) {
|
||||
|
@ -227,6 +260,7 @@ class AccountViewModel @Inject constructor(
|
|||
return
|
||||
accountId.let {
|
||||
obtainAccount(isReload)
|
||||
obtainIdentityProof()
|
||||
if (!isSelf)
|
||||
obtainRelationship(isReload)
|
||||
}
|
||||
|
|
|
@ -17,26 +17,23 @@
|
|||
package com.keylesspalace.tusky.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.keylesspalace.tusky.entity.Account
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.Either
|
||||
import com.keylesspalace.tusky.util.Either.Left
|
||||
import com.keylesspalace.tusky.util.Either.Right
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.withoutFirstWhich
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.rxkotlin.addTo
|
||||
import io.reactivex.subjects.BehaviorSubject
|
||||
import javax.inject.Inject
|
||||
|
||||
data class State(val accounts: Either<Throwable, List<Account>>, val searchResult: List<Account>?)
|
||||
|
||||
class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
|
||||
class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() {
|
||||
|
||||
val state: Observable<State> get() = _state
|
||||
private val _state = BehaviorSubject.createDefault(State(Right(listOf()), null))
|
||||
private val disposable = CompositeDisposable()
|
||||
|
||||
fun load(listId: String) {
|
||||
val state = _state.value!!
|
||||
|
@ -45,7 +42,7 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi)
|
|||
updateState { copy(accounts = Right(accounts)) }
|
||||
}, { e ->
|
||||
updateState { copy(accounts = Left(e)) }
|
||||
}).addTo(disposable)
|
||||
}).autoDispose()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -59,7 +56,7 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi)
|
|||
Log.i(javaClass.simpleName,
|
||||
"Failed to add account to the list: ${account.username}")
|
||||
})
|
||||
.addTo(disposable)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun deleteAccountFromList(listId: String, accountId: String) {
|
||||
|
@ -73,7 +70,7 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi)
|
|||
}, {
|
||||
Log.i(javaClass.simpleName, "Failed to remove account from thelist: $accountId")
|
||||
})
|
||||
.addTo(disposable)
|
||||
.autoDispose()
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
|
@ -85,7 +82,7 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi)
|
|||
updateState { copy(searchResult = result) }
|
||||
}, {
|
||||
updateState { copy(searchResult = listOf()) }
|
||||
}).addTo(disposable)
|
||||
}).autoDispose()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -16,14 +16,12 @@
|
|||
|
||||
package com.keylesspalace.tusky.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.withoutFirstWhich
|
||||
import com.keylesspalace.tusky.util.RxAwareViewModel
|
||||
import com.keylesspalace.tusky.util.replacedFirstWhich
|
||||
import com.keylesspalace.tusky.util.withoutFirstWhich
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.rxkotlin.addTo
|
||||
import io.reactivex.subjects.BehaviorSubject
|
||||
import io.reactivex.subjects.PublishSubject
|
||||
import java.io.IOException
|
||||
|
@ -31,7 +29,7 @@ import java.net.ConnectException
|
|||
import javax.inject.Inject
|
||||
|
||||
|
||||
internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
|
||||
internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() {
|
||||
enum class LoadingState {
|
||||
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
|
||||
}
|
||||
|
@ -46,7 +44,6 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
|
|||
val events: Observable<Event> get() = _events
|
||||
private val _state = BehaviorSubject.createDefault(State(listOf(), LoadingState.INITIAL))
|
||||
private val _events = PublishSubject.create<Event>()
|
||||
private val disposable = CompositeDisposable()
|
||||
|
||||
fun retryLoading() {
|
||||
loadIfNeeded()
|
||||
|
@ -71,7 +68,7 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
|
|||
copy(loadingState = if (err is IOException || err is ConnectException)
|
||||
LoadingState.ERROR_NETWORK else LoadingState.ERROR_OTHER)
|
||||
}
|
||||
}).addTo(disposable)
|
||||
}).autoDispose()
|
||||
}
|
||||
|
||||
fun createNewList(listName: String) {
|
||||
|
@ -81,7 +78,7 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
|
|||
}
|
||||
}, {
|
||||
sendEvent(Event.CREATE_ERROR)
|
||||
}).addTo(disposable)
|
||||
}).autoDispose()
|
||||
}
|
||||
|
||||
fun renameList(listId: String, listName: String) {
|
||||
|
@ -91,7 +88,7 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
|
|||
}
|
||||
}, {
|
||||
sendEvent(Event.RENAME_ERROR)
|
||||
}).addTo(disposable)
|
||||
}).autoDispose()
|
||||
}
|
||||
|
||||
fun deleteList(listId: String) {
|
||||
|
@ -101,7 +98,7 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
|
|||
}
|
||||
}, {
|
||||
sendEvent(Event.DELETE_ERROR)
|
||||
}).addTo(disposable)
|
||||
}).autoDispose()
|
||||
}
|
||||
|
||||
private inline fun updateState(crossinline fn: State.() -> State) {
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="8dp" />
|
||||
<solid android:color="?attr/sensitive_media_warning_background_color" />
|
||||
</shape>
|
|
@ -2,7 +2,7 @@
|
|||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/activity_view_thread"
|
||||
android:id="@+id/activityScheduledToot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context="com.keylesspalace.tusky.AccountListActivity">
|
||||
|
@ -15,7 +15,7 @@
|
|||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar"
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
|
@ -23,6 +23,18 @@
|
|||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/scheduledTootList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<com.keylesspalace.tusky.view.BackgroundMessageView
|
||||
android:id="@+id/errorMessageView"
|
||||
android:layout_width="wrap_content"
|
||||
|
@ -36,18 +48,6 @@
|
|||
tools:src="@drawable/elephant_error"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipe_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/scheduled_toot_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
@ -93,7 +93,7 @@
|
|||
app:layout_constraintEnd_toStartOf="@id/barrierEnd"
|
||||
app:layout_constraintStart_toStartOf="@id/guideBegin"
|
||||
app:layout_constraintTop_toBottomOf="@id/buttonToggleContent"
|
||||
tools:visibility="gone">
|
||||
tools:visibility="visible">
|
||||
|
||||
<com.keylesspalace.tusky.view.MediaPreviewImageView
|
||||
android:id="@+id/status_media_preview_0"
|
||||
|
@ -199,17 +199,21 @@
|
|||
android:visibility="gone"
|
||||
app:layout_constraintLeft_toLeftOf="@+id/status_media_preview_container"
|
||||
app:layout_constraintTop_toTopOf="@+id/status_media_preview_container"
|
||||
app:srcCompat="@drawable/ic_eye_24dp" />
|
||||
app:srcCompat="@drawable/ic_eye_24dp"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_sensitive_media_warning"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:background="?attr/sensitive_media_warning_background_color"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/media_warning_bg"
|
||||
android:gravity="center"
|
||||
android:lineSpacingMultiplier="1.2"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
android:background="?android:colorBackground"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="16dp">
|
||||
|
||||
<ImageView
|
||||
|
@ -15,6 +15,8 @@
|
|||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:src="@drawable/ic_drag_indicator_24dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
@ -24,6 +26,8 @@
|
|||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:layout_weight="1"
|
||||
android:drawablePadding="12dp"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
|
@ -32,10 +36,23 @@
|
|||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/imageView"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_goneMarginBottom="16dp"
|
||||
app:layout_goneMarginBottom="8dp"
|
||||
tools:drawableStart="@drawable/ic_home_24dp"
|
||||
tools:text="Home" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/removeButton"
|
||||
style="?attr/image_button_style"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginTop="4dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_delete"
|
||||
android:src="@drawable/ic_clear_24dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/chipGroup"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -56,4 +73,3 @@
|
|||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
|
|
@ -5,27 +5,28 @@
|
|||
|
||||
<Button
|
||||
android:id="@+id/resetScheduleButton"
|
||||
style="@style/TuskyButton.Outlined"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/action_reset_schedule"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/scheduledDateTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:drawablePadding="4dp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
android:drawablePadding="4dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="1"
|
||||
app:layout_constraintStart_toEndOf="@id/resetScheduleButton"
|
||||
tools:text="2020/01/01 00:00:00" />
|
||||
|
||||
</merge>
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:inset="28%"
|
||||
android:drawable="@drawable/ic_create_24dp" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
|
@ -487,7 +487,6 @@
|
|||
<string name="action_access_scheduled_toot">التبويقات المبَرمَجة</string>
|
||||
<string name="action_schedule_toot">برمجة تبويق</string>
|
||||
<string name="action_reset_schedule">صفّر</string>
|
||||
<string name="hint_configure_scheduled_toot">اضغط هنا لضبط برمجة التبويق.</string>
|
||||
<string name="post_lookup_error_format">خطأ أثناء البحث عن منشور %s</string>
|
||||
|
||||
<string name="title_bookmarks">الفواصل المرجعية</string>
|
||||
|
@ -497,4 +496,6 @@
|
|||
<string name="description_status_bookmarked">أضيف إلى الفواصل المرجعية</string>
|
||||
<string name="select_list_title">اختر قائمة</string>
|
||||
<string name="list">القائمة</string>
|
||||
<string name="gradient_for_media">اظهر ألوانا متدرّجة للوسائط المخفية</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -508,7 +508,6 @@
|
|||
<string name="action_access_scheduled_toot">নির্ধারিত টুটগুলি</string>
|
||||
<string name="action_schedule_toot">নির্ধারিত টুট</string>
|
||||
<string name="action_reset_schedule">রিসেট</string>
|
||||
<string name="hint_configure_scheduled_toot">নির্ধারিত টুট কনফিগার করতে এখানে আলতো চাপুন।</string>
|
||||
<string name="about_powered_by_tusky">টাস্কি দ্বারা চালিত</string>
|
||||
<string name="post_lookup_error_format">%s পোস্ট অনুসন্ধানে ত্রুটি</string>
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue