diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 284f82090..caf0386eb 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -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" diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index eb34a4e4b..6bb5a04de 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -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 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` 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 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 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 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` 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 result){ - PaginatedResponse> 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 filtered=new ArrayList<>(result); + AccountSessionManager.get(accountID).filterStatusContainingObjects(filtered, n->n.status, FilterContext.NOTIFICATIONS); + PaginatedResponse> res=new PaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id); callback.onSuccess(res); putNotifications(result, onlyMentions, maxID==null); if(!onlyMentions){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java index 92b5a26be..a1eb474f1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java @@ -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(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index 158070dab..a20c6ce1d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -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 statuses, FilterContext context){ + filterStatusContainingObjects(statuses, Function.identity(), context); + } + + public void filterStatusContainingObjects(List objects, Function 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; + }); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index 3015a63b1..ca26f57f2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -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); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java index e053d78b3..4c27e7a96 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java @@ -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 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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java index 453590d11..4297376bf 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -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 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 targetList=displayItems.subList(gapPos, gapPos+1); targetList.clear(); List insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1); - List 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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java index ea7c4fcc0..ceb7e760b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java @@ -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 filterStatuses(List statuses){ - List 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 statuses){ + AccountSessionManager.get(accountID).filterStatuses(statuses, FilterContext.THREAD); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java index 2d3ec3c2d..a1367c74a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java @@ -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 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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportReasonChoiceFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportReasonChoiceFragment.java index e02e044da..2bc28423a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportReasonChoiceFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportReasonChoiceFragment.java @@ -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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java index 9c081813b..4655c4169 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java @@ -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 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 diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java b/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java index a768e334c..b374a67c4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java @@ -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 keywords; + public List keywords=new ArrayList<>(); - @RequiredField - public List statuses; + public List statuses=new ArrayList<>(); @Override public void postprocess() throws ObjectValidationException{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/FilterResult.java b/mastodon/src/main/java/org/joinmastodon/android/model/FilterResult.java new file mode 100644 index 000000000..59c70e6dc --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/FilterResult.java @@ -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 keywordMatches; + + @Override + public void postprocess() throws ObjectValidationException{ + super.postprocess(); + filter.postprocess(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/LegacyFilter.java b/mastodon/src/main/java/org/joinmastodon/android/model/LegacyFilter.java index 7b83aa8d5..51980add9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/LegacyFilter.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/LegacyFilter.java @@ -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{"+ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java index 3b36ef89b..fcfcb8c1d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java @@ -50,6 +50,7 @@ public class Status extends BaseModel implements DisplayItemsParent{ public Card card; public String language; public String text; + public List 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+'\''+ '}'; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java index c49e2bc12..f4b739e25 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/SpoilerStatusDisplayItem.java @@ -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 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 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)); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index 6572fe89c..58cc8613b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -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 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 extends BindableViewHolder implements UsableRecyclerView.DisableableClickable{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/TiledDrawable.java b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/TiledDrawable.java new file mode 100644 index 000000000..184f7c5db --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/TiledDrawable.java @@ -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 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); + } + } + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java b/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java deleted file mode 100644 index c9a6e9a0a..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java +++ /dev/null @@ -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{ - private final List filters; - - public StatusFilterPredicate(List 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; - } -} diff --git a/mastodon/src/main/res/drawable/filter_banner_stripe_texture.xml b/mastodon/src/main/res/drawable/filter_banner_stripe_texture.xml new file mode 100644 index 000000000..9ecf567aa --- /dev/null +++ b/mastodon/src/main/res/drawable/filter_banner_stripe_texture.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index 3e021a08a..cf32bb3e2 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -631,4 +631,6 @@ App update ready Version %s Downloading (%d%%) + + Matches filter ā€œ%sā€ \ No newline at end of file