Merge branch 'develop'

This commit is contained in:
Conny Duck 2020-11-25 20:15:27 +01:00
commit d75e4a4ac4
160 changed files with 3673 additions and 2335 deletions

View File

@ -20,8 +20,8 @@ android {
applicationId APP_ID
minSdkVersion 21
targetSdkVersion 29
versionCode 75
versionName "12.1"
versionCode 76
versionName "13.0 beta 1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@ -98,31 +98,31 @@ project.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
ext.lifecycleVersion = "2.2.0"
ext.roomVersion = '2.2.5'
ext.retrofitVersion = '2.9.0'
ext.okhttpVersion = '4.8.1'
ext.okhttpVersion = '4.9.0'
ext.glideVersion = '4.11.0'
ext.daggerVersion = '2.28.3'
ext.materialdrawerVersion = '8.1.4'
ext.daggerVersion = '2.29.1'
ext.materialdrawerVersion = '8.1.8'
// 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.3.1"
implementation "androidx.core:core-ktx:1.3.2"
implementation "androidx.appcompat:appcompat:1.2.0"
implementation "androidx.fragment:fragment-ktx:1.2.5"
implementation "androidx.browser:browser:1.2.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.recyclerview:recyclerview:1.1.0"
implementation "androidx.exifinterface:exifinterface:1.2.0"
implementation "androidx.exifinterface:exifinterface:1.3.1"
implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.preference:preference:1.1.1"
implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.sharetarget:sharetarget:1.0.0"
implementation "androidx.emoji:emoji:1.1.0"
implementation "androidx.emoji:emoji-appcompat:1.1.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycleVersion"
implementation "androidx.constraintlayout:constraintlayout:1.1.3"
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
implementation "androidx.paging:paging-runtime-ktx:2.1.2"
implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.work:work-runtime:2.4.0"
@ -130,7 +130,7 @@ dependencies {
implementation "androidx.room:room-rxjava2:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
implementation "com.google.android.material:material:1.2.0"
implementation "com.google.android.material:material:1.2.1"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
@ -144,7 +144,7 @@ dependencies {
implementation "com.github.bumptech.glide:glide:$glideVersion"
implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion"
implementation "io.reactivex.rxjava2:rxjava:2.2.19"
implementation "io.reactivex.rxjava2:rxjava:2.2.20"
implementation "io.reactivex.rxjava2:rxandroid:2.1.1"
implementation "io.reactivex.rxjava2:rxkotlin:2.4.0"
@ -169,14 +169,12 @@ dependencies {
implementation "de.c1710:filemojicompat:1.0.17"
testImplementation "androidx.test.ext:junit:1.1.1"
testImplementation "org.robolectric:robolectric:4.3.1"
testImplementation "org.mockito:mockito-inline:3.3.3"
testImplementation "androidx.test.ext:junit:1.1.2"
testImplementation "org.robolectric:robolectric:4.4"
testImplementation "org.mockito:mockito-inline:3.6.0"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.2.0"
androidTestImplementation "android.arch.persistence.room:testing:1.1.1"
androidTestImplementation "androidx.test.ext:junit:1.1.1"
debugImplementation "im.dino:dbinspector:4.0.0@aar"
androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0"
androidTestImplementation "androidx.room:room-testing:$roomVersion"
androidTestImplementation "androidx.test.ext:junit:1.1.2"
}

View File

@ -55,6 +55,9 @@
public static *** v(...);
public static *** i(...);
}
-assumenosideeffects class java.lang.String {
public static java.lang.String format(...);
}
# remove some kotlin overhead
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
@ -62,8 +65,3 @@
static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String);
static void throwUninitializedPropertyAccessException(java.lang.String);
}
# without this emoji font downloading fails with AbstractMethodError
-keep class * extends android.os.AsyncTask {
public *;
}

View File

@ -78,46 +78,39 @@ class TimelineDAOTest {
fun cleanup() {
val now = System.currentTimeMillis()
val oldDate = now - TimelineRepository.CLEANUP_INTERVAL - 20_000
val oldByThisAccount = makeStatus(
val oldThisAccount = makeStatus(
statusId = 5,
createdAt = oldDate
)
val oldByAnotherAccount = makeStatus(
val oldAnotherAccount = makeStatus(
statusId = 10,
createdAt = oldDate,
authorServerId = "100"
accountId = 2
)
val oldForAnotherAccount = makeStatus(
accountId = 2,
statusId = 20,
authorServerId = "200",
createdAt = oldDate
)
val recentByThisAccount = makeStatus(
val recentThisAccount = makeStatus(
statusId = 30,
createdAt = System.currentTimeMillis()
)
val recentByAnotherAccount = makeStatus(
val recentAnotherAccount = makeStatus(
statusId = 60,
createdAt = System.currentTimeMillis(),
authorServerId = "200"
)
accountId = 2
)
for ((status, author, reblogAuthor) in listOf(oldByThisAccount, oldByAnotherAccount,
oldForAnotherAccount, recentByThisAccount, recentByAnotherAccount)) {
for ((status, author, reblogAuthor) in listOf(oldThisAccount, oldAnotherAccount, recentThisAccount, recentAnotherAccount)) {
timelineDao.insertInTransaction(status, author, reblogAuthor)
}
timelineDao.cleanup(1, "20", now - TimelineRepository.CLEANUP_INTERVAL)
timelineDao.cleanup(now - TimelineRepository.CLEANUP_INTERVAL)
assertEquals(
listOf(recentByAnotherAccount, recentByThisAccount, oldByThisAccount),
listOf(recentThisAccount),
timelineDao.getStatusesForAccount(1, null, null, 100).blockingGet()
.map { it.toTriple() }
)
assertEquals(
listOf(oldForAnotherAccount),
listOf(recentAnotherAccount),
timelineDao.getStatusesForAccount(2, null, null, 100).blockingGet()
.map { it.toTriple() }
)
@ -217,7 +210,8 @@ class TimelineDAOTest {
application = "application$accountId",
reblogServerId = if (reblog) (statusId * 100).toString() else null,
reblogAccountId = reblogAuthor?.serverId,
poll = null
poll = null,
muted = false
)
return Triple(status, author, reblogAuthor)
}
@ -246,7 +240,8 @@ class TimelineDAOTest {
application = null,
reblogServerId = null,
reblogAccountId = null,
poll = null
poll = null,
muted = false
)
}

View File

@ -36,7 +36,7 @@
</activity>
<activity
android:name=".SavedTootActivity"
android:configChanges="orientation|screenSize|keyboardHidden"/>
android:configChanges="orientation|screenSize|keyboardHidden" />
<activity
android:name=".LoginActivity"
android:windowSoftInputMode="adjustResize">
@ -105,7 +105,7 @@
<activity
android:name=".components.compose.ComposeActivity"
android:theme="@style/TuskyDialogActivityTheme"
android:windowSoftInputMode="stateVisible|adjustResize"/>
android:windowSoftInputMode="stateVisible|adjustResize" />
<activity
android:name=".ViewThreadActivity"
android:configChanges="orientation|screenSize" />
@ -117,7 +117,7 @@
android:name=".AccountActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" />
<activity android:name=".EditProfileActivity" />
<activity android:name=".PreferencesActivity" />
<activity android:name=".components.preference.PreferencesActivity" />
<activity android:name=".StatusListActivity" />
<activity android:name=".AccountListActivity" />
<activity android:name=".AboutActivity" />
@ -145,6 +145,7 @@
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity android:name=".components.instancemute.InstanceListActivity" />
<activity android:name=".components.scheduled.ScheduledTootActivity" />
<activity android:name=".components.announcements.AnnouncementsActivity" />
<receiver android:name=".receiver.NotificationClearBroadcastReceiver" />
<receiver
@ -180,7 +181,7 @@
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
android:exported="false"
tools:node="remove"/>
tools:node="remove" />
</application>
</manifest>
</manifest>

View File

@ -23,6 +23,7 @@ import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Bundle
import android.text.Editable
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -34,7 +35,6 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.emoji.text.EmojiCompat
import androidx.lifecycle.Observer
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.viewpager2.widget.MarginPageTransformer
@ -132,6 +132,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (viewModel.isSelf) {
updateButtons()
saveNoteInfo.hide()
} else {
saveNoteInfo.visibility = View.INVISIBLE
}
}
@ -311,7 +314,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
* Subscribe to data loaded at the view model
*/
private fun subscribeObservables() {
viewModel.accountData.observe(this, Observer {
viewModel.accountData.observe(this) {
when (it) {
is Success -> onAccountChanged(it.data)
is Error -> {
@ -320,8 +323,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
.show()
}
}
})
viewModel.relationshipData.observe(this, Observer {
}
viewModel.relationshipData.observe(this) {
val relation = it?.data
if (relation != null) {
onRelationshipChanged(relation)
@ -333,12 +336,14 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
.show()
}
})
viewModel.accountFieldData.observe(this, Observer {
}
viewModel.accountFieldData.observe(this, {
accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged()
})
viewModel.noteSaved.observe(this) {
saveNoteInfo.visible(it, View.INVISIBLE)
}
}
/**
@ -349,7 +354,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewModel.refresh()
adapter.refreshContent()
}
viewModel.isRefreshing.observe(this, Observer { isRefreshing ->
viewModel.isRefreshing.observe(this, { isRefreshing ->
swipeToRefreshLayout.isRefreshing = isRefreshing == true
})
swipeToRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
@ -407,7 +412,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountAvatarImageView.setOnClickListener { avatarView ->
val intent = ViewMediaActivity.newAvatarIntent(avatarView.context, account.avatar)
val intent = ViewMediaActivity.newSingleImageIntent(avatarView.context, account.avatar)
avatarView.transitionName = account.avatar
val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, avatarView, account.avatar)
@ -533,9 +538,22 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountFollowsYouTextView.visible(relation.followedBy)
accountNoteTextInputLayout.visible(relation.note != null)
accountNoteTextInputLayout.editText?.setText(relation.note)
// add the listener late to avoid it firing on the first change
accountNoteTextInputLayout.editText?.removeTextChangedListener(noteWatcher)
accountNoteTextInputLayout.editText?.addTextChangedListener(noteWatcher)
updateButtons()
}
private val noteWatcher = object: DefaultTextWatcher() {
override fun afterTextChanged(s: Editable) {
viewModel.noteChanged(s.toString())
}
}
private fun updateFollowButton() {
if (viewModel.isSelf) {
accountFollowButton.setText(R.string.action_edit_own_profile)
@ -705,9 +723,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
loadedAccount?.let {
showMuteAccountDialog(
this,
it.username,
{ notifications -> viewModel.muteAccount(notifications) }
)
it.username
) { notifications ->
viewModel.muteAccount(notifications)
}
}
} else {
viewModel.unmuteAccount()

View File

@ -30,8 +30,8 @@ import android.widget.ImageView
import androidx.activity.viewModels
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.FitCenter
@ -40,8 +40,6 @@ import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import com.mikepenz.iconics.IconicsDrawable
@ -112,7 +110,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
addFieldButton.setOnClickListener {
accountFieldEditAdapter.addField()
if(accountFieldEditAdapter.itemCount >= MAX_ACCOUNT_FIELDS) {
it.isEnabled = false
it.isVisible = false
}
scrollView.post{
@ -122,7 +120,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
viewModel.obtainProfile()
viewModel.profileData.observe(this, Observer<Resource<Account>> { profileRes ->
viewModel.profileData.observe(this) { profileRes ->
when (profileRes) {
is Success -> {
val me = profileRes.data
@ -163,10 +161,10 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
})
}
viewModel.obtainInstance()
viewModel.instanceData.observe(this, Observer<Resource<Instance>> { result ->
viewModel.instanceData.observe(this) { result ->
when (result) {
is Success -> {
val instance = result.data
@ -175,12 +173,12 @@ class EditProfileActivity : BaseActivity(), Injectable {
}
}
}
})
}
observeImage(viewModel.avatarData, avatarPreview, avatarProgressBar, true)
observeImage(viewModel.headerData, headerPreview, headerProgressBar, false)
viewModel.saveData.observe(this, Observer<Resource<Nothing>> {
viewModel.saveData.observe(this, {
when(it) {
is Success -> {
finish()
@ -215,7 +213,7 @@ class EditProfileActivity : BaseActivity(), Injectable {
imageView: ImageView,
progressBar: View,
roundedCorners: Boolean) {
liveData.observe(this, Observer<Resource<Bitmap>> {
liveData.observe(this, {
when (it) {
is Success -> {

View File

@ -1,291 +0,0 @@
package com.keylesspalace.tusky;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.util.AttributeSet;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.RadioButton;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AlertDialog;
import androidx.preference.Preference;
import androidx.preference.PreferenceManager;
import com.keylesspalace.tusky.util.EmojiCompatFont;
import java.util.ArrayList;
/**
* This Preference lets the user select their preferred emoji font
*/
public class EmojiPreference extends Preference {
private static final String TAG = "EmojiPreference";
private EmojiCompatFont selected, original;
static final String FONT_PREFERENCE = "selected_emoji_font";
private static final EmojiCompatFont[] FONTS = EmojiCompatFont.FONTS;
// Please note that this array should be sorted in the same way as their fonts.
private static final int[] viewIds = {
R.id.item_nomoji,
R.id.item_blobmoji,
R.id.item_twemoji,
R.id.item_notoemoji};
private ArrayList<RadioButton> radioButtons = new ArrayList<>();
private boolean updated, currentNeedsUpdate;
public EmojiPreference(Context context) {
super(context);
// Find out which font is currently active
this.selected = EmojiCompatFont.byId(PreferenceManager
.getDefaultSharedPreferences(context)
.getInt(FONT_PREFERENCE, 0));
// We'll use this later to determine if anything has changed
this.original = this.selected;
setSummary(selected.getDisplay(context));
}
public EmojiPreference(Context context, AttributeSet attrs) {
super(context, attrs);
// Find out which font is currently active
this.selected = EmojiCompatFont.byId(PreferenceManager
.getDefaultSharedPreferences(context)
.getInt(FONT_PREFERENCE, 0));
// We'll use this later to determine if anything has changed
this.original = this.selected;
setSummary(selected.getDisplay(context));
}
@Override
protected void onClick() {
View view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_emojicompat, null);
for (int i = 0; i < viewIds.length; i++) {
setupItem(view.findViewById(viewIds[i]), FONTS[i]);
}
new AlertDialog.Builder(getContext())
.setView(view)
.setPositiveButton(android.R.string.ok, (dialog, which) -> onDialogOk())
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void setupItem(View container, EmojiCompatFont font) {
Context context = container.getContext();
TextView title = container.findViewById(R.id.emojicompat_name);
TextView caption = container.findViewById(R.id.emojicompat_caption);
ImageView thumb = container.findViewById(R.id.emojicompat_thumb);
ImageButton download = container.findViewById(R.id.emojicompat_download);
ImageButton cancel = container.findViewById(R.id.emojicompat_download_cancel);
RadioButton radio = container.findViewById(R.id.emojicompat_radio);
// Initialize all the views
title.setText(font.getDisplay(context));
caption.setText(font.getCaption(context));
thumb.setImageDrawable(font.getThumb(context));
// There needs to be a list of all the radio buttons in order to uncheck them when one is selected
radioButtons.add(radio);
updateItem(font, container);
// Set actions
download.setOnClickListener((downloadButton) ->
startDownload(font, container));
cancel.setOnClickListener((cancelButton) ->
cancelDownload(font, container));
radio.setOnClickListener((radioButton) ->
select(font, (RadioButton) radioButton));
container.setOnClickListener((containterView) ->
select(font,
containterView.findViewById(R.id.emojicompat_radio
)));
}
private void startDownload(EmojiCompatFont font, View container) {
ImageButton download = container.findViewById(R.id.emojicompat_download);
TextView caption = container.findViewById(R.id.emojicompat_caption);
ProgressBar progressBar = container.findViewById(R.id.emojicompat_progress);
ImageButton cancel = container.findViewById(R.id.emojicompat_download_cancel);
// Switch to downloading style
download.setVisibility(View.GONE);
caption.setVisibility(View.INVISIBLE);
progressBar.setVisibility(View.VISIBLE);
cancel.setVisibility(View.VISIBLE);
font.downloadFont(getContext(), new EmojiCompatFont.Downloader.EmojiDownloadListener() {
@Override
public void onDownloaded(EmojiCompatFont font) {
finishDownload(font, container);
}
@Override
public void onProgress(float progress) {
// The progress is returned as a float between 0 and 1
progress *= progressBar.getMax();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
progressBar.setProgress((int) progress, true);
} else {
progressBar.setProgress((int) progress);
}
}
@Override
public void onFailed() {
Toast.makeText(getContext(), R.string.download_failed, Toast.LENGTH_SHORT).show();
updateItem(font, container);
}
});
}
private void cancelDownload(EmojiCompatFont font, View container) {
font.cancelDownload();
updateItem(font, container);
}
private void finishDownload(EmojiCompatFont font, View container) {
select(font, container.findViewById(R.id.emojicompat_radio));
updateItem(font, container);
// Set the flag to restart the app (because an update has been downloaded)
if (selected == original && currentNeedsUpdate) {
updated = true;
currentNeedsUpdate = false;
}
}
/**
* Select a font both visually and logically
*
* @param font The font to be selected
* @param radio The radio button associated with it's visual item
*/
private void select(EmojiCompatFont font, RadioButton radio) {
selected = font;
// Uncheck all the other buttons
for (RadioButton other : radioButtons) {
if (other != radio) {
other.setChecked(false);
}
}
radio.setChecked(true);
}
/**
* Called when a "consistent" state is reached, i.e. it's not downloading the font
*
* @param font The font to be displayed
* @param container The ConstraintLayout containing the item
*/
private void updateItem(EmojiCompatFont font, View container) {
// Assignments
ImageButton download = container.findViewById(R.id.emojicompat_download);
TextView caption = container.findViewById(R.id.emojicompat_caption);
ProgressBar progress = container.findViewById(R.id.emojicompat_progress);
ImageButton cancel = container.findViewById(R.id.emojicompat_download_cancel);
RadioButton radio = container.findViewById(R.id.emojicompat_radio);
// There's no download going on
progress.setVisibility(View.GONE);
cancel.setVisibility(View.GONE);
caption.setVisibility(View.VISIBLE);
if (font.isDownloaded(getContext())) {
// Make it selectable
download.setVisibility(View.GONE);
radio.setVisibility(View.VISIBLE);
container.setClickable(true);
} else {
// Make it downloadable
download.setVisibility(View.VISIBLE);
radio.setVisibility(View.GONE);
container.setClickable(false);
}
// Select it if necessary
if (font == selected) {
radio.setChecked(true);
// Update available
if (!font.isDownloaded(getContext())) {
currentNeedsUpdate = true;
}
} else {
radio.setChecked(false);
}
}
/**
* In order to be able to use this font later on, it needs to be saved first.
*/
private void saveSelectedFont() {
int index = selected.getId();
Log.i(TAG, "saveSelectedFont: Font ID: " + index);
// It's saved using the key FONT_PREFERENCE
PreferenceManager
.getDefaultSharedPreferences(getContext())
.edit()
.putInt(FONT_PREFERENCE, index)
.apply();
setSummary(selected.getDisplay(getContext()));
}
/**
* That's it. The user doesn't want to switch between these amazing radio buttons anymore!
* That means, the selected font can be saved (if the user hit OK)
*/
private void onDialogOk() {
saveSelectedFont();
if (selected != original || updated) {
new AlertDialog.Builder(getContext())
.setTitle(R.string.restart_required)
.setMessage(R.string.restart_emoji)
.setNegativeButton(R.string.later, null)
.setPositiveButton(R.string.restart, ((dialog, which) -> {
// Restart the app
// From https://stackoverflow.com/a/17166729/5070653
Intent launchIntent = new Intent(getContext(), SplashActivity.class);
PendingIntent mPendingIntent = PendingIntent.getActivity(
getContext(),
// This is the codepoint of the party face emoji :D
0x1f973,
launchIntent,
PendingIntent.FLAG_CANCEL_CURRENT);
AlarmManager mgr =
(AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE);
if (mgr != null) {
mgr.set(
AlarmManager.RTC,
System.currentTimeMillis() + 100,
mPendingIntent);
}
System.exit(0);
})).show();
}
}
}

View File

@ -41,10 +41,8 @@ import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event.*
import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.*
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.color
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import com.mikepenz.iconics.utils.toIconicsColor
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDispose
import dagger.android.DispatchingAndroidInjector

View File

@ -19,7 +19,6 @@ import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.net.Uri
@ -29,7 +28,6 @@ import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
@ -40,16 +38,22 @@ import androidx.lifecycle.Lifecycle
import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.MarginPageTransformer
import com.bumptech.glide.Glide
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.FixedSizeDrawable
import com.bumptech.glide.request.transition.Transition
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.canHandleMimeType
import com.keylesspalace.tusky.components.conversation.ConversationsRepository
import com.keylesspalace.tusky.components.notifications.NotificationHelper
import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.db.AccountEntity
@ -65,6 +69,9 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import com.mikepenz.materialdrawer.holder.BadgeStyle
import com.mikepenz.materialdrawer.holder.ColorHolder
import com.mikepenz.materialdrawer.holder.StringHolder
import com.mikepenz.materialdrawer.iconics.iconicsIcon
import com.mikepenz.materialdrawer.model.*
import com.mikepenz.materialdrawer.model.interfaces.*
@ -74,6 +81,7 @@ import com.uber.autodispose.android.lifecycle.autoDispose
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import kotlinx.android.synthetic.main.activity_main.*
import javax.inject.Inject
@ -91,13 +99,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
lateinit var conversationRepository: ConversationsRepository
private lateinit var header: AccountHeaderView
private lateinit var drawerToggle: ActionBarDrawerToggle
private var notificationTabPosition = 0
private var onTabSelectedListener: OnTabSelectedListener? = null
private var unreadAnnouncementsCount = 0
private val preferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
private lateinit var glide: RequestManager
private val emojiInitCallback = object : InitCallback() {
override fun onInitialized() {
if (!isDestroyed) {
@ -108,7 +119,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (accountManager.activeAccount == null) {
val activeAccount = accountManager.activeAccount
if (activeAccount == null) {
// will be redirected to LoginActivity by BaseActivity
return
}
@ -161,6 +174,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own
setContentView(R.layout.activity_main)
glide = Glide.with(this)
composeButton.setOnClickListener {
val composeIntent = Intent(applicationContext, ComposeActivity::class.java)
startActivity(composeIntent)
@ -169,6 +185,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
mainToolbar.visible(!hideTopToolbar)
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
mainToolbar.menu.add(R.string.action_search).apply {
setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
icon = IconicsDrawable(this@MainActivity, GoogleMaterial.Icon.gmd_search).apply {
@ -187,6 +205,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
* drawer, though, because its callback touches the header in the drawer. */
fetchUserInfo()
fetchAnnouncements()
setupTabs(showNotificationTab)
// Setup push notifications
@ -202,11 +222,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
when (event) {
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
is MainTabsChangedEvent -> setupTabs(false)
is AnnouncementReadEvent -> {
unreadAnnouncementsCount--
updateAnnouncementsBadge()
}
}
}
// Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
Schedulers.io().scheduleDirect {
// Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
}
}
override fun onResume() {
@ -261,8 +287,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
public override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
drawerToggle.syncState()
if (intent != null) {
val statusUrl = intent.getStringExtra(STATUS_URL)
if (statusUrl != null) {
@ -288,7 +312,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) {
drawerToggle = ActionBarDrawerToggle(this, mainDrawerLayout, mainToolbar, com.mikepenz.materialdrawer.R.string.material_drawer_open, com.mikepenz.materialdrawer.R.string.material_drawer_close)
mainToolbar.setNavigationOnClickListener {
mainDrawerLayout.open()
}
header = AccountHeaderView(this).apply {
headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP
@ -312,13 +338,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
DrawerImageLoader.init(object : AbstractDrawerImageLoader() {
override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) {
if (animateAvatars) {
Glide.with(imageView.context)
.load(uri)
glide.load(uri)
.placeholder(placeholder)
.into(imageView)
} else {
Glide.with(imageView.context)
.asBitmap()
glide.asBitmap()
.load(uri)
.placeholder(placeholder)
.into(imageView)
@ -326,7 +350,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
override fun cancel(imageView: ImageView) {
Glide.with(imageView.context).clear(imageView)
glide.clear(imageView)
}
override fun placeholder(ctx: Context, tag: String?): Drawable {
@ -388,6 +412,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
startActivityWithSlideInAnimation(ScheduledTootActivity.newIntent(context))
}
},
primaryDrawerItem {
identifier = DRAWER_ITEM_ANNOUNCEMENTS
nameRes = R.string.title_announcements
iconRes = R.drawable.ic_bullhorn_24dp
onClick = {
startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context))
}
badgeStyle = BadgeStyle().apply {
textColor = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorOnPrimary))
color = ColorHolder.fromColor(ThemeUtils.getColor(this@MainActivity, R.attr.colorPrimary))
}
},
DividerDrawerItem(),
secondaryDrawerItem {
nameRes = R.string.action_view_account_preferences
@ -420,7 +456,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
)
if(addSearchButton) {
if (addSearchButton) {
mainDrawer.addItemsAtPosition(4,
primaryDrawerItem {
nameRes = R.string.action_search
@ -446,25 +482,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
EmojiCompat.get().registerInitCallback(emojiInitCallback)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
drawerToggle.onConfigurationChanged(newConfig)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (drawerToggle.onOptionsItemSelected(item)) {
return true
}
return super.onOptionsItemSelected(item)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(mainDrawer.saveInstanceState(outState))
}
private fun setupTabs(selectNotificationTab: Boolean) {
val activeTabLayout = if(preferences.getString("mainNavPosition", "top") == "bottom") {
val activeTabLayout = if (preferences.getString("mainNavPosition", "top") == "bottom") {
val actionBarSize = ThemeUtils.getDimension(this, R.attr.actionBarSize)
val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
(composeButton.layoutParams as CoordinatorLayout.LayoutParams).bottomMargin = actionBarSize + fabMargin
@ -481,7 +505,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
val adapter = MainPagerAdapter(tabs, this)
viewPager.adapter = adapter
TabLayoutMediator(activeTabLayout, viewPager, TabConfigurationStrategy { _: TabLayout.Tab?, _: Int -> }).attach()
TabLayoutMediator(activeTabLayout, viewPager) { _: TabLayout.Tab?, _: Int -> }.attach()
activeTabLayout.removeAllTabs()
for (i in tabs.indices) {
val tab = activeTabLayout.newTab()
@ -614,11 +638,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
private fun onFetchUserInfoSuccess(me: Account) {
Glide.with(this)
.asBitmap()
glide.asBitmap()
.load(me.header)
.into(header.accountHeaderBackground)
loadDrawerAvatar(me.avatar, false)
accountManager.updateActiveAccount(me)
NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this)
@ -642,6 +667,55 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
updateShortcut(this, accountManager.activeAccount!!)
}
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size)
glide.asDrawable()
.load(avatarUrl)
.transform(
RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp))
)
.apply {
if (showPlaceholder) {
placeholder(R.drawable.avatar_default)
}
}
.into(object : CustomTarget<Drawable>(navIconSize, navIconSize) {
override fun onLoadStarted(placeholder: Drawable?) {
if(placeholder != null) {
mainToolbar.navigationIcon = FixedSizeDrawable(placeholder, navIconSize, navIconSize)
}
}
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
mainToolbar.navigationIcon = resource
}
override fun onLoadCleared(placeholder: Drawable?) {
mainToolbar.navigationIcon = placeholder
}
})
}
private fun fetchAnnouncements() {
mastodonApi.listAnnouncements(false)
.observeOn(AndroidSchedulers.mainThread())
.autoDispose(this, Lifecycle.Event.ON_DESTROY)
.subscribe(
{ announcements ->
unreadAnnouncementsCount = announcements.count { !it.read }
updateAnnouncementsBadge()
},
{
Log.w(TAG, "Failed to fetch announcements.", it)
}
)
}
private fun updateAnnouncementsBadge() {
mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount == 0) null else unreadAnnouncementsCount.toString()))
}
private fun updateProfiles() {
val profiles: MutableList<IProfile> = accountManager.getAllAccountsOrderedByActive().map { acc ->
val emojifiedName = EmojiCompat.get().process(acc.displayName.emojify(acc.emojis, header))
@ -676,6 +750,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private const val TAG = "MainActivity" // logging tag
private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13
private const val DRAWER_ITEM_FOLLOW_REQUESTS: Long = 10
private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14
const val STATUS_URL = "statusUrl"
}
}
@ -705,4 +780,4 @@ private var AbstractDrawerItem<*, *>.onClick: () -> Unit
value()
false
}
}
}

View File

@ -164,6 +164,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
List<String> descriptions = gson.fromJson(item.getDescriptions(), stringListType);
ComposeOptions composeOptions = new ComposeOptions(
/*scheduledTootUid*/null,
item.getUid(),
item.getText(),
jsonUrls,
@ -178,7 +179,8 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
/*mediaAttachments*/null,
/*scheduledAt*/null,
/*sensitive*/null,
/*poll*/null
/*poll*/null,
/* modifiedInitialState */ true
);
Intent intent = ComposeActivity.startIntent(this, composeOptions);
startActivity(intent);

View File

@ -15,19 +15,25 @@
package com.keylesspalace.tusky
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.widget.FrameLayout
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
import at.connyduck.sparkbutton.helpers.Utils
import com.google.android.material.transition.MaterialArcMotion
import com.google.android.material.transition.MaterialContainerTransform
import com.keylesspalace.tusky.adapter.ItemInteractionListener
import com.keylesspalace.tusky.adapter.ListSelectionAdapter
import com.keylesspalace.tusky.adapter.TabAdapter
@ -78,7 +84,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
setDisplayShowHomeEnabled(true)
}
currentTabs = (accountManager.activeAccount?.tabPreferences ?: emptyList()).toMutableList()
currentTabs = accountManager.activeAccount?.tabPreferences.orEmpty().toMutableList()
currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT)
currentTabsRecyclerView.adapter = currentTabsAdapter
currentTabsRecyclerView.layoutManager = LinearLayoutManager(this)
@ -129,19 +135,17 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
touchHelper.attachToRecyclerView(currentTabsRecyclerView)
actionButton.setOnClickListener {
actionButton.isExpanded = true
toggleFab(true)
}
scrim.setOnClickListener {
actionButton.isExpanded = false
toggleFab(false)
}
maxTabsInfo.text = getString(R.string.max_tab_number_reached, MAX_TAB_COUNT)
updateAvailableTabs()
}
override fun onTabAdded(tab: TabData) {
@ -150,7 +154,7 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
return
}
actionButton.isExpanded = false
toggleFab(false)
if (tab.id == HASHTAG) {
showAddHashtagDialog()
@ -175,20 +179,36 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
saveTabs()
}
override fun onActionChipClicked(tab: TabData) {
showAddHashtagDialog(tab)
override fun onActionChipClicked(tab: TabData, tabPosition: Int) {
showAddHashtagDialog(tab, tabPosition)
}
override fun onChipClicked(tab: TabData, chipPosition: Int) {
override fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int) {
val newArguments = tab.arguments.filterIndexed { i, _ -> i != chipPosition }
val newTab = tab.copy(arguments = newArguments)
val position = currentTabs.indexOf(tab)
currentTabs[position] = newTab
currentTabs[tabPosition] = newTab
saveTabs()
currentTabsAdapter.notifyItemChanged(position)
currentTabsAdapter.notifyItemChanged(tabPosition)
}
private fun showAddHashtagDialog(tab: TabData? = null) {
private fun toggleFab(expand: Boolean) {
val transition = MaterialContainerTransform().apply {
startView = if (expand) actionButton else sheet
val endView: View = if (expand) sheet else actionButton
this.endView = endView
addTarget(endView)
scrimColor = Color.TRANSPARENT
setPathMotion(MaterialArcMotion())
}
TransitionManager.beginDelayedTransition(tabPreferenceContainer, transition)
actionButton.visible(!expand)
sheet.visible(expand)
scrim.visible(expand)
}
private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) {
val frameLayout = FrameLayout(this)
val padding = Utils.dpToPx(this, 8)
@ -211,10 +231,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
} else {
val newTab = tab.copy(arguments = tab.arguments + input)
val position = currentTabs.indexOf(tab)
currentTabs[position] = newTab
currentTabs[tabPosition] = newTab
currentTabsAdapter.notifyItemChanged(position)
currentTabsAdapter.notifyItemChanged(tabPosition)
}
updateAvailableTabs()
@ -319,10 +338,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
}
override fun onBackPressed() {
if (actionButton.isExpanded) {
actionButton.isExpanded = false
} else {
if (actionButton.isVisible) {
super.onBackPressed()
} else {
toggleFab(false)
}
}

View File

@ -24,11 +24,16 @@ import androidx.preference.PreferenceManager
import androidx.work.WorkManager
import com.keylesspalace.tusky.components.notifications.NotificationWorkerFactory
import com.keylesspalace.tusky.di.AppInjector
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.EmojiCompatFont
import com.keylesspalace.tusky.util.LocaleManager
import com.keylesspalace.tusky.util.ThemeUtils
import com.uber.autodispose.AutoDisposePlugins
import dagger.Lazy
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import io.reactivex.plugins.RxJavaPlugins
import io.reactivex.schedulers.Schedulers
import org.conscrypt.Conscrypt
import java.security.Security
import javax.inject.Inject
@ -37,11 +42,21 @@ class TuskyApplication : Application(), HasAndroidInjector {
@Inject
lateinit var androidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var notificationWorkerFactory: NotificationWorkerFactory
lateinit var notificationWorkerFactory: Lazy<NotificationWorkerFactory>
override fun onCreate() {
// Uncomment me to get StrictMode violation logs
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
// StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
// .detectDiskReads()
// .detectDiskWrites()
// .detectNetwork()
// .detectUnbufferedIo()
// .penaltyLog()
// .build())
// }
super.onCreate()
Security.insertProviderAt(Conscrypt.newProvider(), 1)
@ -53,7 +68,7 @@ class TuskyApplication : Application(), HasAndroidInjector {
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
// init the custom emoji fonts
val emojiSelection = preferences.getInt(EmojiPreference.FONT_PREFERENCE, 0)
val emojiSelection = preferences.getInt(PrefKeys.EMOJI, 0)
val emojiConfig = EmojiCompatFont.byId(emojiSelection)
.getConfig(this)
.setReplaceAll(true)
@ -63,16 +78,19 @@ class TuskyApplication : Application(), HasAndroidInjector {
val theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT)
ThemeUtils.setAppNightMode(theme)
WorkManager.initialize(
this,
androidx.work.Configuration.Builder()
.setWorkerFactory(notificationWorkerFactory)
.build()
)
RxJavaPlugins.setErrorHandler {
Log.w("RxJava", "undeliverable exception", it)
}
// This will initialize the whole network stack and cache so we don't wan to wait for it
Schedulers.computation().scheduleDirect {
WorkManager.initialize(
this,
androidx.work.Configuration.Builder()
.setWorkerFactory(notificationWorkerFactory.get())
.build()
)
}
}
override fun attachBaseContext(base: Context) {

View File

@ -46,7 +46,7 @@ import com.bumptech.glide.request.FutureTarget
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.fragment.ViewImageFragment
import com.keylesspalace.tusky.pager.AvatarImagePagerAdapter
import com.keylesspalace.tusky.pager.SingleImagePagerAdapter
import com.keylesspalace.tusky.pager.ImagePagerAdapter
import com.keylesspalace.tusky.util.getTemporaryMediaFilename
import com.keylesspalace.tusky.viewdata.AttachmentViewData
@ -68,7 +68,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
companion object {
private const val EXTRA_ATTACHMENTS = "attachments"
private const val EXTRA_ATTACHMENT_INDEX = "index"
private const val EXTRA_AVATAR_URL = "avatar"
private const val EXTRA_SINGLE_IMAGE_URL = "single_image"
private const val TAG = "ViewMediaActivity"
@JvmStatic
@ -79,9 +79,10 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
return intent
}
fun newAvatarIntent(context: Context, url: String): Intent {
@JvmStatic
fun newSingleImageIntent(context: Context, url: String): Intent {
val intent = Intent(context, ViewMediaActivity::class.java)
intent.putExtra(EXTRA_AVATAR_URL, url)
intent.putExtra(EXTRA_SINGLE_IMAGE_URL, url)
return intent
}
}
@ -91,6 +92,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
private var attachments: ArrayList<AttachmentViewData>? = null
private val toolbarVisibilityListeners = mutableListOf<ToolbarVisibilityListener>()
private var imageUrl: String? = null
fun addToolbarVisibilityListener(listener: ToolbarVisibilityListener): Function0<Boolean> {
this.toolbarVisibilityListeners.add(listener)
@ -117,10 +119,10 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
ImagePagerAdapter(this, realAttachs, initialPosition)
} else {
val avatarUrl = intent.getStringExtra(EXTRA_AVATAR_URL)
?: throw IllegalArgumentException("attachment list or avatar url has to be set")
imageUrl = intent.getStringExtra(EXTRA_SINGLE_IMAGE_URL)
?: throw IllegalArgumentException("attachment list or image url has to be set")
AvatarImagePagerAdapter(this, avatarUrl)
SingleImagePagerAdapter(this, imageUrl!!)
}
viewPager.adapter = adapter
@ -161,11 +163,10 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
if (attachments != null) {
menuInflater.inflate(R.menu.view_media_toolbar, menu)
return true
}
return false
menuInflater.inflate(R.menu.view_media_toolbar, menu)
// We don't support 'open status' from single image views
menu?.findItem(R.id.action_open_status)?.isVisible = (attachments != null)
return true
}
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
@ -213,7 +214,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
}
private fun downloadMedia() {
val url = attachments!![viewPager.currentItem].attachment.url
val url = imageUrl ?: attachments!![viewPager.currentItem].attachment.url
val filename = Uri.parse(url).lastPathSegment
Toast.makeText(applicationContext, resources.getString(R.string.download_image, filename), Toast.LENGTH_SHORT).show()
@ -240,8 +241,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
}
private fun copyLink() {
val url = imageUrl ?: attachments!![viewPager.currentItem].attachment.url
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(null, attachments!![viewPager.currentItem].attachment.url))
clipboard.setPrimaryClip(ClipData.newPlainText(null, url))
}
private fun shareMedia() {
@ -251,13 +253,17 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
return
}
val attachment = attachments!![viewPager.currentItem].attachment
when (attachment.type) {
Attachment.Type.IMAGE -> shareImage(directory, attachment.url)
Attachment.Type.AUDIO,
Attachment.Type.VIDEO,
Attachment.Type.GIFV -> shareMediaFile(directory, attachment.url)
else -> Log.e(TAG, "Unknown media format for sharing.")
if (imageUrl != null) {
shareImage(directory, imageUrl!!)
} else {
val attachment = attachments!![viewPager.currentItem].attachment
when (attachment.type) {
Attachment.Type.IMAGE -> shareImage(directory, attachment.url)
Attachment.Type.AUDIO,
Attachment.Type.VIDEO,
Attachment.Type.GIFV -> shareMediaFile(directory, attachment.url)
else -> Log.e(TAG, "Unknown media format for sharing.")
}
}
}

View File

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

View File

@ -53,7 +53,7 @@ public class FollowRequestsAdapter extends AccountAdapter {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
if (getItemViewType(position) == VIEW_TYPE_ACCOUNT) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(accountList.get(position), null);
holder.setupWithAccount(accountList.get(position));
holder.setupActionListener(accountActionListener);
}
}

View File

@ -35,7 +35,6 @@ import android.widget.TextView;
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;
@ -51,6 +50,7 @@ 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.StringUtils;
import com.keylesspalace.tusky.util.TimestampUtils;
import com.keylesspalace.tusky.viewdata.NotificationViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData;
@ -86,7 +86,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
private StatusActionListener statusListener;
private NotificationActionListener notificationActionListener;
private AccountActionListener accountActionListener;
private BidiFormatter bidiFormatter;
private AdapterDataSource<NotificationViewData> dataSource;
public NotificationsAdapter(String accountId,
@ -102,7 +101,6 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
this.statusListener = statusListener;
this.notificationActionListener = notificationActionListener;
this.accountActionListener = accountActionListener;
bidiFormatter = BidiFormatter.getInstance();
}
@NonNull
@ -204,7 +202,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
concreteNotificaton.getAccount().getAvatar());
}
holder.setMessage(concreteNotificaton, statusListener, bidiFormatter);
holder.setMessage(concreteNotificaton, statusListener);
holder.setupButtons(notificationActionListener,
concreteNotificaton.getAccount().getId(),
concreteNotificaton.getId());
@ -221,7 +219,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case VIEW_TYPE_FOLLOW: {
if (payloadForHolder == null) {
FollowViewHolder holder = (FollowViewHolder) viewHolder;
holder.setMessage(concreteNotificaton.getAccount(), bidiFormatter);
holder.setMessage(concreteNotificaton.getAccount());
holder.setupButtons(notificationActionListener, concreteNotificaton.getAccount().getId());
}
break;
@ -229,7 +227,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
case VIEW_TYPE_FOLLOW_REQUEST: {
if (payloadForHolder == null) {
FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder;
holder.setupWithAccount(concreteNotificaton.getAccount(), bidiFormatter);
holder.setupWithAccount(concreteNotificaton.getAccount());
holder.setupActionListener(accountActionListener);
}
}
@ -325,11 +323,11 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
this.statusDisplayOptions = statusDisplayOptions;
}
void setMessage(Account account, BidiFormatter bidiFormatter) {
void setMessage(Account account) {
Context context = message.getContext();
String format = context.getString(R.string.notification_follow_format);
String wrappedDisplayName = bidiFormatter.unicodeWrap(account.getName());
String wrappedDisplayName = StringUtils.unicodeWrap(account.getName());
String wholeMessage = String.format(format, wrappedDisplayName);
CharSequence emojifiedMessage = CustomEmojiHelper.emojify(wholeMessage, account.getEmojis(), message);
message.setText(emojifiedMessage);
@ -459,10 +457,10 @@ public class NotificationsAdapter extends RecyclerView.Adapter {
}
}
void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener, BidiFormatter bidiFormatter) {
void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) {
this.statusViewData = notificationViewData.getStatusViewData();
String displayName = bidiFormatter.unicodeWrap(notificationViewData.getAccount().getName());
String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName());
Notification.Type type = notificationViewData.getType();
Context context = message.getContext();

View File

@ -37,18 +37,21 @@ class PollAdapter: RecyclerView.Adapter<PollViewHolder>() {
private var votersCount: Int? = null
private var mode = RESULT
private var emojis: List<Emoji> = emptyList()
private var resultClickListener: View.OnClickListener? = null
fun setup(
options: List<PollOptionViewData>,
voteCount: Int,
votersCount: Int?,
emojis: List<Emoji>,
mode: Int) {
mode: Int,
resultClickListener: View.OnClickListener?) {
this.pollOptions = options
this.voteCount = voteCount
this.votersCount = votersCount
this.emojis = emojis
this.mode = mode
this.resultClickListener = resultClickListener
notifyDataSetChanged()
}
@ -84,7 +87,7 @@ class PollAdapter: RecyclerView.Adapter<PollViewHolder>() {
val level = percent * 100
holder.resultTextView.background.level = level
holder.resultTextView.setOnClickListener(resultClickListener)
}
SINGLE -> {
val emojifiedPollOptionText = option.title.emojify(emojis, holder.radioButton)

View File

@ -26,10 +26,12 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners;
import com.google.android.material.button.MaterialButton;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Attachment.Focus;
import com.keylesspalace.tusky.entity.Attachment.MetaData;
@ -615,7 +617,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
final String accountId,
final String statusContent,
StatusDisplayOptions statusDisplayOptions) {
avatar.setOnClickListener(v -> listener.onViewAccount(accountId));
View.OnClickListener profileButtonClickListener = button -> {
listener.onViewAccount(accountId);
};
avatar.setOnClickListener(profileButtonClickListener);
displayName.setOnClickListener(profileButtonClickListener);
replyButton.setOnClickListener(v -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
@ -734,7 +742,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
if (cardView != null) {
setupCard(status, statusDisplayOptions.cardViewMode());
setupCard(status, statusDisplayOptions.cardViewMode(), statusDisplayOptions);
}
setupButtons(listener, status.getSenderId(), status.getContent().toString(),
@ -915,12 +923,18 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (expired || poll.getVoted()) {
// no voting possible
pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, PollAdapter.RESULT);
View.OnClickListener viewThreadListener = v -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onViewThread(position);
}
};
pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, PollAdapter.RESULT, viewThreadListener);
pollButton.setVisibility(View.GONE);
} else {
// voting possible
pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE);
pollAdapter.setup(poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE, null);
pollButton.setVisibility(View.VISIBLE);
@ -964,15 +978,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (statusDisplayOptions.useAbsoluteTime()) {
pollDurationInfo = context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.getExpiresAt()));
} else {
String pollDuration = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp);
pollDurationInfo = context.getString(R.string.poll_info_time_relative, pollDuration);
pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp);
}
}
return pollDescription.getContext().getString(R.string.poll_info_format, votesText, pollDurationInfo);
}
protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode) {
protected void setupCard(StatusViewData.Concrete status, CardViewMode cardViewMode, StatusDisplayOptions statusDisplayOptions) {
if (cardViewMode != CardViewMode.NONE &&
status.getAttachments().size() == 0 &&
status.getCard() != null &&
@ -994,7 +1007,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardUrl.setText(card.getUrl());
if (!TextUtils.isEmpty(card.getImage())) {
// Statuses from other activitypub sources can be marked sensitive even if there's no media,
// so let's blur the preview in that case
// If media previews are disabled, show placeholder for cards as well
if (statusDisplayOptions.mediaPreviewEnabled() && !status.isSensitive() && !TextUtils.isEmpty(card.getImage())) {
int topLeftRadius = 0;
int topRightRadius = 0;
@ -1025,12 +1041,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
bottomLeftRadius = radius;
}
RequestBuilder<Drawable> builder = Glide.with(cardImage).load(card.getImage());
if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
builder = builder.placeholder(decodeBlurHash(card.getBlurhash()));
}
builder.transform(
new CenterCrop(),
new GranularRoundedCorners(topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius)
)
.into(cardImage);
} else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) {
int radius = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_radius);
Glide.with(cardImage)
.load(card.getImage())
cardView.setOrientation(LinearLayout.HORIZONTAL);
cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT;
cardImage.getLayoutParams().width = cardImage.getContext().getResources()
.getDimensionPixelSize(R.dimen.card_image_horizontal_width);
cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT;
Glide.with(cardImage).load(decodeBlurHash(card.getBlurhash()))
.transform(
new CenterCrop(),
new GranularRoundedCorners(topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius)
new GranularRoundedCorners(radius, 0, 0, radius)
)
.into(cardImage);
} else {
@ -1043,7 +1076,15 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
cardImage.setImageResource(R.drawable.card_image_placeholder);
}
cardView.setOnClickListener(v -> LinkHelper.openLink(card.getUrl(), v.getContext()));
View.OnClickListener visitLink = v -> LinkHelper.openLink(card.getUrl(), v.getContext());
View.OnClickListener openImage = v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbed_url()));
cardInfo.setOnClickListener(visitLink);
// View embedded photos in our image viewer instead of opening the browser
cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbed_url()) ?
openImage :
visitLink);
cardView.setClipToOutline(true);
} else {
cardView.setVisibility(View.GONE);

View File

@ -106,7 +106,7 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
StatusDisplayOptions statusDisplayOptions,
@Nullable Object payloads) {
super.setupWithStatus(status, listener, statusDisplayOptions, payloads);
setupCard(status, CardViewMode.FULL_WIDTH); // Always show card for detailed status
setupCard(status, CardViewMode.FULL_WIDTH, statusDisplayOptions); // Always show card for detailed status
if (payloads == null) {
setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener);

View File

@ -36,8 +36,8 @@ interface ItemInteractionListener {
fun onTabRemoved(position: Int)
fun onStartDelete(viewHolder: RecyclerView.ViewHolder)
fun onStartDrag(viewHolder: RecyclerView.ViewHolder)
fun onActionChipClicked(tab: TabData)
fun onChipClicked(tab: TabData, chipPosition: Int)
fun onActionChipClicked(tab: TabData, tabPosition: Int)
fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int)
}
class TabAdapter(private var data: List<TabData>,
@ -62,16 +62,17 @@ class TabAdapter(private var data: List<TabData>,
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val context = holder.itemView.context
if (!small && data[position].id == LIST) {
holder.itemView.textView.text = data[position].arguments.getOrNull(1).orEmpty()
val tab = data[position]
if (!small && tab.id == LIST) {
holder.itemView.textView.text = tab.arguments.getOrNull(1).orEmpty()
} else {
holder.itemView.textView.setText(data[position].text)
holder.itemView.textView.setText(tab.text)
}
val iconDrawable = ThemeUtils.getTintedDrawable(context, data[position].icon, android.R.attr.textColorSecondary)
val iconDrawable = ThemeUtils.getTintedDrawable(context, tab.icon, android.R.attr.textColorSecondary)
holder.itemView.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconDrawable, null, null, null)
if (small) {
holder.itemView.textView.setOnClickListener {
listener.onTabAdded(data[position])
listener.onTabAdded(tab)
}
}
holder.itemView.imageView?.setOnTouchListener { _, event ->
@ -96,7 +97,7 @@ class TabAdapter(private var data: List<TabData>,
if (!small) {
if (data[position].id == HASHTAG) {
if (tab.id == HASHTAG) {
holder.itemView.chipGroup.show()
/*
@ -104,34 +105,33 @@ class TabAdapter(private var data: List<TabData>,
* The other dynamic chips are inserted in front of the actionChip.
* This code tries to reuse already added chips to reduce the number of Views created.
*/
data[position].arguments.forEachIndexed { i, arg ->
tab.arguments.forEachIndexed { i, arg ->
val chip = holder.itemView.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip?
?: Chip(context).apply {
text = arg
holder.itemView.chipGroup.addView(this, holder.itemView.chipGroup.size - 1)
}
chip.text = arg
if(data[position].arguments.size <= 1) {
if(tab.arguments.size <= 1) {
chip.chipIcon = null
chip.setOnClickListener(null)
} else {
val cancelIcon = ThemeUtils.getTintedDrawable(context, R.drawable.ic_cancel_24dp, android.R.attr.textColorPrimary)
chip.chipIcon = cancelIcon
chip.setOnClickListener {
listener.onChipClicked(data[position], i)
listener.onChipClicked(tab, holder.adapterPosition, i)
}
}
}
while(holder.itemView.chipGroup.size - 1 > data[position].arguments.size) {
holder.itemView.chipGroup.removeViewAt(data[position].arguments.size - 1)
while(holder.itemView.chipGroup.size - 1 > tab.arguments.size) {
holder.itemView.chipGroup.removeViewAt(tab.arguments.size)
}
holder.itemView.actionChip.setOnClickListener {
listener.onActionChipClicked(data[position])
listener.onActionChipClicked(tab, holder.adapterPosition)
}
} else {
@ -140,9 +140,7 @@ class TabAdapter(private var data: List<TabData>,
}
}
override fun getItemCount(): Int {
return data.size
}
override fun getItemCount() = data.size
fun setRemoveButtonVisible(enabled: Boolean) {
if (removeButtonEnabled != enabled) {

View File

@ -19,4 +19,5 @@ data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable
data class PollVoteEvent(val statusId: String, val poll: Poll) : Dispatchable
data class DomainMuteEvent(val instance: String): Dispatchable
data class DomainMuteEvent(val instance: String): Dispatchable
data class AnnouncementReadEvent(val announcementId: String): Dispatchable

View File

@ -0,0 +1,115 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.announcements
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.view.size
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.util.emojify
import kotlinx.android.synthetic.main.item_announcement.view.*
interface AnnouncementActionListener {
fun openReactionPicker(announcementId: String, target: View)
fun addReaction(announcementId: String, name: String)
fun removeReaction(announcementId: String, name: String)
}
class AnnouncementAdapter(
private var items: List<Announcement> = emptyList(),
private val listener: AnnouncementActionListener
) : RecyclerView.Adapter<AnnouncementAdapter.AnnouncementViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AnnouncementViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_announcement, parent, false)
return AnnouncementViewHolder(view)
}
override fun onBindViewHolder(viewHolder: AnnouncementViewHolder, position: Int) {
viewHolder.bind(items[position])
}
override fun getItemCount() = items.size
fun updateList(items: List<Announcement>) {
this.items = items
notifyDataSetChanged()
}
inner class AnnouncementViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
private val text: TextView = view.text
private val chips: ChipGroup = view.chipGroup
private val addReactionChip: Chip = view.addReactionChip
fun bind(item: Announcement) {
text.text = item.content
item.reactions.forEachIndexed { i, reaction ->
(chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
?: Chip(ContextThemeWrapper(view.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply {
isCheckable = true
checkedIcon = null
chips.addView(this, i)
})
.apply {
val emojiText = if (reaction.url == null) {
reaction.name
} else {
view.context.getString(R.string.emoji_shortcode_format, reaction.name)
}
text = ("$emojiText ${reaction.count}")
.emojify(
listOf(Emoji(
reaction.name,
reaction.url ?: "",
reaction.staticUrl ?: "",
null
)),
this
)
isChecked = reaction.me
setOnClickListener {
if (reaction.me) {
listener.removeReaction(item.id, reaction.name)
} else {
listener.addReaction(item.id, reaction.name)
}
}
}
}
while (chips.size - 1 > item.reactions.size) {
chips.removeViewAt(item.reactions.size)
}
addReactionChip.setOnClickListener {
listener.openReactionPicker(item.id, it)
}
}
}
}

View File

@ -0,0 +1,153 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.announcements
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.widget.PopupWindow
import androidx.activity.viewModels
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.view.EmojiPicker
import kotlinx.android.synthetic.main.activity_announcements.*
import kotlinx.android.synthetic.main.toolbar_basic.*
import javax.inject.Inject
class AnnouncementsActivity : BaseActivity(), AnnouncementActionListener, OnEmojiSelectedListener, Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: AnnouncementsViewModel by viewModels { viewModelFactory }
private val adapter = AnnouncementAdapter(emptyList(), this)
private val picker by lazy { EmojiPicker(this) }
private val pickerDialog by lazy {
PopupWindow(this)
.apply {
contentView = picker
isFocusable = true
setOnDismissListener {
currentAnnouncementId = null
}
}
}
private var currentAnnouncementId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_announcements)
setSupportActionBar(toolbar)
supportActionBar?.apply {
title = getString(R.string.title_announcements)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
swipeRefreshLayout.setOnRefreshListener(this::refreshAnnouncements)
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
announcementsList.setHasFixedSize(true)
announcementsList.layoutManager = LinearLayoutManager(this)
val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
announcementsList.addItemDecoration(divider)
announcementsList.adapter = adapter
viewModel.announcements.observe(this) {
when (it) {
is Success -> {
progressBar.hide()
swipeRefreshLayout.isRefreshing = false
if (it.data.isNullOrEmpty()) {
errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_announcements)
errorMessageView.show()
} else {
errorMessageView.hide()
}
adapter.updateList(it.data ?: listOf())
}
is Loading -> {
errorMessageView.hide()
}
is Error -> {
progressBar.hide()
swipeRefreshLayout.isRefreshing = false
errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshAnnouncements()
}
errorMessageView.show()
}
}
}
viewModel.emojis.observe(this) {
picker.adapter = EmojiAdapter(it, this)
}
viewModel.load()
progressBar.show()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun refreshAnnouncements() {
viewModel.load()
swipeRefreshLayout.isRefreshing = true
}
override fun openReactionPicker(announcementId: String, target: View) {
currentAnnouncementId = announcementId
pickerDialog.showAsDropDown(target)
}
override fun onEmojiSelected(shortcode: String) {
viewModel.addReaction(currentAnnouncementId!!, shortcode)
pickerDialog.dismiss()
}
override fun addReaction(announcementId: String, name: String) {
viewModel.addReaction(announcementId, name)
}
override fun removeReaction(announcementId: String, name: String) {
viewModel.removeReaction(announcementId, name)
}
companion object {
fun newIntent(context: Context) = Intent(context, AnnouncementsActivity::class.java)
}
}

View File

@ -0,0 +1,187 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.announcements
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity
import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.*
import io.reactivex.rxkotlin.Singles
import javax.inject.Inject
class AnnouncementsViewModel @Inject constructor(
accountManager: AccountManager,
private val appDatabase: AppDatabase,
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
) : RxAwareViewModel() {
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
private val emojisMutable = MutableLiveData<List<Emoji>>()
val emojis: LiveData<List<Emoji>> = emojisMutable
init {
Singles.zip(
mastodonApi.getCustomEmojis(),
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext(
mastodonApi.getInstance()
.map { Either.Right(it) }
)
) { emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis)
?: InstanceEntity(
accountManager.activeAccount?.domain!!,
emojis,
either.asRight().maxTootChars,
either.asRight().pollLimits?.maxOptions,
either.asRight().pollLimits?.maxOptionChars,
either.asRight().version
)
}
.doOnSuccess {
appDatabase.instanceDao().insertOrReplace(it)
}
.subscribe({
emojisMutable.postValue(it.emojiList)
}, {
Log.w(TAG, "Failed to get custom emojis.", it)
})
.autoDispose()
}
fun load() {
announcementsMutable.postValue(Loading())
mastodonApi.listAnnouncements()
.subscribe({
announcementsMutable.postValue(Success(it))
it.filter { announcement -> !announcement.read }
.forEach { announcement ->
mastodonApi.dismissAnnouncement(announcement.id)
.subscribe(
{
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
},
{ throwable ->
Log.d(TAG, "Failed to mark announcement as read.", throwable)
}
)
.autoDispose()
}
}, {
announcementsMutable.postValue(Error(cause = it))
})
.autoDispose()
}
fun addReaction(announcementId: String, name: String) {
mastodonApi.addAnnouncementReaction(announcementId, name)
.subscribe({
announcementsMutable.postValue(
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
announcement.reactions.map { reaction ->
if (reaction.name == name) {
reaction.copy(
count = reaction.count + 1,
me = true
)
} else {
reaction
}
}
} else {
listOf(
*announcement.reactions.toTypedArray(),
emojis.value!!.find { emoji -> emoji.shortcode == name }
!!.run {
Announcement.Reaction(
name,
1,
true,
url,
staticUrl
)
}
)
}
)
} else {
announcement
}
}
)
)
}, {
Log.w(TAG, "Failed to add reaction to the announcement.", it)
})
.autoDispose()
}
fun removeReaction(announcementId: String, name: String) {
mastodonApi.removeAnnouncementReaction(announcementId, name)
.subscribe({
announcementsMutable.postValue(
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = announcement.reactions.mapNotNull { reaction ->
if (reaction.name == name) {
if (reaction.count > 1) {
reaction.copy(
count = reaction.count - 1,
me = false
)
} else {
null
}
} else {
reaction
}
}
)
} else {
announcement
}
}
)
)
}, {
Log.w(TAG, "Failed to remove reaction from the announcement.", it)
})
.autoDispose()
}
companion object {
private const val TAG = "AnnouncementsViewModel"
}
}

View File

@ -49,9 +49,7 @@ 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.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.transition.TransitionManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
@ -150,7 +148,7 @@ class ComposeActivity : BaseActivity(),
/* If the composer is started up as a reply to another post, override the "starting" state
* based on what the intent from the reply request passes. */
if (intent != null) {
this.composeOptions = intent.getParcelableExtra<ComposeOptions?>(COMPOSE_OPTIONS_EXTRA)
this.composeOptions = intent.getParcelableExtra(COMPOSE_OPTIONS_EXTRA)
viewModel.setup(composeOptions)
setupReplyViews(composeOptions?.replyingStatusAuthor)
val tootText = composeOptions?.tootText
@ -302,7 +300,7 @@ class ComposeActivity : BaseActivity(),
}
viewModel.media.observe { media ->
mediaAdapter.submitList(media)
if(media.size != mediaCount) {
if (media.size != mediaCount) {
mediaCount = media.size
composeMediaPreviewBar.visible(media.isNotEmpty())
updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value != false, viewModel.showContentWarning.value != false)
@ -312,8 +310,8 @@ class ComposeActivity : BaseActivity(),
pollPreview.visible(poll != null)
poll?.let(pollPreview::setPoll)
}
viewModel.scheduledAt.observe {scheduledAt ->
if(scheduledAt == null) {
viewModel.scheduledAt.observe { scheduledAt ->
if (scheduledAt == null) {
composeScheduleView.resetSchedule()
} else {
composeScheduleView.setDateTime(scheduledAt)
@ -345,7 +343,6 @@ class ComposeActivity : BaseActivity(),
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.
@ -553,7 +550,7 @@ class ComposeActivity : BaseActivity(),
}
private fun onScheduleClick() {
if(viewModel.scheduledAt.value == null) {
if (viewModel.scheduledAt.value == null) {
composeScheduleView.openPickDateDialog()
} else {
showScheduleView()
@ -682,7 +679,15 @@ class ComposeActivity : BaseActivity(),
}
private fun updateVisibleCharactersLeft() {
composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", maximumTootCharacters - calculateTextLength())
val remainingLength = maximumTootCharacters - calculateTextLength()
composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength)
val textColor = if (remainingLength < 0) {
ContextCompat.getColor(this, R.color.tusky_red)
} else {
ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
}
composeCharactersLeftView.setTextColor(textColor)
}
private fun onContentWarningChanged() {
@ -708,9 +713,9 @@ class ComposeActivity : BaseActivity(),
// Verify the returned content's type is of the correct MIME type
val supported = inputContentInfo.description.hasMimeType("image/*")
if(supported) {
if (supported) {
val lacksPermission = (flags and InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0
if(lacksPermission) {
if (lacksPermission) {
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
@ -737,11 +742,13 @@ class ComposeActivity : BaseActivity(),
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)
if (viewModel.media.value!!.isNotEmpty()) {
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 {
viewModel.sendStatus(contentText, spoilerText).observe(this, {
finishingUploadDialog?.dismiss()
deleteDraftAndFinish()
})
@ -762,7 +769,7 @@ class ComposeActivity : BaseActivity(),
Snackbar.LENGTH_SHORT).apply {
}
bar.setAction(R.string.action_retry) { onMediaPick()}
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()
@ -904,7 +911,7 @@ class ComposeActivity : BaseActivity(),
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
Log.d(TAG, event.toString())
if(event.action == KeyEvent.ACTION_DOWN) {
if (event.action == KeyEvent.ACTION_DOWN) {
if (event.isCtrlPressed) {
if (keyCode == KeyEvent.KEYCODE_ENTER) {
// send toot by pressing CTRL + ENTER
@ -994,6 +1001,7 @@ class ComposeActivity : BaseActivity(),
@Parcelize
data class ComposeOptions(
// Let's keep fields var until all consumers are Kotlin
var scheduledTootUid: String? = null,
var savedTootUid: Int? = null,
var tootText: String? = null,
var mediaUrls: List<String>? = null,
@ -1008,7 +1016,8 @@ class ComposeActivity : BaseActivity(),
var mediaAttachments: List<Attachment>? = null,
var scheduledAt: String? = null,
var sensitive: Boolean? = null,
var poll: NewPoll? = null
var poll: NewPoll? = null,
var modifiedInitialState: Boolean? = null
) : Parcelable
companion object {
@ -1017,7 +1026,7 @@ class ComposeActivity : BaseActivity(),
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"
internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS"
private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI"
// Mastodon only counts URLs as this long in terms of status character limits

View File

@ -33,6 +33,7 @@ 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.Observable.just
import io.reactivex.disposables.Disposable
import io.reactivex.rxkotlin.Singles
import java.util.*
@ -58,11 +59,13 @@ class ComposeViewModel
private var replyingStatusContent: String? = null
internal var startingText: String? = null
private var savedTootUid: Int = 0
private var scheduledTootUid: String? = null
private var startingContentWarning: String = ""
private var inReplyToId: String? = null
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
private var contentWarningStateChanged: Boolean = false
private var modifiedInitialState: Boolean = false
private val instance: MutableLiveData<InstanceEntity?> = MutableLiveData(null)
@ -93,6 +96,7 @@ class ComposeViewModel
private val mediaToDisposable = mutableMapOf<Long, Disposable>()
private val isEditingScheduledToot get() = !scheduledTootUid.isNullOrEmpty()
init {
@ -197,7 +201,7 @@ class ComposeViewModel
val mediaChanged = !media.value.isNullOrEmpty()
val pollChanged = poll.value != null
return textChanged || contentWarningChanged || mediaChanged || pollChanged
return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged
}
fun contentWarningChanged(value: Boolean) {
@ -240,7 +244,14 @@ class ComposeViewModel
content: String,
spoilerText: String
): LiveData<Unit> {
return media
val deletionObservable = if (isEditingScheduledToot) {
api.deleteScheduledStatus(scheduledTootUid.toString()).toObservable().map { Unit }
} else {
just(Unit)
}.toLiveData()
val sendObservable = media
.filter { items -> items.all { it.uploadPercent == -1 } }
.map {
val mediaIds = ArrayList<String>()
@ -271,8 +282,13 @@ class ComposeViewModel
idempotencyKey = randomAlphanumericString(16),
retries = 0
)
serviceClient.sendToot(tootToSend)
}
return combineLiveData(deletionObservable, sendObservable) { _, _ -> Unit }
}
fun updateDescription(localId: Long, description: String): LiveData<Boolean> {
@ -369,7 +385,7 @@ class ComposeViewModel
preferredVisibility.num.coerceAtLeast(replyVisibility.num))
inReplyToId = composeOptions?.inReplyToId
modifiedInitialState = composeOptions?.modifiedInitialState == true
val contentWarning = composeOptions?.contentWarning
if (contentWarning != null) {
@ -405,6 +421,7 @@ class ComposeViewModel
savedTootUid = composeOptions?.savedTootUid ?: 0
scheduledTootUid = composeOptions?.scheduledTootUid
startingText = composeOptions?.tootText

View File

@ -21,8 +21,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.paging.PagedList
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -36,7 +34,10 @@ import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide
import kotlinx.android.synthetic.main.fragment_timeline.*
import javax.inject.Inject
@ -83,21 +84,21 @@ class ConversationsFragment : SFragment(), StatusActionListener, Injectable, Res
initSwipeToRefresh()
viewModel.conversations.observe(viewLifecycleOwner, Observer<PagedList<ConversationEntity>> {
viewModel.conversations.observe(viewLifecycleOwner) {
adapter.submitList(it)
})
viewModel.networkState.observe(viewLifecycleOwner, Observer {
}
viewModel.networkState.observe(viewLifecycleOwner) {
adapter.setNetworkState(it)
})
}
viewModel.load()
}
private fun initSwipeToRefresh() {
viewModel.refreshState.observe(viewLifecycleOwner, Observer {
viewModel.refreshState.observe(viewLifecycleOwner) {
swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING
})
}
swipeRefreshLayout.setOnRefreshListener {
viewModel.refresh()
}

View File

@ -0,0 +1,82 @@
package com.keylesspalace.tusky.components.notifications
import android.util.Log
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Marker
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isLessThan
import javax.inject.Inject
class NotificationFetcher @Inject constructor(
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager,
private val notifier: Notifier
) {
fun fetchAndShow() {
for (account in accountManager.getAllAccountsOrderedByActive()) {
if (account.notificationsEnabled) {
try {
val notifications = fetchNotifications(account)
notifications.forEachIndexed { index, notification ->
notifier.show(notification, account, index == 0)
}
accountManager.saveAccount(account)
} catch (e: Exception) {
Log.w(TAG, "Error while fetching notifications", e)
}
}
}
}
private fun fetchNotifications(account: AccountEntity): MutableList<Notification> {
val authHeader = String.format("Bearer %s", account.accessToken)
// We fetch marker to not load/show notifications which user has already seen
val marker = fetchMarker(authHeader, account)
if (marker != null && account.lastNotificationId.isLessThan(marker.lastReadId)) {
account.lastNotificationId = marker.lastReadId
}
Log.d(TAG, "getting Notifications for " + account.fullName)
val notifications = mastodonApi.notificationsWithAuth(
authHeader,
account.domain,
account.lastNotificationId
).blockingGet()
val newId = account.lastNotificationId
var newestId = ""
val result = mutableListOf<Notification>()
for (notification in notifications.reversed()) {
val currentId = notification.id
if (newestId.isLessThan(currentId)) {
newestId = currentId
account.lastNotificationId = currentId
}
if (newId.isLessThan(currentId)) {
result.add(notification)
}
}
return result
}
private fun fetchMarker(authHeader: String, account: AccountEntity): Marker? {
return try {
val allMarkers = mastodonApi.markersWithAuth(
authHeader,
account.domain,
listOf("notifications")
).blockingGet()
val notificationMarker = allMarkers["notifications"]
Log.d(TAG, "Fetched marker: $notificationMarker")
notificationMarker
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch marker", e)
null
}
}
companion object {
const val TAG = "NotificationFetcher"
}
}

View File

@ -37,7 +37,6 @@ import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.RemoteInput;
import androidx.core.app.TaskStackBuilder;
import androidx.core.content.ContextCompat;
import androidx.core.text.BidiFormatter;
import androidx.work.Constraints;
import androidx.work.NetworkType;
import androidx.work.PeriodicWorkRequest;
@ -58,6 +57,7 @@ import com.keylesspalace.tusky.entity.PollOption;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.receiver.NotificationClearBroadcastReceiver;
import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver;
import com.keylesspalace.tusky.util.StringUtils;
import com.keylesspalace.tusky.viewdata.PollViewDataKt;
import org.json.JSONArray;
@ -145,7 +145,6 @@ public class NotificationHelper {
String rawCurrentNotifications = account.getActiveNotifications();
JSONArray currentNotifications;
BidiFormatter bidiFormatter = BidiFormatter.getInstance();
try {
currentNotifications = new JSONArray(rawCurrentNotifications);
@ -174,7 +173,7 @@ public class NotificationHelper {
notificationId++;
builder.setContentTitle(titleForType(context, body, bidiFormatter, account))
builder.setContentTitle(titleForType(context, body, account))
.setContentText(bodyForType(body, context));
if (body.getType() == Notification.Type.MENTION || body.getType() == Notification.Type.POLL) {
@ -243,7 +242,7 @@ public class NotificationHelper {
if (currentNotifications.length() != 1) {
try {
String title = context.getString(R.string.notification_title_summary, currentNotifications.length());
String text = joinNames(context, currentNotifications, bidiFormatter);
String text = joinNames(context, currentNotifications);
summaryBuilder.setContentTitle(title)
.setContentText(text);
} catch (JSONException e) {
@ -573,36 +572,36 @@ public class NotificationHelper {
}
}
private static String wrapItemAt(JSONArray array, int index, BidiFormatter bidiFormatter) throws JSONException {
return bidiFormatter.unicodeWrap(array.get(index).toString());
private static String wrapItemAt(JSONArray array, int index) throws JSONException {
return StringUtils.unicodeWrap(array.get(index).toString());
}
@Nullable
private static String joinNames(Context context, JSONArray array, BidiFormatter bidiFormatter) throws JSONException {
private static String joinNames(Context context, JSONArray array) throws JSONException {
if (array.length() > 3) {
int length = array.length();
return String.format(context.getString(R.string.notification_summary_large),
wrapItemAt(array, length - 1, bidiFormatter),
wrapItemAt(array, length - 2, bidiFormatter),
wrapItemAt(array, length - 3, bidiFormatter),
wrapItemAt(array, length - 1),
wrapItemAt(array, length - 2),
wrapItemAt(array, length - 3),
length - 3);
} else if (array.length() == 3) {
return String.format(context.getString(R.string.notification_summary_medium),
wrapItemAt(array, 2, bidiFormatter),
wrapItemAt(array, 1, bidiFormatter),
wrapItemAt(array, 0, bidiFormatter));
wrapItemAt(array, 2),
wrapItemAt(array, 1),
wrapItemAt(array, 0));
} else if (array.length() == 2) {
return String.format(context.getString(R.string.notification_summary_small),
wrapItemAt(array, 1, bidiFormatter),
wrapItemAt(array, 0, bidiFormatter));
wrapItemAt(array, 1),
wrapItemAt(array, 0));
}
return null;
}
@Nullable
private static String titleForType(Context context, Notification notification, BidiFormatter bidiFormatter, AccountEntity account) {
String accountName = bidiFormatter.unicodeWrap(notification.getAccount().getName());
private static String titleForType(Context context, Notification notification, AccountEntity account) {
String accountName = StringUtils.unicodeWrap(notification.getAccount().getName());
switch (notification.getType()) {
case MENTION:
return String.format(context.getString(R.string.notification_mention_format),

View File

@ -16,82 +16,35 @@
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import android.util.Log
import androidx.work.ListenableWorker
import androidx.work.Worker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isLessThan
import java.io.IOException
import javax.inject.Inject
class NotificationWorker(
private val context: Context,
context: Context,
params: WorkerParameters,
private val mastodonApi: MastodonApi,
private val accountManager: AccountManager
private val notificationsFetcher: NotificationFetcher
) : Worker(context, params) {
override fun doWork(): Result {
val accountList = accountManager.getAllAccountsOrderedByActive()
for (account in accountList) {
if (account.notificationsEnabled) {
try {
Log.d(TAG, "getting Notifications for " + account.fullName)
val notificationsResponse = mastodonApi.notificationsWithAuth(
String.format("Bearer %s", account.accessToken),
account.domain
).execute()
val notifications = notificationsResponse.body()
if (notificationsResponse.isSuccessful && notifications != null) {
onNotificationsReceived(account, notifications)
} else {
Log.w(TAG, "error receiving notifications")
}
} catch (e: IOException) {
Log.w(TAG, "error receiving notifications", e)
}
}
}
notificationsFetcher.fetchAndShow()
return Result.success()
}
private fun onNotificationsReceived(account: AccountEntity, notificationList: List<Notification>) {
val newId = account.lastNotificationId
var newestId = ""
var isFirstOfBatch = true
notificationList.reversed().forEach { notification ->
val currentId = notification.id
if (newestId.isLessThan(currentId)) {
newestId = currentId
}
if (newId.isLessThan(currentId)) {
NotificationHelper.make(context, notification, account, isFirstOfBatch)
isFirstOfBatch = false
}
}
account.lastNotificationId = newestId
accountManager.saveAccount(account)
}
companion object {
private const val TAG = "NotificationWorker"
}
}
class NotificationWorkerFactory @Inject constructor(
val api: MastodonApi,
val accountManager: AccountManager
): WorkerFactory() {
private val notificationsFetcher: NotificationFetcher
) : WorkerFactory() {
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters): ListenableWorker? {
if(workerClassName == NotificationWorker::class.java.name) {
return NotificationWorker(appContext, workerParameters, api, accountManager)
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters
): ListenableWorker? {
if (workerClassName == NotificationWorker::class.java.name) {
return NotificationWorker(appContext, workerParameters, notificationsFetcher)
}
return null
}

View File

@ -0,0 +1,20 @@
package com.keylesspalace.tusky.components.notifications
import android.content.Context
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.entity.Notification
/**
* Shows notifications.
*/
interface Notifier {
fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean)
}
class SystemNotifier(
private val context: Context
) : Notifier {
override fun show(notification: Notification, account: AccountEntity, isFirstInBatch: Boolean) {
NotificationHelper.make(context, notification, account, isFirstInBatch)
}
}

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment.preference
package com.keylesspalace.tusky.components.preference
import android.content.Intent
import android.graphics.drawable.Drawable

View File

@ -0,0 +1,258 @@
package com.keylesspalace.tusky.components.preference
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.SplashActivity
import com.keylesspalace.tusky.util.EmojiCompatFont
import com.keylesspalace.tusky.util.EmojiCompatFont.Companion.FONTS
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import okhttp3.OkHttpClient
import kotlin.system.exitProcess
/**
* This Preference lets the user select their preferred emoji font
*/
class EmojiPreference(
context: Context,
private val okHttpClient: OkHttpClient
) : Preference(context) {
private lateinit var selected: EmojiCompatFont
private lateinit var original: EmojiCompatFont
private val radioButtons = mutableListOf<RadioButton>()
private var updated = false
private var currentNeedsUpdate = false
private val downloadDisposables = MutableList<Disposable?>(FONTS.size) { null }
override fun onAttachedToHierarchy(preferenceManager: PreferenceManager) {
super.onAttachedToHierarchy(preferenceManager)
// Find out which font is currently active
selected = EmojiCompatFont.byId(
PreferenceManager.getDefaultSharedPreferences(context).getInt(key, 0)
)
// We'll use this later to determine if anything has changed
original = selected
summary = selected.getDisplay(context)
}
override fun onClick() {
val view = LayoutInflater.from(context).inflate(R.layout.dialog_emojicompat, null)
viewIds.forEachIndexed { index, viewId ->
setupItem(view.findViewById(viewId), FONTS[index])
}
AlertDialog.Builder(context)
.setView(view)
.setPositiveButton(android.R.string.ok) { _, _ -> onDialogOk() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun setupItem(container: View, font: EmojiCompatFont) {
val title: TextView = container.findViewById(R.id.emojicompat_name)
val caption: TextView = container.findViewById(R.id.emojicompat_caption)
val thumb: ImageView = container.findViewById(R.id.emojicompat_thumb)
val download: ImageButton = container.findViewById(R.id.emojicompat_download)
val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel)
val radio: RadioButton = container.findViewById(R.id.emojicompat_radio)
// Initialize all the views
title.text = font.getDisplay(container.context)
caption.setText(font.caption)
thumb.setImageResource(font.img)
// There needs to be a list of all the radio buttons in order to uncheck them when one is selected
radioButtons.add(radio)
updateItem(font, container)
// Set actions
download.setOnClickListener { startDownload(font, container) }
cancel.setOnClickListener { cancelDownload(font, container) }
radio.setOnClickListener { radioButton: View -> select(font, radioButton as RadioButton) }
container.setOnClickListener { containerView: View ->
select(font, containerView.findViewById(R.id.emojicompat_radio))
}
}
private fun startDownload(font: EmojiCompatFont, container: View) {
val download: ImageButton = container.findViewById(R.id.emojicompat_download)
val caption: TextView = container.findViewById(R.id.emojicompat_caption)
val progressBar: ProgressBar = container.findViewById(R.id.emojicompat_progress)
val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel)
// Switch to downloading style
download.visibility = View.GONE
caption.visibility = View.INVISIBLE
progressBar.visibility = View.VISIBLE
progressBar.progress = 0
cancel.visibility = View.VISIBLE
font.downloadFontFile(context, okHttpClient)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ progress ->
// The progress is returned as a float between 0 and 1, or -1 if it could not determined
if (progress >= 0) {
progressBar.isIndeterminate = false
val max = progressBar.max.toFloat()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
progressBar.setProgress((max * progress).toInt(), true)
} else {
progressBar.progress = (max * progress).toInt()
}
} else {
progressBar.isIndeterminate = true
}
},
{
Toast.makeText(context, R.string.download_failed, Toast.LENGTH_SHORT).show()
updateItem(font, container)
},
{
finishDownload(font, container)
}
).also { downloadDisposables[font.id] = it }
}
private fun cancelDownload(font: EmojiCompatFont, container: View) {
font.deleteDownloadedFile(container.context)
downloadDisposables[font.id]?.dispose()
downloadDisposables[font.id] = null
updateItem(font, container)
}
private fun finishDownload(font: EmojiCompatFont, container: View) {
select(font, container.findViewById(R.id.emojicompat_radio))
updateItem(font, container)
// Set the flag to restart the app (because an update has been downloaded)
if (selected === original && currentNeedsUpdate) {
updated = true
currentNeedsUpdate = false
}
}
/**
* Select a font both visually and logically
*
* @param font The font to be selected
* @param radio The radio button associated with it's visual item
*/
private fun select(font: EmojiCompatFont, radio: RadioButton) {
selected = font
// Uncheck all the other buttons
for (other in radioButtons) {
if (other !== radio) {
other.isChecked = false
}
}
radio.isChecked = true
}
/**
* Called when a "consistent" state is reached, i.e. it's not downloading the font
*
* @param font The font to be displayed
* @param container The ConstraintLayout containing the item
*/
private fun updateItem(font: EmojiCompatFont, container: View) {
// Assignments
val download: ImageButton = container.findViewById(R.id.emojicompat_download)
val caption: TextView = container.findViewById(R.id.emojicompat_caption)
val progress: ProgressBar = container.findViewById(R.id.emojicompat_progress)
val cancel: ImageButton = container.findViewById(R.id.emojicompat_download_cancel)
val radio: RadioButton = container.findViewById(R.id.emojicompat_radio)
// There's no download going on
progress.visibility = View.GONE
cancel.visibility = View.GONE
caption.visibility = View.VISIBLE
if (font.isDownloaded(context)) {
// Make it selectable
download.visibility = View.GONE
radio.visibility = View.VISIBLE
container.isClickable = true
} else {
// Make it downloadable
download.visibility = View.VISIBLE
radio.visibility = View.GONE
container.isClickable = false
}
// Select it if necessary
if (font === selected) {
radio.isChecked = true
// Update available
if (!font.isDownloaded(context)) {
currentNeedsUpdate = true
}
} else {
radio.isChecked = false
}
}
private fun saveSelectedFont() {
val index = selected.id
Log.i(TAG, "saveSelectedFont: Font ID: $index")
PreferenceManager
.getDefaultSharedPreferences(context)
.edit()
.putInt(key, index)
.apply()
summary = selected.getDisplay(context)
}
/**
* User clicked ok -> save the selected font and offer to restart the app if something changed
*/
private fun onDialogOk() {
saveSelectedFont()
if (selected !== original || updated) {
AlertDialog.Builder(context)
.setTitle(R.string.restart_required)
.setMessage(R.string.restart_emoji)
.setNegativeButton(R.string.later, null)
.setPositiveButton(R.string.restart) { _, _ ->
// Restart the app
// From https://stackoverflow.com/a/17166729/5070653
val launchIntent = Intent(context, SplashActivity::class.java)
val mPendingIntent = PendingIntent.getActivity(
context,
0x1f973, // This is the codepoint of the party face emoji :D
launchIntent,
PendingIntent.FLAG_CANCEL_CURRENT)
val mgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
mgr.set(
AlarmManager.RTC,
System.currentTimeMillis() + 100,
mPendingIntent)
exitProcess(0)
}.show()
}
}
companion object {
private const val TAG = "EmojiPreference"
// Please note that this array must sorted in the same way as the fonts.
private val viewIds = intArrayOf(
R.id.item_nomoji,
R.id.item_blobmoji,
R.id.item_twemoji,
R.id.item_notoemoji
)
}
}

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment.preference
package com.keylesspalace.tusky.components.preference
import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
package com.keylesspalace.tusky.components.preference
import android.content.Context
import android.content.Intent
@ -23,9 +23,11 @@ import android.util.Log
import android.view.MenuItem
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.MainActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.fragment.preference.*
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getNonNullString

View File

@ -13,13 +13,13 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment.preference
package com.keylesspalace.tusky.components.preference
import android.os.Bundle
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.keylesspalace.tusky.PreferencesActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.settings.*
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.getNonNullString
@ -27,8 +27,13 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizePx
import okhttp3.OkHttpClient
import javax.inject.Inject
class PreferencesFragment : PreferenceFragmentCompat() {
class PreferencesFragment : PreferenceFragmentCompat(), Injectable {
@Inject
lateinit var okhttpclient: OkHttpClient
private val iconSize by lazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) }
private var httpProxyPref: Preference? = null
@ -47,7 +52,7 @@ class PreferencesFragment : PreferenceFragmentCompat() {
icon = makeIcon(GoogleMaterial.Icon.gmd_palette)
}
emojiPreference {
emojiPreference(okhttpclient) {
setDefaultValue("system_default")
setIcon(R.drawable.ic_emoji_24dp)
key = PrefKeys.EMOJI

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment.preference
package com.keylesspalace.tusky.components.preference
import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat

View File

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment.preference
package com.keylesspalace.tusky.components.preference
import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat

View File

@ -20,7 +20,6 @@ import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.lifecycle.Observer
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.adapter.ReportPagerAdapter
@ -77,7 +76,7 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
}
private fun subscribeObservables() {
viewModel.navigation.observe(this, Observer { screen ->
viewModel.navigation.observe(this) { screen ->
if (screen != null) {
viewModel.navigated()
when (screen) {
@ -88,14 +87,14 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector {
Screen.Finish -> closeScreen()
}
}
})
}
viewModel.checkUrl.observe(this, Observer {
viewModel.checkUrl.observe(this) {
if (!it.isNullOrBlank()) {
viewModel.urlChecked()
viewUrl(it)
}
})
}
}
private fun showPreviousScreen() {

View File

@ -19,6 +19,9 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.paging.PagedList
import com.keylesspalace.tusky.appstore.BlockEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MuteEvent
import com.keylesspalace.tusky.components.report.adapter.StatusesRepository
import com.keylesspalace.tusky.components.report.model.StatusViewState
import com.keylesspalace.tusky.entity.Relationship
@ -31,6 +34,7 @@ import javax.inject.Inject
class ReportViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val statusesRepository: StatusesRepository) : RxAwareViewModel() {
private val navigationMutable = MutableLiveData<Screen>()
@ -96,7 +100,7 @@ class ReportViewModel @Inject constructor(
val ids = listOf(accountId)
muteStateMutable.value = Loading()
blockStateMutable.value = Loading()
mastodonApi.relationshipsObservable(ids)
mastodonApi.relationships(ids)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
@ -123,16 +127,21 @@ class ReportViewModel @Inject constructor(
}
fun toggleMute() {
if (muteStateMutable.value?.data == true) {
mastodonApi.unmuteAccountObservable(accountId)
val alreadyMuted = muteStateMutable.value?.data == true
if (alreadyMuted) {
mastodonApi.unmuteAccount(accountId)
} else {
mastodonApi.muteAccountObservable(accountId)
mastodonApi.muteAccount(accountId)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ relationship ->
muteStateMutable.value = Success(relationship?.muting == true)
val muting = relationship?.muting == true
muteStateMutable.value = Success(muting)
if (muting) {
eventHub.dispatch(MuteEvent(accountId))
}
},
{ error ->
muteStateMutable.value = Error(false, error.message)
@ -143,16 +152,21 @@ class ReportViewModel @Inject constructor(
}
fun toggleBlock() {
if (blockStateMutable.value?.data == true) {
mastodonApi.unblockAccountObservable(accountId)
val alreadyBlocked = blockStateMutable.value?.data == true
if (alreadyBlocked) {
mastodonApi.unblockAccount(accountId)
} else {
mastodonApi.blockAccountObservable(accountId)
mastodonApi.blockAccount(accountId)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ relationship ->
blockStateMutable.value = Success(relationship?.blocking == true)
val blocking = relationship?.blocking == true
blockStateMutable.value = Success(blocking)
if (blocking) {
eventHub.dispatch(BlockEvent(accountId))
}
},
{ error ->
blockStateMutable.value = Error(false, error.message)

View File

@ -16,10 +16,8 @@
package com.keylesspalace.tusky.components.report.adapter
import android.view.View
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener
import java.util.ArrayList
interface AdapterHandler: LinkListener {
fun showMedia(v: View?, status: Status?, idx: Int)

View File

@ -21,7 +21,6 @@ import androidx.paging.toLiveData
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.BiListing
import com.keylesspalace.tusky.util.Listing
import io.reactivex.disposables.CompositeDisposable
import java.util.concurrent.Executors
import javax.inject.Inject

View File

@ -22,7 +22,6 @@ import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.report.Screen
@ -55,7 +54,7 @@ class ReportDoneFragment : Fragment(), Injectable {
}
private fun subscribeObservables() {
viewModel.muteState.observe(viewLifecycleOwner, Observer {
viewModel.muteState.observe(viewLifecycleOwner) {
if (it !is Loading) {
buttonMute.show()
progressMute.show()
@ -68,9 +67,9 @@ class ReportDoneFragment : Fragment(), Injectable {
true -> R.string.action_unmute
else -> R.string.action_mute
})
})
}
viewModel.blockState.observe(viewLifecycleOwner, Observer {
viewModel.blockState.observe(viewLifecycleOwner) {
if (it !is Loading) {
buttonBlock.show()
progressBlock.show()
@ -83,7 +82,7 @@ class ReportDoneFragment : Fragment(), Injectable {
true -> R.string.action_unblock
else -> R.string.action_block
})
})
}
}

View File

@ -22,7 +22,6 @@ import android.view.ViewGroup
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.report.ReportViewModel
@ -81,14 +80,14 @@ class ReportNoteFragment : Fragment(), Injectable {
}
private fun subscribeObservables() {
viewModel.reportingState.observe(viewLifecycleOwner, Observer {
viewModel.reportingState.observe(viewLifecycleOwner) {
when (it) {
is Success -> viewModel.navigateTo(Screen.Done)
is Loading -> showLoading()
is Error -> showError(it.cause)
}
})
}
}
private fun showError(error: Throwable?) {

View File

@ -23,8 +23,6 @@ import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.paging.PagedList
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
@ -43,7 +41,10 @@ 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.*
import com.keylesspalace.tusky.util.CardViewMode
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import kotlinx.android.synthetic.main.fragment_report_statuses.*
import javax.inject.Inject
@ -129,11 +130,11 @@ class ReportStatusesFragment : Fragment(), Injectable, AdapterHandler {
recyclerView.adapter = adapter
(recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
viewModel.statuses.observe(viewLifecycleOwner, Observer<PagedList<Status>> {
viewModel.statuses.observe(viewLifecycleOwner) {
adapter.submitList(it)
})
}
viewModel.networkStateAfter.observe(viewLifecycleOwner, Observer {
viewModel.networkStateAfter.observe(viewLifecycleOwner) {
if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING)
progressBarBottom.show()
else
@ -141,9 +142,9 @@ class ReportStatusesFragment : Fragment(), Injectable, AdapterHandler {
if (it?.status == com.keylesspalace.tusky.util.Status.FAILED)
showError(it.msg)
})
}
viewModel.networkStateBefore.observe(viewLifecycleOwner, Observer {
viewModel.networkStateBefore.observe(viewLifecycleOwner) {
if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING)
progressBarTop.show()
else
@ -151,9 +152,9 @@ class ReportStatusesFragment : Fragment(), Injectable, AdapterHandler {
if (it?.status == com.keylesspalace.tusky.util.Status.FAILED)
showError(it.msg)
})
}
viewModel.networkStateRefresh.observe(viewLifecycleOwner, Observer {
viewModel.networkStateRefresh.observe(viewLifecycleOwner) {
if (it?.status == com.keylesspalace.tusky.util.Status.RUNNING && !swipeRefreshLayout.isRefreshing)
progressBarLoading.show()
else
@ -163,7 +164,7 @@ class ReportStatusesFragment : Fragment(), Injectable, AdapterHandler {
swipeRefreshLayout.isRefreshing = false
if (it?.status == com.keylesspalace.tusky.util.Status.FAILED)
showError(it.msg)
})
}
}
private fun showError(@Suppress("UNUSED_PARAMETER") msg: String?) {

View File

@ -19,7 +19,6 @@ 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
@ -30,7 +29,6 @@ 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.*
@ -68,11 +66,11 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
viewModel = ViewModelProvider(this, viewModelFactory)[ScheduledTootViewModel::class.java]
viewModel.data.observe(this, Observer {
viewModel.data.observe(this) {
adapter.submitList(it)
})
}
viewModel.networkState.observe(this, Observer { (status) ->
viewModel.networkState.observe(this) { (status) ->
when(status) {
Status.SUCCESS -> {
progressBar.hide()
@ -103,9 +101,7 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
}
}
}
})
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -124,6 +120,7 @@ class ScheduledTootActivity : BaseActivity(), ScheduledTootActionListener, Injec
override fun edit(item: ScheduledStatus) {
val intent = ComposeActivity.startIntent(this, ComposeActivity.ComposeOptions(
scheduledTootUid = item.id,
tootText = item.params.text,
contentWarning = item.params.spoilerText,
mediaAttachments = item.mediaAttachments,

View File

@ -7,7 +7,6 @@ import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.paging.PagedList
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DividerItemDecoration
@ -61,11 +60,11 @@ abstract class SearchFragment<T> : Fragment(),
}
private fun subscribeObservables() {
data.observe(viewLifecycleOwner, Observer {
data.observe(viewLifecycleOwner) {
adapter.submitList(it)
})
}
networkStateRefresh.observe(viewLifecycleOwner, Observer {
networkStateRefresh.observe(viewLifecycleOwner) {
searchProgressBar.visible(it == NetworkState.LOADING)
@ -73,17 +72,16 @@ abstract class SearchFragment<T> : Fragment(),
showError()
}
checkNoData()
}
})
networkState.observe(viewLifecycleOwner, Observer {
networkState.observe(viewLifecycleOwner) {
progressBarBottom.visible(it == NetworkState.LOADING)
if (it.status == Status.FAILED) {
showError()
}
})
}
}
private fun checkNoData() {

View File

@ -26,8 +26,6 @@ import android.net.Uri
import android.os.Environment
import android.util.Log
import android.view.View
import android.widget.CheckBox
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
@ -376,9 +374,10 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
private fun onMute(accountId: String, accountUsername: String) {
showMuteAccountDialog(
this.requireActivity(),
accountUsername,
{ notifications -> viewModel.muteAccount(accountId, notifications) }
)
accountUsername
) { notifications ->
viewModel.muteAccount(accountId, notifications)
}
}
private fun accountIsInMentions(account: AccountEntity?, mentions: Array<Mention>): Boolean {

View File

@ -21,7 +21,6 @@ import com.keylesspalace.tusky.entity.Status
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.Comparator
/**
* This class caches the account database and handles all account related operations
@ -63,7 +62,7 @@ class AccountManager @Inject constructor(db: AppDatabase) {
accountDao.insertOrReplace(it)
}
val maxAccountId = accounts.maxBy { it.id }?.id ?: 0
val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0
val newAccountId = maxAccountId + 1
activeAccount = AccountEntity(id = newAccountId, domain = domain.toLowerCase(Locale.ROOT), accessToken = accessToken, isActive = true)
@ -166,13 +165,13 @@ class AccountManager @Inject constructor(db: AppDatabase) {
*/
fun getAllAccountsOrderedByActive(): List<AccountEntity> {
val accountsCopy = accounts.toMutableList()
accountsCopy.sortWith(Comparator { l, r ->
accountsCopy.sortWith { l, r ->
when {
l.isActive && !r.isActive -> -1
r.isActive && !l.isActive -> 1
else -> 0
}
})
}
return accountsCopy
}

View File

@ -99,9 +99,8 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId =
AND serverId = :statusId""")
abstract fun delete(accountId: Long, statusId: String)
@Query("""DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId
AND authorServerId != :accountServerId AND createdAt < :olderThan""")
abstract fun cleanup(accountId: Long, accountServerId: String, olderThan: Long)
@Query("""DELETE FROM TimelineStatusEntity WHERE createdAt < :olderThan""")
abstract fun cleanup(olderThan: Long)
@Query("""UPDATE TimelineStatusEntity SET poll = :poll
WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""")

View File

@ -16,8 +16,10 @@
package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.*
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledTootActivity
import com.keylesspalace.tusky.components.search.SearchActivity
@ -102,4 +104,7 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesScheduledTootActivity(): ScheduledTootActivity
@ContributesAndroidInjector
abstract fun contributesAnnouncementsActivity(): AnnouncementsActivity
}

View File

@ -25,6 +25,8 @@ import androidx.room.Room
import com.keylesspalace.tusky.TuskyApplication
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.EventHubImpl
import com.keylesspalace.tusky.components.notifications.Notifier
import com.keylesspalace.tusky.components.notifications.SystemNotifier
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
@ -79,7 +81,11 @@ class AppModule {
AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19,
AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22,
AppDatabase.MIGRATION_22_23)
.build()
.build()
}
@Provides
@Singleton
fun notifier(context: Context): Notifier = SystemNotifier(context)
}

View File

@ -20,14 +20,15 @@ import com.keylesspalace.tusky.AccountsInListFragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment
import com.keylesspalace.tusky.fragment.*
import com.keylesspalace.tusky.fragment.preference.AccountPreferencesFragment
import com.keylesspalace.tusky.fragment.preference.NotificationPreferencesFragment
import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment
import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment
import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment
import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment
import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment
import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment
import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
import com.keylesspalace.tusky.components.preference.PreferencesFragment
import dagger.Module
import dagger.android.ContributesAndroidInjector
@ -85,4 +86,7 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector
abstract fun searchHashtagsFragment(): SearchHashtagsFragment
@ContributesAndroidInjector
abstract fun preferencesFragment(): PreferencesFragment
}

View File

@ -4,6 +4,7 @@ package com.keylesspalace.tusky.di
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel
import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.components.report.ReportViewModel
@ -85,5 +86,10 @@ abstract class ViewModelModule {
@ViewModelKey(ScheduledTootViewModel::class)
internal abstract fun scheduledTootViewModel(viewModel: ScheduledTootViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(AnnouncementsViewModel::class)
internal abstract fun announcementsViewModel(viewModel: AnnouncementsViewModel): ViewModel
//Add more ViewModels here
}
}

View File

@ -0,0 +1,57 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.entity
import android.text.Spanned
import com.google.gson.annotations.SerializedName
import java.util.*
data class Announcement(
val id: String,
val content: Spanned,
@SerializedName("starts_at") val startsAt: Date?,
@SerializedName("ends_at") val endsAt: Date?,
@SerializedName("all_day") val allDay: Boolean,
@SerializedName("published_at") val publishedAt: Date,
@SerializedName("updated_at") val updatedAt: Date,
val read: Boolean,
val mentions: List<Status.Mention>,
val statuses: List<Status>,
val tags: List<HashTag>,
val emojis: List<Emoji>,
val reactions: List<Reaction>
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val announcement = other as Announcement?
return id == announcement?.id
}
override fun hashCode(): Int {
return id.hashCode()
}
data class Reaction(
val name: String,
var count: Int,
var me: Boolean,
val url: String?,
@SerializedName("static_url") val staticUrl: String?
)
}

View File

@ -26,7 +26,9 @@ data class Card(
val image: String,
val type: String,
val width: Int,
val height: Int
val height: Int,
val blurhash: String?,
val embed_url: String?
) {
override fun hashCode(): Int {
@ -41,4 +43,7 @@ data class Card(
return account?.url == this.url
}
companion object {
const val TYPE_PHOTO = "photo"
}
}

View File

@ -23,5 +23,6 @@ import kotlinx.android.parcel.Parcelize
data class Emoji(
val shortcode: String,
val url: String,
@SerializedName("static_url") val staticUrl: String,
@SerializedName("visible_in_picker") val visibleInPicker: Boolean?
) : Parcelable
) : Parcelable

View File

@ -0,0 +1,15 @@
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
import java.util.*
/**
* API type for saving the scroll position of a timeline.
*/
data class Marker(
@SerializedName("last_read_id")
val lastReadId: String,
val version: Int,
@SerializedName("updated_at")
val updatedAt: Date
)

View File

@ -26,5 +26,6 @@ data class Relationship (
@SerializedName("muting_notifications") val mutingNotifications: Boolean,
val requested: Boolean,
@SerializedName("showing_reblogs") val showingReblogs: Boolean,
@SerializedName("domain_blocking") val blockingDomain: Boolean
@SerializedName("domain_blocking") val blockingDomain: Boolean,
val note: String? // nullable for backward compatibility / feature detection
)

View File

@ -52,7 +52,6 @@ import java.io.IOException
import java.util.HashMap
import javax.inject.Inject
class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
@Inject
@ -116,27 +115,17 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
}
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
val callback = object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>, response: Response<Relationship>) {
if (response.isSuccessful) {
onMuteSuccess(mute, id, position, notifications)
} else {
onMuteFailure(mute, id, notifications)
}
}
override fun onFailure(call: Call<Relationship>, t: Throwable) {
onMuteFailure(mute, id, notifications)
}
}
val call = if (!mute) {
if (!mute) {
api.unmuteAccount(id)
} else {
api.muteAccount(id, notifications)
}
callList.add(call)
call.enqueue(callback)
.autoDispose(from(this))
.subscribe({
onMuteSuccess(mute, id, position, notifications)
}, {
onMuteFailure(mute, id, notifications)
})
}
private fun onMuteSuccess(muted: Boolean, id: String, position: Int, notifications: Boolean) {
@ -171,27 +160,17 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
}
override fun onBlock(block: Boolean, id: String, position: Int) {
val cb = object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>, response: Response<Relationship>) {
if (response.isSuccessful) {
onBlockSuccess(block, id, position)
} else {
onBlockFailure(block, id)
}
}
override fun onFailure(call: Call<Relationship>, t: Throwable) {
onBlockFailure(block, id)
}
}
val call = if (!block) {
if (!block) {
api.unblockAccount(id)
} else {
api.blockAccount(id)
}
callList.add(call)
call.enqueue(cb)
.autoDispose(from(this))
.subscribe({
onBlockSuccess(block, id, position)
}, {
onBlockFailure(block, id)
})
}
private fun onBlockSuccess(blocked: Boolean, id: String, position: Int) {
@ -350,29 +329,16 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
}
private fun fetchRelationships(ids: List<String>) {
val callback = object : Callback<List<Relationship>> {
override fun onResponse(call: Call<List<Relationship>>, response: Response<List<Relationship>>) {
val body = response.body()
if (response.isSuccessful && body != null) {
onFetchRelationshipsSuccess(body)
} else {
api.relationships(ids)
.autoDispose(from(this))
.subscribe(::onFetchRelationshipsSuccess) {
onFetchRelationshipsFailure(ids)
}
}
override fun onFailure(call: Call<List<Relationship>>, t: Throwable) {
onFetchRelationshipsFailure(ids)
}
}
val call = api.relationships(ids)
callList.add(call)
call.enqueue(callback)
}
private fun onFetchRelationshipsSuccess(relationships: List<Relationship>) {
val mutesAdapter = adapter as MutesAdapter
var mutingNotificationsMap = HashMap<String, Boolean>()
val mutingNotificationsMap = HashMap<String, Boolean>()
relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) }
mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap)
}

View File

@ -457,6 +457,7 @@ public abstract class SFragment extends BaseFragment implements Injectable {
composeOptions.setContentWarning(deletedStatus.getSpoilerText());
composeOptions.setMediaAttachments(deletedStatus.getAttachments());
composeOptions.setSensitive(deletedStatus.getSensitive());
composeOptions.setModifiedInitialState(true);
if (deletedStatus.getPoll() != null) {
composeOptions.setPoll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt()));
}

View File

@ -392,6 +392,7 @@ public class TimelineFragment extends SFragment implements
// home, notifications, public, thread
switch (kind) {
case HOME:
case LIST:
return filterContext.contains(Filter.HOME);
case PUBLIC_FEDERATED:
case PUBLIC_LOCAL:
@ -1209,7 +1210,7 @@ public class TimelineFragment extends SFragment implements
if (status != null
&& ((status.getInReplyToId() != null && filterRemoveReplies)
|| (status.getReblog() != null && filterRemoveReblogs)
|| shouldFilterStatus(status))) {
|| shouldFilterStatus(status.getActionableStatus()))) {
it.remove();
}
}

View File

@ -99,9 +99,9 @@ class ViewImageFragment : ViewMediaFragment() {
url = attachment.url
description = attachment.description
} else {
url = arguments.getString(ARG_AVATAR_URL)
url = arguments.getString(ARG_SINGLE_IMAGE_URL)
if (url == null) {
throw IllegalArgumentException("attachment or avatar url has to be set")
throw IllegalArgumentException("attachment or image url has to be set")
}
}
@ -158,6 +158,9 @@ class ViewImageFragment : ViewMediaFragment() {
}
private fun onGestureEnd() {
if (photoView == null) {
return
}
if (abs(photoView.translationY) > 180) {
photoActionsListener.onDismiss()
} else {

View File

@ -42,7 +42,7 @@ abstract class ViewMediaFragment : BaseFragment() {
@JvmStatic
protected val ARG_ATTACHMENT = "attach"
@JvmStatic
protected val ARG_AVATAR_URL = "avatarUrl"
protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl"
@JvmStatic
fun newInstance(attachment: Attachment, shouldStartPostponedTransition: Boolean): ViewMediaFragment {
@ -62,10 +62,10 @@ abstract class ViewMediaFragment : BaseFragment() {
}
@JvmStatic
fun newAvatarInstance(avatarUrl: String): ViewMediaFragment {
fun newSingleImageInstance(imageUrl: String): ViewMediaFragment {
val arguments = Bundle(2)
val fragment = ViewImageFragment()
arguments.putString(ARG_AVATAR_URL, avatarUrl)
arguments.putString(ARG_SINGLE_IMAGE_URL, imageUrl)
arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, true)
fragment.arguments = arguments

View File

@ -189,7 +189,7 @@ class ViewVideoFragment : ViewMediaFragment() {
mediaDescription.animate().alpha(alpha)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
mediaDescription.visible(isDescriptionVisible)
mediaDescription?.visible(isDescriptionVisible)
animation.removeListener(this)
}
})

View File

@ -99,11 +99,19 @@ interface MastodonApi {
@Query("exclude_types[]") excludes: Set<Notification.Type>?
): Call<List<Notification>>
@GET("api/v1/markers")
fun markersWithAuth(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@Query("timeline[]") timelines: List<String>
): Single<Map<String, Marker>>
@GET("api/v1/notifications")
fun notificationsWithAuth(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String
): Call<List<Notification>>
@Header(DOMAIN_HEADER) domain: String,
@Query("since_id") sinceId: String?
): Single<List<Notification>>
@POST("api/v1/notifications/clear")
fun clearNotifications(): Call<ResponseBody>
@ -261,7 +269,7 @@ interface MastodonApi {
@GET("api/v1/accounts/{id}")
fun account(
@Path("id") accountId: String
): Call<Account>
): Single<Account>
/**
* Method to fetch statuses for the specified account.
@ -300,44 +308,44 @@ interface MastodonApi {
fun followAccount(
@Path("id") accountId: String,
@Field("reblogs") showReblogs: Boolean
): Call<Relationship>
): Single<Relationship>
@POST("api/v1/accounts/{id}/unfollow")
fun unfollowAccount(
@Path("id") accountId: String
): Call<Relationship>
): Single<Relationship>
@POST("api/v1/accounts/{id}/block")
fun blockAccount(
@Path("id") accountId: String
): Call<Relationship>
): Single<Relationship>
@POST("api/v1/accounts/{id}/unblock")
fun unblockAccount(
@Path("id") accountId: String
): Call<Relationship>
): Single<Relationship>
@FormUrlEncoded
@POST("api/v1/accounts/{id}/mute")
fun muteAccount(
@Path("id") accountId: String,
@Field("notifications") notifications: Boolean
): Call<Relationship>
@Field("notifications") notifications: Boolean? = null
): Single<Relationship>
@POST("api/v1/accounts/{id}/unmute")
fun unmuteAccount(
@Path("id") accountId: String
): Call<Relationship>
): Single<Relationship>
@GET("api/v1/accounts/relationships")
fun relationships(
@Query("id[]") accountIds: List<String>
): Call<List<Relationship>>
): Single<List<Relationship>>
@GET("api/v1/accounts/{id}/identity_proofs")
fun identityProofs(
@Path("id") accountId: String
): Call<List<IdentityProof>>
): Single<List<IdentityProof>>
@GET("api/v1/blocks")
fun blocks(
@ -505,30 +513,27 @@ interface MastodonApi {
@Field("choices[]") choices: List<Int>
): Single<Poll>
@POST("api/v1/accounts/{id}/block")
fun blockAccountObservable(
@Path("id") accountId: String
): Single<Relationship>
@GET("api/v1/announcements")
fun listAnnouncements(
@Query("with_dismissed") withDismissed: Boolean = true
): Single<List<Announcement>>
@POST("api/v1/accounts/{id}/unblock")
fun unblockAccountObservable(
@Path("id") accountId: String
): Single<Relationship>
@POST("api/v1/announcements/{id}/dismiss")
fun dismissAnnouncement(
@Path("id") announcementId: String
): Single<ResponseBody>
@POST("api/v1/accounts/{id}/mute")
fun muteAccountObservable(
@Path("id") accountId: String
): Single<Relationship>
@PUT("api/v1/announcements/{id}/reactions/{name}")
fun addAnnouncementReaction(
@Path("id") announcementId: String,
@Path("name") name: String
): Single<ResponseBody>
@POST("api/v1/accounts/{id}/unmute")
fun unmuteAccountObservable(
@Path("id") accountId: String
): Single<Relationship>
@GET("api/v1/accounts/relationships")
fun relationshipsObservable(
@Query("id[]") accountIds: List<String>
): Single<List<Relationship>>
@DELETE("api/v1/announcements/{id}/reactions/{name}")
fun removeAnnouncementReaction(
@Path("id") announcementId: String,
@Path("name") name: String
): Single<ResponseBody>
@FormUrlEncoded
@POST("api/v1/reports")
@ -563,4 +568,11 @@ interface MastodonApi {
@Query("following") following: Boolean? = null
): Single<SearchResult>
@FormUrlEncoded
@POST("api/v1/accounts/{id}/note")
fun updateAccountNote(
@Path("id") accountId: String,
@Field("comment") note: String
): Single<Relationship>
}

View File

@ -15,17 +15,14 @@
package com.keylesspalace.tusky.network
import android.util.Log
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.Status
import io.reactivex.Single
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.rxkotlin.addTo
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.lang.IllegalStateException
/**
@ -108,24 +105,23 @@ class TimelineCasesImpl(
}
override fun mute(id: String, notifications: Boolean) {
val call = mastodonApi.muteAccount(id, notifications)
call.enqueue(object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>, response: Response<Relationship>) {}
override fun onFailure(call: Call<Relationship>, t: Throwable) {}
})
eventHub.dispatch(MuteEvent(id))
mastodonApi.muteAccount(id, notifications)
.subscribe({
eventHub.dispatch(MuteEvent(id))
}, { t ->
Log.w("Failed to mute account", t)
})
.addTo(cancelDisposable)
}
override fun block(id: String) {
val call = mastodonApi.blockAccount(id)
call.enqueue(object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>, response: Response<Relationship>) {}
override fun onFailure(call: Call<Relationship>, t: Throwable) {}
})
eventHub.dispatch(BlockEvent(id))
mastodonApi.blockAccount(id)
.subscribe({
eventHub.dispatch(BlockEvent(id))
}, { t ->
Log.w("Failed to block account", t)
})
.addTo(cancelDisposable)
}
override fun delete(id: String): Single<DeletedStatus> {

View File

@ -5,14 +5,14 @@ import androidx.fragment.app.FragmentActivity
import com.keylesspalace.tusky.ViewMediaAdapter
import com.keylesspalace.tusky.fragment.ViewMediaFragment
class AvatarImagePagerAdapter(
class SingleImagePagerAdapter(
activity: FragmentActivity,
private val avatarUrl: String
private val imageUrl: String
) : ViewMediaAdapter(activity) {
override fun createFragment(position: Int): Fragment {
return if (position == 0) {
ViewMediaFragment.newAvatarInstance(avatarUrl)
ViewMediaFragment.newSingleImageInstance(imageUrl)
} else {
throw IllegalStateException()
}

View File

@ -1,6 +1,5 @@
package com.keylesspalace.tusky.repository
import android.text.Spanned
import android.text.SpannedString
import androidx.core.text.parseAsHtml
import androidx.core.text.toHtml
@ -184,14 +183,10 @@ class TimelineRepositoryImpl(
}
private fun cleanup() {
Single.fromCallable {
Schedulers.io().scheduleDirect {
val olderThan = System.currentTimeMillis() - TimelineRepository.CLEANUP_INTERVAL
for (account in accountManager.getAllAccountsOrderedByActive()) {
timelineDao.cleanup(account.id, account.accountId, olderThan)
}
timelineDao.cleanup(olderThan)
}
.subscribeOn(Schedulers.io())
.subscribe()
}
private fun TimelineStatusWithAccount.toStatus(): TimelineStatus {

View File

@ -17,7 +17,7 @@ object PrefKeys {
// each preference a key for it to work.
const val APP_THEME = "appTheme"
const val EMOJI = "emojiCompat"
const val EMOJI = "selected_emoji_font"
const val FAB_HIDE = "fabHide"
const val LANGUAGE = "language"
const val STATUS_TEXT_SIZE = "statusTextSize"

View File

@ -3,7 +3,8 @@ package com.keylesspalace.tusky.settings
import android.content.Context
import androidx.annotation.StringRes
import androidx.preference.*
import com.keylesspalace.tusky.EmojiPreference
import com.keylesspalace.tusky.components.preference.EmojiPreference
import okhttp3.OkHttpClient
class PreferenceParent(
val context: Context,
@ -24,8 +25,8 @@ inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit):
return pref
}
inline fun PreferenceParent.emojiPreference(builder: EmojiPreference.() -> Unit): EmojiPreference {
val pref = EmojiPreference(context)
inline fun PreferenceParent.emojiPreference(okHttpClient: OkHttpClient, builder: EmojiPreference.() -> Unit): EmojiPreference {
val pref = EmojiPreference(context, okHttpClient)
builder(pref)
addPref(pref)
return pref

View File

@ -54,7 +54,7 @@ fun CharSequence.emojify(emojis: List<Emoji>?, view: View) : CharSequence {
while(matcher.find()) {
val span = EmojiSpan(WeakReference(view))
builder.setSpan(span, matcher.start(), matcher.end(), 0);
builder.setSpan(span, matcher.start(), matcher.end(), 0)
Glide.with(view)
.asBitmap()
.load(url)
@ -89,7 +89,7 @@ class EmojiSpan(val viewWeakReference: WeakReference<View>) : ReplacementSpan()
drawable.setBounds(0, 0, emojiSize, emojiSize)
var transY = bottom - drawable.bounds.bottom
transY -= paint.fontMetricsInt.descent / 2;
transY -= paint.fontMetricsInt.descent / 2
canvas.translate(x, transY.toFloat())
drawable.draw(canvas)

View File

@ -1,564 +0,0 @@
package com.keylesspalace.tusky.util;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.util.Log;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import com.keylesspalace.tusky.R;
import java.io.EOFException;
import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import de.c1710.filemojicompat.FileEmojiCompatConfig;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.BufferedSink;
import okio.Okio;
import okio.Source;
/**
* This class bundles information about an emoji font as well as many convenient actions.
*/
public class EmojiCompatFont {
private static final String TAG = "EmojiCompatFont";
/**
* This String represents the sub-directory the fonts are stored in.
*/
private static final String DIRECTORY = "emoji";
// These are the items which are also present in the JSON files
private final String name, display, url;
// The thumbnail image and the caption are provided as resource ids
private final int img, caption;
// The version is stored as a String in the x.xx.xx format (to be able to compare versions)
private final String version;
private final int[] versionCode;
private AsyncTask fontDownloader;
// The system font gets some special behavior...
private static final EmojiCompatFont SYSTEM_DEFAULT =
new EmojiCompatFont("system-default",
"System Default",
R.string.caption_systememoji,
R.drawable.ic_emoji_34dp,
"",
"0");
private static final EmojiCompatFont BLOBMOJI =
new EmojiCompatFont("Blobmoji",
"Blobmoji",
R.string.caption_blobmoji,
R.drawable.ic_blobmoji,
"https://tusky.app/hosted/emoji/BlobmojiCompat.ttf",
"12.0.0"
);
private static final EmojiCompatFont TWEMOJI =
new EmojiCompatFont("Twemoji",
"Twemoji",
R.string.caption_twemoji,
R.drawable.ic_twemoji,
"https://tusky.app/hosted/emoji/TwemojiCompat.ttf",
"12.0.0"
);
private static final EmojiCompatFont NOTOEMOJI =
new EmojiCompatFont("NotoEmoji",
"Noto Emoji",
R.string.caption_notoemoji,
R.drawable.ic_notoemoji,
"https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf",
"11.0.0"
);
/**
* This array stores all available EmojiCompat fonts.
* References to them can simply be saved by saving their indices
*/
public static final EmojiCompatFont[] FONTS = {SYSTEM_DEFAULT, BLOBMOJI, TWEMOJI, NOTOEMOJI};
// A list of all available font files and whether they are older than the current version or not
// They are ordered by there version codes in ascending order
private ArrayList<Pair<File, int[]>> existingFontFiles;
private EmojiCompatFont(String name,
String display,
int caption,
int img,
String url,
String version) {
this.name = name;
this.display = display;
this.caption = caption;
this.img = img;
this.url = url;
this.version = version;
this.versionCode = getVersionCode(version);
}
/**
* Returns the Emoji font associated with this ID
*
* @param id the ID of this font
* @return the corresponding font. Will default to SYSTEM_DEFAULT if not in range.
*/
public static EmojiCompatFont byId(int id) {
if (id >= 0 && id < FONTS.length) {
return FONTS[id];
} else {
return SYSTEM_DEFAULT;
}
}
public int getId() {
return Arrays.asList(FONTS).indexOf(this);
}
public String getName() {
return name;
}
public String getDisplay(Context context) {
return this != SYSTEM_DEFAULT ? display : context.getString(R.string.system_default);
}
public String getCaption(Context context) {
return context.getResources().getString(caption);
}
public String getUrl() {
return url;
}
public Drawable getThumb(Context context) {
return ContextCompat.getDrawable(context, img);
}
public String getVersion() {
return version;
}
public int[] getVersionCode() {
return versionCode;
}
/**
* This method will return the actual font file (regardless of its existence) for
* the current version (not necessarily the latest!).
*
* @return The font (TTF) file or null if called on SYSTEM_FONT
*/
@Nullable
private File getFont(Context context) {
if (this != SYSTEM_DEFAULT) {
File directory = new File(context.getExternalFilesDir(null), DIRECTORY);
return new File(directory, this.getName() + this.getVersion() + ".ttf");
} else {
return null;
}
}
public FileEmojiCompatConfig getConfig(Context context) {
return new FileEmojiCompatConfig(context, getLatestFontFile(context));
}
public boolean isDownloaded(Context context) {
// The existence of the current version is actually checked twice, although the first method should
// be much faster and more common.
return this == SYSTEM_DEFAULT || getFont(context) != null
&& (getFont(context).exists() || newerFileExists(context));
}
/**
* Checks whether there is already a font version that satisfies the current version, i.e. it
* has a higher or equal version code.
*
* @param context The Context
* @return Whether there is a font file with a higher or equal version code to the current
*/
private boolean newerFileExists(Context context) {
loadExistingFontFiles(context);
if (!existingFontFiles.isEmpty())
// The last file is already the newest one...
return compareVersions(existingFontFiles.get(existingFontFiles.size() - 1).second,
getVersionCode()) >= 0;
return false;
}
/**
* Downloads the TTF file for this font
*
* @param listeners The listeners which will be notified when the download has been finished
*/
public void downloadFont(Context context, Downloader.EmojiDownloadListener... listeners) {
if (this != SYSTEM_DEFAULT) {
// Additionally run a cleanup process after the download has been successful.
Downloader.EmojiDownloadListener cleanup = font -> deleteOldVersions(context);
List<Downloader.EmojiDownloadListener> allListeners
= new ArrayList<>(Arrays.asList(listeners));
allListeners.add(cleanup);
Downloader.EmojiDownloadListener[] allListenersA =
new Downloader.EmojiDownloadListener[allListeners.size()];
fontDownloader = new Downloader(
this,
allListeners.toArray(allListenersA))
.execute(getFont(context));
} else {
for (Downloader.EmojiDownloadListener listener : listeners) {
// The system emoji font is always downloaded...
listener.onDownloaded(this);
}
}
}
/**
* Deletes any older version of a font
*
* @param context The current Context
*/
private void deleteOldVersions(Context context) {
loadExistingFontFiles(context);
Log.d(TAG, "deleting old versions...");
Log.d(TAG, String.format("deleteOldVersions: Found %d other font files", existingFontFiles.size()));
for (Pair<File, int[]> fileExists : existingFontFiles) {
if (compareVersions(fileExists.second, getVersionCode()) < 0) {
File file = fileExists.first;
// Uses side effects!
Log.d(TAG, String.format("Deleted %s successfully: %s", file.getAbsolutePath(),
file.delete()));
}
}
}
private static final Comparator<Pair<File, int[]>> pairComparator = (o1, o2) -> compareVersions(o1.second, o2.second);
/**
* Loads all font files that are inside the files directory into an ArrayList with the information
* on whether they are older than the currently available version or not.
*
* @param context The Context
*/
private void loadExistingFontFiles(Context context) {
// Only load it once
if (this.existingFontFiles == null) {
// If we call this on the system default font, just return nothing...
if (this == SYSTEM_DEFAULT) {
existingFontFiles = new ArrayList<>(0);
}
File directory = new File(context.getExternalFilesDir(null), DIRECTORY);
// It will search for old versions using a regex that matches the font's name plus
// (if present) a version code. No version code will be regarded as version 0.
Pattern fontRegex = Pattern.compile(getName() + "(\\d+(\\.\\d+)*)?" + "\\.ttf");
FilenameFilter ttfFilter = (dir, name) -> name.endsWith(".ttf");
File[] existingFontFiles = directory.isDirectory() ? directory.listFiles(ttfFilter) : new File[0];
Log.d(TAG, String.format("loadExistingFontFiles: %d other font files found",
existingFontFiles.length));
// This is actually the upper bound
this.existingFontFiles = new ArrayList<>(existingFontFiles.length);
for (File file : existingFontFiles) {
Matcher matcher = fontRegex.matcher(file.getName());
if (matcher.matches()) {
String version = matcher.group(1);
int[] versionCode = getVersionCode(version);
Pair<File, int[]> entry = new Pair<>(file, versionCode);
// https://stackoverflow.com/a/51893026
// Insert it in a sorted way
int index = Collections.binarySearch(this.existingFontFiles, entry, pairComparator);
if (index < 0) {
index = -index - 1;
}
this.existingFontFiles.add(index, entry);
}
}
}
}
/**
* Returns the current or latest version of this font file (if there is any)
*
* @param context The Context
* @return The file for this font with the current or (if not existent) highest version code or null if there is no file for this font.
*/
private File getLatestFontFile(@NonNull Context context) {
File current = getFont(context);
if (current != null && current.exists())
return current;
loadExistingFontFiles(context);
try {
return existingFontFiles.get(existingFontFiles.size() - 1).first;
} catch (IndexOutOfBoundsException e) {
return getFont(context);
}
}
private @Nullable
int[] getVersionCode(@Nullable String version) {
if (version == null)
return null;
String[] versions = version.split("\\.");
int[] versionCodes = new int[versions.length];
for (int i = 0; i < versions.length; i++)
versionCodes[i] = parseInt(versions[i], 0);
return versionCodes;
}
/**
* A small helper method to convert a String to an int with a default value
*
* @param value The String to be parsed
* @param def The default value
* @return Either the String parsed to an int or - if this is not possible - the default value
*/
private int parseInt(@Nullable String value, int def) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException | NullPointerException e) {
e.printStackTrace();
return def;
}
}
/**
* Compares two version codes to each other
*
* @param versionA The first version
* @param versionB The second version
* @return -1 if versionA < versionB, 1 if versionA > versionB and 0 otherwise
*/
private static int compareVersions(int[] versionA, int[] versionB) {
// This saves us much headache about handling a null version
if (versionA == null)
versionA = new int[]{0};
int len = Math.max(versionB.length, versionA.length);
int vA, vB;
// Compare the versions
for (int i = 0; i < len; i++) {
// Just to make sure there is something specified here
if (versionA.length > i) {
vA = versionA[i];
} else {
vA = 0;
}
if (versionB.length > i) {
vB = versionB[i];
} else {
vB = 0;
}
// It needs to be decided on the next level
if (vB == vA)
continue;
// Okay, is version B newer or version A?
return Integer.compare(vA, vB);
}
// The versions are equal
return 0;
}
/**
* Stops downloading the font. If no one started a font download, nothing happens.
*/
public void cancelDownload() {
if (fontDownloader != null) {
fontDownloader.cancel(false);
fontDownloader = null;
}
}
/**
* This class is used to easily manage the download of a font
*/
public static class Downloader extends AsyncTask<File, Float, File> {
// All interested objects/methods
private final EmojiDownloadListener[] listeners;
// The MIME-Type which might be unnecessary
private static final String MIME = "application/woff";
// The font belonging to this download
private final EmojiCompatFont font;
private static final String TAG = "Emoji-Font Downloader";
private static long CHUNK_SIZE = 4096;
private boolean failed = false;
Downloader(EmojiCompatFont font, EmojiDownloadListener... listeners) {
super();
this.listeners = listeners;
this.font = font;
}
@Override
protected File doInBackground(File... files) {
// Only download to one file...
File downloadFile = files[0];
try {
// It is possible (and very likely) that the file does not exist yet
if (!downloadFile.exists()) {
downloadFile.getParentFile().mkdirs();
downloadFile.createNewFile();
}
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder().url(font.getUrl())
.addHeader("Content-Type", MIME)
.build();
Response response = client.newCall(request).execute();
BufferedSink sink = Okio.buffer(Okio.sink(downloadFile));
Source source = null;
try {
long size;
// Download!
if (response.body() != null
&& response.isSuccessful()
&& (size = networkResponseLength(response)) > 0) {
float progress = 0;
source = response.body().source();
try {
while (!isCancelled()) {
sink.write(response.body().source(), CHUNK_SIZE);
progress += CHUNK_SIZE;
publishProgress(progress / size);
}
} catch (EOFException ex) {
/*
This means we've finished downloading the file since sink.write
will throw an EOFException when the file to be read is empty.
*/
}
} else {
Log.e(TAG, "downloading " + font.getUrl() + " failed. No content to download.");
Log.e(TAG, "Status code: " + response.code());
failed = true;
}
} finally {
if (source != null) {
source.close();
}
sink.close();
// This 'if' uses side effects to delete the File.
if (isCancelled() && !downloadFile.delete()) {
Log.e(TAG, "Could not delete file " + downloadFile);
}
}
} catch (IOException ex) {
ex.printStackTrace();
failed = true;
}
return downloadFile;
}
@Override
public void onProgressUpdate(Float... progress) {
for (EmojiDownloadListener listener : listeners) {
listener.onProgress(progress[0]);
}
}
@Override
public void onPostExecute(File downloadedFile) {
if (!failed && downloadedFile.exists()) {
for (EmojiDownloadListener listener : listeners) {
listener.onDownloaded(font);
}
} else {
fail(downloadedFile);
}
}
private void fail(File failedFile) {
if (failedFile.exists() && !failedFile.delete()) {
Log.e(TAG, "Could not delete file " + failedFile);
}
for (EmojiDownloadListener listener : listeners) {
listener.onFailed();
}
}
/**
* This interfaced is used to get notified when a download has been finished
*/
public interface EmojiDownloadListener {
/**
* Called after successfully finishing a download.
*
* @param font The font related to this download. This will help identifying the download
*/
void onDownloaded(EmojiCompatFont font);
// TODO: Add functionality
/**
* Called when something went wrong with the download.
* This one won't be called when the download has been cancelled though.
*/
default void onFailed() {
// Oh no! D:
}
/**
* Called whenever the progress changed
*
* @param Progress A value between 0 and 1 representing the current progress
*/
default void onProgress(float Progress) {
// ARE WE THERE YET?
}
}
/**
* This method is needed because when transparent compression is used OkHttp reports
* {@link ResponseBody#contentLength()} as -1. We try to get the header which server sent
* us manually here.
*
* @see <a href="https://github.com/square/okhttp/issues/259">OkHttp issue 259</a>
*/
private long networkResponseLength(Response response) {
Response networkResponse = response.networkResponse();
if (networkResponse == null) {
// In case it's a fully cached response
ResponseBody body = response.body();
return body == null ? -1 : body.contentLength();
}
String header = networkResponse.header("Content-Length");
if (header == null) {
return -1;
}
try {
return Integer.parseInt(header);
} catch (NumberFormatException e) {
return -1;
}
}
}
@Override
@NonNull
public String toString() {
return display;
}
}

View File

@ -0,0 +1,351 @@
package com.keylesspalace.tusky.util
import android.content.Context
import android.util.Log
import android.util.Pair
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import com.keylesspalace.tusky.R
import de.c1710.filemojicompat.FileEmojiCompatConfig
import io.reactivex.Observable
import io.reactivex.ObservableEmitter
import io.reactivex.schedulers.Schedulers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.toLongOrDefault
import okio.Source
import okio.buffer
import okio.sink
import java.io.EOFException
import java.io.File
import java.io.FilenameFilter
import java.io.IOException
import kotlin.math.max
/**
* This class bundles information about an emoji font as well as many convenient actions.
*/
class EmojiCompatFont(
val name: String,
private val display: String,
@StringRes val caption: Int,
@DrawableRes val img: Int,
val url: String,
// The version is stored as a String in the x.xx.xx format (to be able to compare versions)
val version: String) {
private val versionCode = getVersionCode(version)
// A list of all available font files and whether they are older than the current version or not
// They are ordered by their version codes in ascending order
private var existingFontFileCache: List<Pair<File, List<Int>>>? = null
val id: Int
get() = FONTS.indexOf(this)
fun getDisplay(context: Context): String {
return if (this !== SYSTEM_DEFAULT) display else context.getString(R.string.system_default)
}
/**
* This method will return the actual font file (regardless of its existence) for
* the current version (not necessarily the latest!).
*
* @return The font (TTF) file or null if called on SYSTEM_FONT
*/
private fun getFontFile(context: Context): File? {
return if (this !== SYSTEM_DEFAULT) {
val directory = File(context.getExternalFilesDir(null), DIRECTORY)
File(directory, "$name$version.ttf")
} else {
null
}
}
fun getConfig(context: Context): FileEmojiCompatConfig {
return FileEmojiCompatConfig(context, getLatestFontFile(context))
}
fun isDownloaded(context: Context): Boolean {
return this === SYSTEM_DEFAULT || getFontFile(context)?.exists() == true || fontFileExists(context)
}
/**
* Checks whether there is already a font version that satisfies the current version, i.e. it
* has a higher or equal version code.
*
* @param context The Context
* @return Whether there is a font file with a higher or equal version code to the current
*/
private fun fontFileExists(context: Context): Boolean {
val existingFontFiles = getExistingFontFiles(context)
return if (existingFontFiles.isNotEmpty()) {
compareVersions(existingFontFiles.last().second, versionCode) >= 0
} else {
false
}
}
/**
* Deletes any older version of a font
*
* @param context The current Context
*/
private fun deleteOldVersions(context: Context) {
val existingFontFiles = getExistingFontFiles(context)
Log.d(TAG, "deleting old versions...")
Log.d(TAG, String.format("deleteOldVersions: Found %d other font files", existingFontFiles.size))
for (fileExists in existingFontFiles) {
if (compareVersions(fileExists.second, versionCode) < 0) {
val file = fileExists.first
// Uses side effects!
Log.d(TAG, String.format("Deleted %s successfully: %s", file.absolutePath,
file.delete()))
}
}
}
/**
* Loads all font files that are inside the files directory into an ArrayList with the information
* on whether they are older than the currently available version or not.
*
* @param context The Context
*/
private fun getExistingFontFiles(context: Context): List<Pair<File, List<Int>>> {
// Only load it once
existingFontFileCache?.let {
return it
}
// If we call this on the system default font, just return nothing...
if (this === SYSTEM_DEFAULT) {
existingFontFileCache = emptyList()
return emptyList()
}
val directory = File(context.getExternalFilesDir(null), DIRECTORY)
// It will search for old versions using a regex that matches the font's name plus
// (if present) a version code. No version code will be regarded as version 0.
val fontRegex = "$name(\\d+(\\.\\d+)*)?\\.ttf".toPattern()
val ttfFilter = FilenameFilter { _, name: String -> name.endsWith(".ttf") }
val foundFontFiles = directory.listFiles(ttfFilter).orEmpty()
Log.d(TAG, String.format("loadExistingFontFiles: %d other font files found",
foundFontFiles.size))
return foundFontFiles.map { file ->
val matcher = fontRegex.matcher(file.name)
val versionCode = if (matcher.matches()) {
val version = matcher.group(1)
getVersionCode(version)
} else {
listOf(0)
}
Pair(file, versionCode)
}.sortedWith { a, b ->
compareVersions(a.second, b.second)
}.also {
existingFontFileCache = it
}
}
/**
* Returns the current or latest version of this font file (if there is any)
*
* @param context The Context
* @return The file for this font with the current or (if not existent) highest version code or null if there is no file for this font.
*/
private fun getLatestFontFile(context: Context): File? {
val current = getFontFile(context)
if (current != null && current.exists()) return current
val existingFontFiles = getExistingFontFiles(context)
return existingFontFiles.firstOrNull()?.first
}
private fun getVersionCode(version: String?): List<Int> {
if (version == null) return listOf(0)
return version.split(".").map {
it.toIntOrNull() ?: 0
}
}
fun downloadFontFile(context: Context,
okHttpClient: OkHttpClient): Observable<Float> {
return Observable.create { emitter: ObservableEmitter<Float> ->
// It is possible (and very likely) that the file does not exist yet
val downloadFile = getFontFile(context)!!
if (!downloadFile.exists()) {
downloadFile.parentFile?.mkdirs()
downloadFile.createNewFile()
}
val request = Request.Builder().url(url)
.build()
val sink = downloadFile.sink().buffer()
var source: Source? = null
try {
// Download!
val response = okHttpClient.newCall(request).execute()
val responseBody = response.body
if (response.isSuccessful && responseBody != null) {
val size = response.length()
var progress = 0f
source = responseBody.source()
try {
while (!emitter.isDisposed) {
sink.write(source, CHUNK_SIZE)
progress += CHUNK_SIZE.toFloat()
if(size > 0) {
emitter.onNext(progress / size)
} else {
emitter.onNext(-1f)
}
}
} catch (ex: EOFException) {
/*
This means we've finished downloading the file since sink.write
will throw an EOFException when the file to be read is empty.
*/
}
} else {
Log.e(TAG, "Downloading $url failed. Status code: ${response.code}")
emitter.tryOnError(Exception())
}
} catch (ex: IOException) {
Log.e(TAG, "Downloading $url failed.", ex)
downloadFile.deleteIfExists()
emitter.tryOnError(ex)
} finally {
source?.close()
sink.close()
if (emitter.isDisposed) {
downloadFile.deleteIfExists()
} else {
deleteOldVersions(context)
emitter.onComplete()
}
}
}
.subscribeOn(Schedulers.io())
}
/**
* Deletes the downloaded file, if it exists. Should be called when a download gets cancelled.
*/
fun deleteDownloadedFile(context: Context) {
getFontFile(context)?.deleteIfExists()
}
override fun toString(): String {
return display
}
companion object {
private const val TAG = "EmojiCompatFont"
/**
* This String represents the sub-directory the fonts are stored in.
*/
private const val DIRECTORY = "emoji"
private const val CHUNK_SIZE = 4096L
// The system font gets some special behavior...
private val SYSTEM_DEFAULT = EmojiCompatFont("system-default",
"System Default",
R.string.caption_systememoji,
R.drawable.ic_emoji_34dp,
"",
"0")
private val BLOBMOJI = EmojiCompatFont("Blobmoji",
"Blobmoji",
R.string.caption_blobmoji,
R.drawable.ic_blobmoji,
"https://tusky.app/hosted/emoji/BlobmojiCompat.ttf",
"12.0.0"
)
private val TWEMOJI = EmojiCompatFont("Twemoji",
"Twemoji",
R.string.caption_twemoji,
R.drawable.ic_twemoji,
"https://tusky.app/hosted/emoji/TwemojiCompat.ttf",
"12.0.0"
)
private val NOTOEMOJI = EmojiCompatFont("NotoEmoji",
"Noto Emoji",
R.string.caption_notoemoji,
R.drawable.ic_notoemoji,
"https://tusky.app/hosted/emoji/NotoEmojiCompat.ttf",
"11.0.0"
)
/**
* This array stores all available EmojiCompat fonts.
* References to them can simply be saved by saving their indices
*/
val FONTS = listOf(SYSTEM_DEFAULT, BLOBMOJI, TWEMOJI, NOTOEMOJI)
/**
* Returns the Emoji font associated with this ID
*
* @param id the ID of this font
* @return the corresponding font. Will default to SYSTEM_DEFAULT if not in range.
*/
fun byId(id: Int): EmojiCompatFont = FONTS.getOrElse(id) { SYSTEM_DEFAULT }
/**
* Compares two version codes to each other
*
* @param versionA The first version
* @param versionB The second version
* @return -1 if versionA < versionB, 1 if versionA > versionB and 0 otherwise
*/
@VisibleForTesting
fun compareVersions(versionA: List<Int>, versionB: List<Int>): Int {
val len = max(versionB.size, versionA.size)
for (i in 0 until len) {
val vA = versionA.getOrElse(i) { 0 }
val vB = versionB.getOrElse(i) { 0 }
// It needs to be decided on the next level
if (vA == vB) continue
// Okay, is version B newer or version A?
return vA.compareTo(vB)
}
// The versions are equal
return 0
}
/**
* This method is needed because when transparent compression is used OkHttp reports
* [ResponseBody.contentLength] as -1. We try to get the header which server sent
* us manually here.
*
* @see [OkHttp issue 259](https://github.com/square/okhttp/issues/259)
*/
private fun Response.length(): Long {
networkResponse?.let {
val header = it.header("Content-Length") ?: return -1
return header.toLongOrDefault(-1)
}
// In case it's a fully cached response
return body?.contentLength() ?: -1
}
private fun File.deleteIfExists() {
if(exists() && !delete()) {
Log.e(TAG, "Could not delete file $this")
}
}
}
}

View File

@ -285,8 +285,7 @@ class StatusViewHelper(private val itemView: View) {
if (useAbsoluteTime) {
context.getString(R.string.poll_info_time_absolute, getAbsoluteTime(poll.expiresAt))
} else {
val pollDuration = TimestampUtils.formatPollDuration(context, poll.expiresAt!!.time, timestamp)
context.getString(R.string.poll_info_time_relative, pollDuration)
TimestampUtils.formatPollDuration(context, poll.expiresAt!!.time, timestamp)
}
}

View File

@ -80,3 +80,12 @@ fun Spanned.trimTrailingWhitespace(): Spanned {
} while (i >= 0 && get(i).isWhitespace())
return subSequence(0, i + 1) as Spanned
}
/**
* BidiFormatter.unicodeWrap is insufficient in some cases (see #1921)
* So we force isolation manually
* https://unicode.org/reports/tr9/#Explicit_Directional_Isolates
*/
fun CharSequence.unicodeWrap(): String {
return "\u2068${this}\u2069"
}

View File

@ -0,0 +1,17 @@
package com.keylesspalace.tusky.view
import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
class EmojiPicker @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : RecyclerView(context, attrs) {
init {
clipToPadding = false
layoutManager = GridLayoutManager(context, 3, GridLayoutManager.HORIZONTAL, false)
}
}

View File

@ -17,7 +17,7 @@ fun showMuteAccountDialog(
(view.findViewById(R.id.warning) as TextView).text =
activity.getString(R.string.dialog_mute_warning, accountUsername)
val checkbox: CheckBox = view.findViewById(R.id.checkbox)
checkbox.setChecked(true)
checkbox.isChecked = true
AlertDialog.Builder(activity)
.setView(view)

View File

@ -2,7 +2,6 @@ package com.keylesspalace.tusky.viewmodel
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Account
@ -11,70 +10,66 @@ import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.*
import io.reactivex.Single
import io.reactivex.disposables.Disposable
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class AccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager
) : ViewModel() {
) : RxAwareViewModel() {
val accountData = MutableLiveData<Resource<Account>>()
val relationshipData = MutableLiveData<Resource<Relationship>>()
val noteSaved = MutableLiveData<Boolean>()
private val identityProofData = MutableLiveData<List<IdentityProof>>()
val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs ->
identityProofs.orEmpty().map { Either.Left<IdentityProof, Field>(it) }
.plus(accountRes?.data?.fields.orEmpty().map { Either.Right<IdentityProof, Field>(it) })
.plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) })
}
private val callList: MutableList<Call<*>> = mutableListOf()
private val disposable: Disposable = eventHub.events
.subscribe { event ->
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
accountData.postValue(Success(event.newProfileData))
}
}
val isRefreshing = MutableLiveData<Boolean>().apply { value = false }
private var isDataLoading = false
lateinit var accountId: String
var isSelf = false
private var noteDisposable: Disposable? = null
init {
eventHub.events
.subscribe { event ->
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
accountData.postValue(Success(event.newProfileData))
}
}.autoDispose()
}
private fun obtainAccount(reload: Boolean = false) {
if (accountData.value == null || reload) {
isDataLoading = true
accountData.postValue(Loading())
val call = mastodonApi.account(accountId)
call.enqueue(object : Callback<Account> {
override fun onResponse(call: Call<Account>,
response: Response<Account>) {
if (response.isSuccessful) {
accountData.postValue(Success(response.body()))
} else {
mastodonApi.account(accountId)
.subscribe({ account ->
accountData.postValue(Success(account))
isDataLoading = false
isRefreshing.postValue(false)
}, {t ->
Log.w(TAG, "failed obtaining account", t)
accountData.postValue(Error())
}
isDataLoading = false
isRefreshing.postValue(false)
}
override fun onFailure(call: Call<Account>, t: Throwable) {
Log.w(TAG, "failed obtaining account", t)
accountData.postValue(Error())
isDataLoading = false
isRefreshing.postValue(false)
}
})
callList.add(call)
isDataLoading = false
isRefreshing.postValue(false)
})
.autoDispose()
}
}
@ -83,51 +78,27 @@ class AccountViewModel @Inject constructor(
relationshipData.postValue(Loading())
val ids = listOf(accountId)
val call = mastodonApi.relationships(ids)
call.enqueue(object : Callback<List<Relationship>> {
override fun onResponse(call: Call<List<Relationship>>,
response: Response<List<Relationship>>) {
val relationships = response.body()
if (response.isSuccessful && relationships != null && relationships.getOrNull(0) != null) {
val relationship = relationships[0]
relationshipData.postValue(Success(relationship))
} else {
mastodonApi.relationships(listOf(accountId))
.subscribe({ relationships ->
relationshipData.postValue(Success(relationships[0]))
}, { t ->
Log.w(TAG, "failed obtaining relationships", t)
relationshipData.postValue(Error())
}
}
override fun onFailure(call: Call<List<Relationship>>, t: Throwable) {
Log.w(TAG, "failed obtaining relationships", t)
relationshipData.postValue(Error())
}
})
callList.add(call)
})
.autoDispose()
}
}
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 ) {
mastodonApi.identityProofs(accountId)
.subscribe({ proofs ->
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)
}, { t ->
Log.w(TAG, "failed obtaining identity proofs", t)
})
.autoDispose()
}
}
@ -230,11 +201,15 @@ class AccountViewModel @Inject constructor(
relationshipData.postValue(Loading(newRelation))
}
val callback = object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>,
response: Response<Relationship>) {
val relationship = response.body()
if (response.isSuccessful && relationship != null) {
when (relationshipAction) {
RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, parameter ?: true)
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true)
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
}.subscribe(
{ relationship ->
relationshipData.postValue(Success(relationship))
when (relationshipAction) {
@ -244,37 +219,35 @@ class AccountViewModel @Inject constructor(
else -> {
}
}
} else {
},
{
relationshipData.postValue(Error(relation))
}
)
.autoDispose()
}
}
override fun onFailure(call: Call<Relationship>, t: Throwable) {
relationshipData.postValue(Error(relation))
}
}
val call = when (relationshipAction) {
RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, parameter ?: true)
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true)
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
}
call.enqueue(callback)
callList.add(call)
fun noteChanged(newNote: String) {
noteSaved.postValue(false)
noteDisposable?.dispose()
noteDisposable = Single.timer(1500, TimeUnit.MILLISECONDS)
.flatMap {
mastodonApi.updateAccountNote(accountId, newNote)
}
.doOnSuccess {
noteSaved.postValue(true)
}
.delay(4, TimeUnit.SECONDS)
.subscribe({
noteSaved.postValue(false)
}, {
Log.e(TAG, "Error updating note", it)
})
}
override fun onCleared() {
callList.forEach {
it.cancel()
}
disposable.dispose()
super.onCleared()
noteDisposable?.dispose()
}
fun refresh() {

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,8H4A2,2 0,0 0,2 10V14A2,2 0,0 0,4 16H5V20A1,1 0,0 0,6 21H8A1,1 0,0 0,9 20V16H12L17,20V4L12,8M21.5,12C21.5,13.71 20.54,15.26 19,16V8C20.53,8.75 21.5,10.3 21.5,12Z" />
</vector>

View File

@ -168,16 +168,43 @@
app:barrierDirection="bottom"
app:constraint_referenced_ids="accountFollowsYouTextView,accountBadgeTextView" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/accountNoteTextInputLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/labelBarrier"
tools:visibility="visible">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/account_note_hint" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/saveNoteInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/account_note_saved"
android:textColor="@color/tusky_blue"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/accountNoteTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hyphenationFrequency="full"
android:lineSpacingMultiplier="1.1"
android:paddingTop="10dp"
android:paddingTop="2dp"
android:textColor="?android:textColorTertiary"
android:textSize="?attr/status_text_medium"
app:layout_constraintTop_toBottomOf="@id/labelBarrier"
app:layout_constraintTop_toBottomOf="@id/saveNoteInfo"
tools:text="This is a test description. Descriptions can be quite looooong." />
<androidx.recyclerview.widget.RecyclerView
@ -244,6 +271,7 @@
android:text="@string/title_statuses"
android:textColor="@color/account_tab_font_color"
android:textSize="?attr/status_text_medium" />
</LinearLayout>
<LinearLayout

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<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:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/toolbar_basic" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/announcementsList"
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"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:src="@android:color/transparent"
android:visibility="gone"
tools:src="@drawable/elephant_error"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -193,14 +193,12 @@
android:textSize="?attr/status_text_medium" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
<com.keylesspalace.tusky.view.EmojiPicker
android:id="@+id/emojiView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
android:clipToPadding="false"
android:elevation="12dp"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
@ -327,6 +325,7 @@
android:layout_height="wrap_content"
android:textColor="?android:textColorTertiary"
android:textSize="?attr/status_text_medium"
android:textStyle="bold"
tools:text="500" />
<com.keylesspalace.tusky.components.compose.view.TootButton

View File

@ -5,7 +5,7 @@
android:id="@+id/activity_view_thread"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.PreferencesActivity">
tools:context="com.keylesspalace.tusky.components.preference.PreferencesActivity">
<include layout="@layout/toolbar_basic" />

View File

@ -2,6 +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/tabPreferenceContainer"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -19,8 +20,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/scrimBackground"
android:visibility="invisible"
app:layout_behavior="@string/fab_transformation_scrim_behavior" />
android:visibility="invisible" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/actionButton"
@ -30,7 +30,7 @@
android:layout_margin="16dp"
android:src="@drawable/ic_plus_24dp" />
<com.google.android.material.transformation.TransformationChildCard
<com.google.android.material.card.MaterialCardView
android:id="@+id/sheet"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -38,8 +38,7 @@
android:layout_margin="16dp"
android:visibility="invisible"
app:cardBackgroundColor="?attr/colorSurface"
app:cardElevation="2dp"
app:layout_behavior="@string/fab_transformation_sheet_behavior">
app:cardElevation="2dp">
<LinearLayout
android:layout_width="240dp"
@ -76,6 +75,7 @@
android:textColor="?attr/colorOnPrimary"
android:textSize="?attr/status_text_large" />
</LinearLayout>
</com.google.android.material.transformation.TransformationChildCard>
</com.google.android.material.card.MaterialCardView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,30 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="androidx.cardview.widget.CardView"
tools:layout_height="wrap_content"
tools:layout_width="match_parent"
tools:layout_height="wrap_content">
tools:parentTag="com.google.android.material.card.MaterialCardView">
<LinearLayout
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:orientation="vertical">
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@+id/licenseCardName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"/>
android:fontFamily="sans-serif-medium"
android:textColor="?android:attr/textColorSecondary" />
<TextView
android:id="@+id/licenseCardLicense"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorTertiary" />
<TextView
android:id="@+id/licenseCardLink"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/tusky_blue"/>
android:textColor="@color/tusky_blue" />
</LinearLayout>
</merge>

View File

@ -1,33 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingTop="16dp">
<LinearLayout
android:id="@+id/emoji_font_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent">
<include
android:id="@+id/item_blobmoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_blobmoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_twemoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_twemoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_notoemoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_notoemoji"
layout="@layout/item_emoji_pref" />
<include
android:id="@+id/item_nomoji"
layout="@layout/item_emoji_pref" />
</LinearLayout>
<include
android:id="@+id/item_nomoji"
layout="@layout/item_emoji_pref" />
<TextView
android:id="@+id/emoji_download_label"
@ -39,9 +31,6 @@
android:paddingEnd="24dp"
android:paddingBottom="8dp"
android:text="@string/download_fonts"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/emoji_font_list" />
android:textColor="?android:attr/textColorSecondary" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.emoji.widget.EmojiTextView
android:id="@+id/text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:lineSpacingMultiplier="1.1"
android:padding="8dp"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroup"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/text">
<com.google.android.material.chip.Chip
android:id="@+id/addReactionChip"
style="@style/Widget.MaterialComponents.Chip.Action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checkable="false"
app:chipEndPadding="4dp"
app:chipIcon="@drawable/ic_plus_24dp"
app:chipSurfaceColor="@color/tusky_blue"
app:textEndPadding="0dp"
app:textStartPadding="0dp" />
</com.google.android.material.chip.ChipGroup>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -20,7 +20,7 @@
android:gravity="center_vertical"
android:maxLines="1"
android:paddingStart="28dp"
android:textColor="?android:textColorTertiary"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"

View File

@ -476,6 +476,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_poll_button"

View File

@ -349,7 +349,6 @@
<string name="compose_shortcut_long_label">تحرير تبويق</string>
<string name="compose_shortcut_short_label">كتابة</string>
<string name="notification_clear_text">هل تريد حقا مسح كافة إشعاراتك؟</string>
<string name="poll_info_time_relative">%s متبقي</string>
<string name="poll_info_time_absolute">ينتهي في %s</string>
<string name="poll_info_closed">انتهى</string>
<string name="poll_vote">صَوِّت</string>
@ -384,36 +383,36 @@
</plurals>
<string name="pref_title_bot_overlay">إظهار علامة البوتات</string>
<plurals name="poll_timespan_days">
<item quantity="zero">%d أيام</item>
<item quantity="one">%d يوم</item>
<item quantity="two">%d يومين</item>
<item quantity="few">%d أيام</item>
<item quantity="many">%d أيام</item>
<item quantity="other">%d أيام</item>
<item quantity="zero">%d أيام متبقية</item>
<item quantity="one">%d يوم متبقي</item>
<item quantity="two">%d يومين متبقيين</item>
<item quantity="few">%d أيام متبقية</item>
<item quantity="many">%d أيام متبقية</item>
<item quantity="other">%d أيام متبقية</item>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="zero">%d ساعات</item>
<item quantity="one">%d ساعة</item>
<item quantity="two">%d ساعتين</item>
<item quantity="few">%d ساعات</item>
<item quantity="many">%d ساعات</item>
<item quantity="other">%d ساعات</item>
<item quantity="zero">%d ساعات متبقية</item>
<item quantity="one">%d ساعة متبقية</item>
<item quantity="two">%d ساعتان متبقيتان</item>
<item quantity="few">%d ساعات متبقية</item>
<item quantity="many">%d ساعات متبقية</item>
<item quantity="other">%d ساعات متبقية</item>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="zero">%d دقائق</item>
<item quantity="one">%d دقيقة</item>
<item quantity="two">%d دقيقتين</item>
<item quantity="few">%d دقائق</item>
<item quantity="many">%d دقائق</item>
<item quantity="other">%d دقائق</item>
<item quantity="zero">%d دقائق متبقية</item>
<item quantity="one">%d دقيقة متبقية</item>
<item quantity="two">%d دقيقتان متبقيتان</item>
<item quantity="few">%d دقائق متبقية</item>
<item quantity="many">%d دقائق متبقية</item>
<item quantity="other">%d دقائق متبقية</item>
</plurals>
<plurals name="poll_timespan_seconds">
<item quantity="zero">%d ثوان</item>
<item quantity="one">%d ثانية</item>
<item quantity="two">%d ثانيتين</item>
<item quantity="few">%d ثوان</item>
<item quantity="many">%d ثوان</item>
<item quantity="other">%d ثوان</item>
<item quantity="zero">%d ثوان متبقية</item>
<item quantity="one">%d ثانية متبقية</item>
<item quantity="two">%d ثانيتان متبقيتان</item>
<item quantity="few">%d ثوان متبقية</item>
<item quantity="many">%d ثوان متبقية</item>
<item quantity="other">%d ثوان متبقية</item>
</plurals>
<string name="compose_preview_image_description">إجراءات على الصورة %s</string>
<string name="caption_notoemoji">حزمة الإيموجي الحالية لـ غوغل</string>

View File

@ -10,7 +10,7 @@
<string name="action_close">ⵎⴷⴻⵍ</string>
<string name="error_generic">ⵝⴻⵍⵍⴰ ⴷ ⵝⵓⵛⴹⴰ.</string>
<string name="title_lists">ⵝⴰⴲⴸⴰⵔⵉⵏ</string>
<string name="action_lists">ⴲⴸⴰⵔⵉⵏ</string>
<string name="action_lists">ⴲⴸⴰⵔⵉⵏ</string>
<string name="about_title_activity">ⵖⴻⴼ</string>
<string name="action_reset_schedule">ⵡⴻⵏⵏⴻⵣ ⵝⵉⴽⴻⵍⵜ ⵏⵏⵉⴸⴻⵏ</string>
<string name="action_search">ⵏⴰⴸⵉ</string>
@ -22,12 +22,22 @@
<string name="button_continue">ⴽⴻⵎⵎⴻⵍ</string>
<string name="filter_dialog_update_button">ⵍⵇⴻⵎ</string>
<string name="filter_dialog_remove_button">ⴽⴽⴻⵙ</string>
<string name="action_send_public">ⵊⴻⵡⵡⴻⵇ</string>
<string name="action_send_public">ⵊⴻⵡⵡⴻⵇ!</string>
<string name="action_send">ⵊⴻⵡⵡⴻⵇ</string>
<string name="action_login">ⵇⵇⴻⵏ ⵖⴻⵔ ⵎⴰⵚⵟⵓⴷⵓⵏ</string>
<string name="link_whats_an_instance"> ⴰⵛⵓ ⵓ ⵜⵙⵓⵎⵎⴰⵏⵜ\?</string>
<string name="link_whats_an_instance"> ⴰⵛⵓ ⵓ ⵜⵜⵓⵎⵎⴰⵏⵜ\?</string>
<string name="notification_favourite_name">ⵉⵙⵎⴻⵏⵢⵉⴼⴻⵏ</string>
<string name="action_remove">ⴽⴽⴻⵙ</string>
<string name="action_access_saved_toot">ⵉⵔⴻⵡⵡⴰⵢⴻⵏ</string>
<string name="title_blocks">ⵉⵎⵙⴻⵇⴷⴰⵛⴻⵏ ⵜⵙⵡⴰⵃⵍⴻⵎ</string>
<string name="pref_title_status_tabs">ⵉⵛⵛⴰⵔⴻⵏ</string>
<string name="hint_domain">ⴰⵏⵜⴰ ⵝⵓⵎⵎⴰⵏⵜ\?</string>
<string name="add_account_description">ⵔⵏⵓ ⵢⵉⵡⴻⵏ ⵓⵎⵉⴹⴰⵏ ⴰⵎⴰⵢⵏⵓⵝ ⵎⴰⵚⵟⵓⴷⵓⵏ</string>
<string name="add_account_name">ⵔⵏⵓ ⴰⵎⵉⴹⴰⵏ</string>
<string name="hint_compose">ⴸⴰⵛⵓ ⵉⴳⴻⵍⵍⴰⵏ ⴰⵎⴰⵢⵏⵓⵝ\?</string>
<string name="action_schedule_toot">ⵙⵖⵉⵡⴻⵙ ⵝⵉⵊⴻⵡⵡⵉⵇⵝⴰ</string>
<string name="action_access_scheduled_toot">ⵝⵉⵊⴻⵡⵡⵉⵇⵉⵏ ⵢⴻⵜⵜⵖⴰⵙⵖⴰⵡⵙⴻⵏ</string>
<string name="title_scheduled_toot">ⵝⵉⵊⴻⵡⵡⵉⵇⵉⵏ ⵢⴻⵜⵜⵖⴰⵙⵖⴰⵡⵙⴻⵏ</string>
<string name="title_bookmarks">ⵝⵉⵛⵔⴰⴹ</string>
<string name="action_view_bookmarks">ⵝⵉⵛⵔⴰⴹ</string>
</resources>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="conversation_1_recipients">%1$s</string>
<string name="abbreviated_seconds_ago">%ds</string>
<string name="abbreviated_seconds_ago">%dসে</string>
<string name="description_poll">পছন্দগুলি সহ নর্বাচন: %1$s, %2$s, %3$s, %4$s; %5$s</string>
<string name="description_visiblity_direct">সরাসরি</string>
<string name="description_visiblity_private">অনুগামিবৃন্দ</string>

View File

@ -366,7 +366,6 @@
<string name="notification_clear_text">আপনি কি আপনার সমস্ত বিজ্ঞপ্তি স্থায়ীভাবে মুছে ফেলতে চান\?</string>
<string name="compose_preview_image_description">ছবি %s এর জন্য ক্রিয়া</string>
<string name="poll_info_format"> <!-- ১৫ ভোট • ১ ঘন্টা বাকি --> %1$s • %2$s</string>
<string name="poll_info_time_relative">%s বাকি</string>
<string name="poll_info_time_absolute">%s এ শেষ হবে</string>
<string name="poll_info_closed">বন্ধ</string>
<string name="poll_vote">ভোট</string>
@ -424,8 +423,8 @@
<string name="dialog_mute_warning">নিঃশব্দ @%s\?</string>
<string name="dialog_block_warning">অবরুদ্ধ @%s\?</string>
<plurals name="poll_timespan_days">
<item quantity="one">%d দিন</item>
<item quantity="other">%d দিন</item>
<item quantity="one">%d দিন বাকি</item>
<item quantity="other">%d দিন বাকি</item>
</plurals>
<string name="pref_title_gradient_for_media">লুকানো মিডিয়ার জন্য রঙিন গ্রেডিয়েন্ট ব্যবহার করি</string>
<string name="action_unmute_conversation">আলাপ বন্ধ করো</string>
@ -437,16 +436,16 @@
<string name="notification_follow_request_name">অনুরোধ অনুসরণ করুন</string>
<string name="pref_title_confirm_reblogs">বুস্ট করার আগে নিশ্চিত করো</string>
<plurals name="poll_timespan_seconds">
<item quantity="one">%d সেকেন্ড</item>
<item quantity="other">%d সেকেন্ড</item>
<item quantity="one">%d সেকেন্ড বাকি</item>
<item quantity="other">%d সেকেন্ড বাকি</item>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one">%d মিনিট</item>
<item quantity="other">%d মিনিট</item>
<item quantity="one">%d মিনিট বাকি</item>
<item quantity="other">%d মিনিট বাকি</item>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one">%d ঘন্টা</item>
<item quantity="other">%d ঘন্টা</item>
<item quantity="one">%d ঘন্টা বাকি</item>
<item quantity="other">%d ঘন্টা বাকি</item>
</plurals>
<plurals name="poll_info_people">
<item quantity="one">%s জন</item>
@ -470,4 +469,5 @@
<string name="no_scheduled_status">তোমার কোনো সময়সূচীত স্ট্যাটাস নেই।</string>
<string name="no_saved_status">তোমার কোনো খসড়া নেই।</string>
<string name="warning_scheduling_interval">মাস্টোডনের সর্বনিম্ন ৫ মিনিটের সময়সূচীর বিরতি আছে।</string>
<string name="pref_title_hide_top_toolbar">শীর্ষস্থানীয় সরঞ্জামদণ্ডের শিরোনামটি লুকান</string>
</resources>

View File

@ -330,12 +330,12 @@
<string name="pref_title_absolute_time">Utilitzar el temps absolut</string>
<string name="label_remote_account">La informació de sota pot mostrar el perfil incomplert de l\'usuari. Clica per obrir el perfil complert al navegador.</string>
<plurals name="favs">
<item quantity="one"></item>
<item quantity="other"></item>
<item quantity="one"><b>%1$s</b> Favorit</item>
<item quantity="other"><b>%1$s</b> Favorits</item>
</plurals>
<plurals name="reblogs">
<item quantity="one"></item>
<item quantity="other"></item>
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<string name="title_reblogged_by">Impulsat per</string>
<string name="title_favourited_by">Marcat favorit per</string>
@ -364,27 +364,10 @@
<item quantity="one">%s vots</item>
<item quantity="other">%s vots</item>
</plurals>
<string name="poll_info_time_relative">%s restants</string>
<string name="poll_info_time_absolute">Acaba a %s</string>
<string name="poll_info_closed">tancat</string>
<string name="poll_ended_voted">L\'enquesta on has votat està tancada</string>
<string name="poll_ended_created">La enquesta que heu creat ha finalitzat</string>
<plurals name="poll_timespan_days">
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<plurals name="poll_timespan_seconds">
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<string name="description_status_cw">Advertència: %s</string>
<string name="title_statuses_pinned">Toot fixat</string>
<string name="unpin_action">Toot no fixat</string>

View File

@ -377,7 +377,6 @@
<item quantity="few">%s hlasy</item>
<item quantity="other">%s hlasů</item>
</plurals>
<string name="poll_info_time_relative">zbývá %s</string>
<string name="poll_info_time_absolute">končí v %s</string>
<string name="poll_info_closed">uzavřena</string>
<string name="poll_vote">Hlasovat</string>
@ -388,24 +387,19 @@
<string name="poll_ended_voted">Anketa, ve které jste hlasoval/a, skončila</string>
<string name="poll_ended_created">Anketa, kterou jste vytvořil/a, skončila</string>
<plurals name="poll_timespan_days">
<item quantity="one"/>
<item quantity="few"/>
<item quantity="other"/>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one"/>
<item quantity="few"/>
<item quantity="other"/>
<item quantity="one">zbývá %d den</item>
<item quantity="few">zbývá %d dny</item>
<item quantity="other">zbývá %d dní</item>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one">%d minuta</item>
<item quantity="few">%d minut</item>
<item quantity="other"/>
<item quantity="one">zbývá %d minuta</item>
<item quantity="few">zbývá %d minuty</item>
<item quantity="other">zbývá %d minut</item>
</plurals>
<plurals name="poll_timespan_seconds">
<item quantity="one"/>
<item quantity="few"/>
<item quantity="other"/>
<item quantity="one">zbývá %d sekunda</item>
<item quantity="few">zbývá %d sekundy</item>
<item quantity="other">zbývá %d sekund</item>
</plurals>
<string name="pref_title_animate_gif_avatars">Animovat avatary GIF</string>
<string name="description_poll">Anketa s volbami: %1$s, %2$s, %3$s, %4$s; %5$s</string>
@ -482,9 +476,9 @@
<string name="action_unmute_desc">Odkrýt %s</string>
<string name="dialog_mute_warning">Ztišit @%s\?</string>
<plurals name="poll_info_people">
<item quantity="one">%s osoba</item>
<item quantity="few">%s osoby</item>
<item quantity="other">%s osob</item>
<item quantity="one"/>
<item quantity="few"/>
<item quantity="other"/>
</plurals>
<string name="notification_follow_request_format">%s požádal/a aby vás mohl/a sledovat</string>
<string name="pref_title_confirm_reblogs">Zobrazit dialogové okno s potvrzením při boostování</string>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="error_generic">Ein Fehler ist aufgetreten.</string>
<string name="error_network">Ein Netzwerkfehler ist aufgetreten! Bitte überprüfen deine Internetverbindung und versuche es erneut!</string>
<string name="error_network">Ein Netzwerkfehler ist aufgetreten! Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut!</string>
<string name="error_empty">Dies darf nicht leer sein.</string>
<string name="error_invalid_domain">Ungültige Domain angegeben</string>
<string name="error_failed_app_registration">Authentifizieren mit dieser Instanz fehlgeschlagen.</string>
@ -10,7 +10,7 @@
<string name="error_authorization_denied">Autorisierung fehlgeschlagen.</string>
<string name="error_retrieving_oauth_token">Es konnte kein Login-Token abgerufen werden.</string>
<string name="error_compose_character_limit">Der Beitrag ist zu lang!</string>
<string name="error_image_upload_size">Die Datei muss kleiner als 8MB sein.</string>
<string name="error_image_upload_size">Die Datei muss kleiner als 8 MB sein.</string>
<string name="error_video_upload_size">Videodateien müssen kleiner als 40 MB sein.</string>
<string name="error_media_upload_type">Dieser Dateityp darf nicht hochgeladen werden.</string>
<string name="error_media_upload_opening">Die Datei konnte nicht geöffnet werden.</string>
@ -18,7 +18,7 @@
<string name="error_media_download_permission">Eine Berechtigung wird zum Speichern des Mediums benötigt.</string>
<string name="error_media_upload_image_or_video">Bilder und Videos können nicht beide gleichzeitig an einen Beitrag angehängt werden.</string>
<string name="error_media_upload_sending">Die Mediendatei konnte nicht hochgeladen werden.</string>
<string name="error_sender_account_gone">Fehler beim Senden des Status</string>
<string name="error_sender_account_gone">Fehler beim Senden des Beitrags.</string>
<string name="title_home">Start</string>
<string name="title_notifications">Benachrichtigungen</string>
<string name="title_public_local">Lokal</string>
@ -36,7 +36,7 @@
<string name="title_blocks">Blockierte Profile</string>
<string name="title_follow_requests">Folgeanfragen</string>
<string name="title_edit_profile">Dein Profil bearbeiten</string>
<string name="title_saved_toot">Gespeicherte Beiträge</string>
<string name="title_saved_toot">Entwürfe</string>
<string name="title_licenses">Lizenzen</string>
<string name="status_username_format">\@%s</string>
<string name="status_boosted_format">%s teilte</string>
@ -59,7 +59,7 @@
<string name="action_reblog">Teilen</string>
<string name="action_unreblog">Boost entfernen</string>
<string name="action_favourite">Favorisieren</string>
<string name="action_unfavourite">Favoriten entfernen</string>
<string name="action_unfavourite">Favorisierung entfernen</string>
<string name="action_more">Mehr</string>
<string name="action_compose">Beitrag erstellen</string>
<string name="action_login">Anmelden mit Mastodon</string>
@ -92,7 +92,7 @@
<string name="action_mute">Stummschalten</string>
<string name="action_unmute">Lautschalten</string>
<string name="action_mention">Erwähnen</string>
<string name="action_hide_media">Verstecke Medien</string>
<string name="action_hide_media">Medien verstecken</string>
<string name="action_open_drawer">Drawer öffnen</string>
<string name="action_save">Speichern</string>
<string name="action_edit_profile">Profil bearbeiten</string>
@ -101,7 +101,7 @@
<string name="action_accept">Akzeptieren</string>
<string name="action_reject">Ablehnen</string>
<string name="action_search">Suche</string>
<string name="action_access_saved_toot">Gespeicherte Beiträge</string>
<string name="action_access_saved_toot">Entwürfe</string>
<string name="action_toggle_visibility">Beitragssichtbarkeit</string>
<string name="action_content_warning">Inhaltswarnung</string>
<string name="action_emoji_keyboard">Emoji</string>
@ -149,7 +149,7 @@
\n\nWeitere Informationen gibt es auf <a href="https://joinmastodon.org">joinmastodon.org</a>.
</string>
<string name="dialog_title_finishing_media_upload">Stelle Medienupload fertig</string>
<string name="dialog_message_uploading_media">Lade hoch…</string>
<string name="dialog_message_uploading_media">Lade hoch </string>
<string name="dialog_download_image">Herunterladen</string>
<string name="dialog_message_cancel_follow_request">Folgeanfrage zurückziehen?</string>
<string name="dialog_unfollow_warning">Willst du diesem Profil wirklich nicht mehr folgen?</string>
@ -159,7 +159,7 @@
<string name="visibility_private">Nur Folgende: Nur für Folgende sichtbar</string>
<string name="visibility_direct">Direkt: Nur für Erwähnte sichtbar</string>
<string name="pref_title_edit_notification_settings">Benachrichtigungen</string>
<string name="pref_title_notifications_enabled">Benachrichtigungseinstellungen</string>
<string name="pref_title_notifications_enabled">Benachrichtigungen</string>
<string name="pref_title_notification_alerts">Benachrichtigungen</string>
<string name="pref_title_notification_alert_sound">Benachrichtige mit Sound</string>
<string name="pref_title_notification_alert_vibrate">Benachrichtige mit Vibration</string>
@ -197,7 +197,7 @@
<string name="pref_publishing">Beiträge</string>
<string name="pref_failed_to_sync">Fehler beim Synchronisieren</string>
<string name="post_privacy_public">Öffentlich</string>
<string name="post_privacy_unlisted">Ungelistet</string>
<string name="post_privacy_unlisted">Nicht gelistet</string>
<string name="post_privacy_followers_only">Nur Folgende</string>
<string name="pref_status_text_size">Schriftgröße</string>
<string name="status_text_size_smallest">Kleiner</string>
@ -208,10 +208,10 @@
<string name="notification_mention_name">Neue Erwähnungen</string>
<string name="notification_mention_descriptions">Benachrichtigungen über neue Erwähnungen</string>
<string name="notification_follow_name">Neue Folgende</string>
<string name="notification_follow_description">Benachrichtigungen über neue Folgende</string>
<string name="notification_follow_description">Benachrichtigunen über neue Folgende</string>
<string name="notification_boost_name">Geteilte Beiträge</string>
<string name="notification_boost_description">Benachrichtigungen wenn deine Beiträge geteilt werden</string>
<string name="notification_favourite_name">Favoriten</string>
<string name="notification_favourite_name">Favorisierte Beiträge</string>
<string name="notification_favourite_description">Benachrichtigungen wenn deine Beiträge favorisiert werden</string>
<string name="notification_mention_format">%s hat dich erwähnt</string>
<string name="notification_summary_large">%1$s, %2$s, %3$s und %4$d andere</string>
@ -227,7 +227,7 @@
to show we do not mean the software is gratis. Source: https://www.gnu.org/philosophy/free-sw.html
* the url can be changed to link to the localized version of the license.
-->
<string name="about_project_site">Webseite des Projekts:
<string name="about_project_site">Website des Projekts:
\n https://tusky.app</string>
<string name="about_bug_feature_request_site"> Fehlermeldungen &amp; Verbesserungsvorschläge:\n
https://github.com/tuskyapp/Tusky/issues
@ -286,10 +286,10 @@
<string name="later">Später</string>
<string name="restart">Neustarten</string>
<string name="caption_systememoji">Die Standard-Emojis deines Geräts</string>
<string name="caption_blobmoji">Die Blob-Emojis aus Android 4.4-7.1</string>
<string name="caption_blobmoji">Die BlobEmojis aus Android 4.47.1</string>
<string name="caption_twemoji">Die Standard-Emojis von Mastodon</string>
<string name="caption_notoemoji">Die aktuellen Emojis von Google</string>
<string name="download_failed">Download fehlgeschlagen</string>
<string name="download_failed">Download fehlgeschlagen.</string>
<string name="profile_badge_bot_text">Bot</string>
<string name="account_moved_description">%1$s ist umgezogen auf:</string>
<string name="reblog_private">An ursprüngliches Publikum teilen</string>
@ -354,34 +354,33 @@
<item quantity="one">%s Stimme</item>
<item quantity="other">%s Stimmen</item>
</plurals>
<string name="poll_info_time_relative">%s verbleibend</string>
<string name="poll_info_time_absolute">endet um %s</string>
<string name="poll_info_closed">Geschlossen</string>
<string name="poll_vote">Abstimmen</string>
<string name="poll_ended_voted">Eine Umfrage in der du abgestimmt hast ist vorbei</string>
<string name="poll_ended_created">Eine Umfrage, die du erstellt hast, ist vorbei</string>
<string name="poll_ended_created">Eine Umfrage die du erstellt hast ist vorbei</string>
<plurals name="poll_timespan_days">
<item quantity="one">%d Tag</item>
<item quantity="other">%d Tage</item>
<item quantity="one">%d Tag verbleibend</item>
<item quantity="other">%d Tage verbleibend</item>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one">%d Stunde</item>
<item quantity="other">%d Stunden</item>
<item quantity="one">%d Stunde verbleibend</item>
<item quantity="other">%d Stunden verbleibend</item>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one">%d Minute</item>
<item quantity="other">%d Minuten</item>
<item quantity="one">%d Minute verbleibend</item>
<item quantity="other">%d Minuten verbleibend</item>
</plurals>
<plurals name="poll_timespan_seconds">
<item quantity="one">%d Sekunde</item>
<item quantity="other">%d Sekunden</item>
<item quantity="one">%d Sekunde verbleibend</item>
<item quantity="other">%d Sekunden verbleibend</item>
</plurals>
<string name="title_domain_mutes">Versteckte Domains</string>
<string name="action_view_domain_mutes">Versteckte Domains</string>
<string name="action_mute_domain">%s verstecken</string>
<string name="confirmation_domain_unmuted">%s nicht mehr versteckt</string>
<string name="mute_domain_warning">Bist du dir sicher, dass du die Domain %s blockieren willst\? Nach der Blockierung wirst du nichts mehr von dieser Domain in öffentlichen Zeitleisten oder Benachrichtigungen sehen. Deine Follower von dieser Domain werden entfernt.</string>
<string name="mute_domain_warning_dialog_ok">ganze Domain verbergen</string>
<string name="mute_domain_warning_dialog_ok">Ganze Domain verbergen</string>
<string name="pref_title_animate_gif_avatars">GIF-Avatare animieren</string>
<string name="filter_dialog_whole_word">Ganzes Wort</string>
<string name="filter_dialog_whole_word_description">Wenn das Schlagwort nur aus Buchstaben und Zahlen besteht, wird es nur angewendet, wenn es dem ganzen Wort entspricht</string>
@ -404,7 +403,7 @@
<string name="poll_duration_3_days">3 Tage</string>
<string name="poll_duration_7_days">7 Tage</string>
<string name="edit_poll">Editieren</string>
<string name="about_tusky_version">Tusky %s</string>
<string name="about_tusky_version">test %s</string>
<string name="action_add_poll">Umfrage hinzufügen</string>
<string name="pref_title_alway_open_spoiler">Beiträge mit Inhaltswarnungen immer ausklappen</string>
<string name="description_poll">Umfrage mit den Möglichkeiten: %1$s, %2$s, %3$s, %4$s; %5$s</string>
@ -420,7 +419,7 @@
<string name="action_access_scheduled_toot">Geplante Beiträge</string>
<string name="action_schedule_toot">Plane Beitrag</string>
<string name="action_reset_schedule">Zurücksetzen</string>
<string name="error_audio_upload_size">Audiodateien müssen kleiner als 40MB sein.</string>
<string name="error_audio_upload_size">Audiodateien müssen kleiner als 40 MB sein.</string>
<string name="title_bookmarks">Lesezeichen</string>
<string name="action_bookmark">Lesezeichen</string>
<string name="action_view_bookmarks">Lesezeichen</string>
@ -452,7 +451,7 @@
<string name="pref_title_gradient_for_media">Farbverlauf für versteckte Medien anzeigen</string>
<string name="pref_main_nav_position_option_bottom">Unten</string>
<string name="pref_main_nav_position_option_top">Oben</string>
<string name="action_unmute_domain">Ton einschalten %s</string>
<string name="action_unmute_domain">%s nicht mehr verstecken</string>
<plurals name="reblogs">
<item quantity="one"><b>%s</b> Boost</item>
<item quantity="other"><b>%s</b> Boosts</item>
@ -461,5 +460,14 @@
<string name="dialog_mute_hide_notifications">Benachrichtigungen ausblenden</string>
<string name="action_mute_notifications_desc">Benachrichtigungen stummschalten von %s</string>
<string name="action_unmute_notifications_desc">Benachrichtigungs-Ton einschalten von %s</string>
<string name="action_unmute_desc">Ton einschalten %s</string>
<string name="action_unmute_desc">%s nicht mehr verstecken</string>
<string name="abbreviated_seconds_ago">%dSek.</string>
<string name="abbreviated_hours_ago">%dSt.</string>
<string name="abbreviated_in_days">in %d T.</string>
<string name="abbreviated_years_ago">%dJ.</string>
<string name="account_note_saved">Gespeichert!</string>
<string name="account_note_hint">Private Notiz über diesen Account</string>
<string name="pref_title_hide_top_toolbar">Titel der Hauptnavigation verstecken</string>
<string name="no_announcements">Im Moment gibt es keine Ankündigungen.</string>
<string name="title_announcements">Ankündigungen</string>
</resources>

View File

@ -376,28 +376,12 @@
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<string name="poll_info_time_relative">%s restas</string>
<string name="poll_info_time_absolute">finiĝos je %s</string>
<string name="poll_info_closed">finiĝita</string>
<string name="poll_vote">Voĉdoni</string>
<string name="poll_ended_voted">Enketo al kiu vi voĉdonis finiĝis</string>
<string name="poll_ended_created">Enketo kiu vi kreis finiĝis</string>
<plurals name="poll_timespan_days">
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<plurals name="poll_timespan_seconds">
<item quantity="one"/>
<item quantity="other"/>
</plurals>
<string name="title_domain_mutes">Kaŝitaj domajnoj</string>
<string name="action_view_domain_mutes">Kaŝitaj domajnoj</string>
<string name="action_mute_domain">Silentigi %s</string>

View File

@ -16,7 +16,7 @@
<string name="error_media_upload_opening">No pudo abrirse el fichero.</string>
<string name="error_media_upload_permission">Se requiere permiso para acceder al almacenamiento.</string>
<string name="error_media_download_permission">Se requiere permiso para descargar al almacenamiento.</string>
<string name="error_media_upload_image_or_video">No se pueden adjuntar imágenes y vídeos en el mismo estado</string>
<string name="error_media_upload_image_or_video">No se pueden adjuntar imágenes y vídeos en el mismo estado.</string>
<string name="error_media_upload_sending">La subida falló.</string>
<string name="error_sender_account_gone">Error al publicar.</string>
<string name="title_home">Inicio</string>
@ -72,7 +72,7 @@
<string name="action_report">Reportar</string>
<string name="action_delete">Borrar</string>
<string name="action_send">Enviar</string>
<string name="action_send_public">Publicar</string>
<string name="action_send_public">¡Publicar!</string>
<string name="action_retry">Reintentar</string>
<string name="action_close">Cerrar</string>
<string name="action_view_profile">Perfil</string>
@ -116,7 +116,7 @@
<string name="confirmation_unmuted">El usuario ya no está silenciado</string>
<string name="status_sent">¡Enviado!</string>
<string name="status_sent_long">Respuesta enviada correctamente.</string>
<string name="hint_domain">Indique la instancia</string>
<string name="hint_domain">¿Qué instancia\?</string>
<string name="hint_compose">¿En qué estás pensando?</string>
<string name="hint_content_warning">Aviso de contenido</string>
<string name="hint_display_name">Nombre</string>
@ -300,8 +300,8 @@
<item quantity="other">&lt;b&gt;%1$s&lt;/b&gt; Favoritos</item>
</plurals>
<plurals name="reblogs">
<item quantity="one"><b>%s</b> Boost</item>
<item quantity="other"><b>%s</b> Boosts</item>
<item quantity="one"><b>%s</b> impulso</item>
<item quantity="other"><b>%s</b> impulsos</item>
</plurals>
<string name="title_reblogged_by">Impulsado por</string>
<string name="title_favourited_by">Marcado como favorito por</string>
@ -330,20 +330,20 @@
<string name="pref_title_bot_overlay">Mostrar indicador de bots</string>
<string name="poll_vote">Votar</string>
<plurals name="poll_timespan_days">
<item quantity="one"/>
<item quantity="other"/>
<item quantity="one">%d día restante</item>
<item quantity="other">%d días restante</item>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one"/>
<item quantity="other"/>
<item quantity="one">%d hora restante</item>
<item quantity="other">%d horas restante</item>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one">%d minuto</item>
<item quantity="other">%d minutos</item>
<item quantity="one">%d minuto restante</item>
<item quantity="other">%d minutos restante</item>
</plurals>
<plurals name="poll_timespan_seconds">
<item quantity="one">%d segundo</item>
<item quantity="other">%d segundos</item>
<item quantity="one">%d segundo restante</item>
<item quantity="other">%d segundos restante</item>
</plurals>
<string name="poll_info_format"> <!-- 15 votos • queda 1 hora --> %1$s • %2$s</string>
<plurals name="poll_info_votes">
@ -388,7 +388,6 @@
<string name="compose_shortcut_short_label">Redactar</string>
<string name="notification_clear_text">¿Estás seguro de que quieres eliminar permanentemente todas tus notificaciones\?</string>
<string name="compose_preview_image_description">Acciones para la imagen %s</string>
<string name="poll_info_time_relative">%s restante</string>
<string name="poll_info_time_absolute">termina en %s</string>
<string name="poll_ended_voted">Una encuesta en la que has votado ha terminado</string>
<string name="poll_ended_created">Una encuesta que has creado ha terminado</string>
@ -475,4 +474,5 @@
<string name="dialog_mute_hide_notifications">Ocultar notificaciones</string>
<string name="action_mute_notifications_desc">Silenciar notificaciones desde %s</string>
<string name="action_unmute_notifications_desc">Dejar de silenciar notificaciones desde %s</string>
<string name="pref_title_hide_top_toolbar">Ocultar el título de la barra de herramientas superior</string>
</resources>

View File

@ -45,7 +45,7 @@
<string name="footer_empty">Edukirik ez. Arrastatu behera birkargatzeko!</string>
<string name="notification_reblog_format">%s-(e)k zure tuta bultzatu du</string>
<string name="notification_favourite_format">%s-(e)k zure tuta gogoko du</string>
<string name="notification_follow_format">%s-(e)k jarraitu dizu</string>
<string name="notification_follow_format">%s(e)k jarraitu zaitu</string>
<string name="report_username_format">\@%s salatu</string>
<string name="report_comment_hint">Informazio gehigarria?</string>
<string name="action_quick_reply">Erantzun azkarra</string>
@ -75,7 +75,7 @@
<string name="action_view_favourites">Gogokoak</string>
<string name="action_view_mutes">Isilduak</string>
<string name="action_view_blocks">Blokeatuak</string>
<string name="action_view_follow_requests">Eskariak</string>
<string name="action_view_follow_requests">Eskakizunak</string>
<string name="action_view_media">Multimedia</string>
<string name="action_open_in_web">Nabigatzailean ireki</string>
<string name="action_add_media">Multimedia erantsi</string>
@ -129,7 +129,7 @@
<string name="dialog_title_finishing_media_upload">Mediaren igoera bukatzen</string>
<string name="dialog_message_uploading_media">Igotzen…</string>
<string name="dialog_download_image">Jaitsi</string>
<string name="dialog_message_cancel_follow_request">Jarraipen-eskaerari uko egin\?</string>
<string name="dialog_message_cancel_follow_request">Jarraipen-eskakizunari uko egin\?</string>
<string name="dialog_unfollow_warning">Kontu hau jarraitzeari utzi\?</string>
<string name="dialog_delete_toot_warning">Tuta ezabatu\?</string>
<string name="visibility_public">Publikoa: Istorio publikoetan erakutsi</string>
@ -156,7 +156,7 @@
<string name="app_theme_auto">Automatikoa</string>
<string name="pref_title_browser_settings">Nabigatzailea</string>
<string name="pref_title_custom_tabs">Chromeko fitxak erabili</string>
<string name="pref_title_hide_follow_button">Tut egiteko botoia ezkutatu beherantz joaterakoan.</string>
<string name="pref_title_hide_follow_button">Tut egiteko botoia ezkutatu beherantz joaterakoan</string>
<string name="pref_title_status_filter">Denbora-lerro filtroak</string>
<string name="pref_title_status_tabs">Fitxak</string>
<string name="pref_title_show_boosts">Bultzadak erakutsi</string>
@ -279,7 +279,7 @@
<string name="pin_action">Ainguratu</string>
<string name="error_network">Sareko errore bat sortu da! Zure konexioa ziurta ezazu berriro, mesedez!</string>
<string name="title_direct_messages">Mezu Zuzenak</string>
<string name="title_tab_preferences">Kategoriak</string>
<string name="title_tab_preferences">Fitxak</string>
<string name="title_statuses_pinned">Lotuta</string>
<string name="title_domain_mutes">Ezkutuko domeinuak</string>
<string name="title_scheduled_toot">Programatutako tutak</string>
@ -291,7 +291,7 @@
<string name="action_delete_and_redraft">Ezabatu eta zirriborroa berriro egin</string>
<string name="action_view_domain_mutes">Ezkutuko domeinuak</string>
<string name="action_add_poll">Galdeketa gehitu</string>
<string name="action_mute_domain">%s isilarazi</string>
<string name="action_mute_domain">Mututu %s</string>
<string name="action_access_scheduled_toot">Programatutako tutak</string>
<string name="action_schedule_toot">Tuta programatu</string>
<string name="action_reset_schedule">Berrezarri</string>
@ -361,7 +361,7 @@
<string name="conversation_1_recipients">%1$s</string>
<string name="conversation_2_recipients">%1$s eta %2$s</string>
<string name="conversation_more_recipients">%1$s, %2$s eta %3$d gehiago</string>
<string name="max_tab_number_reached">geienezko %1$d fitxa iritsita</string>
<string name="max_tab_number_reached">gehienezko %1$d fitxa iritsita</string>
<string name="description_status_media">Media: %s</string>
<string name="description_status_cw">Edukiaren abisua: %s</string>
<string name="description_status_media_no_description_placeholder">Deskribapenik ez</string>
@ -383,8 +383,8 @@
<string name="compose_preview_image_description">%s irudiarentzako ekintzak</string>
<string name="poll_info_format"> <!-- 15 boto • Ordu 1 geratzen da --> %1$s • %2$s</string>
<plurals name="poll_info_votes">
<item quantity="one"/>
<item quantity="other"/>
<item quantity="one">Boto %s</item>
<item quantity="other">%s Boto</item>
</plurals>
<string name="poll_info_time_absolute">%s amaitzen da</string>
<string name="poll_info_closed">Itxita</string>
@ -392,20 +392,16 @@
<string name="poll_ended_voted">Botoa eman duzun galdeketa amaitu da</string>
<string name="poll_ended_created">Sortu duzun galdeketa amaitu da</string>
<plurals name="poll_timespan_days">
<item quantity="one">Egun %d</item>
<item quantity="other">%d egun</item>
<item quantity="one">Egun %d geratzen da</item>
<item quantity="other">%d egun geratzen da</item>
</plurals>
<plurals name="poll_timespan_hours">
<item quantity="one">Ordu %d</item>
<item quantity="other">%d ordu</item>
</plurals>
<plurals name="poll_timespan_minutes">
<item quantity="one">Minutu %d</item>
<item quantity="other">%d minutu</item>
<item quantity="one">Ordu %d geratzen da</item>
<item quantity="other">%d ordu geratzen da</item>
</plurals>
<plurals name="poll_timespan_seconds">
<item quantity="one">Segundu %d</item>
<item quantity="other">%d segundu</item>
<item quantity="one">Segundu %d geratzen da</item>
<item quantity="other">%d segundu geratzen da</item>
</plurals>
<string name="button_continue">Jarraitu</string>
<string name="button_back">Itzuli</string>
@ -439,7 +435,6 @@
<string name="action_open_reblogger">Ireki bultzadaren egilea</string>
<string name="pref_title_public_filter_keywords">Denbora lerro publikoak</string>
<string name="description_status_bookmarked">Laster-markatuta</string>
<string name="poll_info_time_relative">%s geratzen da</string>
<string name="error_audio_upload_size">Audioak 40MB baino gutxiago izan behar ditu.</string>
<string name="select_list_title">Aukeratu zerrenda</string>
<string name="list">Zerrenda</string>
@ -450,13 +445,31 @@
<string name="notification_follow_request_description">Jarraitzeko eskaereri buruzko jakinarazpenak</string>
<string name="dialog_mute_warning">\@%s isildu\?</string>
<string name="dialog_block_warning">\@%s blokeatu\?</string>
<string name="action_mute_conversation">Elkarrizketa isildu</string>
<string name="notification_follow_request_format">%s -k zu jarraitzeko eskatu dizu</string>
<string name="action_mute_conversation">Mututu elkarrizketa</string>
<string name="notification_follow_request_format">%s(e)k zu jarraitzeko eskatu dizu</string>
<string name="hashtags">Traolak</string>
<string name="dialog_mute_hide_notifications">Ez erakutsi jakinarazpenak</string>
<string name="action_unmute_desc">Desmututu %s</string>
<plurals name="poll_info_people">
<item quantity="one">Pertsona %s</item>
<item quantity="other">%s pertsona</item>
<item quantity="one">Pertsona %1</item>
<item quantity="other">%2 pertsona</item>
</plurals>
<string name="pref_title_hide_top_toolbar">Ezkutatu goiko tresna-barraren izenburua</string>
<string name="pref_title_confirm_reblogs">Erakutsi berrespen-abisua tuta bultzatu aurretik</string>
<string name="pref_title_show_cards_in_timelines">Erakutsi esteken aurrebista denbora-lerroetan</string>
<string name="pref_title_enable_swipe_for_tabs">Gaitu pasatze-keinua fitxetan zehar aldatzeko</string>
<plurals name="poll_timespan_minutes">
<item quantity="one">Minutu %d faltan</item>
<item quantity="other">%d minutu faltan</item>
</plurals>
<string name="add_hashtag_title">Gehitu traola</string>
<string name="pref_main_nav_position_option_bottom">Azpia</string>
<string name="pref_main_nav_position_option_top">Goia</string>
<string name="pref_main_nav_position">Nabigatze posizio nagusia</string>
<string name="pref_title_gradient_for_media">Erakutsi gradiente koloretsua ezkutuko mediarentzako</string>
<string name="pref_title_notification_filter_follow_requests">jarraipena-eskaera</string>
<string name="action_unmute_conversation">Desmututu elkarrizketa</string>
<string name="action_unmute_domain">Desmututu %s</string>
<string name="action_mute_notifications_desc">Mututu %s(r)en jakinarazpenak</string>
<string name="action_unmute_notifications_desc">Desmututu %s(r)en jakinarazpenak</string>
</resources>

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