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

@@ -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;
}
}