Filtered posts in timelines (AND-8)

This commit is contained in:
Grishka 2023-06-07 04:47:54 +03:00
parent a24b4363d7
commit 17957b69d1
22 changed files with 253 additions and 148 deletions

View File

@ -9,7 +9,7 @@ android {
applicationId "org.joinmastodon.android"
minSdk 23
targetSdk 33
versionCode 57
versionCode 58
versionName "1.3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs "ar-rSA", "be-rBY", "bn-rBD", "bs-rBA", "ca-rES", "cs-rCZ", "da-rDK", "de-rDE", "el-rGR", "es-rES", "eu-rES", "fa-rIR", "fi-rFI", "fil-rPH", "fr-rFR", "ga-rIE", "gd-rGB", "gl-rES", "hi-rIN", "hr-rHR", "hu-rHU", "hy-rAM", "ig-rNG", "in-rID", "is-rIS", "it-rIT", "iw-rIL", "ja-rJP", "kab", "ko-rKR", "my-rMM", "nl-rNL", "no-rNO", "oc-rFR", "pl-rPL", "pt-rBR", "pt-rPT", "ro-rRO", "ru-rRU", "si-rLK", "sl-rSI", "sv-rSE", "th-rTH", "tr-rTR", "uk-rUA", "ur-rIN", "vi-rVN", "zh-rCN", "zh-rTW"

View File

@ -16,19 +16,16 @@ 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.FilterContext;
import org.joinmastodon.android.model.LegacyFilter;
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;
import java.util.EnumSet;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
@ -60,7 +57,6 @@ public class CacheController{
cancelDelayedClose();
databaseThread.postRunnable(()->{
try{
List<LegacyFilter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(FilterContext.HOME)).collect(Collectors.toList());
if(!forceReload){
SQLiteDatabase db=getOrOpenDatabase();
try(Cursor cursor=db.query("home_timeline", new String[]{"json", "flags"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`time` DESC", count+"")){
@ -68,20 +64,16 @@ public class CacheController{
ArrayList<Status> result=new ArrayList<>();
cursor.moveToFirst();
String newMaxID;
outer:
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(LegacyFilter filter:filters){
if(filter.matches(status))
continue outer;
}
result.add(status);
}while(cursor.moveToNext());
String _newMaxID=newMaxID;
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME);
uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true)));
return;
}
@ -93,7 +85,9 @@ public class CacheController{
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Status> result){
callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false));
ArrayList<Status> filtered=new ArrayList<>(result);
AccountSessionManager.get(accountID).filterStatuses(filtered, FilterContext.HOME);
callback.onSuccess(new CacheablePaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id, false));
putHomeTimeline(result, maxID==null);
}
@ -140,7 +134,6 @@ public class CacheController{
}
return;
}
List<LegacyFilter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(FilterContext.NOTIFICATIONS)).collect(Collectors.toList());
if(!forceReload){
SQLiteDatabase db=getOrOpenDatabase();
try(Cursor cursor=db.query(onlyMentions ? "notifications_mentions" : "notifications_all", new String[]{"json"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`time` DESC", count+"")){
@ -148,20 +141,14 @@ public class CacheController{
ArrayList<Notification> result=new ArrayList<>();
cursor.moveToFirst();
String newMaxID;
outer:
do{
Notification ntf=MastodonAPIController.gson.fromJson(cursor.getString(0), Notification.class);
ntf.postprocess();
newMaxID=ntf.id;
if(ntf.status!=null){
for(LegacyFilter filter:filters){
if(filter.matches(ntf.status))
continue outer;
}
}
result.add(ntf);
}while(cursor.moveToNext());
String _newMaxID=newMaxID;
AccountSessionManager.get(accountID).filterStatusContainingObjects(result, n->n.status, FilterContext.NOTIFICATIONS);
uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(result, _newMaxID)));
return;
}
@ -175,16 +162,9 @@ public class CacheController{
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Notification> result){
PaginatedResponse<List<Notification>> res=new PaginatedResponse<>(result.stream().filter(ntf->{
if(ntf.status!=null){
for(LegacyFilter filter:filters){
if(filter.matches(ntf.status)){
return false;
}
}
}
return true;
}).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id);
ArrayList<Notification> filtered=new ArrayList<>(result);
AccountSessionManager.get(accountID).filterStatusContainingObjects(filtered, n->n.status, FilterContext.NOTIFICATIONS);
PaginatedResponse<List<Notification>> res=new PaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id);
callback.onSuccess(res);
putNotifications(result, onlyMentions, maxID==null);
if(!onlyMentions){

View File

@ -9,6 +9,7 @@ public class AccountLocalPreferences{
public boolean customEmojiInNames;
public boolean showCWs;
public boolean hideSensitiveMedia;
public boolean serverSideFiltersSupported;
public AccountLocalPreferences(SharedPreferences prefs){
this.prefs=prefs;
@ -16,6 +17,7 @@ public class AccountLocalPreferences{
customEmojiInNames=prefs.getBoolean("emojiInNames", true);
showCWs=prefs.getBoolean("showCWs", true);
hideSensitiveMedia=prefs.getBoolean("hideSensitive", true);
serverSideFiltersSupported=prefs.getBoolean("serverSideFilters", false);
}
public long getNotificationsPauseEndTime(){
@ -32,6 +34,7 @@ public class AccountLocalPreferences{
.putBoolean("emojiInNames", customEmojiInNames)
.putBoolean("showCWs", showCWs)
.putBoolean("hideSensitive", hideSensitiveMedia)
.putBoolean("serverSideFilters", serverSideFiltersSupported)
.apply();
}
}

View File

@ -21,9 +21,13 @@ import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Application;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterResult;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.PushSubscription;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineMarkers;
import org.joinmastodon.android.model.Token;
import org.joinmastodon.android.utils.ObjectIdComparator;
@ -31,6 +35,7 @@ import org.joinmastodon.android.utils.ObjectIdComparator;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
@ -213,4 +218,47 @@ public class AccountSession{
localPreferences=new AccountLocalPreferences(getRawLocalPreferences());
return localPreferences;
}
public void filterStatuses(List<Status> statuses, FilterContext context){
filterStatusContainingObjects(statuses, Function.identity(), context);
}
public <T> void filterStatusContainingObjects(List<T> objects, Function<T, Status> extractor, FilterContext context){
if(getLocalPreferences().serverSideFiltersSupported){
// Even with server-side filters, clients are expected to remove statuses that match a filter that hides them
objects.removeIf(o->{
Status s=extractor.apply(o);
if(s==null)
return false;
if(s.filtered==null)
return false;
for(FilterResult filter:s.filtered){
if(filter.filter.isActive() && filter.filter.filterAction==FilterAction.HIDE)
return true;
}
return false;
});
return;
}
if(wordFilters==null)
return;
for(T obj:objects){
Status s=extractor.apply(obj);
if(s!=null && s.filtered!=null){
getLocalPreferences().serverSideFiltersSupported=true;
getLocalPreferences().save();
return;
}
}
objects.removeIf(o->{
Status s=extractor.apply(o);
if(s==null)
return false;
for(LegacyFilter filter:wordFilters){
if(filter.context.contains(context) && filter.matches(s) && filter.isActive())
return true;
}
return false;
});
}
}

View File

@ -260,7 +260,7 @@ public class AccountSessionManager{
if(now-session.infoLastUpdated>24L*3600_000L){
updateSessionLocalInfo(session);
}
if(now-session.filtersLastUpdated>3600_000L){
if(!session.getLocalPreferences().serverSideFiltersSupported && now-session.filtersLastUpdated>3600_000L){
updateSessionWordFilters(session);
}
}

View File

@ -12,6 +12,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.drawables.EmptyDrawable;
import org.joinmastodon.android.ui.views.FilterChipView;
@ -63,7 +64,9 @@ public class AccountTimelineFragment extends StatusListFragment{
public void onSuccess(List<Status> result){
if(getActivity()==null)
return;
onDataLoaded(result, !result.isEmpty());
boolean empty=result.isEmpty();
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.ACCOUNT);
onDataLoaded(result, !empty);
}
})
.exec(accountID);

View File

@ -33,20 +33,17 @@ import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineMarkers;
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.updater.GithubSelfUpdater;
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;
@ -202,10 +199,7 @@ public class HomeTimelineFragment extends StatusListFragment{
result.get(result.size()-1).hasGapAfter=true;
toAdd=result;
}
List<LegacyFilter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(FilterContext.HOME)).collect(Collectors.toList());
if(!filters.isEmpty()){
toAdd=toAdd.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList());
}
AccountSessionManager.get(accountID).filterStatuses(toAdd, FilterContext.HOME);
if(!toAdd.isEmpty()){
prependItems(toAdd, true);
showNewPostsButton();
@ -279,19 +273,13 @@ public class HomeTimelineFragment extends StatusListFragment{
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
targetList.clear();
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
List<LegacyFilter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(FilterContext.HOME)).collect(Collectors.toList());
outer:
for(Status s:result){
if(idsBelowGap.contains(s.id))
break;
for(LegacyFilter filter:filters){
if(filter.matches(s)){
continue outer;
}
}
targetList.addAll(buildDisplayItems(s));
insertedPosts.add(s);
}
AccountSessionManager.get(accountID).filterStatuses(insertedPosts, FilterContext.HOME);
if(targetList.isEmpty()){
// oops. We didn't add new posts, but at least we know there are none.
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);

View File

@ -81,8 +81,8 @@ public class ThreadFragment extends StatusListFragment{
data.add(mainStatus);
onAppendItems(Collections.singletonList(mainStatus));
}
result.descendants=filterStatuses(result.descendants);
result.ancestors=filterStatuses(result.ancestors);
filterStatuses(result.descendants);
filterStatuses(result.ancestors);
if(footerProgress!=null)
footerProgress.setVisibility(View.GONE);
data.addAll(result.descendants);
@ -103,17 +103,8 @@ public class ThreadFragment extends StatusListFragment{
.exec(accountID);
}
private List<Status> filterStatuses(List<Status> statuses){
List<LegacyFilter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(FilterContext.THREAD)).collect(Collectors.toList());
if(filters.isEmpty())
return statuses;
return statuses.stream().filter(status->{
for(LegacyFilter filter:filters){
if(filter.matches(status))
return false;
}
return true;
}).collect(Collectors.toList());
private void filterStatuses(List<Status> statuses){
AccountSessionManager.get(accountID).filterStatuses(statuses, FilterContext.THREAD);
}
@Override

View File

@ -4,14 +4,13 @@ import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.FilterContext;
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;
@ -27,7 +26,9 @@ public class LocalTimelineFragment extends StatusListFragment{
public void onSuccess(List<Status> result){
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
onDataLoaded(result.stream().filter(new StatusFilterPredicate(accountID, FilterContext.PUBLIC)).collect(Collectors.toList()), !result.isEmpty());
boolean empty=result.isEmpty();
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
onDataLoaded(result, !empty);
}
})
.exec(accountID);

View File

@ -80,7 +80,7 @@ public class ReportReasonChoiceFragment extends StatusListFragment{
reportAccount=Parcels.unwrap(getArguments().getParcelable("reportAccount"));
reportStatus=Parcels.unwrap(getArguments().getParcelable("status"));
if(reportStatus!=null){
Status hiddenStatus=new Status(reportStatus);
Status hiddenStatus=reportStatus.clone();
hiddenStatus.spoilerText=getString(R.string.post_hidden);
onDataLoaded(Collections.singletonList(hiddenStatus));
setTitle(R.string.report_title_post);

View File

@ -1,7 +1,6 @@
package org.joinmastodon.android.fragments.settings;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.os.Parcelable;
import android.view.Menu;
@ -43,7 +42,6 @@ import java.util.stream.Collectors;
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.OnBackPressedListener;
@ -316,7 +314,7 @@ public class EditFilterFragment extends BaseSettingsFragment<Void> implements On
}
private boolean isDirty(){
return dirty || (filter!=null && !titleEdit.getText().toString().equals(filter.title));
return dirty || (filter!=null && !titleEdit.getText().toString().equals(filter.title)) || (filter!=null && (filter.filterAction==FilterAction.WARN)!=cwItem.checked);
}
@Override

View File

@ -5,6 +5,7 @@ import org.joinmastodon.android.api.RequiredField;
import org.parceler.Parcel;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
@ -22,11 +23,9 @@ public class Filter extends BaseModel{
public Instant expiresAt;
public FilterAction filterAction;
@RequiredField
public List<FilterKeyword> keywords;
public List<FilterKeyword> keywords=new ArrayList<>();
@RequiredField
public List<FilterStatus> statuses;
public List<FilterStatus> statuses=new ArrayList<>();
@Override
public void postprocess() throws ObjectValidationException{

View File

@ -0,0 +1,21 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
import org.parceler.Parcel;
import java.util.List;
@Parcel
public class FilterResult extends BaseModel{
@RequiredField
public Filter filter;
public List<String> keywordMatches;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
filter.postprocess();
}
}

View File

@ -54,6 +54,10 @@ public class LegacyFilter extends BaseModel{
return matches(status.getContentStatus().getStrippedText());
}
public boolean isActive(){
return expiresAt==null || expiresAt.isAfter(Instant.now());
}
@Override
public String toString(){
return "Filter{"+

View File

@ -50,6 +50,7 @@ public class Status extends BaseModel implements DisplayItemsParent{
public Card card;
public String language;
public String text;
public List<FilterResult> filtered;
public boolean favourited;
public boolean reblogged;
@ -63,39 +64,6 @@ public class Status extends BaseModel implements DisplayItemsParent{
public Status(){}
public Status(Status other){
this.id=other.id;
this.uri=other.uri;
this.createdAt=other.createdAt;
this.account=other.account;
this.content=other.content;
this.visibility=other.visibility;
this.sensitive=other.sensitive;
this.spoilerText=other.spoilerText;
this.mediaAttachments=other.mediaAttachments;
this.application=other.application;
this.mentions=other.mentions;
this.tags=other.tags;
this.emojis=other.emojis;
this.reblogsCount=other.reblogsCount;
this.favouritesCount=other.favouritesCount;
this.repliesCount=other.repliesCount;
this.editedAt=other.editedAt;
this.url=other.url;
this.inReplyToId=other.inReplyToId;
this.inReplyToAccountId=other.inReplyToAccountId;
this.reblog=other.reblog;
this.poll=other.poll;
this.card=other.card;
this.language=other.language;
this.text=other.text;
this.favourited=other.favourited;
this.reblogged=other.reblogged;
this.muted=other.muted;
this.bookmarked=other.bookmarked;
this.pinned=other.pinned;
}
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
@ -116,6 +84,10 @@ public class Status extends BaseModel implements DisplayItemsParent{
card.postprocess();
if(reblog!=null)
reblog.postprocess();
if(filtered!=null){
for(FilterResult fr:filtered)
fr.postprocess();
}
spoilerRevealed=!sensitive;
}
@ -139,6 +111,7 @@ public class Status extends BaseModel implements DisplayItemsParent{
", reblogsCount="+reblogsCount+
", favouritesCount="+favouritesCount+
", repliesCount="+repliesCount+
", editedAt="+editedAt+
", url='"+url+'\''+
", inReplyToId='"+inReplyToId+'\''+
", inReplyToAccountId='"+inReplyToAccountId+'\''+
@ -147,11 +120,15 @@ public class Status extends BaseModel implements DisplayItemsParent{
", card="+card+
", language='"+language+'\''+
", text='"+text+'\''+
", filtered="+filtered+
", favourited="+favourited+
", reblogged="+reblogged+
", muted="+muted+
", bookmarked="+bookmarked+
", pinned="+pinned+
", spoilerRevealed="+spoilerRevealed+
", hasGapAfter="+hasGapAfter+
", strippedText='"+strippedText+'\''+
'}';
}

View File

@ -3,6 +3,7 @@ package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
@ -12,6 +13,7 @@ import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable;
import org.joinmastodon.android.ui.drawables.TiledDrawable;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
@ -25,18 +27,25 @@ public class SpoilerStatusDisplayItem extends StatusDisplayItem{
public final ArrayList<StatusDisplayItem> contentItems=new ArrayList<>();
private final CharSequence parsedTitle;
private final CustomEmojiHelper emojiHelper;
private final Type type;
public SpoilerStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status){
public SpoilerStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, String title, Status status, Type type){
super(parentID, parentFragment);
this.status=status;
parsedTitle=HtmlParser.parseCustomEmoji(status.spoilerText, status.emojis);
emojiHelper=new CustomEmojiHelper();
emojiHelper.setText(parsedTitle);
this.type=type;
if(TextUtils.isEmpty(title)){
parsedTitle=HtmlParser.parseCustomEmoji(status.spoilerText, status.emojis);
emojiHelper=new CustomEmojiHelper();
emojiHelper.setText(parsedTitle);
}else{
parsedTitle=title;
emojiHelper=null;
}
}
@Override
public int getImageCount(){
return emojiHelper.getImageCount();
return emojiHelper==null ? 0 : emojiHelper.getImageCount();
}
@Override
@ -46,14 +55,14 @@ public class SpoilerStatusDisplayItem extends StatusDisplayItem{
@Override
public Type getType(){
return Type.SPOILER;
return type;
}
public static class Holder extends StatusDisplayItem.Holder<SpoilerStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView title, action;
private final View button;
public Holder(Context context, ViewGroup parent){
public Holder(Context context, ViewGroup parent, Type type){
super(context, R.layout.display_item_spoiler, parent);
title=findViewById(R.id.spoiler_title);
action=findViewById(R.id.spoiler_action);
@ -62,8 +71,14 @@ public class SpoilerStatusDisplayItem extends StatusDisplayItem{
button.setOutlineProvider(OutlineProviders.roundedRect(8));
button.setClipToOutline(true);
LayerDrawable spoilerBg=(LayerDrawable) button.getBackground().mutate();
spoilerBg.setDrawableByLayerId(R.id.left_drawable, new SpoilerStripesDrawable(true));
spoilerBg.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable(false));
if(type==Type.SPOILER){
spoilerBg.setDrawableByLayerId(R.id.left_drawable, new SpoilerStripesDrawable(true));
spoilerBg.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable(false));
}else if(type==Type.FILTER_SPOILER){
Drawable texture=context.getDrawable(R.drawable.filter_banner_stripe_texture);
spoilerBg.setDrawableByLayerId(R.id.left_drawable, new TiledDrawable(texture));
spoilerBg.setDrawableByLayerId(R.id.right_drawable, new TiledDrawable(texture));
}
button.setBackground(spoilerBg);
button.setOnClickListener(v->item.parentFragment.onRevealSpoilerClick(this));
}

View File

@ -2,11 +2,11 @@ package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.content.Context;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
@ -14,6 +14,7 @@ import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.DisplayItemsParent;
import org.joinmastodon.android.model.FilterResult;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
@ -72,7 +73,7 @@ public abstract class StatusDisplayItem{
case GAP -> new GapStatusDisplayItem.Holder(activity, parent);
case EXTENDED_FOOTER -> new ExtendedFooterStatusDisplayItem.Holder(activity, parent);
case MEDIA_GRID -> new MediaGridStatusDisplayItem.Holder(activity, parent);
case SPOILER -> new SpoilerStatusDisplayItem.Holder(activity, parent);
case SPOILER, FILTER_SPOILER -> new SpoilerStatusDisplayItem.Holder(activity, parent, type);
case SECTION_HEADER -> new SectionHeaderStatusDisplayItem.Holder(activity, parent);
case NOTIFICATION_HEADER -> new NotificationHeaderStatusDisplayItem.Holder(activity, parent);
};
@ -106,9 +107,24 @@ public abstract class StatusDisplayItem{
items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null));
}
boolean filtered=false;
if(status.filtered!=null){
for(FilterResult filter:status.filtered){
if(filter.filter.isActive()){
filtered=true;
break;
}
}
}
ArrayList<StatusDisplayItem> contentItems;
if(!TextUtils.isEmpty(statusForContent.spoilerText) && AccountSessionManager.get(accountID).getLocalPreferences().showCWs){
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, statusForContent);
if(filtered){
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, fragment.getString(R.string.post_matches_filter_x, status.filtered.get(0).filter.title), statusForContent, Type.FILTER_SPOILER);
items.add(spoilerItem);
contentItems=spoilerItem.contentItems;
statusForContent.spoilerRevealed=false;
}else if(!TextUtils.isEmpty(statusForContent.spoilerText) && AccountSessionManager.get(accountID).getLocalPreferences().showCWs){
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, null, statusForContent, Type.SPOILER);
items.add(spoilerItem);
contentItems=spoilerItem.contentItems;
}else{
@ -116,7 +132,11 @@ public abstract class StatusDisplayItem{
}
if(!TextUtils.isEmpty(statusForContent.content)){
TextStatusDisplayItem text=new TextStatusDisplayItem(parentID, HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID), fragment, statusForContent);
SpannableStringBuilder parsedText=HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID);
if(filtered){
HtmlParser.applyFilterHighlights(fragment.getActivity(), parsedText, status.filtered);
}
TextStatusDisplayItem text=new TextStatusDisplayItem(parentID, parsedText, fragment, statusForContent);
text.reduceTopPadding=header==null;
contentItems.add(text);
}else if(header!=null){
@ -192,7 +212,8 @@ public abstract class StatusDisplayItem{
SPOILER,
SECTION_HEADER,
HEADER_CHECKABLE,
NOTIFICATION_HEADER
NOTIFICATION_HEADER,
FILTER_SPOILER
}
public static abstract class Holder<T extends StatusDisplayItem> extends BindableViewHolder<T> implements UsableRecyclerView.DisableableClickable{

View File

@ -0,0 +1,48 @@
package org.joinmastodon.android.ui.drawables;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class TiledDrawable extends Drawable{
private final Drawable drawable;
public TiledDrawable(Drawable drawable){
this.drawable=drawable;
}
@Override
public void draw(@NonNull Canvas canvas){
Rect bounds=getBounds();
canvas.save();
canvas.clipRect(bounds);
int w=drawable.getIntrinsicWidth();
int h=drawable.getIntrinsicHeight();
for(int y=bounds.top;y<bounds.bottom;y+=h){
for(int x=bounds.left;x<bounds.right;x+=w){
drawable.setBounds(x, y, x+w, y+h);
drawable.draw(canvas);
}
}
canvas.restore();
}
@Override
public void setAlpha(int alpha){
drawable.setAlpha(alpha);
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
drawable.setColorFilter(colorFilter);
}
@Override
public int getOpacity(){
return drawable.getOpacity();
}
}

View File

@ -1,13 +1,18 @@
package org.joinmastodon.android.ui.text;
import android.content.Context;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import android.widget.TextView;
import com.twitter.twittertext.Regex;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.FilterResult;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.Mention;
import org.joinmastodon.android.ui.utils.UiUtils;
@ -212,4 +217,22 @@ public class HtmlParser{
}while(matcher.find()); // Find more URLs
return ssb;
}
public static void applyFilterHighlights(Context context, SpannableStringBuilder text, List<FilterResult> filters){
int fgColor=UiUtils.getThemeColor(context, R.attr.colorM3Error);
int bgColor=UiUtils.getThemeColor(context, R.attr.colorM3ErrorContainer);
for(FilterResult filter:filters){
if(!filter.filter.isActive())
continue;;
for(String word:filter.keywordMatches){
Matcher matcher=Pattern.compile("\\b"+Pattern.quote(word)+"\\b", Pattern.CASE_INSENSITIVE).matcher(text);
while(matcher.find()){
ForegroundColorSpan fg=new ForegroundColorSpan(fgColor);
BackgroundColorSpan bg=new BackgroundColorSpan(bgColor);
text.setSpan(bg, matcher.start(), matcher.end(), 0);
text.setSpan(fg, matcher.start(), matcher.end(), 0);
}
}
}
}
}

View File

@ -1,31 +0,0 @@
package org.joinmastodon.android.utils;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.LegacyFilter;
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<LegacyFilter> filters;
public StatusFilterPredicate(List<LegacyFilter> filters){
this.filters=filters;
}
public StatusFilterPredicate(String accountID, FilterContext context){
filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(context)).collect(Collectors.toList());
}
@Override
public boolean test(Status status){
for(LegacyFilter filter:filters){
if(filter.matches(status))
return false;
}
return true;
}
}

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="42dp"
android:height="10dp"
android:viewportWidth="42"
android:viewportHeight="8">
<path
android:pathData="M0,0H42V10H-42Z"
android:fillColor="#2F0C7A"/>
<group android:translateX="-1" android:translateY="-1">
<path
android:pathData="M6.892,9.333H9.474L9.588,8.687C6.388,7.519 4.277,7.067 1,7.067H0.667V8.533H1C3.269,8.533 4.921,8.759 6.847,9.32L6.892,9.333ZM31.834,8.688L31.951,9.333H34.489L34.535,9.32C36.5,8.748 38.285,8.533 41,8.533H41.333V7.067H41C37.161,7.067 35.016,7.487 31.834,8.688ZM10.219,6.487C14.172,7.965 16.703,8.533 21,8.533C25.273,8.533 27.446,8.012 31.272,6.481C34.946,5.012 36.94,4.533 41,4.533H41.333V3.067H41C36.727,3.067 34.554,3.588 30.728,5.119C27.054,6.588 25.06,7.067 21,7.067C16.905,7.067 14.542,6.537 10.733,5.113C10.711,5.105 10.646,5.081 10.563,5.05C10.385,4.983 10.124,4.885 10.025,4.848C6.577,3.562 4.425,3.067 1,3.067H0.667V4.533H1C4.216,4.533 6.2,4.986 9.513,6.222C9.611,6.259 9.869,6.356 10.046,6.422C10.131,6.454 10.197,6.479 10.219,6.487ZM10.219,2.487C14.172,3.965 16.703,4.533 21,4.533C25.273,4.533 27.446,4.012 31.272,2.481C32.505,1.988 33.545,1.608 34.535,1.32L34.442,0.667H31.891L31.834,0.688C31.479,0.822 31.111,0.966 30.728,1.119C27.054,2.588 25.06,3.067 21,3.067C16.905,3.067 14.542,2.537 10.733,1.113C10.711,1.105 10.646,1.081 10.563,1.05C10.385,0.983 10.124,0.885 10.025,0.848C9.877,0.793 9.731,0.739 9.588,0.687L9.533,0.667H6.94L6.847,1.32C7.661,1.557 8.527,1.855 9.513,2.222C9.611,2.259 9.869,2.356 10.047,2.422C10.131,2.454 10.197,2.479 10.219,2.487Z"
android:fillColor="#858AFA"/>
</group>
</vector>

View File

@ -631,4 +631,6 @@
<string name="app_update_ready">App update ready</string>
<string name="app_update_version">Version %s</string>
<string name="downloading_update">Downloading (%d%%)</string>
<!-- Shown like a content warning, %s is the name of the filter -->
<string name="post_matches_filter_x">Matches filter “%s”</string>
</resources>