add the ability to see who faved or boosted a toot (#962)

* move reblog/fav count up in detailed status view and make them clickable

* use status object returned by api when reblogging/faving

* Reblogs -> Boosts

* add support for viewing who faved/reblogged a status

* add onShowReblogs/onShowFavs to listener, fix display bug

* remove unneeded icon from previous revision

* small code improvements

* fix liking/boosting toot with card
This commit is contained in:
Konrad Pozniak 2018-12-27 09:48:24 +01:00 committed by GitHub
parent 4864bb79d9
commit c869886c19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 651 additions and 648 deletions

View File

@ -264,7 +264,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF
val accountListClickListener = { v: View ->
val type = when (v.id) {
R.id.accountFollowers-> AccountListActivity.Type.FOLLOWERS
R.id.accountFollowing -> AccountListActivity.Type.FOLLOWING
R.id.accountFollowing -> AccountListActivity.Type.FOLLOWS
else -> throw AssertionError()
}
val accountListIntent = AccountListActivity.newIntent(this, type, accountId)

View File

@ -1,149 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.widget.Toolbar;
import android.view.MenuItem;
import com.keylesspalace.tusky.fragment.AccountListFragment;
import javax.inject.Inject;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.support.HasSupportFragmentInjector;
public final class AccountListActivity extends BaseActivity implements HasSupportFragmentInjector {
@Inject
public DispatchingAndroidInjector<Fragment> dispatchingAndroidInjector;
private static final String TYPE_EXTRA = "type";
private static final String ARG_EXTRA = "arg";
public static Intent newIntent(@NonNull Context context, @NonNull Type type,
@Nullable String argument) {
Intent intent = new Intent(context, AccountListActivity.class);
intent.putExtra(TYPE_EXTRA, type);
if (argument != null) {
intent.putExtra(ARG_EXTRA, argument);
}
return intent;
}
public enum Type {
BLOCKS,
MUTES,
FOLLOW_REQUESTS,
FOLLOWERS,
FOLLOWING,
}
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_account_list);
Type type;
Intent intent = getIntent();
if (intent != null) {
type = (Type) intent.getSerializableExtra("type");
} else {
type = Type.BLOCKS;
}
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar bar = getSupportActionBar();
if (bar != null) {
switch (type) {
case BLOCKS: {
bar.setTitle(getString(R.string.title_blocks));
break;
}
case MUTES: {
bar.setTitle(getString(R.string.title_mutes));
break;
}
case FOLLOW_REQUESTS: {
bar.setTitle(getString(R.string.title_follow_requests));
break;
}
case FOLLOWERS:
bar.setTitle(getString(R.string.title_followers));
break;
case FOLLOWING:
bar.setTitle(getString(R.string.title_follows));
}
bar.setDisplayHomeAsUpEnabled(true);
bar.setDisplayShowHomeEnabled(true);
}
FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction();
AccountListFragment fragment;
switch (type) {
default:
case BLOCKS: {
fragment = AccountListFragment.newInstance(AccountListFragment.Type.BLOCKS);
break;
}
case MUTES: {
fragment = AccountListFragment.newInstance(AccountListFragment.Type.MUTES);
break;
}
case FOLLOWERS: {
String argument = intent.getStringExtra(ARG_EXTRA);
fragment = AccountListFragment.newInstance(AccountListFragment.Type.FOLLOWERS, argument);
break;
}
case FOLLOWING: {
String argument = intent.getStringExtra(ARG_EXTRA);
fragment = AccountListFragment.newInstance(AccountListFragment.Type.FOLLOWS, argument);
break;
}
case FOLLOW_REQUESTS: {
fragment = AccountListFragment.newInstance(AccountListFragment.Type.FOLLOW_REQUESTS);
break;
}
}
fragmentTransaction.replace(R.id.fragment_container, fragment);
fragmentTransaction.commit();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home: {
onBackPressed();
return true;
}
}
return super.onOptionsItemSelected(item);
}
@Override
public AndroidInjector<Fragment> supportFragmentInjector() {
return dispatchingAndroidInjector;
}
}

View File

@ -0,0 +1,102 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.MenuItem
import com.keylesspalace.tusky.fragment.AccountListFragment
import javax.inject.Inject
import dagger.android.AndroidInjector
import dagger.android.DispatchingAndroidInjector
import dagger.android.support.HasSupportFragmentInjector
import kotlinx.android.synthetic.main.toolbar_basic.*
class AccountListActivity : BaseActivity(), HasSupportFragmentInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Fragment>
enum class Type {
FOLLOWS,
FOLLOWERS,
BLOCKS,
MUTES,
FOLLOW_REQUESTS,
REBLOGGED,
FAVOURITED
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_account_list)
val type = intent.getSerializableExtra(EXTRA_TYPE) as Type
val id: String? = intent.getStringExtra(EXTRA_ID)
setSupportActionBar(toolbar)
supportActionBar?.apply {
when (type) {
AccountListActivity.Type.BLOCKS -> setTitle(R.string.title_blocks)
AccountListActivity.Type.MUTES -> setTitle(R.string.title_mutes)
AccountListActivity.Type.FOLLOW_REQUESTS -> setTitle(R.string.title_follow_requests)
AccountListActivity.Type.FOLLOWERS -> setTitle(R.string.title_followers)
AccountListActivity.Type.FOLLOWS -> setTitle(R.string.title_follows)
AccountListActivity.Type.REBLOGGED -> setTitle(R.string.title_reblogged_by)
AccountListActivity.Type.FAVOURITED -> setTitle(R.string.title_favourited_by)
}
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, AccountListFragment.newInstance(type, id))
.commit()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun supportFragmentInjector(): AndroidInjector<Fragment>? {
return dispatchingAndroidInjector
}
companion object {
private const val EXTRA_TYPE = "type"
private const val EXTRA_ID = "id"
@JvmStatic
fun newIntent(context: Context, type: Type, id: String? = null): Intent {
return Intent(context, AccountListActivity::class.java).apply {
putExtra(EXTRA_TYPE, type)
putExtra(EXTRA_ID, id)
}
}
}
}

View File

@ -30,7 +30,6 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
static final int VIEW_TYPE_ACCOUNT = 0;
static final int VIEW_TYPE_FOOTER = 1;
List<Account> accountList;
AccountActionListener accountActionListener;
private boolean bottomLoading;
@ -60,7 +59,7 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
notifyDataSetChanged();
}
public void addItems(List<Account> newAccounts) {
public void addItems(@NonNull List<Account> newAccounts) {
int end = accountList.size();
Account last = accountList.get(end - 1);
if (last != null && !findAccount(newAccounts, last.getId())) {
@ -82,7 +81,7 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
}
}
private static boolean findAccount(List<Account> accounts, String id) {
private static boolean findAccount(@NonNull List<Account> accounts, String id) {
for (Account account : accounts) {
if (account.getId().equals(id)) {
return true;
@ -101,7 +100,7 @@ public abstract class AccountAdapter extends RecyclerView.Adapter {
return account;
}
public void addItem(Account account, int position) {
public void addItem(@NonNull Account account, int position) {
if (position < 0 || position > accountList.size()) {
return;
}

View File

@ -21,6 +21,7 @@ import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.CustomURLSpan;
import com.keylesspalace.tusky.util.HtmlUtils;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import com.squareup.picasso.Picasso;
@ -30,6 +31,7 @@ import java.text.NumberFormat;
import java.util.Date;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
class StatusDetailedViewHolder extends StatusBaseViewHolder {
private TextView reblogs;
@ -40,6 +42,10 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
private TextView cardTitle;
private TextView cardDescription;
private TextView cardUrl;
private View infoDivider;
private View favReblogInfoContainer;
private NumberFormat numberFormat = NumberFormat.getNumberInstance();
StatusDetailedViewHolder(View view) {
super(view, false);
@ -51,6 +57,8 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
cardTitle = view.findViewById(R.id.card_title);
cardDescription = view.findViewById(R.id.card_description);
cardUrl = view.findViewById(R.id.card_link);
infoDivider = view.findViewById(R.id.status_info_divider);
favReblogInfoContainer = view.findViewById(R.id.status_reblog_fav_info);
}
@Override
@ -68,6 +76,45 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
}
}
private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) {
if(reblogCount > 0) {
String reblogCountString = numberFormat.format(reblogCount);
reblogs.setText(HtmlUtils.fromHtml(reblogs.getResources().getQuantityString(R.plurals.reblogs, reblogCount, reblogCountString)));
reblogs.setVisibility(View.VISIBLE);
} else {
reblogs.setVisibility(View.GONE);
}
if(favCount > 0) {
String favCountString = numberFormat.format(favCount);
favourites.setText(HtmlUtils.fromHtml(favourites.getResources().getQuantityString(R.plurals.favs, favCount, favCountString)));
favourites.setVisibility(View.VISIBLE);
} else {
favourites.setVisibility(View.GONE);
}
if(reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) {
infoDivider.setVisibility(View.GONE);
favReblogInfoContainer.setVisibility(View.GONE);
} else {
infoDivider.setVisibility(View.VISIBLE);
favReblogInfoContainer.setVisibility(View.VISIBLE);
}
reblogs.setOnClickListener( v -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onShowReblogs(position);
}
});
favourites.setOnClickListener( v -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onShowFavs(position);
}
});
}
private void setApplication(@Nullable Status.Application app) {
if (app != null) {
@ -91,12 +138,11 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
boolean mediaPreviewEnabled) {
super.setupWithStatus(status, listener, mediaPreviewEnabled);
NumberFormat numberFormat = NumberFormat.getNumberInstance();
setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener);
reblogs.setText(numberFormat.format(status.getReblogsCount()));
favourites.setText(numberFormat.format(status.getFavouritesCount()));
setApplication(status.getApplication());
View.OnLongClickListener longClickListener = view -> {
TextView textView = (TextView)view;
ClipboardManager clipboard = (ClipboardManager) view.getContext().getSystemService(Context.CLIPBOARD_SERVICE);

View File

@ -1,404 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.snackbar.Snackbar;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.keylesspalace.tusky.AccountActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.AccountAdapter;
import com.keylesspalace.tusky.adapter.BlocksAdapter;
import com.keylesspalace.tusky.adapter.FollowAdapter;
import com.keylesspalace.tusky.adapter.FollowRequestsAdapter;
import com.keylesspalace.tusky.adapter.MutesAdapter;
import com.keylesspalace.tusky.di.Injectable;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.interfaces.AccountActionListener;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.util.HttpHeaderLink;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
import java.util.List;
import javax.inject.Inject;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class AccountListFragment extends BaseFragment implements AccountActionListener,
Injectable {
private static final String TAG = "AccountList"; // logging tag
public AccountListFragment() {
}
public enum Type {
FOLLOWS,
FOLLOWERS,
BLOCKS,
MUTES,
FOLLOW_REQUESTS,
}
@Inject
public MastodonApi api;
private Type type;
private String accountId;
private LinearLayoutManager layoutManager;
private RecyclerView recyclerView;
private EndlessOnScrollListener scrollListener;
private AccountAdapter adapter;
private boolean fetching = false;
private String bottomId;
public static AccountListFragment newInstance(Type type) {
Bundle arguments = new Bundle();
AccountListFragment fragment = new AccountListFragment();
arguments.putSerializable("type", type);
fragment.setArguments(arguments);
return fragment;
}
public static AccountListFragment newInstance(Type type, String accountId) {
Bundle arguments = new Bundle();
AccountListFragment fragment = new AccountListFragment();
arguments.putSerializable("type", type);
arguments.putString("accountId", accountId);
fragment.setArguments(arguments);
return fragment;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Bundle arguments = getArguments();
type = (Type) arguments.getSerializable("type");
accountId = arguments.getString("accountId");
api = null;
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_account_list, container, false);
Context context = getContext();
recyclerView = rootView.findViewById(R.id.recycler_view);
recyclerView.setHasFixedSize(true);
layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager);
DividerItemDecoration divider = new DividerItemDecoration(
context, layoutManager.getOrientation());
Drawable drawable = ThemeUtils.getDrawable(context, R.attr.status_divider_drawable,
R.drawable.status_divider_dark);
divider.setDrawable(drawable);
recyclerView.addItemDecoration(divider);
scrollListener = null;
if (type == Type.BLOCKS) {
adapter = new BlocksAdapter(this);
} else if (type == Type.MUTES) {
adapter = new MutesAdapter(this);
} else if (type == Type.FOLLOW_REQUESTS) {
adapter = new FollowRequestsAdapter(this);
} else {
adapter = new FollowAdapter(this);
}
recyclerView.setAdapter(adapter);
return rootView;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
// Just use the basic scroll listener to load more accounts.
scrollListener = new EndlessOnScrollListener(layoutManager) {
@Override
public void onLoadMore(int totalItemsCount, RecyclerView view) {
AccountListFragment.this.onLoadMore();
}
};
recyclerView.addOnScrollListener(scrollListener);
fetchAccounts(null);
}
@Override
public void onViewAccount(String id) {
Context context = getContext();
if(context != null) {
Intent intent = AccountActivity.getIntent(context, id);
startActivity(intent);
}
}
@Override
public void onMute(final boolean mute, final String id, final int position) {
Callback<Relationship> callback = new Callback<Relationship>() {
@Override
public void onResponse(@NonNull Call<Relationship> call, @NonNull Response<Relationship> response) {
if (response.isSuccessful()) {
onMuteSuccess(mute, id, position);
} else {
onMuteFailure(mute, id);
}
}
@Override
public void onFailure(@NonNull Call<Relationship> call, @NonNull Throwable t) {
onMuteFailure(mute, id);
}
};
Call<Relationship> call;
if (!mute) {
call = api.unmuteAccount(id);
} else {
call = api.muteAccount(id);
}
callList.add(call);
call.enqueue(callback);
}
private void onMuteSuccess(boolean muted, final String id, final int position) {
if (muted) {
return;
}
final MutesAdapter mutesAdapter = (MutesAdapter) adapter;
final Account unmutedUser = mutesAdapter.removeItem(position);
View.OnClickListener listener = v -> {
mutesAdapter.addItem(unmutedUser, position);
onMute(true, id, position);
};
Snackbar.make(recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo, listener)
.show();
}
private void onMuteFailure(boolean mute, String id) {
String verb;
if (mute) {
verb = "mute";
} else {
verb = "unmute";
}
Log.e(TAG, String.format("Failed to %s account id %s", verb, id));
}
@Override
public void onBlock(final boolean block, final String id, final int position) {
Callback<Relationship> cb = new Callback<Relationship>() {
@Override
public void onResponse(@NonNull Call<Relationship> call, @NonNull Response<Relationship> response) {
if (response.isSuccessful()) {
onBlockSuccess(block, id, position);
} else {
onBlockFailure(block, id);
}
}
@Override
public void onFailure(@NonNull Call<Relationship> call, @NonNull Throwable t) {
onBlockFailure(block, id);
}
};
Call<Relationship> call;
if (!block) {
call = api.unblockAccount(id);
} else {
call = api.blockAccount(id);
}
callList.add(call);
call.enqueue(cb);
}
private void onBlockSuccess(boolean blocked, final String id, final int position) {
if (blocked) {
return;
}
final BlocksAdapter blocksAdapter = (BlocksAdapter) adapter;
final Account unblockedUser = blocksAdapter.removeItem(position);
View.OnClickListener listener = v -> {
blocksAdapter.addItem(unblockedUser, position);
onBlock(true, id, position);
};
Snackbar.make(recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo, listener)
.show();
}
private void onBlockFailure(boolean block, String id) {
String verb;
if (block) {
verb = "block";
} else {
verb = "unblock";
}
Log.e(TAG, String.format("Failed to %s account id %s", verb, id));
}
@Override
public void onRespondToFollowRequest(final boolean accept, final String accountId,
final int position) {
Callback<Relationship> callback = new Callback<Relationship>() {
@Override
public void onResponse(@NonNull Call<Relationship> call, @NonNull Response<Relationship> response) {
if (response.isSuccessful()) {
onRespondToFollowRequestSuccess(position);
} else {
onRespondToFollowRequestFailure(accept, accountId);
}
}
@Override
public void onFailure(@NonNull Call<Relationship> call, @NonNull Throwable t) {
onRespondToFollowRequestFailure(accept, accountId);
}
};
Call<Relationship> call;
if (accept) {
call = api.authorizeFollowRequest(accountId);
} else {
call = api.rejectFollowRequest(accountId);
}
callList.add(call);
call.enqueue(callback);
}
private void onRespondToFollowRequestSuccess(int position) {
FollowRequestsAdapter followRequestsAdapter = (FollowRequestsAdapter) adapter;
followRequestsAdapter.removeItem(position);
}
private void onRespondToFollowRequestFailure(boolean accept, String accountId) {
String verb;
if (accept) {
verb = "accept";
} else {
verb = "reject";
}
String message = String.format("Failed to %s account id %s.", verb, accountId);
Log.e(TAG, message);
}
private Call<List<Account>> getFetchCallByListType(Type type, String fromId) {
switch (type) {
default:
case FOLLOWS:
return api.accountFollowing(accountId, fromId, null, null);
case FOLLOWERS:
return api.accountFollowers(accountId, fromId, null, null);
case BLOCKS:
return api.blocks(fromId, null, null);
case MUTES:
return api.mutes(fromId, null, null);
case FOLLOW_REQUESTS:
return api.followRequests(fromId, null, null);
}
}
private void fetchAccounts(String id) {
if (fetching) {
return;
}
fetching = true;
if (id != null) {
recyclerView.post(() -> adapter.setBottomLoading(true));
}
Callback<List<Account>> cb = new Callback<List<Account>>() {
@Override
public void onResponse(@NonNull Call<List<Account>> call, @NonNull Response<List<Account>> response) {
if (response.isSuccessful()) {
String linkHeader = response.headers().get("Link");
onFetchAccountsSuccess(response.body(), linkHeader);
} else {
onFetchAccountsFailure(new Exception(response.message()));
}
}
@Override
public void onFailure(@NonNull Call<List<Account>> call, @NonNull Throwable t) {
onFetchAccountsFailure((Exception) t);
}
};
Call<List<Account>> listCall = getFetchCallByListType(type, id);
callList.add(listCall);
listCall.enqueue(cb);
}
private void onFetchAccountsSuccess(List<Account> accounts, String linkHeader) {
adapter.setBottomLoading(false);
List<HttpHeaderLink> links = HttpHeaderLink.parse(linkHeader);
HttpHeaderLink next = HttpHeaderLink.findByRelationType(links, "next");
String fromId = null;
if (next != null) {
fromId = next.uri.getQueryParameter("max_id");
}
if (adapter.getItemCount() > 1) {
adapter.addItems(accounts);
} else {
adapter.update(accounts);
}
bottomId = fromId;
fetching = false;
adapter.setBottomLoading(false);
}
private void onFetchAccountsFailure(Exception exception) {
fetching = false;
Log.e(TAG, "Fetch failure: " + exception.getMessage());
}
private void onLoadMore() {
if(bottomId == null) {
return;
}
fetchAccounts(bottomId);
}
}

View File

@ -0,0 +1,336 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment
import android.os.Bundle
import com.google.android.material.snackbar.Snackbar
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.AccountListActivity.Type
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.AccountAdapter
import com.keylesspalace.tusky.adapter.BlocksAdapter
import com.keylesspalace.tusky.adapter.FollowAdapter
import com.keylesspalace.tusky.adapter.FollowRequestsAdapter
import com.keylesspalace.tusky.adapter.MutesAdapter
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import kotlinx.android.synthetic.main.fragment_account_list.*
import javax.inject.Inject
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
@Inject
lateinit var api: MastodonApi
private lateinit var type: Type
private var id: String? = null
private lateinit var scrollListener: EndlessOnScrollListener
private lateinit var adapter: AccountAdapter
private var fetching = false
private var bottomId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
type = arguments?.getSerializable(ARG_TYPE) as Type
id = arguments?.getString(ARG_ID)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.fragment_account_list, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView.setHasFixedSize(true)
val layoutManager = LinearLayoutManager(context)
recyclerView.layoutManager = layoutManager
val divider = DividerItemDecoration(context, layoutManager.orientation)
val drawable = ThemeUtils.getDrawable(context, R.attr.status_divider_drawable, R.drawable.status_divider_dark)
divider.setDrawable(drawable)
recyclerView.addItemDecoration(divider)
adapter = when(type) {
Type.BLOCKS -> BlocksAdapter(this)
Type.MUTES -> MutesAdapter(this)
Type.FOLLOW_REQUESTS -> FollowRequestsAdapter(this)
else -> FollowAdapter(this)
}
recyclerView.adapter = adapter
scrollListener = object : EndlessOnScrollListener(layoutManager) {
override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) {
if (bottomId == null) {
return
}
fetchAccounts(bottomId)
}
}
recyclerView.addOnScrollListener(scrollListener)
fetchAccounts()
}
override fun onViewAccount(id: String) {
(activity as BaseActivity?)?.let {
val intent = AccountActivity.getIntent(it, id)
it.startActivityWithSlideInAnimation(intent)
}
}
override fun onMute(mute: Boolean, id: String, position: Int) {
val callback = object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>, response: Response<Relationship>) {
if (response.isSuccessful) {
onMuteSuccess(mute, id, position)
} else {
onMuteFailure(mute, id)
}
}
override fun onFailure(call: Call<Relationship>, t: Throwable) {
onMuteFailure(mute, id)
}
}
val call = if (!mute) {
api.unmuteAccount(id)
} else {
api.muteAccount(id)
}
callList.add(call)
call.enqueue(callback)
}
private fun onMuteSuccess(muted: Boolean, id: String, position: Int) {
if (muted) {
return
}
val mutesAdapter = adapter as MutesAdapter
val unmutedUser = mutesAdapter.removeItem(position)
if(unmutedUser != null) {
Snackbar.make(recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
mutesAdapter.addItem(unmutedUser, position)
onMute(true, id, position)
}
.show()
}
}
private fun onMuteFailure(mute: Boolean, accountId: String) {
val verb = if (mute) {
"mute"
} else {
"unmute"
}
Log.e(TAG, "Failed to $verb account id $accountId")
}
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) {
api.unblockAccount(id)
} else {
api.blockAccount(id)
}
callList.add(call)
call.enqueue(cb)
}
private fun onBlockSuccess(blocked: Boolean, id: String, position: Int) {
if (blocked) {
return
}
val blocksAdapter = adapter as BlocksAdapter
val unblockedUser = blocksAdapter.removeItem(position)
if(unblockedUser != null) {
Snackbar.make(recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
blocksAdapter.addItem(unblockedUser, position)
onBlock(true, id, position)
}
.show()
}
}
private fun onBlockFailure(block: Boolean, accountId: String) {
val verb = if (block) {
"block"
} else {
"unblock"
}
Log.e(TAG, "Failed to $verb account accountId $accountId")
}
override fun onRespondToFollowRequest(accept: Boolean, accountId: String,
position: Int) {
val callback = object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>, response: Response<Relationship>) {
if (response.isSuccessful) {
onRespondToFollowRequestSuccess(position)
} else {
onRespondToFollowRequestFailure(accept, accountId)
}
}
override fun onFailure(call: Call<Relationship>, t: Throwable) {
onRespondToFollowRequestFailure(accept, accountId)
}
}
val call = if (accept) {
api.authorizeFollowRequest(accountId)
} else {
api.rejectFollowRequest(accountId)
}
callList.add(call)
call.enqueue(callback)
}
private fun onRespondToFollowRequestSuccess(position: Int) {
val followRequestsAdapter = adapter as FollowRequestsAdapter
followRequestsAdapter.removeItem(position)
}
private fun onRespondToFollowRequestFailure(accept: Boolean, accountId: String) {
val verb = if (accept) {
"accept"
} else {
"reject"
}
Log.e(TAG, "Failed to $verb account id $accountId.")
}
private fun getFetchCallByListType(type: Type, fromId: String?): Call<List<Account>> {
return when (type) {
Type.FOLLOWS -> api.accountFollowing(id, fromId)
Type.FOLLOWERS -> api.accountFollowers(id, fromId)
Type.BLOCKS -> api.blocks(fromId)
Type.MUTES -> api.mutes(fromId)
Type.FOLLOW_REQUESTS -> api.followRequests(fromId)
Type.REBLOGGED -> api.statusRebloggedBy(id, fromId)
Type.FAVOURITED -> api.statusFavouritedBy(id, fromId)
}
}
private fun fetchAccounts(id: String? = null) {
if (fetching) {
return
}
fetching = true
if (id != null) {
recyclerView.post { adapter.setBottomLoading(true) }
}
val cb = object : Callback<List<Account>> {
override fun onResponse(call: Call<List<Account>>, response: Response<List<Account>>) {
val accountList = response.body()
if (response.isSuccessful && accountList != null) {
val linkHeader = response.headers().get("Link")
onFetchAccountsSuccess(accountList, linkHeader)
} else {
onFetchAccountsFailure(Exception(response.message()))
}
}
override fun onFailure(call: Call<List<Account>>, t: Throwable) {
onFetchAccountsFailure(t as Exception)
}
}
val listCall = getFetchCallByListType(type, id)
callList.add(listCall)
listCall.enqueue(cb)
}
private fun onFetchAccountsSuccess(accounts: List<Account>, linkHeader: String?) {
adapter.setBottomLoading(false)
val links = HttpHeaderLink.parse(linkHeader)
val next = HttpHeaderLink.findByRelationType(links, "next")
val fromId = next?.uri?.getQueryParameter("max_id")
if (adapter.itemCount > 0) {
adapter.addItems(accounts)
} else {
adapter.update(accounts)
}
bottomId = fromId
fetching = false
}
private fun onFetchAccountsFailure(exception: Exception) {
fetching = false
Log.e(TAG, "Fetch failure", exception)
}
companion object {
private const val TAG = "AccountList" // logging tag
private const val ARG_TYPE = "type"
private const val ARG_ID = "id"
fun newInstance(type: Type, id: String? = null): AccountListFragment {
return AccountListFragment().apply {
arguments = Bundle(2).apply {
putSerializable(ARG_TYPE, type)
putString(ARG_ID, id)
}
}
}
}
}

View File

@ -18,6 +18,7 @@ package com.keylesspalace.tusky.fragment;
import androidx.arch.core.util.Function;
import androidx.lifecycle.Lifecycle;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
@ -37,6 +38,8 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.keylesspalace.tusky.AccountListActivity;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BuildConfig;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ViewThreadActivity;
@ -237,7 +240,8 @@ public final class ViewThreadFragment extends SFragment implements
@Override
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) {
setReblogForStatus(position, status, reblog);
updateStatus(position, response.body());
eventHub.dispatch(new ReblogEvent(status.getId(), reblog));
}
}
@ -250,24 +254,6 @@ public final class ViewThreadFragment extends SFragment implements
});
}
private void setReblogForStatus(int position, Status status, boolean reblog) {
status.setReblogged(reblog);
if (status.getReblog() != null) {
status.getReblog().setReblogged(reblog);
}
StatusViewData.Concrete viewdata = statuses.getPairedItem(position);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata));
viewDataBuilder.setReblogged(reblog);
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, true);
}
@Override
public void onFavourite(final boolean favourite, final int position) {
final Status status = statuses.get(position);
@ -275,7 +261,8 @@ public final class ViewThreadFragment extends SFragment implements
@Override
public void onResponse(@NonNull Call<Status> call, @NonNull Response<Status> response) {
if (response.isSuccessful()) {
setFavForStatus(position, status, favourite);
updateStatus(position, response.body());
eventHub.dispatch(new FavoriteEvent(status.getId(), favourite));
}
}
@ -288,22 +275,20 @@ public final class ViewThreadFragment extends SFragment implements
});
}
private void setFavForStatus(int position, Status status, boolean favourite) {
status.setFavourited(favourite);
private void updateStatus(int position, Status status) {
if(position >= 0 && position < statuses.size()) {
statuses.set(position, status);
if(position == statusIndex && card != null) {
StatusViewData.Concrete viewData = new StatusViewData.Builder(statuses.getPairedItem(position))
.setCard(card)
.createStatusViewData();
statuses.setPairedItem(position, viewData);
}
adapter.setItem(position, statuses.getPairedItem(position), true);
if (status.getReblog() != null) {
status.getReblog().setFavourited(favourite);
}
StatusViewData.Concrete viewdata = statuses.getPairedItem(position);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata));
viewDataBuilder.setFavourited(favourite);
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
statuses.setPairedItem(position, newViewData);
adapter.setItem(position, newViewData, true);
}
@Override
@ -355,10 +340,24 @@ public final class ViewThreadFragment extends SFragment implements
}
@Override
public void onLoadMore(int pos) {
public void onLoadMore(int position) {
}
@Override
public void onShowReblogs(int position) {
String statusId = statuses.get(position).getId();
Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.REBLOGGED, statusId);
((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent);
}
@Override
public void onShowFavs(int position) {
String statusId = statuses.get(position).getId();
Intent intent = AccountListActivity.newIntent(getContext(), AccountListActivity.Type.FAVOURITED, statusId);
((BaseActivity) getActivity()).startActivityWithSlideInAnimation(intent);
}
@Override
public void onContentCollapsedChange(boolean isCollapsed, int position) {
if (position < 0 || position >= statuses.size()) {
@ -615,14 +614,44 @@ public final class ViewThreadFragment extends SFragment implements
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
if (posAndStatus == null) return;
//noinspection ConstantConditions
setFavForStatus(posAndStatus.first, posAndStatus.second, event.getFavourite());
boolean favourite = event.getFavourite();
posAndStatus.second.setFavourited(favourite);
if (posAndStatus.second.getReblog() != null) {
posAndStatus.second.getReblog().setFavourited(favourite);
}
StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata));
viewDataBuilder.setFavourited(favourite);
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
statuses.setPairedItem(posAndStatus.first, newViewData);
adapter.setItem(posAndStatus.first, newViewData, true);
}
private void handleReblogEvent(ReblogEvent event) {
Pair<Integer, Status> posAndStatus = findStatusAndPos(event.getStatusId());
if (posAndStatus == null) return;
//noinspection ConstantConditions
setReblogForStatus(posAndStatus.first, posAndStatus.second, event.getReblog());
boolean reblog = event.getReblog();
posAndStatus.second.setReblogged(reblog);
if (posAndStatus.second.getReblog() != null) {
posAndStatus.second.getReblog().setReblogged(reblog);
}
StatusViewData.Concrete viewdata = statuses.getPairedItem(posAndStatus.first);
StatusViewData.Builder viewDataBuilder = new StatusViewData.Builder((viewdata));
viewDataBuilder.setReblogged(reblog);
StatusViewData.Concrete newViewData = viewDataBuilder.createStatusViewData();
statuses.setPairedItem(posAndStatus.first, newViewData);
adapter.setItem(posAndStatus.first, newViewData, true);
}
private void handleStatusComposedEvent(StatusComposedEvent event) {

View File

@ -37,4 +37,17 @@ public interface StatusActionListener extends LinkListener {
* @param position The position of the status in the list.
*/
void onContentCollapsedChange(boolean isCollapsed, int position);
/**
* called when the reblog count has been clicked
* @param position The position of the status in the list.
*/
default void onShowReblogs(int position) {}
/**
* called when the favourite count has been clicked
* @param position The position of the status in the list.
*/
default void onShowFavs(int position) {}
}

View File

@ -51,6 +51,10 @@ import retrofit2.http.Part;
import retrofit2.http.Path;
import retrofit2.http.Query;
/**
* for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/
*/
public interface MastodonApi {
String ENDPOINT_AUTHORIZE = "/oauth/authorize";
String DOMAIN_HEADER = "domain";
@ -131,16 +135,12 @@ public interface MastodonApi {
@GET("api/v1/statuses/{id}/reblogged_by")
Call<List<Account>> statusRebloggedBy(
@Path("id") String statusId,
@Query("max_id") String maxId,
@Query("since_id") String sinceId,
@Query("limit") Integer limit);
@Query("max_id") String maxId);
@GET("api/v1/statuses/{id}/favourited_by")
Call<List<Account>> statusFavouritedBy(
@Path("id") String statusId,
@Query("max_id") String maxId,
@Query("since_id") String sinceId,
@Query("limit") Integer limit);
@Query("max_id") String maxId);
@DELETE("api/v1/statuses/{id}")
Call<ResponseBody> deleteStatus(@Path("id") String statusId);
@ -218,16 +218,12 @@ public interface MastodonApi {
@GET("api/v1/accounts/{id}/followers")
Call<List<Account>> accountFollowers(
@Path("id") String accountId,
@Query("max_id") String maxId,
@Query("since_id") String sinceId,
@Query("limit") Integer limit);
@Query("max_id") String maxId);
@GET("api/v1/accounts/{id}/following")
Call<List<Account>> accountFollowing(
@Path("id") String accountId,
@Query("max_id") String maxId,
@Query("since_id") String sinceId,
@Query("limit") Integer limit);
@Query("max_id") String maxId);
@FormUrlEncoded
@POST("api/v1/accounts/{id}/follow")
@ -252,16 +248,10 @@ public interface MastodonApi {
Call<List<Relationship>> relationships(@Query("id[]") List<String> accountIds);
@GET("api/v1/blocks")
Call<List<Account>> blocks(
@Query("max_id") String maxId,
@Query("since_id") String sinceId,
@Query("limit") Integer limit);
Call<List<Account>> blocks(@Query("max_id") String maxId);
@GET("api/v1/mutes")
Call<List<Account>> mutes(
@Query("max_id") String maxId,
@Query("since_id") String sinceId,
@Query("limit") Integer limit);
Call<List<Account>> mutes(@Query("max_id") String maxId);
@GET("api/v1/favourites")
Call<List<Status>> favourites(
@ -270,10 +260,7 @@ public interface MastodonApi {
@Query("limit") Integer limit);
@GET("api/v1/follow_requests")
Call<List<Account>> followRequests(
@Query("max_id") String maxId,
@Query("since_id") String sinceId,
@Query("limit") Integer limit);
Call<List<Account>> followRequests(@Query("max_id") String maxId);
@POST("api/v1/follow_requests/{id}/authorize")
Call<Relationship> authorizeFollowRequest(@Path("id") String accountId);

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/recycler_view"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@ -15,8 +15,8 @@
android:id="@+id/status_avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="14dp"
android:layout_marginTop="14dp"
android:layout_marginEnd="14dp"
android:contentDescription="@string/action_view_profile"
android:scaleType="centerCrop"
tools:src="@drawable/avatar_default" />
@ -25,8 +25,8 @@
android:id="@+id/status_name_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginTop="14dp"
android:layout_marginBottom="8dp"
android:layout_toEndOf="@+id/status_avatar"
android:gravity="center_vertical"
android:minHeight="48dp"
@ -71,15 +71,15 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/status_content_warning_description"
android:layout_marginBottom="4dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:background="?attr/content_warning_button"
android:minHeight="0dp"
android:minWidth="160dp"
android:paddingBottom="4dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="4dp"
android:paddingRight="16dp"
android:paddingBottom="4dp"
android:textAllCaps="true"
android:textOff="@string/status_content_warning_show_more"
android:textOn="@string/status_content_warning_show_less"
@ -118,10 +118,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="6dp"
android:paddingLeft="6dp"
android:paddingTop="6dp"
android:paddingRight="6dp"
android:paddingTop="6dp">
android:paddingBottom="6dp">
<!--TODO: check if this needs emoji support-->
<androidx.emoji.widget.EmojiTextView
@ -164,8 +164,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/card_view"
android:layout_marginBottom="4dp"
android:layout_marginTop="@dimen/status_media_preview_margin_top">
android:layout_marginTop="@dimen/status_media_preview_margin_top"
android:layout_marginBottom="4dp">
<ImageView
android:id="@+id/status_media_preview_0"
@ -308,23 +308,66 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/status_media_preview_container"
android:layout_marginBottom="6dp"
android:layout_marginTop="10dp"
android:layout_marginBottom="6dp"
android:drawablePadding="4dp"
android:textColor="?android:textColorTertiary"
android:textSize="?attr/status_text_medium" />
<View
android:id="@+id/status_info_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@id/status_timestamp_info"
android:background="?attr/status_divider_drawable"
android:paddingStart="16dp"
android:paddingEnd="16dp" />
<LinearLayout
android:id="@+id/status_reblog_fav_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/status_timestamp_info"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp">
<TextView
android:id="@+id/status_reblogs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:background="?attr/selectableItemBackground"
android:padding="4dp"
android:textSize="?attr/status_text_medium" />
<TextView
android:id="@+id/status_favourites"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:padding="4dp"
android:textSize="?attr/status_text_medium" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@id/status_reblog_fav_info"
android:background="?attr/status_divider_drawable"
android:paddingStart="16dp"
android:paddingEnd="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/status_reblog_fav_info"
android:layout_marginEnd="8dp"
android:clipChildren="false"
android:clipToPadding="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingBottom="4dp"
android:paddingTop="4dp">
android:paddingTop="4dp"
android:paddingBottom="4dp">
<ImageButton
android:id="@+id/status_reply"
@ -353,12 +396,6 @@
sparkbutton:primaryColor="@color/tusky_blue"
sparkbutton:secondaryColor="@color/tusky_blue_light" />
<TextView
android:id="@+id/status_reblogs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium" />
<Space
android:layout_width="0dp"
android:layout_height="match_parent"
@ -377,12 +414,6 @@
sparkbutton:primaryColor="@color/tusky_orange"
sparkbutton:secondaryColor="@color/tusky_orange_light" />
<TextView
android:id="@+id/status_favourites"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="?attr/status_text_medium" />
<Space
android:layout_width="0dp"
android:layout_height="match_parent"

View File

@ -360,4 +360,17 @@
<string name="unpin_action">Unpin</string>
<string name="pin_action">Pin</string>
<plurals name="favs">
<item quantity="one">&lt;b>%1$s&lt;/b> Favourite</item>
<item quantity="other">&lt;b>%1$s&lt;/b> Favourites</item>
</plurals>
<plurals name="reblogs">
<item quantity="one">&lt;b>%s&lt;/b> Boost</item>
<item quantity="other">&lt;b>%s&lt;/b> Boosts</item>
</plurals>
<string name="title_reblogged_by">Boosted by</string>
<string name="title_favourited_by">Favourited by</string>
</resources>