Compare commits

..

29 Commits

Author SHA1 Message Date
sk
12599db0ff change dev versioning 2022-05-02 23:00:55 +02:00
sk
c751c85c1c add version number for upstream changes 2022-05-02 22:43:05 +02:00
sk
f1331a0f6d start fork versioning 2022-05-02 21:35:51 +02:00
sk
c75c9b60f9 add fork readme 2022-05-02 21:35:42 +02:00
sk
eb3adf1dfd Merge branch 'feature/add-federated-timeline' into fork 2022-05-02 21:35:14 +02:00
sk
6533163fd0 Merge branch 'master' into fork 2022-05-02 21:17:04 +02:00
Gregory K
fa75570254 Merge pull request #112 from sk22/fix/space-in-empty-reply
Fix compose containing a wrong space as initial reply text
2022-05-02 22:06:12 +03:00
sk
1406ea376d fix space as reply initial text 2022-05-02 20:59:22 +02:00
sk
1becad6016 Merge branch 'feature/enable-unlisted-as-default' into fork 2022-05-02 19:32:26 +02:00
sk
d34653750e set unlisted as default visibility 2022-05-02 19:31:43 +02:00
sk
705592aefd set unlisted as default 2022-05-02 19:17:25 +02:00
sk
583325d6e8 add unlisted visibility option 2022-05-02 19:16:53 +02:00
sk
318d271127 add federation tab and change tab order 2022-05-02 18:31:29 +02:00
Grishka
5562bf936e toot -> post 2022-05-02 05:49:59 +03:00
Grishka
02a1f2ef8c Add following/followers lists
closes #25
2022-05-02 05:45:51 +03:00
Grishka
7b26649521 Probably fix #95 2022-05-02 01:20:07 +03:00
Grishka
8059120136 Better account switcher 2022-05-01 00:44:28 +03:00
Grishka
ec38210dde Fix #99 2022-04-30 20:30:29 +03:00
Grishka
38eadca4e2 Report fixes 2022-04-30 00:02:13 +03:00
Grishka
31cb17d549 Update readme 2022-04-29 20:39:19 +03:00
Grishka
10a5bf0a82 Fix notification policy layout for long strings 2022-04-29 17:38:48 +03:00
Grishka
a58a279e8c Client-side filtering is annoying 2022-04-29 17:25:32 +03:00
Grishka
0fe58e49b6 Display more relationship states + locked accounts
closes #85, #73, #30
2022-04-29 17:11:23 +03:00
Grishka
089e297656 fix #93 2022-04-29 15:17:29 +03:00
Grishka
2a65bdb08f Add strings for follow states 2022-04-29 03:45:41 +03:00
Grishka
93fbc52f6a More crash fixes 2022-04-29 03:37:18 +03:00
Grishka
4e4b5fcfe4 Probably a crash fix 2022-04-29 03:06:06 +03:00
Grishka
620bc2285c Fix notification settings getting reset on app update 2022-04-28 23:32:43 +03:00
Grishka
f73849dbb7 Home timeline auto-refresh
close #32
2022-04-28 23:22:55 +03:00
64 changed files with 2059 additions and 212 deletions

View File

@@ -1,11 +1,17 @@
# Mastodon for Android
# Forked Mastodon for Android
[![Crowdin](https://badges.crowdin.net/mastodon-for-android/localized.svg)](https://crowdin.com/project/mastodon-for-android)
<a href="https://play.google.com/store/apps/details?id=org.joinmastodon.android"><img src="img/google-play-badge.png" height="50"></a>
This is the repository for the official Android app for Mastodon.
This is the repository for an officially forked Android app for Mastodon.
Learn more about this app in the [blog post](https://blog.joinmastodon.org/2022/02/official-mastodon-for-android-app-is-coming-soon/).
## Changes
* [Enable "Unlisted" as a visibility option](https://github.com/sk22/mastodon-android-fork/tree/feature/enable-unlisted)
([Pull request](https://github.com/mastodon/mastodon-android/pull/103)) and
[set as default](https://github.com/sk22/mastodon-android-fork/tree/feature/enable-unlisted-as-default)
* [Add "Federation" tab and change Discover tab order](https://github.com/sk22/mastodon-android-fork/tree/feature/add-federated-timeline)
## Building
As this app is using Java 17 features, you need JDK 17 or newer to build it. Other than that, everything is pretty standard. You can either import the project into Android Studio and build it from there, or run the following command in the project directory:

View File

@@ -9,8 +9,8 @@ android {
applicationId "org.joinmastodon.android"
minSdk 23
targetSdk 31
versionCode 33
versionName "1.0.3"
versionCode 3
versionName '1.0.4-dev+fork.1.1'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -58,7 +58,7 @@ dependencies {
implementation 'me.grishka.litex:dynamicanimation:1.1.0-alpha03'
implementation 'me.grishka.litex:viewpager:1.0.0'
implementation 'me.grishka.litex:viewpager2:1.0.0'
implementation 'me.grishka.appkit:appkit:1.2.2'
implementation 'me.grishka.appkit:appkit:1.2.6'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'org.jsoup:jsoup:1.14.3'
implementation 'com.squareup:otto:1.3.8'

View File

@@ -14,11 +14,13 @@ import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.requests.notifications.GetNotifications;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.io.IOException;
import java.util.ArrayList;
@@ -41,6 +43,8 @@ public class CacheController{
private DatabaseHelper db;
private final Runnable databaseCloseRunnable=this::closeDatabase;
private static final int POST_FLAG_GAP_AFTER=1;
static{
databaseThread.start();
}
@@ -49,14 +53,14 @@ public class CacheController{
this.accountID=accountID;
}
public void getHomeTimeline(String maxID, int count, boolean forceReload, Callback<PaginatedResponse<List<Status>>> callback){
public void getHomeTimeline(String maxID, int count, boolean forceReload, Callback<CacheablePaginatedResponse<List<Status>>> callback){
cancelDelayedClose();
databaseThread.postRunnable(()->{
try{
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
if(!forceReload){
SQLiteDatabase db=getOrOpenDatabase();
try(Cursor cursor=db.query("home_timeline", new String[]{"json"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`id` DESC", count+"")){
try(Cursor cursor=db.query("home_timeline", new String[]{"json", "flags"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`id` DESC", count+"")){
if(cursor.getCount()==count){
ArrayList<Status> result=new ArrayList<>();
cursor.moveToFirst();
@@ -65,6 +69,8 @@ public class CacheController{
do{
Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class);
status.postprocess();
int flags=cursor.getInt(1);
status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0);
newMaxID=status.id;
for(Filter filter:filters){
if(filter.matches(status.getContentStatus().content))
@@ -73,25 +79,18 @@ public class CacheController{
result.add(status);
}while(cursor.moveToNext());
String _newMaxID=newMaxID;
uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(result, _newMaxID)));
uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true)));
return;
}
}catch(IOException x){
Log.w(TAG, "getHomeTimeline: corrupted status object in database", x);
}
}
new GetHomeTimeline(maxID, null, count)
new GetHomeTimeline(maxID, null, count, null)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Status> result){
callback.onSuccess(new PaginatedResponse<>(result.stream().filter(post->{
for(Filter filter:filters){
if(filter.matches(post.getContentStatus().content)){
return false;
}
}
return true;
}).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id));
callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false));
putHomeTimeline(result, maxID==null);
}
@@ -110,14 +109,18 @@ public class CacheController{
}, 0);
}
private void putHomeTimeline(List<Status> posts, boolean clear){
public void putHomeTimeline(List<Status> posts, boolean clear){
runOnDbThread((db)->{
if(clear)
db.delete("home_timeline", null, null);
ContentValues values=new ContentValues(2);
ContentValues values=new ContentValues(3);
for(Status s:posts){
values.put("id", s.id);
values.put("json", MastodonAPIController.gson.toJson(s));
int flags=0;
if(s.hasGapAfter)
flags|=POST_FLAG_GAP_AFTER;
values.put("flags", flags);
db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
});

View File

@@ -1,14 +1,10 @@
package org.joinmastodon.android.api;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.util.Log;
import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonIOException;
import com.google.gson.JsonObject;
@@ -16,11 +12,9 @@ import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.gson.IsoInstantTypeAdapter;
import org.joinmastodon.android.api.gson.IsoLocalDateTypeAdapter;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.model.BaseModel;
import java.io.IOException;
import java.io.Reader;
@@ -144,7 +138,7 @@ public class MastodonAPIController{
}
try{
req.validateAndPostprocessResponse(respObj);
req.validateAndPostprocessResponse(respObj, response);
}catch(IOException x){
if(BuildConfig.DEBUG)
Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x);

View File

@@ -28,6 +28,7 @@ import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import okhttp3.Call;
import okhttp3.RequestBody;
import okhttp3.Response;
public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
private static final String TAG="MastodonAPIRequest";
@@ -75,9 +76,14 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
}
public MastodonAPIRequest<T> exec(String accountID){
account=AccountSessionManager.getInstance().getAccount(accountID);
domain=account.domain;
account.getApiController().submitRequest(this);
try{
account=AccountSessionManager.getInstance().getAccount(accountID);
domain=account.domain;
account.getApiController().submitRequest(this);
}catch(Exception x){
Log.e(TAG, "exec: this shouldn't happen, but it still did", x);
invokeErrorCallback(new MastodonErrorResponse(x.getLocalizedMessage(), -1));
}
return this;
}
@@ -153,7 +159,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
}
@CallSuper
public void validateAndPostprocessResponse(T respObj) throws IOException{
public void validateAndPostprocessResponse(T respObj, Response httpResponse) throws IOException{
if(respObj instanceof BaseModel){
((BaseModel) respObj).postprocess();
}else if(respObj instanceof List){

View File

@@ -121,10 +121,6 @@ public class PushSubscriptionManager{
return !TextUtils.isEmpty(deviceToken);
}
public void registerAccountForPush(){
registerAccountForPush(null);
}
public void registerAccountForPush(PushSubscription subscription){
if(TextUtils.isEmpty(deviceToken))
throw new IllegalStateException("No device push token available");
@@ -367,7 +363,7 @@ public class PushSubscriptionManager{
private static void registerAllAccountsForPush(boolean forceReRegister){
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
if(session.pushSubscription==null || forceReRegister)
session.getPushSubscriptionManager().registerAccountForPush();
session.getPushSubscriptionManager().registerAccountForPush(session.pushSubscription);
else if(session.needUpdatePushSettings)
session.getPushSubscriptionManager().updatePushSettings(session.pushSubscription);
}

View File

@@ -0,0 +1,57 @@
package org.joinmastodon.android.api.requests;
import android.net.Uri;
import android.text.TextUtils;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.HeaderPaginationList;
import java.io.IOException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import okhttp3.Response;
public abstract class HeaderPaginationRequest<I> extends MastodonAPIRequest<HeaderPaginationList<I>>{
private static final Pattern LINK_HEADER_PATTERN=Pattern.compile("(?:(?:,\\s*)?<([^>]+)>|;\\s*(\\w+)=['\"](\\w+)['\"])");
public HeaderPaginationRequest(HttpMethod method, String path, Class<HeaderPaginationList<I>> respClass){
super(method, path, respClass);
}
public HeaderPaginationRequest(HttpMethod method, String path, TypeToken<HeaderPaginationList<I>> respTypeToken){
super(method, path, respTypeToken);
}
@Override
public void validateAndPostprocessResponse(HeaderPaginationList<I> respObj, Response httpResponse) throws IOException{
super.validateAndPostprocessResponse(respObj, httpResponse);
String link=httpResponse.header("Link");
if(!TextUtils.isEmpty(link)){
Matcher matcher=LINK_HEADER_PATTERN.matcher(link);
String url=null;
while(matcher.find()){
if(url==null){
String _url=matcher.group(1);
if(_url==null)
continue;
url=_url;
}else{
String paramName=matcher.group(2);
String paramValue=matcher.group(3);
if(paramName==null || paramValue==null)
return;
if("rel".equals(paramName)){
switch(paramValue){
case "next" -> respObj.nextPageUri=Uri.parse(url);
case "prev" -> respObj.prevPageUri=Uri.parse(url);
}
url=null;
}
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Account;
public class GetAccountFollowers extends HeaderPaginationRequest<Account>{
public GetAccountFollowers(String id, String maxID, int limit){
super(HttpMethod.GET, "/accounts/"+id+"/followers", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View File

@@ -0,0 +1,16 @@
package org.joinmastodon.android.api.requests.accounts;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.model.Account;
public class GetAccountFollowing extends HeaderPaginationRequest<Account>{
public GetAccountFollowing(String id, String maxID, int limit){
super(HttpMethod.GET, "/accounts/"+id+"/following", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", limit+"");
}
}

View File

@@ -8,12 +8,14 @@ import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetHomeTimeline extends MastodonAPIRequest<List<Status>>{
public GetHomeTimeline(String maxID, String minID, int limit){
public GetHomeTimeline(String maxID, String minID, int limit, String sinceID){
super(HttpMethod.GET, "/timelines/home", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(minID!=null)
addQueryParameter("min_id", minID);
if(sinceID!=null)
addQueryParameter("since_id", sinceID);
if(limit>0)
addQueryParameter("limit", ""+limit);
}

View File

@@ -100,7 +100,7 @@ public class AccountSessionManager{
writeAccountsFile();
updateInstanceEmojis(instance, instance.uri);
if(PushSubscriptionManager.arePushNotificationsAvailable()){
session.getPushSubscriptionManager().registerAccountForPush();
session.getPushSubscriptionManager().registerAccountForPush(null);
}
}

View File

@@ -0,0 +1,381 @@
package org.joinmastodon.android.fragments;
import android.app.ProgressDialog;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.view.ContextMenu;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toolbar;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class BaseAccountListFragment extends BaseRecyclerFragment<BaseAccountListFragment.AccountItem>{
protected HashMap<String, Relationship> relationships=new HashMap<>();
protected String accountID;
protected ArrayList<APIRequest<?>> relationshipsRequests=new ArrayList<>();
public BaseAccountListFragment(){
super(40);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
}
@Override
protected void onDataLoaded(List<AccountItem> d, boolean more){
if(refreshing){
relationships.clear();
}
loadRelationships(d);
super.onDataLoaded(d, more);
}
@Override
public void onRefresh(){
for(APIRequest<?> req:relationshipsRequests){
req.cancel();
}
relationshipsRequests.clear();
super.onRefresh();
}
protected void loadRelationships(List<AccountItem> accounts){
Set<String> ids=accounts.stream().map(ai->ai.account.id).collect(Collectors.toSet());
GetAccountRelationships req=new GetAccountRelationships(ids);
relationshipsRequests.add(req);
req.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Relationship> result){
relationshipsRequests.remove(req);
for(Relationship rel:result){
relationships.put(rel.id, rel);
}
if(list==null)
return;
for(int i=0;i<list.getChildCount();i++){
if(list.getChildViewHolder(list.getChildAt(i)) instanceof AccountViewHolder avh){
avh.bindRelationship();
}
}
}
@Override
public void onError(ErrorResponse error){
relationshipsRequests.remove(req);
}
})
.exec(accountID);
}
@Override
protected RecyclerView.Adapter getAdapter(){
return new AccountsAdapter();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
// list.setPadding(0, V.dp(16), 0, V.dp(16));
list.setClipToPadding(false);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 1, 72, 16));
updateToolbar();
}
@Override
public void onConfigurationChanged(Configuration newConfig){
super.onConfigurationChanged(newConfig);
updateToolbar();
}
@CallSuper
protected void updateToolbar(){
Toolbar toolbar=getToolbar();
if(toolbar!=null && toolbar.getNavigationIcon()!=null){
toolbar.setNavigationContentDescription(R.string.back);
toolbar.setTitleTextAppearance(getActivity(), R.style.m3_title_medium);
toolbar.setSubtitleTextAppearance(getActivity(), R.style.m3_body_medium);
int color=UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary);
toolbar.setTitleTextColor(color);
toolbar.setSubtitleTextColor(color);
}
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){
list.setPadding(0, V.dp(16), 0, V.dp(16)+insets.getSystemWindowInsetBottom());
insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom());
}else{
list.setPadding(0, V.dp(16), 0, V.dp(16));
}
super.onApplyWindowInsets(insets);
}
protected class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
public AccountsAdapter(){
super(imgLoader);
}
@NonNull
@Override
public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new AccountViewHolder();
}
@Override
public void onBindViewHolder(AccountViewHolder holder, int position){
holder.bind(data.get(position));
super.onBindViewHolder(holder, position);
}
@Override
public int getItemCount(){
return data.size();
}
@Override
public int getImageCountForItem(int position){
return data.get(position).emojiHelper.getImageCount()+1;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
AccountItem item=data.get(position);
return image==0 ? item.avaRequest : item.emojiHelper.getImageRequest(image-1);
}
}
protected class AccountViewHolder extends BindableViewHolder<AccountItem> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable, UsableRecyclerView.LongClickable{
private final TextView name, username;
private final ImageView avatar;
private final Button button;
private final PopupMenu contextMenu;
private final View menuAnchor;
public AccountViewHolder(){
super(getActivity(), R.layout.item_account_list, list);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
avatar=findViewById(R.id.avatar);
button=findViewById(R.id.button);
menuAnchor=findViewById(R.id.menu_anchor);
avatar.setOutlineProvider(OutlineProviders.roundedRect(12));
avatar.setClipToOutline(true);
button.setOnClickListener(this::onButtonClick);
contextMenu=new PopupMenu(getActivity(), menuAnchor);
contextMenu.inflate(R.menu.profile);
contextMenu.setOnMenuItemClickListener(this::onContextMenuItemSelected);
}
@Override
public void onBind(AccountItem item){
name.setText(item.parsedName);
username.setText("@"+item.account.acct);
bindRelationship();
}
public void bindRelationship(){
Relationship rel=relationships.get(item.account.id);
if(rel==null){
button.setVisibility(View.GONE);
}else{
button.setVisibility(View.VISIBLE);
UiUtils.setRelationshipToActionButton(rel, button);
}
}
@Override
public void setImage(int index, Drawable image){
if(index==0){
avatar.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-1, image);
name.invalidate();
}
if(image instanceof Animatable a && !a.isRunning())
a.start();
}
@Override
public void clearImage(int index){
setImage(index, null);
}
@Override
public void onClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(item.account));
Nav.go(getActivity(), ProfileFragment.class, args);
}
@Override
public boolean onLongClick(){
return false;
}
@Override
public boolean onLongClick(float x, float y){
Relationship relationship=relationships.get(item.account.id);
if(relationship==null)
return false;
Menu menu=contextMenu.getMenu();
Account account=item.account;
menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getDisplayUsername()));
menu.findItem(R.id.mute).setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername()));
menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername()));
menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.getDisplayUsername()));
if(relationship.following)
menu.findItem(R.id.hide_boosts).setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername()));
else
menu.findItem(R.id.hide_boosts).setVisible(false);
if(!account.isLocal())
menu.findItem(R.id.block_domain).setTitle(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
else
menu.findItem(R.id.block_domain).setVisible(false);
menuAnchor.setTranslationX(x);
menuAnchor.setTranslationY(y);
contextMenu.show();
return true;
}
private void onButtonClick(View v){
ProgressDialog progress=new ProgressDialog(getActivity());
progress.setMessage(getString(R.string.loading));
progress.setCancelable(false);
UiUtils.performAccountAction(getActivity(), item.account, accountID, relationships.get(item.account.id), button, progressShown->{
itemView.setHasTransientState(progressShown);
if(progressShown)
progress.show();
else
progress.dismiss();
}, result->{
relationships.put(item.account.id, result);
bindRelationship();
});
}
private boolean onContextMenuItemSelected(MenuItem item){
Relationship relationship=relationships.get(this.item.account.id);
if(relationship==null)
return false;
Account account=this.item.account;
int id=item.getItemId();
if(id==R.id.share){
Intent intent=new Intent(Intent.ACTION_SEND);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TEXT, account.url);
startActivity(Intent.createChooser(intent, item.getTitle()));
}else if(id==R.id.mute){
UiUtils.confirmToggleMuteUser(getActivity(), accountID, account, relationship.muting, this::updateRelationship);
}else if(id==R.id.block){
UiUtils.confirmToggleBlockUser(getActivity(), accountID, account, relationship.blocking, this::updateRelationship);
}else if(id==R.id.report){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("reportAccount", Parcels.wrap(account));
Nav.go(getActivity(), ReportReasonChoiceFragment.class, args);
}else if(id==R.id.open_in_browser){
UiUtils.launchWebBrowser(getActivity(), account.url);
}else if(id==R.id.block_domain){
UiUtils.confirmToggleBlockDomain(getActivity(), accountID, account.getDomain(), relationship.domainBlocking, ()->{
relationship.domainBlocking=!relationship.domainBlocking;
bindRelationship();
});
}else if(id==R.id.hide_boosts){
new SetAccountFollowed(account.id, true, !relationship.showingReblogs)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Relationship result){
relationships.put(AccountViewHolder.this.item.account.id, result);
bindRelationship();
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, false)
.exec(accountID);
}
return true;
}
private void updateRelationship(Relationship r){
relationships.put(item.account.id, r);
bindRelationship();
}
}
protected static class AccountItem{
public final Account account;
public final ImageLoaderRequest avaRequest;
public final CustomEmojiHelper emojiHelper;
public final CharSequence parsedName;
public AccountItem(Account account){
this.account=account;
avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(50), V.dp(50));
emojiHelper=new CustomEmojiHelper();
emojiHelper.setText(parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis));
}
}
}

View File

@@ -31,6 +31,7 @@ import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.TileGridLayoutManager;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem;
@@ -281,6 +282,10 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
list.getDecoratedBoundsWithMargins(view, outRect);
RecyclerView.ViewHolder holder=list.getChildViewHolder(view);
if(holder instanceof StatusDisplayItem.Holder){
if(((StatusDisplayItem.Holder<?>) holder).getItem().getType()==StatusDisplayItem.Type.GAP){
outRect.setEmpty();
return;
}
String id=((StatusDisplayItem.Holder<?>) holder).getItemID();
for(int i=0;i<list.getChildCount();i++){
View child=list.getChildAt(i);
@@ -474,6 +479,8 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
}
public void onGapClick(GapStatusDisplayItem.Holder item){}
public String getAccountID(){
return accountID;
}
@@ -653,8 +660,8 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
View bottomSibling=parent.getChildAt(i+1);
RecyclerView.ViewHolder holder=parent.getChildViewHolder(child);
RecyclerView.ViewHolder siblingHolder=parent.getChildViewHolder(bottomSibling);
if(holder instanceof StatusDisplayItem.Holder && siblingHolder instanceof StatusDisplayItem.Holder
&& !((StatusDisplayItem.Holder<?>) holder).getItemID().equals(((StatusDisplayItem.Holder<?>) siblingHolder).getItemID())){
if(holder instanceof StatusDisplayItem.Holder<?> ih && siblingHolder instanceof StatusDisplayItem.Holder<?> sh
&& !ih.getItemID().equals(sh.getItemID()) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP){
drawDivider(child, bottomSibling, holder, siblingHolder, parent, c, dividerPaint);
}
}

View File

@@ -167,7 +167,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private ImageView sendError;
private View sendingOverlay;
private WindowManager wm;
private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC;
private StatusPrivacy statusVisibility=StatusPrivacy.UNLISTED;
private ComposeAutocompleteSpan currentAutocompleteSpan;
private FrameLayout mainEditTextWrap;
private ComposeAutocompleteViewController autocompleteViewController;
@@ -382,6 +382,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public void onTextChanged(CharSequence s, int start, int before, int count){
if(s.length()==0)
return;
// offset one char back to catch an already typed '@' or '#' or ':'
int realStart=start;
start=Math.max(0, start-1);
@@ -447,7 +449,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
if(!mentions.contains(m))
mentions.add(m);
}
initialText=TextUtils.join(" ", mentions)+" ";
initialText=mentions.isEmpty() ? "" : TextUtils.join(" ", mentions)+" ";
if(savedInstanceState==null){
mainEditText.setText(initialText);
mainEditText.setSelection(mainEditText.length());
@@ -714,25 +716,27 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
return false;
}
String type=getActivity().getContentResolver().getType(uri);
if(instance.configuration!=null && instance.configuration.mediaAttachments!=null){
if(instance!=null && instance.configuration!=null && instance.configuration.mediaAttachments!=null){
if(instance.configuration.mediaAttachments.supportedMimeTypes!=null && !instance.configuration.mediaAttachments.supportedMimeTypes.contains(type)){
showMediaAttachmentError(getString(R.string.media_attachment_unsupported_type, UiUtils.getFileName(uri)));
return false;
}
int sizeLimit=type.startsWith("image/") ? instance.configuration.mediaAttachments.imageSizeLimit : instance.configuration.mediaAttachments.videoSizeLimit;
int size;
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
cursor.moveToFirst();
size=cursor.getInt(0);
}catch(Exception x){
Log.w("ComposeFragment", x);
return false;
}
if(size>sizeLimit){
float mb=sizeLimit/(float)(1024*1024);
String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%f" : "%.2f", mb);
showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb));
return false;
if(!type.startsWith("image/")){
int sizeLimit=instance.configuration.mediaAttachments.videoSizeLimit;
int size;
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
cursor.moveToFirst();
size=cursor.getInt(0);
}catch(Exception x){
Log.w("ComposeFragment", x);
return false;
}
if(size>sizeLimit){
float mb=sizeLimit/(float) (1024*1024);
String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%f" : "%.2f", mb);
showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb));
return false;
}
}
}
pollBtn.setEnabled(false);
@@ -1025,7 +1029,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
UiUtils.enablePopupMenuIcons(getActivity(), menu);
m.setGroupCheckable(0, true, true);
m.findItem(switch(statusVisibility){
case PUBLIC, UNLISTED -> R.id.vis_public;
case PUBLIC -> R.id.vis_public;
case UNLISTED -> R.id.vis_unlisted;
case PRIVATE -> R.id.vis_followers;
case DIRECT -> R.id.vis_private;
}).setChecked(true);
@@ -1035,6 +1040,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
int id=item.getItemId();
if(id==R.id.vis_public){
statusVisibility=StatusPrivacy.PUBLIC;
}else if(id==R.id.vis_unlisted){
statusVisibility=StatusPrivacy.UNLISTED;
}else if(id==R.id.vis_followers){
statusVisibility=StatusPrivacy.PRIVATE;
}else if(id==R.id.vis_private){
@@ -1062,7 +1069,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public void onSelectionChanged(int start, int end){
if(start==end){
if(start==end && mainEditText.length()>0){
ComposeAutocompleteSpan[] spans=mainEditText.getText().getSpans(start, end, ComposeAutocompleteSpan.class);
if(spans.length>0){
assert spans.length==1;
@@ -1096,7 +1103,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public String[] onGetAllowedMediaMimeTypes(){
if(instance.configuration!=null && instance.configuration.mediaAttachments!=null && instance.configuration.mediaAttachments.supportedMimeTypes!=null)
if(instance!=null && instance.configuration!=null && instance.configuration.mediaAttachments!=null && instance.configuration.mediaAttachments.supportedMimeTypes!=null)
return instance.configuration.mediaAttachments.supportedMimeTypes.toArray(new String[0]);
return new String[]{"image/jpeg", "image/gif", "image/png", "video/mp4"};
}

View File

@@ -0,0 +1,49 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountFollowers;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.parceler.Parcels;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
public class FollowerListFragment extends BaseAccountListFragment{
private Account account;
private String nextMaxID;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
account=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
setTitle("@"+account.acct);
setSubtitle(getResources().getQuantityString(R.plurals.x_followers, account.followersCount, account.followersCount));
}
@Override
public void onResume(){
super.onResume();
if(!loaded && !dataLoading)
loadData();
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetAccountFollowers(account.id, offset==0 ? null : nextMaxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result.stream().map(AccountItem::new).collect(Collectors.toList()), nextMaxID!=null);
}
})
.exec(accountID);
}
}

View File

@@ -0,0 +1,49 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountFollowing;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.parceler.Parcels;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
public class FollowingListFragment extends BaseAccountListFragment{
private Account account;
private String nextMaxID;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
account=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
setTitle("@"+account.acct);
setSubtitle(getResources().getQuantityString(R.plurals.x_following, account.followingCount, account.followingCount));
}
@Override
public void onResume(){
super.onResume();
if(!loaded && !dataLoading)
loadData();
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetAccountFollowing(account.id, offset==0 ? null : nextMaxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result.stream().map(AccountItem::new).collect(Collectors.toList()), nextMaxID!=null);
}
})
.exec(accountID);
}
}

View File

@@ -5,6 +5,7 @@ import android.app.NotificationManager;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Outline;
import android.graphics.drawable.ColorDrawable;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
@@ -17,6 +18,7 @@ import android.view.WindowInsets;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
@@ -28,6 +30,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.discover.DiscoverFragment;
import org.joinmastodon.android.fragments.discover.SearchFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.TabBar;
@@ -46,6 +49,7 @@ import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.BottomSheet;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class HomeFragment extends AppKitFragment implements OnBackPressedListener{
@@ -238,21 +242,11 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
private boolean onTabLongClick(@IdRes int tab){
if(tab==R.id.tab_profile){
ArrayList<String> options=new ArrayList<>();
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")");
}
new M3AlertDialogBuilder(getActivity())
.setItems(options.toArray(new String[0]), (dialog, which)->{
AccountSession session=AccountSessionManager.getInstance().getLoggedInAccounts().get(which);
AccountSessionManager.getInstance().setLastActiveAccountID(session.getID());
getActivity().finish();
getActivity().startActivity(new Intent(getActivity(), MainActivity.class));
})
.setNegativeButton(R.string.add_account, (dialog, which)->{
Nav.go(getActivity(), SplashFragment.class, null);
})
.show();
ArrayList<String> options=new ArrayList<>();
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")");
}
new AccountSwitcherSheet(getActivity()).show();
return true;
}
return false;

View File

@@ -1,14 +1,22 @@
package org.joinmastodon.android.fragments;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.Toolbar;
@@ -16,20 +24,38 @@ import android.widget.Toolbar;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class HomeTimelineFragment extends StatusListFragment{
private ImageButton fab;
private ImageView toolbarLogo;
private Button toolbarShowNewPostsBtn;
private boolean newPostsBtnShown;
private AnimatorSet currentNewPostsAnim;
private String maxID;
@@ -50,11 +76,13 @@ public class HomeTimelineFragment extends StatusListFragment{
.getAccount(accountID).getCacheController()
.getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){
@Override
public void onSuccess(PaginatedResponse<List<Status>> result){
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
if(getActivity()==null)
return;
onDataLoaded(result.items, !result.items.isEmpty());
maxID=result.maxID;
if(result.isFromCache())
loadNewPosts();
}
});
}
@@ -65,6 +93,14 @@ public class HomeTimelineFragment extends StatusListFragment{
fab=view.findViewById(R.id.fab);
fab.setOnClickListener(this::onFabClick);
updateToolbarLogo();
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
if(newPostsBtnShown && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){
hideNewPostsButton();
}
}
});
}
@Override
@@ -89,8 +125,13 @@ public class HomeTimelineFragment extends StatusListFragment{
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
loadData();
if(!getArguments().getBoolean("noAutoLoad")){
if(!loaded && !dataLoading){
loadData();
}else if(!dataLoading){
loadNewPosts();
}
}
}
@Subscribe
@@ -104,12 +145,256 @@ public class HomeTimelineFragment extends StatusListFragment{
Nav.go(getActivity(), ComposeFragment.class, args);
}
private void loadNewPosts(){
dataLoading=true;
// The idea here is that we request the timeline such that if there are fewer than `limit` posts,
// we'll get the currently topmost post as last in the response. This way we know there's no gap
// between the existing and newly loaded parts of the timeline.
String sinceID=data.size()>1 ? data.get(1).id : "1";
currentRequest=new GetHomeTimeline(null, null, 20, sinceID)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
dataLoading=false;
if(result.isEmpty() || getActivity()==null)
return;
Status last=result.get(result.size()-1);
List<Status> toAdd;
if(last.id.equals(data.get(0).id)){ // This part intersects with the existing one
toAdd=result.subList(0, result.size()-1); // Remove the already known last post
}else{
result.get(result.size()-1).hasGapAfter=true;
toAdd=result;
}
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
if(!filters.isEmpty()){
toAdd=toAdd.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList());
}
if(!toAdd.isEmpty()){
prependItems(toAdd, true);
showNewPostsButton();
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false);
}
}
@Override
public void onError(ErrorResponse error){
currentRequest=null;
dataLoading=false;
}
})
.exec(accountID);
}
@Override
public void onGapClick(GapStatusDisplayItem.Holder item){
if(dataLoading)
return;
item.getItem().loading=true;
V.setVisibilityAnimated(item.progress, View.VISIBLE);
V.setVisibilityAnimated(item.text, View.GONE);
GapStatusDisplayItem gap=item.getItem();
dataLoading=true;
currentRequest=new GetHomeTimeline(item.getItemID(), null, 20, null)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
dataLoading=false;
if(getActivity()==null)
return;
int gapPos=displayItems.indexOf(gap);
if(gapPos==-1)
return;
if(result.isEmpty()){
displayItems.remove(gapPos);
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
Status gapStatus=getStatusByID(gap.parentID);
if(gapStatus!=null){
gapStatus.hasGapAfter=false;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false);
}
}else{
Set<String> idsBelowGap=new HashSet<>();
boolean belowGap=false;
int gapPostIndex=0;
for(Status s:data){
if(belowGap){
idsBelowGap.add(s.id);
}else if(s.id.equals(gap.parentID)){
belowGap=true;
s.hasGapAfter=false;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false);
}else{
gapPostIndex++;
}
}
int endIndex=0;
for(Status s:result){
endIndex++;
if(idsBelowGap.contains(s.id))
break;
}
if(endIndex==result.size()){
result.get(result.size()-1).hasGapAfter=true;
}else{
result=result.subList(0, endIndex);
}
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
targetList.clear();
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
outer:
for(Status s:result){
if(idsBelowGap.contains(s.id))
break;
for(Filter filter:filters){
if(filter.matches(s.getContentStatus().content)){
continue outer;
}
}
targetList.addAll(buildDisplayItems(s));
insertedPosts.add(s);
}
if(targetList.isEmpty()){
// oops. We didn't add new posts, but at least we know there are none.
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
}else{
adapter.notifyItemChanged(getMainAdapterOffset()+gapPos);
adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1);
}
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false);
}
}
@Override
public void onError(ErrorResponse error){
currentRequest=null;
dataLoading=false;
gap.loading=false;
Activity a=getActivity();
if(a!=null){
error.showToast(a);
int gapPos=displayItems.indexOf(gap);
if(gapPos>=0)
adapter.notifyItemChanged(gapPos);
}
}
})
.exec(accountID);
}
@Override
public void onRefresh(){
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
dataLoading=false;
}
super.onRefresh();
}
private void updateToolbarLogo(){
ImageView logo=new ImageView(getActivity());
logo.setScaleType(ImageView.ScaleType.CENTER);
logo.setImageResource(R.drawable.logo);
logo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)));
toolbarLogo=new ImageView(getActivity());
toolbarLogo.setScaleType(ImageView.ScaleType.CENTER);
toolbarLogo.setImageResource(R.drawable.logo);
toolbarLogo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)));
toolbarShowNewPostsBtn=new Button(getActivity());
toolbarShowNewPostsBtn.setTextAppearance(R.style.m3_title_medium);
toolbarShowNewPostsBtn.setTextColor(0xffffffff);
toolbarShowNewPostsBtn.setStateListAnimator(null);
toolbarShowNewPostsBtn.setBackgroundResource(R.drawable.bg_button_new_posts);
toolbarShowNewPostsBtn.setText(R.string.see_new_posts);
toolbarShowNewPostsBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_fluent_arrow_up_16_filled, 0, 0, 0);
toolbarShowNewPostsBtn.setCompoundDrawableTintList(toolbarShowNewPostsBtn.getTextColors());
toolbarShowNewPostsBtn.setCompoundDrawablePadding(V.dp(8));
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.N)
UiUtils.fixCompoundDrawableTintOnAndroid6(toolbarShowNewPostsBtn);
toolbarShowNewPostsBtn.setOnClickListener(this::onNewPostsBtnClick);
if(newPostsBtnShown){
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
toolbarLogo.setVisibility(View.INVISIBLE);
toolbarLogo.setAlpha(0f);
}else{
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
toolbarShowNewPostsBtn.setAlpha(0f);
toolbarShowNewPostsBtn.setScaleX(.8f);
toolbarShowNewPostsBtn.setScaleY(.8f);
toolbarLogo.setVisibility(View.VISIBLE);
}
FrameLayout logoWrap=new FrameLayout(getActivity());
logoWrap.addView(toolbarLogo, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER));
logoWrap.addView(toolbarShowNewPostsBtn, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, V.dp(32), Gravity.CENTER));
Toolbar toolbar=getToolbar();
toolbar.addView(logo, new Toolbar.LayoutParams(Gravity.CENTER));
toolbar.addView(logoWrap, new Toolbar.LayoutParams(Gravity.CENTER));
}
private void showNewPostsButton(){
if(newPostsBtnShown)
return;
newPostsBtnShown=true;
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(toolbarLogo, View.ALPHA, 0f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, 1f)
);
set.setDuration(300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
toolbarLogo.setVisibility(View.INVISIBLE);
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
private void hideNewPostsButton(){
if(!newPostsBtnShown)
return;
newPostsBtnShown=false;
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
toolbarLogo.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(toolbarLogo, View.ALPHA, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 0f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, .8f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, .8f)
);
set.setDuration(300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
private void onNewPostsBtnClick(View v){
if(newPostsBtnShown){
hideNewPostsButton();
scrollToTop();
}
}
}

View File

@@ -14,6 +14,8 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.ImageSpan;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.Menu;
@@ -104,6 +106,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
private ProgressBar actionProgress;
private FrameLayout[] tabViews;
private TabLayoutMediator tabLayoutMediator;
private TextView followsYouView;
private Account account;
private String accountID;
@@ -178,6 +181,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
bioEdit=content.findViewById(R.id.bio_edit);
actionProgress=content.findViewById(R.id.action_progress);
fab=content.findViewById(R.id.fab);
followsYouView=content.findViewById(R.id.follows_you);
avatar.setOutlineProvider(new ViewOutlineProvider(){
@Override
@@ -257,6 +261,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
fab.setVisibility(View.GONE);
}
followersBtn.setOnClickListener(this::onFollowersOrFollowingClick);
followingBtn.setOnClickListener(this::onFollowersOrFollowingClick);
return sizeWrapper;
}
@@ -400,8 +407,25 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
HtmlParser.parseCustomEmoji(ssb, account.emojis);
name.setText(ssb);
setTitle(ssb);
username.setText('@'+account.acct);
bio.setText(HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID));
if(account.locked){
ssb=new SpannableStringBuilder("@");
ssb.append(account.acct);
ssb.append(" ");
Drawable lock=username.getResources().getDrawable(R.drawable.ic_fluent_lock_closed_20_filled, getActivity().getTheme()).mutate();
lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight());
lock.setTint(username.getCurrentTextColor());
ssb.append(getString(R.string.manually_approves_followers), new ImageSpan(lock, ImageSpan.ALIGN_BOTTOM), 0);
username.setText(ssb);
}else{
username.setText('@'+account.acct);
}
CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
if(TextUtils.isEmpty(parsedBio)){
bio.setVisibility(View.GONE);
}else{
bio.setVisibility(View.VISIBLE);
bio.setText(parsedBio);
}
followersCount.setText(UiUtils.abbreviateNumber(account.followersCount));
followingCount.setText(UiUtils.abbreviateNumber(account.followingCount));
postsCount.setText(UiUtils.abbreviateNumber(account.statusesCount));
@@ -565,6 +589,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
invalidateOptionsMenu();
actionButton.setVisibility(View.VISIBLE);
UiUtils.setRelationshipToActionButton(relationship, actionButton);
actionProgress.setIndeterminateTintList(actionButton.getTextColors());
followsYouView.setVisibility(relationship.followedBy ? View.VISIBLE : View.GONE);
}
private void onScrollChanged(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY){
@@ -825,6 +851,20 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
scrollView.smoothScrollTo(0, 0);
}
private void onFollowersOrFollowingClick(View v){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("targetAccount", Parcels.wrap(account));
Class<? extends Fragment> cls;
if(v.getId()==R.id.followers_btn)
cls=FollowerListFragment.class;
else if(v.getId()==R.id.following_btn)
cls=FollowingListFragment.class;
else
return;
Nav.go(getActivity(), cls, args);
}
private class ProfilePagerAdapter extends RecyclerView.Adapter<SimpleViewHolder>{
@NonNull
@Override

View File

@@ -51,6 +51,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
private DiscoverAccountsFragment accountsFragment;
private SearchFragment searchFragment;
private LocalTimelineFragment localTimelineFragment;
private FederatedTimelineFragment federatedTimelineFragment;
private String accountID;
private Runnable searchDebouncer=this::onSearchChangedDebounced;
@@ -72,15 +73,16 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
tabLayout=view.findViewById(R.id.tabbar);
pager=view.findViewById(R.id.pager);
tabViews=new FrameLayout[5];
tabViews=new FrameLayout[6];
for(int i=0;i<tabViews.length;i++){
FrameLayout tabView=new FrameLayout(getActivity());
tabView.setId(switch(i){
case 0 -> R.id.discover_posts;
case 1 -> R.id.discover_hashtags;
case 2 -> R.id.discover_news;
case 3 -> R.id.discover_local_timeline;
case 4 -> R.id.discover_users;
case 0 -> R.id.discover_local_timeline;
case 1 -> R.id.discover_federated_timeline;
case 2 -> R.id.discover_hashtags;
case 3 -> R.id.discover_posts;
case 4 -> R.id.discover_news;
case 5 -> R.id.discover_users;
default -> throw new IllegalStateException("Unexpected value: "+i);
});
tabView.setVisibility(View.GONE);
@@ -106,7 +108,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
}
});
if(postsFragment==null){
if(localTimelineFragment==null){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putBoolean("__is_tab", true);
@@ -126,9 +128,13 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
localTimelineFragment=new LocalTimelineFragment();
localTimelineFragment.setArguments(args);
federatedTimelineFragment=new FederatedTimelineFragment();
federatedTimelineFragment.setArguments(args);
getChildFragmentManager().beginTransaction()
.add(R.id.discover_posts, postsFragment)
.add(R.id.discover_local_timeline, localTimelineFragment)
.add(R.id.discover_federated_timeline, federatedTimelineFragment)
.add(R.id.discover_hashtags, hashtagsFragment)
.add(R.id.discover_news, newsFragment)
.add(R.id.discover_users, accountsFragment)
@@ -139,11 +145,12 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
@Override
public void onConfigureTab(@NonNull TabLayout.Tab tab, int position){
tab.setText(switch(position){
case 0 -> R.string.posts;
case 1 -> R.string.hashtags;
case 2 -> R.string.news;
case 3 -> R.string.local_timeline;
case 4 -> R.string.for_you;
case 0 -> R.string.local_timeline;
case 1 -> R.string.federated_timeline;
case 2 -> R.string.hashtags;
case 3 -> R.string.posts;
case 4 -> R.string.news;
case 5 -> R.string.for_you;
default -> throw new IllegalStateException("Unexpected value: "+position);
});
tab.view.textView.setAllCaps(true);
@@ -217,8 +224,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
}
public void loadData(){
if(postsFragment!=null && !postsFragment.loaded && !postsFragment.dataLoading)
postsFragment.loadData();
if(localTimelineFragment!=null && !localTimelineFragment.loaded && !localTimelineFragment.dataLoading)
localTimelineFragment.loadData();
}
private void onSearchEditFocusChanged(View v, boolean hasFocus){
@@ -254,11 +261,12 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
private Fragment getFragmentForPage(int page){
return switch(page){
case 0 -> postsFragment;
case 1 -> hashtagsFragment;
case 2 -> newsFragment;
case 3 -> localTimelineFragment;
case 4 -> accountsFragment;
case 0 -> localTimelineFragment;
case 1 -> federatedTimelineFragment;
case 2 -> hashtagsFragment;
case 3 -> postsFragment;
case 4 -> newsFragment;
case 5 -> accountsFragment;
default -> throw new IllegalStateException("Unexpected value: "+page);
};
}

View File

@@ -0,0 +1,41 @@
package org.joinmastodon.android.fragments.discover;
import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
public class FederatedTimelineFragment extends StatusListFragment{
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.FEDERATED_TIMELINE);
private String maxID;
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetPublicTimeline(false, false, refreshing ? null : maxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
onDataLoaded(result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()), !result.isEmpty());
}
})
.exec(accountID);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
bannerHelper.maybeAddBanner(contentWrap);
}
}

View File

@@ -5,23 +5,29 @@ import android.view.View;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
public class LocalTimelineFragment extends StatusListFragment{
private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE);
private String maxID;
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : getMaxID(), count)
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
onDataLoaded(result, !result.isEmpty());
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
onDataLoaded(result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()), !result.isEmpty());
}
})
.exec(accountID);

View File

@@ -14,6 +14,7 @@ import android.view.WindowInsets;
import android.widget.Button;
import android.widget.Toast;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
@@ -136,6 +137,13 @@ public class AccountActivationFragment extends AppKitFragment{
}
private void tryGetAccount(){
if(AccountSessionManager.getInstance().tryGetAccount(accountID)==null){
uiHandler.removeCallbacks(pollRunnable);
getActivity().finish();
Intent intent=new Intent(getActivity(), MainActivity.class);
startActivity(intent);
return;
}
currentRequest=new GetOwnAccount()
.setCallback(new Callback<>(){
@Override

View File

@@ -15,6 +15,7 @@ import android.widget.TextView;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
import org.joinmastodon.android.events.FinishReportFragmentsEvent;
@@ -60,6 +61,13 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
setRetainInstance(true);
setListLayoutId(R.layout.fragment_content_report_posts);
setLayout(R.layout.fragment_report_posts);
E.register(this);
}
@Override
public void onDestroy(){
E.unregister(this);
super.onDestroy();
}
@Override

View File

@@ -5,7 +5,9 @@ import android.os.Bundle;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.FinishReportFragmentsEvent;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.ReportReason;
import org.parceler.Parcels;
@@ -21,7 +23,10 @@ public class ReportReasonChoiceFragment extends BaseReportChoiceFragment{
protected void populateItems(){
items.add(new Item(getString(R.string.report_reason_personal), getString(R.string.report_reason_personal_subtitle), ReportReason.PERSONAL.name()));
items.add(new Item(getString(R.string.report_reason_spam), getString(R.string.report_reason_spam_subtitle), ReportReason.SPAM.name()));
items.add(new Item(getString(R.string.report_reason_violation), getString(R.string.report_reason_violation_subtitle), ReportReason.VIOLATION.name()));
Instance inst=AccountSessionManager.getInstance().getInstanceInfo(AccountSessionManager.getInstance().getAccount(accountID).domain);
if(inst!=null && inst.rules!=null && !inst.rules.isEmpty()){
items.add(new Item(getString(R.string.report_reason_violation), getString(R.string.report_reason_violation_subtitle), ReportReason.VIOLATION.name()));
}
items.add(new Item(getString(R.string.report_reason_other), getString(R.string.report_reason_other_subtitle), ReportReason.OTHER.name()));
}

View File

@@ -0,0 +1,14 @@
package org.joinmastodon.android.model;
public class CacheablePaginatedResponse<T> extends PaginatedResponse<T>{
private final boolean fromCache;
public CacheablePaginatedResponse(T items, String maxID, boolean fromCache){
super(items, maxID);
this.fromCache=fromCache;
}
public boolean isFromCache(){
return fromCache;
}
}

View File

@@ -0,0 +1,24 @@
package org.joinmastodon.android.model;
import android.net.Uri;
import java.util.ArrayList;
import java.util.Collection;
import androidx.annotation.NonNull;
public class HeaderPaginationList<T> extends ArrayList<T>{
public Uri nextPageUri, prevPageUri;
public HeaderPaginationList(int initialCapacity){
super(initialCapacity);
}
public HeaderPaginationList(){
super();
}
public HeaderPaginationList(@NonNull Collection<? extends T> c){
super(c);
}
}

View File

@@ -54,6 +54,7 @@ public class Status extends BaseModel implements DisplayItemsParent{
public boolean pinned;
public transient boolean spoilerRevealed;
public transient boolean hasGapAfter;
@Override
public void postprocess() throws ObjectValidationException{

View File

@@ -0,0 +1,249 @@
package org.joinmastodon.android.ui;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.SplashFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.List;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
import me.grishka.appkit.imageloader.RecyclerViewDelegate;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.BottomSheet;
import me.grishka.appkit.views.UsableRecyclerView;
public class AccountSwitcherSheet extends BottomSheet{
private final Activity activity;
private UsableRecyclerView list;
private List<WrappedAccount> accounts;
private ListImageLoaderWrapper imgLoader;
public AccountSwitcherSheet(@NonNull Activity activity){
super(activity);
this.activity=activity;
accounts=AccountSessionManager.getInstance().getLoggedInAccounts().stream().map(WrappedAccount::new).collect(Collectors.toList());
list=new UsableRecyclerView(activity);
imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null);
list.setClipToPadding(false);
list.setLayoutManager(new LinearLayoutManager(activity));
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
View handle=new View(activity);
handle.setBackgroundResource(R.drawable.bg_bottom_sheet_handle);
adapter.addAdapter(new SingleViewRecyclerAdapter(handle));
adapter.addAdapter(new AccountsAdapter());
AccountViewHolder holder=new AccountViewHolder();
holder.more.setVisibility(View.GONE);
holder.currentIcon.setVisibility(View.GONE);
holder.name.setText(R.string.add_account);
holder.avatar.setScaleType(ImageView.ScaleType.CENTER);
holder.avatar.setImageResource(R.drawable.ic_fluent_add_circle_24_filled);
holder.avatar.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(activity, android.R.attr.textColorPrimary)));
adapter.addAdapter(new ClickableSingleViewRecyclerAdapter(holder.itemView, ()->{
Nav.go(activity, SplashFragment.class, null);
dismiss();
}));
list.setAdapter(adapter);
DividerItemDecoration divider=new DividerItemDecoration(activity, R.attr.colorPollVoted, .5f, 72, 16, DividerItemDecoration.NOT_FIRST);
divider.setDrawBelowLastItem(true);
list.addItemDecoration(divider);
FrameLayout content=new FrameLayout(activity);
content.setBackgroundResource(R.drawable.bg_bottom_sheet);
content.addView(list);
setContentView(content);
setNavigationBarBackground(new ColorDrawable(UiUtils.getThemeColor(activity, R.attr.colorWindowBackground)), !UiUtils.isDarkTheme());
}
private void confirmLogOut(String accountID){
new M3AlertDialogBuilder(activity)
.setTitle(R.string.log_out)
.setMessage(R.string.confirm_log_out)
.setPositiveButton(R.string.log_out, (dialog, which) -> logOut(accountID))
.setNegativeButton(R.string.cancel, null)
.show();
}
private void logOut(String accountID){
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
new RevokeOauthToken(session.app.clientId, session.app.clientSecret, session.token.accessToken)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Object result){
onLoggedOut(accountID);
}
@Override
public void onError(ErrorResponse error){
onLoggedOut(accountID);
}
})
.wrapProgress(activity, R.string.loading, false)
.exec(accountID);
}
private void onLoggedOut(String accountID){
AccountSessionManager.getInstance().removeAccount(accountID);
dismiss();
}
@Override
protected void onWindowInsetsUpdated(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29){
int tappableBottom=insets.getTappableElementInsets().bottom;
int insetBottom=insets.getSystemWindowInsetBottom();
if(tappableBottom==0 && insetBottom>0){
list.setPadding(0, 0, 0, V.dp(48)-insetBottom);
}else{
list.setPadding(0, 0, 0, V.dp(24));
}
}else{
list.setPadding(0, 0, 0, V.dp(24));
}
}
private class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
public AccountsAdapter(){
super(imgLoader);
}
@NonNull
@Override
public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new AccountViewHolder();
}
@Override
public int getItemCount(){
return accounts.size();
}
@Override
public void onBindViewHolder(AccountViewHolder holder, int position){
holder.bind(accounts.get(position).session);
super.onBindViewHolder(holder, position);
}
@Override
public int getImageCountForItem(int position){
return 1;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
return accounts.get(position).req;
}
}
private class AccountViewHolder extends BindableViewHolder<AccountSession> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
private final TextView name;
private final ImageView avatar;
private final ImageButton more;
private final View currentIcon;
private final PopupMenu menu;
public AccountViewHolder(){
super(activity, R.layout.item_account_switcher, list);
name=findViewById(R.id.name);
avatar=findViewById(R.id.avatar);
more=findViewById(R.id.more);
currentIcon=findViewById(R.id.current);
avatar.setOutlineProvider(OutlineProviders.roundedRect(12));
avatar.setClipToOutline(true);
menu=new PopupMenu(activity, more);
menu.inflate(R.menu.account_switcher);
menu.setOnMenuItemClickListener(item1 -> {
confirmLogOut(item.getID());
return true;
});
more.setOnClickListener(v->menu.show());
}
@SuppressLint("SetTextI18n")
@Override
public void onBind(AccountSession item){
name.setText("@"+item.self.username+"@"+item.domain);
if(AccountSessionManager.getInstance().getLastActiveAccountID().equals(item.getID())){
more.setVisibility(View.GONE);
currentIcon.setVisibility(View.VISIBLE);
}else{
more.setVisibility(View.VISIBLE);
currentIcon.setVisibility(View.GONE);
}
menu.getMenu().findItem(R.id.log_out).setTitle(activity.getString(R.string.log_out_account, "@"+item.self.username));
UiUtils.enablePopupMenuIcons(activity, menu);
}
@Override
public void setImage(int index, Drawable image){
avatar.setImageDrawable(image);
if(image instanceof Animatable a)
a.start();
}
@Override
public void clearImage(int index){
setImage(index, null);
}
@Override
public void onClick(){
AccountSessionManager.getInstance().setLastActiveAccountID(item.getID());
activity.finish();
activity.startActivity(new Intent(activity, MainActivity.class));
}
}
private static class WrappedAccount{
public final AccountSession session;
public final ImageLoaderRequest req;
public WrappedAccount(AccountSession session){
this.session=session;
req=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? session.self.avatar : session.self.avatarStatic, V.dp(50), V.dp(50));
}
}
}

View File

@@ -0,0 +1,34 @@
package org.joinmastodon.android.ui;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.views.UsableRecyclerView;
public class ClickableSingleViewRecyclerAdapter extends SingleViewRecyclerAdapter{
private final Runnable onClick;
public ClickableSingleViewRecyclerAdapter(View view, Runnable onClick){
super(view);
this.onClick=onClick;
}
@NonNull
@Override
public ViewViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new ClickableViewViewHolder(view);
}
public class ClickableViewViewHolder extends ViewViewHolder implements UsableRecyclerView.Clickable{
public ClickableViewViewHolder(@NonNull View itemView){
super(itemView);
}
@Override
public void onClick(){
onClick.run();
}
}
}

View File

@@ -18,6 +18,7 @@ public class DividerItemDecoration extends RecyclerView.ItemDecoration{
private Paint paint=new Paint();
private int paddingStart, paddingEnd;
private Predicate<RecyclerView.ViewHolder> drawDividerPredicate;
private boolean drawBelowLastItem;
public static final Predicate<RecyclerView.ViewHolder> NOT_FIRST=vh->vh.getAbsoluteAdapterPosition()>0;
@@ -34,6 +35,10 @@ public class DividerItemDecoration extends RecyclerView.ItemDecoration{
this.drawDividerPredicate=drawDividerPredicate;
}
public void setDrawBelowLastItem(boolean drawBelowLastItem){
this.drawBelowLastItem=drawBelowLastItem;
}
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
boolean isRTL=parent.getLayoutDirection()==View.LAYOUT_DIRECTION_RTL;
@@ -43,7 +48,7 @@ public class DividerItemDecoration extends RecyclerView.ItemDecoration{
for(int i=0;i<parent.getChildCount();i++){
View child=parent.getChildAt(i);
int pos=parent.getChildAdapterPosition(child);
if(pos<totalItems-1 && (drawDividerPredicate==null || drawDividerPredicate.test(parent.getChildViewHolder(child)))){
if((drawBelowLastItem || pos<totalItems-1) && (drawDividerPredicate==null || drawDividerPredicate.test(parent.getChildViewHolder(child)))){
float y=Math.round(child.getY()+child.getHeight());
y-=(y-paint.getStrokeWidth()/2f)%1f; // Make sure the line aligns with the pixel grid
paint.setAlpha(Math.round(255f*child.getAlpha()));

View File

@@ -0,0 +1,48 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ProgressBar;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.ui.drawables.SawtoothTearDrawable;
// Mind the gap!
public class GapStatusDisplayItem extends StatusDisplayItem{
public boolean loading;
public GapStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment){
super(parentID, parentFragment);
}
@Override
public Type getType(){
return Type.GAP;
}
public static class Holder extends StatusDisplayItem.Holder<GapStatusDisplayItem>{
public final ProgressBar progress;
public final TextView text;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_gap, parent);
progress=findViewById(R.id.progress);
text=findViewById(R.id.text);
itemView.setForeground(new SawtoothTearDrawable(context));
}
@Override
public void onBind(GapStatusDisplayItem item){
text.setVisibility(item.loading ? View.GONE : View.VISIBLE);
progress.setVisibility(item.loading ? View.VISIBLE : View.GONE);
}
@Override
public void onClick(){
item.parentFragment.onGapClick(this);
}
}
}

View File

@@ -63,6 +63,7 @@ public abstract class StatusDisplayItem{
case ACCOUNT_CARD -> new AccountCardStatusDisplayItem.Holder(activity, parent);
case ACCOUNT -> new AccountStatusDisplayItem.Holder(activity, parent);
case HASHTAG -> new HashtagStatusDisplayItem.Holder(activity, parent);
case GAP -> new GapStatusDisplayItem.Holder(activity, parent);
};
}
@@ -112,6 +113,8 @@ public abstract class StatusDisplayItem{
}
if(addFooter){
items.add(new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID));
if(status.hasGapAfter)
items.add(new GapStatusDisplayItem(parentID, fragment));
}
int i=1;
for(StatusDisplayItem item:items){
@@ -142,7 +145,8 @@ public abstract class StatusDisplayItem{
FOOTER,
ACCOUNT_CARD,
ACCOUNT,
HASHTAG
HASHTAG,
GAP
}
public static abstract class Holder<T extends StatusDisplayItem> extends BindableViewHolder<T> implements UsableRecyclerView.DisableableClickable{

View File

@@ -0,0 +1,107 @@
package org.joinmastodon.android.ui.drawables;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.Shader;
import android.graphics.drawable.Drawable;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class SawtoothTearDrawable extends Drawable{
private final Paint topPaint, bottomPaint;
private static final int TOP_SAWTOOTH_HEIGHT=5;
private static final int BOTTOM_SAWTOOTH_HEIGHT=3;
private static final int STROKE_WIDTH=2;
private static final int SAWTOOTH_PERIOD=14;
public SawtoothTearDrawable(Context context){
topPaint=makeShaderPaint(makeSawtoothTexture(context, TOP_SAWTOOTH_HEIGHT, SAWTOOTH_PERIOD, false, STROKE_WIDTH));
bottomPaint=makeShaderPaint(makeSawtoothTexture(context, BOTTOM_SAWTOOTH_HEIGHT, SAWTOOTH_PERIOD, true, STROKE_WIDTH));
Matrix matrix=new Matrix();
//noinspection IntegerDivisionInFloatingPointContext
matrix.setTranslate(V.dp(SAWTOOTH_PERIOD/2), 0);
bottomPaint.getShader().setLocalMatrix(matrix);
}
private Bitmap makeSawtoothTexture(Context context, int height, int period, boolean fillBottom, int strokeWidth){
int actualStrokeWidth=V.dp(strokeWidth);
int actualPeriod=V.dp(period);
int actualHeight=V.dp(height);
Bitmap bitmap=Bitmap.createBitmap(actualPeriod, actualHeight+actualStrokeWidth*2, Bitmap.Config.ARGB_8888);
Canvas c=new Canvas(bitmap);
Path path=new Path();
//noinspection SuspiciousNameCombination
path.moveTo(-actualPeriod/2f, actualStrokeWidth);
path.lineTo(0, actualHeight+actualStrokeWidth);
//noinspection SuspiciousNameCombination
path.lineTo(actualPeriod/2f, actualStrokeWidth);
path.lineTo(actualPeriod, actualHeight+actualStrokeWidth);
//noinspection SuspiciousNameCombination
path.lineTo(actualPeriod*1.5f, actualStrokeWidth);
if(fillBottom){
path.lineTo(actualPeriod*1.5f, actualHeight*20);
path.lineTo(-actualPeriod/2f, actualHeight*20);
}else{
path.lineTo(actualPeriod*1.5f, -actualHeight);
path.lineTo(-actualPeriod/2f, -actualHeight);
}
path.close();
Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorWindowBackground));
c.drawPath(path, paint);
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorPollVoted));
paint.setStrokeWidth(actualStrokeWidth);
paint.setStyle(Paint.Style.STROKE);
c.drawPath(path, paint);
return bitmap;
}
private Paint makeShaderPaint(Bitmap bitmap){
BitmapShader shader=new BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.CLAMP);
Paint paint=new Paint();
paint.setShader(shader);
return paint;
}
@Override
public void draw(@NonNull Canvas canvas){
int strokeWidth=V.dp(STROKE_WIDTH);
Rect bounds=getBounds();
canvas.save();
canvas.translate(bounds.left, bounds.top);
canvas.drawRect(0, 0, bounds.width(), V.dp(TOP_SAWTOOTH_HEIGHT)+strokeWidth*2, topPaint);
int bottomHeight=V.dp(BOTTOM_SAWTOOTH_HEIGHT)+strokeWidth*2;
canvas.translate(0, bounds.height()-bottomHeight);
canvas.drawRect(0, 0, bounds.width(), bottomHeight, bottomPaint);
canvas.restore();
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.TRANSLUCENT;
}
}

View File

@@ -133,18 +133,18 @@ public class PhotoViewer implements ZoomPanView.Listener{
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29){
DisplayCutout cutout=insets.getDisplayCutout();
Insets tappable=insets.getTappableElementInsets();
if(cutout!=null){
// Make controls extend beneath the cutout, and replace insets to avoid cutout insets being filled with "navigation bar color"
Insets tappable=insets.getTappableElementInsets();
int leftInset=Math.max(0, cutout.getSafeInsetLeft()-tappable.left);
int rightInset=Math.max(0, cutout.getSafeInsetRight()-tappable.right);
insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom);
toolbarWrap.setPadding(leftInset, 0, rightInset, 0);
videoControls.setPadding(leftInset, 0, rightInset, 0);
}else{
toolbarWrap.setPadding(0, 0, 0, 0);
videoControls.setPadding(0, 0, 0, 0);
}
insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom);
}
uiOverlay.dispatchApplyWindowInsets(insets);
int bottomInset=insets.getSystemWindowInsetBottom();

View File

@@ -36,6 +36,7 @@ public class DiscoverInfoBannerHelper{
case TRENDING_HASHTAGS -> R.string.trending_hashtags_info_banner;
case TRENDING_LINKS -> R.string.trending_links_info_banner;
case LOCAL_TIMELINE -> R.string.local_timeline_info_banner;
case FEDERATED_TIMELINE -> R.string.federated_timeline_info_banner;
});
}
}
@@ -59,6 +60,7 @@ public class DiscoverInfoBannerHelper{
TRENDING_HASHTAGS,
TRENDING_LINKS,
LOCAL_TIMELINE,
FEDERATED_TIMELINE,
// ACCOUNTS
}
}

View File

@@ -141,12 +141,15 @@ public class UiUtils{
@SuppressLint("DefaultLocale")
public static String abbreviateNumber(int n){
if(n<1000)
if(n<1000){
return String.format("%,d", n);
else if(n<1_000_000)
return String.format("%,.1fK", n/1000f);
else
return String.format("%,.1fM", n/1_000_000f);
}else if(n<1_000_000){
float a=n/1000f;
return a>99f ? String.format("%,dK", (int)Math.floor(a)) : String.format("%,.1fK", a);
}else{
float a=n/1_000_000f;
return a>99f ? String.format("%,dM", (int)Math.floor(a)) : String.format("%,.1fM", n/1_000_000f);
}
}
/**
@@ -182,7 +185,7 @@ public class UiUtils{
String name=cursor.getString(0);
if(name!=null)
return name;
}
}catch(Throwable ignore){}
}
return uri.getLastPathSegment();
}
@@ -340,13 +343,38 @@ public class UiUtils{
}
public static void setRelationshipToActionButton(Relationship relationship, Button button){
boolean secondaryStyle;
if(relationship.blocking){
button.setText(R.string.button_blocked);
}else if(relationship.muting){
button.setText(R.string.button_muted);
secondaryStyle=true;
}else if(relationship.blockedBy){
button.setText(R.string.button_follow);
secondaryStyle=false;
}else if(relationship.requested){
button.setText(R.string.button_follow_pending);
secondaryStyle=true;
}else if(!relationship.following){
button.setText(relationship.followedBy ? R.string.follow_back : R.string.button_follow);
secondaryStyle=false;
}else{
button.setText(relationship.following ? R.string.button_following : R.string.button_follow);
button.setText(R.string.button_following);
secondaryStyle=true;
}
button.setEnabled(!relationship.blockedBy);
int attr=secondaryStyle ? R.attr.secondaryButtonStyle : android.R.attr.buttonStyle;
TypedArray ta=button.getContext().obtainStyledAttributes(new int[]{attr});
int styleRes=ta.getResourceId(0, 0);
ta.recycle();
ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background});
button.setBackground(ta.getDrawable(0));
ta.recycle();
ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.textColor});
if(relationship.blocking)
button.setTextColor(button.getResources().getColorStateList(R.color.error_600));
else
button.setTextColor(ta.getColorStateList(0));
ta.recycle();
}
public static void performAccountAction(Activity activity, Account account, String accountID, Relationship relationship, Button button, Consumer<Boolean> progressCallback, Consumer<Relationship> resultCallback){
@@ -356,7 +384,7 @@ public class UiUtils{
confirmToggleMuteUser(activity, accountID, account, true, resultCallback);
}else{
progressCallback.accept(true);
new SetAccountFollowed(account.id, !relationship.following, true)
new SetAccountFollowed(account.id, !relationship.following && !relationship.requested, true)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Relationship result){

View File

@@ -0,0 +1,37 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
public class AutoOrientationLinearLayout extends LinearLayout{
public AutoOrientationLinearLayout(Context context){
this(context, null);
}
public AutoOrientationLinearLayout(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public AutoOrientationLinearLayout(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
int hPadding=getPaddingLeft()+getPaddingRight();
int childrenTotalWidth=0;
for(int i=0;i<getChildCount();i++){
View child=getChildAt(i);
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
childrenTotalWidth+=child.getMeasuredWidth();
}
if(childrenTotalWidth>MeasureSpec.getSize(widthMeasureSpec)-hPadding){
setOrientation(VERTICAL);
}else{
setOrientation(HORIZONTAL);
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}

View File

@@ -0,0 +1,31 @@
package org.joinmastodon.android.utils;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.Status;
import java.util.List;
import java.util.function.Predicate;
import java.util.stream.Collectors;
public class StatusFilterPredicate implements Predicate<Status>{
private final List<Filter> filters;
public StatusFilterPredicate(List<Filter> filters){
this.filters=filters;
}
public StatusFilterPredicate(String accountID, Filter.FilterContext context){
filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(context)).collect(Collectors.toList());
}
@Override
public boolean test(Status status){
CharSequence content=status.getContentStatus().content;
for(Filter filter:filters){
if(filter.matches(content))
return false;
}
return true;
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape>
<solid android:color="?colorWindowBackground"/>
<corners android:topLeftRadius="12dp" android:topRightRadius="12dp"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:gravity="center" android:width="36dp" android:height="4dp">
<shape>
<solid android:color="?android:textColorSecondary"/>
<corners android:radius="2dp"/>
</shape>
</item>
<item android:height="20dp">
<color android:color="#00000000"/>
</item>
</layer-list>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android" android:color="@color/highlight_over_dark">
<item>
<shape>
<solid android:color="?android:colorAccent"/>
<corners android:radius="16dp"/>
<padding android:left="16dp" android:right="16dp"/>
</shape>
</item>
</ripple>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#E6000000"/>
<corners android:radius="5dp"/>
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<color android:color="?colorBackgroundLightest"/>
</item>
<item android:drawable="?android:selectableItemBackground"/>
</layer-list>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2zm0 5c-0.38 0-0.694 0.282-0.743 0.648L11.25 7.75v3.5h-3.5C7.336 11.25 7 11.586 7 12c0 0.38 0.282 0.694 0.648 0.743L7.75 12.75h3.5v3.5c0 0.414 0.336 0.75 0.75 0.75 0.38 0 0.694-0.282 0.743-0.648l0.007-0.102v-3.5h3.5c0.414 0 0.75-0.336 0.75-0.75 0-0.38-0.282-0.694-0.648-0.743L16.25 11.25h-3.5v-3.5C12.75 7.336 12.414 7 12 7z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="16dp" android:height="16dp" android:viewportWidth="16" android:viewportHeight="16">
<path android:pathData="M8 14c-0.414 0-0.75-0.336-0.75-0.75V4.463L4.309 7.75c-0.276 0.31-0.75 0.335-1.06 0.06-0.308-0.276-0.334-0.75-0.058-1.06L7.441 2C7.583 1.842 7.787 1.75 8 1.75c0.213 0 0.417 0.09 0.559 0.25l4.25 4.75c0.276 0.309 0.25 0.783-0.059 1.059-0.309 0.276-0.783 0.25-1.059-0.059L8.75 4.463v8.787C8.75 13.664 8.414 14 8 14z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M8.5 16.586l-3.793-3.793c-0.39-0.39-1.024-0.39-1.414 0-0.39 0.39-0.39 1.024 0 1.414l4.5 4.5c0.39 0.39 1.024 0.39 1.414 0l11-11c0.39-0.39 0.39-1.024 0-1.414-0.39-0.39-1.024-0.39-1.414 0L8.5 16.586z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="16dp" android:height="16dp" android:viewportWidth="16" android:viewportHeight="16">
<path android:pathData="M4.146 6.354c0.196 0.195 0.512 0.195 0.708 0L8 3.207l3.146 3.147c0.196 0.195 0.512 0.195 0.708 0 0.195-0.196 0.195-0.512 0-0.708l-3.5-3.5c-0.196-0.195-0.512-0.195-0.707 0l-3.5 3.5c-0.196 0.196-0.196 0.512 0 0.708zm0 3.292c0.196-0.195 0.512-0.195 0.708 0L8 12.793l3.146-3.146c0.196-0.196 0.512-0.196 0.708 0 0.195 0.195 0.195 0.511 0 0.707l-3.5 3.5c-0.196 0.195-0.512 0.195-0.707 0l-3.5-3.5c-0.196-0.196-0.196-0.512 0-0.707z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="20dp" android:height="20dp" android:viewportWidth="20" android:viewportHeight="20">
<path android:pathData="M10 2c1.657 0 3 1.343 3 3v1h1c1.105 0 2 0.895 2 2v7c0 1.105-0.895 2-2 2H6c-1.105 0-2-0.895-2-2V8c0-1.105 0.895-2 2-2h1V5c0-1.657 1.343-3 3-3zm0 8.5c-0.552 0-1 0.448-1 1s0.448 1 1 1 1-0.448 1-1-0.448-1-1-1zM10 4C9.448 4 9 4.448 9 5v1h2V5c0-0.552-0.448-1-1-1z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 7.75c-0.966 0-1.75-0.784-1.75-1.75S11.034 4.25 12 4.25 13.75 5.034 13.75 6 12.966 7.75 12 7.75zm0 6c-0.966 0-1.75-0.784-1.75-1.75s0.784-1.75 1.75-1.75 1.75 0.784 1.75 1.75-0.784 1.75-1.75 1.75zM10.25 18c0 0.966 0.784 1.75 1.75 1.75s1.75-0.784 1.75-1.75-0.784-1.75-1.75-1.75-1.75 0.784-1.75 1.75z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="25dp" android:height="24dp" android:viewportWidth="25" android:viewportHeight="24">
<path android:pathData="M11.416 17.5c0-1.288 0.375-2.49 1.022-3.5h-7.77c-1.242 0-2.249 1.007-2.249 2.25v0.919c0 0.572 0.179 1.13 0.51 1.596C4.473 20.929 6.996 22 10.417 22c0.931 0 1.796-0.08 2.592-0.238-0.992-1.142-1.592-2.632-1.592-4.263zm-1-15.495c2.761 0 5 2.239 5 5s-2.239 5-5 5c-2.762 0-5-2.239-5-5s2.238-5 5-5zm13 15.495c0 3.038-2.463 5.5-5.5 5.5-3.038 0-5.5-2.462-5.5-5.5 0-3.037 2.462-5.5 5.5-5.5 3.037 0 5.5 2.463 5.5 5.5zm-4.647-2.853c-0.195-0.196-0.511-0.196-0.707 0-0.195 0.195-0.195 0.512 0 0.707L19.71 17h-4.293c-0.276 0-0.5 0.224-0.5 0.5s0.224 0.5 0.5 0.5h4.293l-1.647 1.647c-0.195 0.195-0.195 0.512 0 0.707 0.196 0.195 0.512 0.195 0.707 0l2.5-2.5c0.054-0.053 0.092-0.116 0.117-0.182 0.018-0.05 0.029-0.105 0.03-0.163V17.5c0-0.077-0.018-0.15-0.049-0.215-0.015-0.032-0.034-0.063-0.057-0.092-0.013-0.018-0.028-0.035-0.045-0.05l-2.496-2.496z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="75dp"
android:background="@drawable/bg_timeline_gap">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:gravity="center_horizontal"
android:textAppearance="@style/m3_body_large"
android:textColor="?android:textColorSecondary"
android:text="@string/load_missing_posts"/>
<ProgressBar
android:id="@+id/progress"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center"
android:visibility="gone"/>
</FrameLayout>

View File

@@ -15,6 +15,13 @@
android:paddingBottom="16dp"
android:background="?android:statusBarColor">
<!-- https://github.com/mastodon/mastodon-android/issues/95 -->
<View
android:layout_width="1px"
android:layout_height="1px"
android:focusable="true"
android:focusableInTouchMode="true"/>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@@ -22,7 +22,7 @@
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="23dp"
android:paddingBottom="15dp"
android:clipToPadding="false">
<org.joinmastodon.android.ui.views.CoverImageView
@@ -33,6 +33,25 @@
android:contentDescription="@string/profile_header"
android:scaleType="centerCrop"/>
<TextView
android:id="@+id/follows_you"
android:layout_width="wrap_content"
android:layout_height="28dp"
android:layout_alignEnd="@id/cover"
android:layout_alignBottom="@id/cover"
android:layout_margin="16dp"
android:paddingRight="8dp"
android:paddingLeft="8dp"
android:textColor="@color/gray_50t"
android:textAllCaps="true"
android:fontFamily="sans-serif-medium"
android:textSize="14dp"
android:gravity="center"
android:background="@drawable/bg_profile_follows_you"
android:visibility="gone"
tools:visibility="visible"
android:text="@string/follows_you"/>
<View
android:id="@+id/avatar_border"
android:layout_width="102dp"
@@ -56,80 +75,95 @@
tools:src="#0f0" />
<LinearLayout
android:id="@+id/following_btn"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:layout_alignParentEnd="true"
android:id="@+id/profile_counters"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/cover"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:padding="4dp"
android:orientation="vertical"
android:gravity="center_horizontal">
<TextView
android:id="@+id/following_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_large"
tools:text="123"/>
<TextView
android:id="@+id/following_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_small"
tools:text="following"/>
</LinearLayout>
android:layout_toEndOf="@id/avatar_border"
android:gravity="end">
<LinearLayout
android:id="@+id/followers_btn"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:layout_toStartOf="@id/following_btn"
android:layout_below="@id/cover"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:padding="4dp"
android:orientation="vertical"
android:gravity="center_horizontal">
<TextView
android:id="@+id/followers_count"
<LinearLayout
android:id="@+id/posts_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_large"
tools:text="123"/>
<TextView
android:id="@+id/followers_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_small"
tools:text="following"/>
</LinearLayout>
android:layout_height="56dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="4dp">
<TextView
android:id="@+id/posts_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_large"
android:singleLine="true"
android:ellipsize="end"
tools:text="123" />
<TextView
android:id="@+id/posts_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_small"
android:singleLine="true"
android:ellipsize="middle"
tools:text="posts" />
</LinearLayout>
<LinearLayout
android:id="@+id/posts_btn"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:layout_below="@id/cover"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:layout_toStartOf="@id/followers_btn"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="4dp">
<TextView
android:id="@+id/posts_count"
<LinearLayout
android:id="@+id/following_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_large"
tools:text="123" />
android:layout_height="56dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:padding="4dp"
android:orientation="vertical"
android:background="?android:selectableItemBackgroundBorderless"
android:gravity="center_horizontal">
<TextView
android:id="@+id/following_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_large"
android:singleLine="true"
android:ellipsize="end"
tools:text="123"/>
<TextView
android:id="@+id/following_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_small"
android:singleLine="true"
android:ellipsize="middle"
tools:text="following"/>
</LinearLayout>
<TextView
android:id="@+id/posts_label"
<LinearLayout
android:id="@+id/followers_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_small"
tools:text="following" />
android:layout_height="56dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:padding="4dp"
android:orientation="vertical"
android:background="?android:selectableItemBackgroundBorderless"
android:gravity="center_horizontal">
<TextView
android:id="@+id/followers_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_large"
android:singleLine="true"
android:ellipsize="end"
tools:text="123"/>
<TextView
android:id="@+id/followers_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/m3_title_small"
android:singleLine="true"
android:ellipsize="middle"
tools:text="followers"/>
</LinearLayout>
</LinearLayout>
<FrameLayout
@@ -137,7 +171,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_below="@id/following_btn"
android:layout_below="@id/profile_counters"
android:padding="16dp"
android:clipToPadding="false">
<org.joinmastodon.android.ui.views.ProgressBarButton

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="62dp"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:clipToPadding="false">
<ImageView
android:id="@+id/avatar"
android:layout_width="46dp"
android:layout_height="46dp"
android:layout_marginEnd="10dp"
android:importantForAccessibility="no"
tools:src="#0f0"/>
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginStart="8dp"
tools:text="Follow"/>
<TextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="24dp"
android:layout_toEndOf="@id/avatar"
android:layout_toStartOf="@id/button"
android:singleLine="true"
android:ellipsize="end"
android:gravity="center_vertical"
android:textAppearance="@style/m3_title_medium"
tools:text="User"/>
<TextView
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="20dp"
android:layout_below="@id/name"
android:layout_toEndOf="@id/avatar"
android:layout_toStartOf="@id/button"
android:singleLine="true"
android:ellipsize="end"
android:gravity="center_vertical"
android:textAppearance="@style/m3_title_small"
tools:text="\@user@server"/>
<View
android:id="@+id/menu_anchor"
android:layout_width="1px"
android:layout_height="1px"
android:layout_alignParentTop="true"
android:layout_alignParentLeft="true"
android:layout_marginLeft="-16dp"/>
</RelativeLayout>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="48dp"
android:paddingLeft="20dp"
android:paddingRight="20dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/avatar"
android:layout_width="32dp"
android:layout_height="32dp"
android:importantForAccessibility="no"/>
<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="24dp"
android:textSize="16dp"
android:textColor="?android:textColorPrimary"
android:singleLine="true"
android:ellipsize="end"/>
<View
android:id="@+id/current"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="@drawable/ic_fluent_checkmark_24_filled"
android:backgroundTint="?android:textColorSecondary"
android:contentDescription="@string/current_account"/>
<ImageButton
android:id="@+id/more"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_fluent_more_vertical_24_regular"
android:tint="?android:textColorSecondary"
android:contentDescription="@string/more_options"
android:background="?android:selectableItemBackgroundBorderless"/>
</LinearLayout>

View File

@@ -1,16 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<org.joinmastodon.android.ui.views.AutoOrientationLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="32dp"
android:layout_height="wrap_content"
android:layoutDirection="locale"
android:paddingLeft="16dp"
android:paddingRight="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_height="32dp"
android:layout_marginEnd="8dp"
android:gravity="center_vertical"
android:textColor="?android:textColorPrimary"
android:textSize="20dp"
@@ -19,12 +20,10 @@
android:ellipsize="end"
android:text="@string/notify_me_when"/>
<!-- TODO make this LL vertical when the button doesn't fit -->
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_height="32dp"
android:background="@drawable/bg_inline_button"
android:textColor="?android:textColorPrimary"
android:textSize="20dp"
@@ -37,4 +36,4 @@
android:ellipsize="middle"
tools:text="@string/notify_followed"/>
</LinearLayout>
</org.joinmastodon.android.ui.views.AutoOrientationLinearLayout>

View File

@@ -12,11 +12,11 @@
android:id="@+id/tabbar"
android:layout_width="match_parent"
android:layout_height="52dp"
android:paddingLeft="20dp"
android:paddingRight="20dp">
android:paddingLeft="16dp"
android:paddingRight="16dp">
<ImageView
android:id="@+id/tab_home"
android:layout_width="52dp"
android:layout_width="60dp"
android:layout_height="52dp"
android:scaleType="center"
android:contentDescription="@string/home_timeline"
@@ -31,7 +31,7 @@
<ImageView
android:id="@+id/tab_search"
android:layout_width="52dp"
android:layout_width="60dp"
android:layout_height="52dp"
android:scaleType="center"
android:contentDescription="@string/search_hint"
@@ -46,7 +46,7 @@
<ImageView
android:id="@+id/tab_notifications"
android:layout_width="52dp"
android:layout_width="60dp"
android:layout_height="52dp"
android:scaleType="center"
android:contentDescription="@string/notifications"
@@ -61,7 +61,7 @@
<FrameLayout
android:id="@+id/tab_profile"
android:layout_width="52dp"
android:layout_width="60dp"
android:layout_height="52dp"
android:contentDescription="@string/my_profile"
android:foreground="@drawable/bg_tab_profile"
@@ -73,6 +73,13 @@
android:layout_gravity="center"
android:scaleType="centerCrop"
android:src="@null"/>
<View
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="-4dp"
android:backgroundTint="?android:colorPrimary"
android:background="@drawable/ic_fluent_chevron_up_down_16_regular"/>
</FrameLayout>
</org.joinmastodon.android.ui.views.TabBar>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/log_out" android:title="@string/log_out_account" android:icon="@drawable/ic_fluent_person_arrow_right_24_filled"/>
</menu>

View File

@@ -3,6 +3,9 @@
<item android:id="@+id/vis_public"
android:icon="@drawable/ic_fluent_earth_24_filled"
android:title="@string/visibility_public"/>
<item android:id="@+id/vis_unlisted"
android:icon="@drawable/ic_fluent_people_community_24_regular"
android:title="@string/visibility_unlisted"/>
<item android:id="@+id/vis_followers"
android:icon="@drawable/ic_fluent_people_checkmark_24_regular"
android:title="@string/visibility_followers_only"/>

View File

@@ -213,6 +213,7 @@
<string name="alt_text_subtitle">Alternativtext erscheint für blinde Menschen. Versuche, nur so viele Details einzubeziehen, um den Kontext zu verstehen.</string>
<string name="alt_text_hint">z.B. Eine Giraffe auf einem Dreirad während sie eine Banane isst</string>
<string name="visibility_public">Öffentlich</string>
<string name="visibility_unlisted">Nicht gelistet</string>
<string name="visibility_followers_only">Nur Folgende</string>
<string name="visibility_private">Nur Leute, die ich erwähne</string>
<string name="search_all">Alle</string>

View File

@@ -12,6 +12,7 @@
<item name="discover_news" type="id"/>
<item name="discover_users" type="id"/>
<item name="discover_local_timeline" type="id"/>
<item name="discover_federated_timeline" type="id"/>
<item name="notifications_all" type="id"/>
<item name="notifications_mentions" type="id"/>

View File

@@ -11,14 +11,14 @@
<string name="ok">OK</string>
<string name="preparing_auth">Preparing for authentication…</string>
<string name="finishing_auth">Finishing authentication…</string>
<string name="user_boosted">%s boosted</string>
<string name="user_boosted">%s reblogged</string>
<string name="in_reply_to">In reply to %s</string>
<string name="notifications">Notifications</string>
<string name="user_followed_you">followed you</string>
<string name="user_sent_follow_request">sent you a follow request</string>
<string name="user_favorited">favorited your toot</string>
<string name="notification_boosted">boosted your toot</string>
<string name="user_favorited">favorited your post</string>
<string name="notification_boosted">reblogged your post</string>
<string name="poll_ended">poll ended</string>
<string name="time_seconds">%ds</string>
@@ -218,6 +218,7 @@
<string name="alt_text_subtitle">Alt text describes your photos for people with low or no vision. Try to only include enough detail to understand the context.</string>
<string name="alt_text_hint">e.g. A dog looking around suspiciously with narrowed eyes at the camera.</string>
<string name="visibility_public">Public</string>
<string name="visibility_unlisted">Unlisted</string>
<string name="visibility_followers_only">Followers only</string>
<string name="visibility_private">Only people I mention</string>
<string name="search_all">All</string>
@@ -227,7 +228,7 @@
<string name="skip">Skip</string>
<string name="notification_type_follow">New followers</string>
<string name="notification_type_favorite">Favorites</string>
<string name="notification_type_reblog">Boosts</string>
<string name="notification_type_reblog">Reblogs</string>
<string name="notification_type_mention">Mentions</string>
<string name="notification_type_poll">Polls</string>
<string name="choose_account">Choose account</string>
@@ -290,8 +291,8 @@
<string name="unfollowed_user">Unfollowed %s</string>
<string name="followed_user">You\'re now following %s</string>
<string name="open_in_browser">Open in browser</string>
<string name="hide_boosts_from_user">Hide boosts from %s</string>
<string name="show_boosts_from_user">Show boosts from %s</string>
<string name="hide_boosts_from_user">Hide reblogs from %s</string>
<string name="show_boosts_from_user">Show reblogs from %s</string>
<string name="signup_reason">why do you want to join?</string>
<string name="signup_reason_note">This will help us review your application.</string>
<string name="clear">Clear</string>
@@ -307,9 +308,37 @@
<string name="downloading">Downloading…</string>
<string name="no_app_to_handle_action">There\'s no app to handle this action</string>
<string name="local_timeline">Community</string>
<string name="federated_timeline">Federation</string>
<string name="trending_posts_info_banner">These are the posts gaining traction in your corner of Mastodon.</string>
<string name="trending_hashtags_info_banner">These are the hashtags gaining traction in your corner of Mastodon.</string>
<string name="trending_links_info_banner">These are the news stories being shared the most in your corner of Mastodon.</string>
<string name="local_timeline_info_banner">These are the most recent posts by the people who use the same Mastodon server as you.</string>
<string name="federated_timeline_info_banner">These are the most recent posts by the people in your federation.</string>
<string name="dismiss">Dismiss</string>
<string name="see_new_posts">See new posts</string>
<string name="load_missing_posts">Load missing posts</string>
<string name="follow_back">Follow Back</string>
<string name="button_follow_pending">Pending</string>
<string name="follows_you">Follows you</string>
<string name="manually_approves_followers">Manually approves followers</string>
<string name="current_account">Current account</string>
<string name="log_out_account">Log Out %s</string>
<!-- translators: %,d is a valid placeholder, it formats the number with locale-dependent grouping separators -->
<plurals name="x_followers">
<item quantity="one">%,d follower</item>
<item quantity="other">%,d followers</item>
</plurals>
<plurals name="x_following">
<item quantity="one">%,d following</item>
<item quantity="other">%,d following</item>
</plurals>
<plurals name="x_favorites">
<item quantity="one">%,d favorite</item>
<item quantity="other">%,d favorites</item>
</plurals>
<plurals name="x_reblogs">
<item quantity="one">%,d reblog</item>
<item quantity="other">%,d reblogs</item>
</plurals>
</resources>

View File

@@ -66,7 +66,7 @@
<item name="colorButtonText">@color/gray_800</item>
<item name="colorSecondary">#E9EDF2</item>
<item name="colorBackgroundLight">@color/gray_700</item>
<item name="colorBackgroundLightest">@color/gray_700</item>
<item name="colorBackgroundLightest">@color/gray_900</item>
<item name="colorDarkIcon">@color/gray_25</item>
<item name="colorWindowBackground">@color/gray_800</item>
<item name="android:statusBarColor">@color/gray_800</item>