chore(merging-upstream): bunch of conflicts to solve

This commit is contained in:
LucasGGamerM
2024-02-14 20:50:54 -03:00
parent 8dffbff97c
commit 0af8dbf09b
2000 changed files with 52238 additions and 10716 deletions

View File

@@ -1,47 +1,41 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.HorizontalScrollView;
import android.widget.LinearLayout;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusUnpinnedEvent;
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;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.parceler.Parcels;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class AccountTimelineFragment extends StatusListFragment{
private Account user;
private GetAccountStatuses.Filter filter;
private HorizontalScrollView filtersBar;
private FilterChipView defaultFilter, withRepliesFilter, mediaFilter;
public AccountTimelineFragment(){
setListLayoutId(R.layout.recycler_fragment_no_refresh);
}
public static AccountTimelineFragment newInstance(String accountID, Account profileAccount, boolean load){
public static AccountTimelineFragment newInstance(String accountID, Account profileAccount, GetAccountStatuses.Filter filter, boolean load){
AccountTimelineFragment f=new AccountTimelineFragment();
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(profileAccount));
args.putString("filter", filter.toString());
if(!load)
args.putBoolean("noAutoLoad", true);
args.putBoolean("__is_tab", true);
@@ -52,21 +46,20 @@ public class AccountTimelineFragment extends StatusListFragment{
@Override
public void onAttach(Activity activity){
user=Parcels.unwrap(getArguments().getParcelable("profileAccount"));
filter=GetAccountStatuses.Filter.DEFAULT;
filter=GetAccountStatuses.Filter.valueOf(getArguments().getString("filter"));
super.onAttach(activity);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetAccountStatuses(user.id, offset>0 ? getMaxID() : null, null, count, filter)
currentRequest=new GetAccountStatuses(user.id, getMaxID(), null, count, filter)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(getActivity()==null)
return;
boolean empty=result.isEmpty();
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.ACCOUNT);
onDataLoaded(result, !empty);
if(getActivity()==null) return;
boolean more=applyMaxID(result);
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext(), user);
onDataLoaded(result, more);
}
})
.exec(accountID);
@@ -86,17 +79,40 @@ public class AccountTimelineFragment extends StatusListFragment{
}
protected void onStatusCreated(Status status){
if(!AccountSessionManager.getInstance().isSelf(accountID, status.account))
AccountSessionManager asm = AccountSessionManager.getInstance();
if(!asm.isSelf(accountID, status.account) || !asm.isSelf(accountID, user))
return;
if(filter==GetAccountStatuses.Filter.PINNED) return;
if(filter==GetAccountStatuses.Filter.DEFAULT){
// Keep replies to self, discard all other replies
if(status.inReplyToAccountId!=null && !status.inReplyToAccountId.equals(AccountSessionManager.getInstance().getAccount(accountID).self.id))
return;
}else if(filter==GetAccountStatuses.Filter.MEDIA){
if(status.mediaAttachments.isEmpty())
if(Optional.ofNullable(status.mediaAttachments).map(List::isEmpty).orElse(true))
return;
}
prependItems(Collections.singletonList(status), true);
if (isOnTop()) scrollToTop();
}
protected void onStatusUnpinned(StatusUnpinnedEvent ev){
if(!ev.accountID.equals(accountID) || filter!=GetAccountStatuses.Filter.PINNED)
return;
Status status=getStatusByID(ev.id);
data.remove(status);
preloadedData.remove(status);
HeaderStatusDisplayItem item=findItemOfType(ev.id, HeaderStatusDisplayItem.class);
if(item==null)
return;
int index=displayItems.indexOf(item);
int lastIndex;
for(lastIndex=index;lastIndex<displayItems.size();lastIndex++){
if(!displayItems.get(lastIndex).parentID.equals(ev.id))
break;
}
displayItems.subList(index, lastIndex).clear();
adapter.notifyItemRangeRemoved(index, lastIndex-index);
}
@Override
@@ -104,76 +120,18 @@ public class AccountTimelineFragment extends StatusListFragment{
// no-op
}
@Override
protected RecyclerView.Adapter getAdapter(){
filtersBar=new HorizontalScrollView(getActivity());
LinearLayout filtersLayout=new LinearLayout(getActivity());
filtersBar.addView(filtersLayout);
filtersLayout.setOrientation(LinearLayout.HORIZONTAL);
filtersLayout.setPadding(V.dp(16), V.dp(16), V.dp(16), V.dp(8));
filtersLayout.setDividerDrawable(new EmptyDrawable(V.dp(8), 1));
filtersLayout.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE);
defaultFilter=new FilterChipView(getActivity());
defaultFilter.setText(R.string.posts);
defaultFilter.setTag(GetAccountStatuses.Filter.DEFAULT);
defaultFilter.setSelected(filter==GetAccountStatuses.Filter.DEFAULT);
defaultFilter.setOnClickListener(this::onFilterClick);
filtersLayout.addView(defaultFilter);
withRepliesFilter=new FilterChipView(getActivity());
withRepliesFilter.setText(R.string.posts_and_replies);
withRepliesFilter.setTag(GetAccountStatuses.Filter.INCLUDE_REPLIES);
withRepliesFilter.setSelected(filter==GetAccountStatuses.Filter.INCLUDE_REPLIES);
withRepliesFilter.setOnClickListener(this::onFilterClick);
filtersLayout.addView(withRepliesFilter);
mediaFilter=new FilterChipView(getActivity());
mediaFilter.setText(R.string.media);
mediaFilter.setTag(GetAccountStatuses.Filter.MEDIA);
mediaFilter.setSelected(filter==GetAccountStatuses.Filter.MEDIA);
mediaFilter.setOnClickListener(this::onFilterClick);
filtersLayout.addView(mediaFilter);
MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(filtersBar));
mergeAdapter.addAdapter(super.getAdapter());
return mergeAdapter;
protected FilterContext getFilterContext() {
return FilterContext.ACCOUNT;
}
@Override
protected int getMainAdapterOffset(){
return super.getMainAdapterOffset()+1;
}
private FilterChipView getViewForFilter(GetAccountStatuses.Filter filter){
return switch(filter){
case DEFAULT -> defaultFilter;
case INCLUDE_REPLIES -> withRepliesFilter;
case MEDIA -> mediaFilter;
default -> throw new IllegalStateException("Unexpected value: "+filter);
};
}
private void onFilterClick(View v){
GetAccountStatuses.Filter newFilter=(GetAccountStatuses.Filter) v.getTag();
if(newFilter==filter)
return;
// TODO maybe cache the filtered timelines that were already loaded?
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
getViewForFilter(filter).setSelected(false);
filter=newFilter;
v.setSelected(true);
data.clear();
preloadedData.clear();
int size=displayItems.size();
displayItems.clear();
adapter.notifyItemRangeRemoved(0, size);
loaded=false;
dataLoading=true;
doLoadData();
public Uri getWebUri(Uri.Builder base) {
// could return different uris based on filter (e.g. media -> "/media"), but i want to
// return the remote url to the user, and i don't know whether i'd need to append
// '#media' (akkoma/pleroma) or '/media' (glitch/mastodon) since i don't know anything
// about the remote instance. so, just returning the base url to the user instead
return Uri.parse(user.url);
}
}

View File

@@ -0,0 +1,119 @@
package org.joinmastodon.android.fragments;
import static java.util.stream.Collectors.toList;
import android.app.Activity;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.ImageButton;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.announcements.GetAnnouncements;
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
import org.joinmastodon.android.api.requests.statuses.GetScheduledStatuses;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.ScheduledStatusCreatedEvent;
import org.joinmastodon.android.events.ScheduledStatusDeletedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Announcement;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.ScheduledStatus;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.DummyStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.EmojiReactionsStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.PaginatedList;
import me.grishka.appkit.api.SimpleCallback;
public class AnnouncementsFragment extends BaseStatusListFragment<Announcement> {
private Instance instance;
private AccountSession session;
private List<String> unreadIDs = null;
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setTitle(R.string.sk_announcements);
session = AccountSessionManager.getInstance().getAccount(accountID);
instance = AccountSessionManager.getInstance().getInstanceInfo(session.domain);
loadData();
}
@Override
protected List<StatusDisplayItem> buildDisplayItems(Announcement a) {
if(TextUtils.isEmpty(a.content)) return List.of();
Account instanceUser = new Account();
instanceUser.id = instanceUser.acct = instanceUser.username = session.domain;
instanceUser.displayName = instance.title;
instanceUser.url = "https://"+session.domain+"/about";
instanceUser.avatar = instanceUser.avatarStatic = instance.thumbnail;
instanceUser.emojis = List.of();
Status fakeStatus = a.toStatus();
TextStatusDisplayItem textItem = new TextStatusDisplayItem(a.id, HtmlParser.parse(a.content, a.emojis, a.mentions, a.tags, accountID), this, fakeStatus, true);
textItem.textSelectable = true;
List<StatusDisplayItem> items=new ArrayList<>();
items.add(HeaderStatusDisplayItem.fromAnnouncement(a, fakeStatus, instanceUser, this, accountID, this::onMarkAsRead));
items.add(textItem);
if(!isInstanceAkkoma()) items.add(new EmojiReactionsStatusDisplayItem(a.id, this, fakeStatus, accountID, false, true));
return items;
}
public void onMarkAsRead(String id) {
if (unreadIDs == null) return;
unreadIDs.remove(id);
if (unreadIDs.isEmpty()) setResult(true, null);
}
@Override
protected void addAccountToKnown(Announcement s) {}
@Override
public void onItemClick(String id) {}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetAnnouncements(true)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Announcement> result){
if(getActivity()==null) return;
// get unread items first
List<Announcement> data = result.stream().filter(a -> !a.read).collect(toList());
if (data.isEmpty()) setResult(true, null);
else unreadIDs = data.stream().map(a -> a.id).collect(toList());
// append read items at the end
data.addAll(result.stream().filter(a -> a.read).collect(toList()));
onDataLoaded(data, false);
}
})
.exec(accountID);
}
@Override
public Uri getWebUri(Uri.Builder base) {
return isInstanceAkkoma() ? base.path("/announcements").build() : null;
}
}

View File

@@ -1,6 +1,7 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.app.assist.AssistContent;
import android.content.res.Configuration;
import android.graphics.Canvas;
import android.graphics.Paint;
@@ -11,17 +12,28 @@ import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.animation.TranslateAnimation;
import android.widget.ImageButton;
import android.widget.Toolbar;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.polls.SubmitPollVote;
import org.joinmastodon.android.api.requests.statuses.AkkomaTranslateStatus;
import org.joinmastodon.android.api.requests.statuses.TranslateStatus;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.AkkomaTranslation;
import org.joinmastodon.android.model.DisplayItemsParent;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Relationship;
@@ -29,21 +41,29 @@ import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.Translation;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.sheets.NonMutualPreReplySheet;
import org.joinmastodon.android.ui.sheets.OldPostPreReplySheet;
import org.joinmastodon.android.ui.NonMutualPreReplySheet;
import org.joinmastodon.android.ui.OldPostPreReplySheet;
import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PollOptionStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PreviewlessMediaGridStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.SpoilerStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem;
import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost;
import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration;
import org.joinmastodon.android.ui.utils.MediaAttachmentViewController;
import org.joinmastodon.android.ui.utils.PreviewlessMediaAttachmentViewController;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.joinmastodon.android.utils.TypedObjectPool;
import java.time.Instant;
@@ -55,13 +75,17 @@ import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
@@ -70,23 +94,38 @@ import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class BaseStatusListFragment<T extends DisplayItemsParent> extends MastodonRecyclerFragment<T> implements PhotoViewerHost, ScrollableToTop{
public abstract class BaseStatusListFragment<T extends DisplayItemsParent> extends MastodonRecyclerFragment<T> implements PhotoViewerHost, ScrollableToTop, IsOnTop, HasFab, ProvidesAssistContent.ProvidesWebUri {
protected ArrayList<StatusDisplayItem> displayItems=new ArrayList<>();
protected DisplayItemsAdapter adapter;
protected String accountID;
protected PhotoViewer currentPhotoViewer;
protected ImageButton fab;
protected int scrollDiff = 0;
protected HashMap<String, Account> knownAccounts=new HashMap<>();
protected HashMap<String, Relationship> relationships=new HashMap<>();
protected Rect tmpRect=new Rect();
protected TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, MediaAttachmentViewController> attachmentViewsPool=new TypedObjectPool<>(this::makeNewMediaAttachmentView);
protected TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, PreviewlessMediaAttachmentViewController> previewlessAttachmentViewsPool=new TypedObjectPool<>(this::makeNewPreviewlessMediaAttachmentView);
protected boolean currentlyScrolling;
protected String maxID;
public BaseStatusListFragment(){
super(20);
if (wantsComposeButton()) setListLayoutId(R.layout.recycler_fragment_with_fab);
}
protected boolean wantsComposeButton() {
return false;
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
if(GlobalUserPreferences.toolbarMarquee){
setTitleMarqueeEnabled(false);
setSubtitleMarqueeEnabled(false);
}
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
}
@@ -120,7 +159,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
displayItems.clear();
}
protected void prependItems(List<T> items, boolean notify){
protected int prependItems(List<T> items, boolean notify){
data.addAll(0, items);
int offset=0;
for(T s:items){
@@ -134,9 +173,12 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
if(notify)
adapter.notifyItemRangeInserted(0, offset);
loadRelationships(items.stream().map(DisplayItemsParent::getAccountID).filter(Objects::nonNull).collect(Collectors.toSet()));
return offset;
}
protected String getMaxID(){
if(refreshing) return null;
if(maxID!=null) return maxID;
if(!preloadedData.isEmpty())
return preloadedData.get(preloadedData.size()-1).getID();
else if(!data.isEmpty())
@@ -145,6 +187,12 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return null;
}
protected boolean applyMaxID(List<Status> result){
boolean empty=result.isEmpty();
if(!empty) maxID=result.get(result.size()-1).id;
return !empty;
}
protected abstract List<StatusDisplayItem> buildDisplayItems(T s);
protected abstract void addAccountToKnown(T s);
@@ -194,7 +242,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
@Override
public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){
MediaAttachmentViewController holder=findPhotoViewHolder(index);
if(holder!=null){
if(holder!=null && list!=null){
transitioningHolder=holder;
View view=transitioningHolder.photo;
int[] pos={0, 0};
@@ -262,21 +310,159 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
gridHolder.itemView.setHasTransientState(true);
}
public void openPreviewlessMediaPhotoViewer(String parentID, Status _status, int attachmentIndex, PreviewlessMediaGridStatusDisplayItem.Holder gridHolder){
final Status status=_status.getContentStatus();
currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, status, accountID, new PhotoViewer.Listener(){
private PreviewlessMediaAttachmentViewController transitioningHolder;
@Override
public void setPhotoViewVisibility(int index, boolean visible){
}
@Override
public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){
PreviewlessMediaAttachmentViewController holder=findPhotoViewHolder(index);
if(holder!=null && list!=null){
transitioningHolder=holder;
View view=transitioningHolder.inner;
int[] pos={0, 0};
view.getLocationOnScreen(pos);
outRect.set(pos[0], pos[1], pos[0]+view.getWidth(), pos[1]+view.getHeight());
list.setClipChildren(false);
gridHolder.setClipChildren(false);
transitioningHolder.view.setElevation(1f);
return true;
}
return false;
}
@Override
public void setTransitioningViewTransform(float translateX, float translateY, float scale){
View view=transitioningHolder.inner;
view.setTranslationX(translateX);
view.setTranslationY(translateY);
view.setScaleX(scale);
view.setScaleY(scale);
}
@Override
public void endPhotoViewTransition(){
View view=transitioningHolder.inner;
view.setTranslationX(0f);
view.setTranslationY(0f);
view.setScaleX(1f);
view.setScaleY(1f);
transitioningHolder.view.setElevation(0f);
if(list!=null)
list.setClipChildren(true);
gridHolder.setClipChildren(true);
transitioningHolder=null;
}
@Nullable
@Override
public Drawable getPhotoViewCurrentDrawable(int index){
return null;
}
@Override
public void photoViewerDismissed(){
currentPhotoViewer=null;
}
@Override
public void onRequestPermissions(String[] permissions){
requestPermissions(permissions, PhotoViewer.PERMISSION_REQUEST);
}
private PreviewlessMediaAttachmentViewController findPhotoViewHolder(int index){
return gridHolder.getViewController(index);
}
});
}
@Override
public @Nullable View getFab() {
if (getParentFragment() instanceof HasFab l) return l.getFab();
else return fab;
}
@Override
public void showFab() {
View fab = getFab();
if (fab == null || fab.getVisibility() == View.VISIBLE) return;
fab.setVisibility(View.VISIBLE);
TranslateAnimation animate = new TranslateAnimation(
0,
0,
fab.getHeight() * 2,
0);
animate.setDuration(300);
fab.startAnimation(animate);
}
public boolean isScrolling() {
return currentlyScrolling;
}
@Override
public void hideFab() {
View fab = getFab();
if (fab == null || fab.getVisibility() != View.VISIBLE) return;
TranslateAnimation animate = new TranslateAnimation(
0,
0,
0,
fab.getHeight() * 2);
animate.setDuration(300);
fab.startAnimation(animate);
fab.setVisibility(View.INVISIBLE);
scrollDiff = 0;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
fab=view.findViewById(R.id.fab);
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
if(currentPhotoViewer!=null)
currentPhotoViewer.offsetView(-dx, -dy);
View fab = getFab();
if (fab!=null && GlobalUserPreferences.autoHideFab && dy != UiUtils.SCROLL_TO_TOP_DELTA) {
if (dy > 0 && fab.getVisibility() == View.VISIBLE) {
hideFab();
} else if (dy < 0 && fab.getVisibility() != View.VISIBLE) {
if (list.getChildAt(0).getTop() == 0 || scrollDiff > 400) {
showFab();
scrollDiff = 0;
} else {
scrollDiff += Math.abs(dy);
}
}
}
}
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
currentlyScrolling = newState != RecyclerView.SCROLL_STATE_IDLE;
}
});
list.addItemDecoration(new StatusListItemDecoration());
list.addItemDecoration(new InsetStatusItemDecoration(this));
((UsableRecyclerView)list).setSelectorBoundsProvider(new UsableRecyclerView.SelectorBoundsProvider(){
private Rect tmpRect=new Rect();
@Override
public void getSelectorBounds(View view, Rect outRect){
if(list!=view.getParent()) return;
boolean hasDescendant=false, hasAncestor=false, isWarning=false;
int lastIndex=-1, firstIndex=-1;
if(((UsableRecyclerView) list).isIncludeMarginsInItemHitbox()){
list.getDecoratedBoundsWithMargins(view, outRect);
}else{
@@ -292,23 +478,55 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
for(int i=0;i<list.getChildCount();i++){
View child=list.getChildAt(i);
holder=list.getChildViewHolder(child);
if(holder instanceof StatusDisplayItem.Holder){
if(holder instanceof StatusDisplayItem.Holder<?> h){
String otherID=((StatusDisplayItem.Holder<?>) holder).getItemID();
if(otherID.equals(id)){
if (firstIndex < 0) firstIndex = i;
lastIndex = i;
StatusDisplayItem item = h.getItem();
hasDescendant = item.hasDescendantNeighbor;
// no for direct descendants because main status (right above) is
// being displayed with an extended footer - no connected layout
hasAncestor = item.hasAncestoringNeighbor && !item.isDirectDescendant;
list.getDecoratedBoundsWithMargins(child, tmpRect);
outRect.left=Math.min(outRect.left, tmpRect.left);
outRect.top=Math.min(outRect.top, tmpRect.top);
outRect.right=Math.max(outRect.right, tmpRect.right);
outRect.bottom=Math.max(outRect.bottom, tmpRect.bottom);
if (holder instanceof WarningFilteredStatusDisplayItem.Holder) {
isWarning = true;
}
}
}
}
}
// shifting the selection box down
// see also: FooterStatusDisplayItem#onBind (setMargins)
if (isWarning || firstIndex < 0 || lastIndex < 0 ||
!(list.getChildViewHolder(list.getChildAt(lastIndex))
instanceof FooterStatusDisplayItem.Holder)) return;
int prevIndex = firstIndex - 1, nextIndex = lastIndex + 1;
boolean prevIsWarning = prevIndex > 0 && prevIndex < list.getChildCount() &&
list.getChildViewHolder(list.getChildAt(prevIndex))
instanceof WarningFilteredStatusDisplayItem.Holder;
boolean nextIsWarning = nextIndex > 0 && nextIndex < list.getChildCount() &&
list.getChildViewHolder(list.getChildAt(nextIndex))
instanceof WarningFilteredStatusDisplayItem.Holder;
if (!prevIsWarning && hasAncestor) outRect.top += V.dp(4);
if (!nextIsWarning && hasDescendant) outRect.bottom += V.dp(4);
}
});
list.setItemAnimator(new BetterItemAnimator());
((UsableRecyclerView) list).setIncludeMarginsInItemHitbox(true);
updateToolbar();
if (wantsComposeButton() && !getArguments().getBoolean("__disable_fab", false)) {
fab.setVisibility(View.VISIBLE);
fab.setOnClickListener(this::onFabClick);
fab.setOnLongClickListener(this::onFabLongClick);
} else if (fab != null) {
fab.setVisibility(View.GONE);
}
}
@Override
@@ -349,10 +567,14 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
protected void updatePoll(String itemID, Status status, Poll poll){
status.poll=poll;
int firstOptionIndex=-1, footerIndex=-1;
int spoilerFirstOptionIndex=-1, spoilerFooterIndex=-1;
SpoilerStatusDisplayItem spoilerItem=null;
int i=0;
for(StatusDisplayItem item:displayItems){
if(item.parentID.equals(itemID)){
if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
if(item instanceof SpoilerStatusDisplayItem){
spoilerItem=(SpoilerStatusDisplayItem) item;
}else if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
firstOptionIndex=i;
}else if(item instanceof PollFooterStatusDisplayItem){
footerIndex=i;
@@ -361,12 +583,39 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
i++;
}
// This is a temporary measure to deal with the app crashing when the poll isn't updated.
// This is needed because of a possible id mismatch that screws with things
if(firstOptionIndex==-1 || footerIndex==-1){
for(StatusDisplayItem item:displayItems){
if(status.id.equals(itemID)){
if(item instanceof SpoilerStatusDisplayItem){
spoilerItem=(SpoilerStatusDisplayItem) item;
}else if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
firstOptionIndex=i;
}else if(item instanceof PollFooterStatusDisplayItem){
footerIndex=i;
break;
}
}
i++;
}
}
if(firstOptionIndex==-1 || footerIndex==-1)
throw new IllegalStateException("Can't find all poll items in displayItems");
List<StatusDisplayItem> pollItems=displayItems.subList(firstOptionIndex, footerIndex+1);
int prevSize=pollItems.size();
if(spoilerItem!=null){
spoilerFirstOptionIndex=spoilerItem.contentItems.indexOf(pollItems.get(0));
spoilerFooterIndex=spoilerItem.contentItems.indexOf(pollItems.get(pollItems.size()-1));
}
pollItems.clear();
StatusDisplayItem.buildPollItems(itemID, this, poll, status, pollItems);
if(spoilerItem!=null){
spoilerItem.contentItems.subList(spoilerFirstOptionIndex, spoilerFooterIndex+1).clear();
spoilerItem.contentItems.addAll(spoilerFirstOptionIndex, pollItems);
}
if(prevSize!=pollItems.size()){
adapter.notifyItemRangeRemoved(firstOptionIndex, prevSize);
adapter.notifyItemRangeInserted(firstOptionIndex, pollItems.size());
@@ -378,10 +627,13 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
public void onPollOptionClick(PollOptionStatusDisplayItem.Holder holder){
Poll poll=holder.getItem().poll;
Poll.Option option=holder.getItem().option;
if(poll.multiple){
// MEGALODON: always show vote button
// if(poll.multiple){
if(poll.selectedOptions==null)
poll.selectedOptions=new ArrayList<>();
if(poll.selectedOptions.contains(option)){
boolean optionContained=poll.selectedOptions.contains(option);
if(!poll.multiple) poll.selectedOptions.clear();
if(optionContained){
poll.selectedOptions.remove(option);
holder.itemView.setSelected(false);
}else{
@@ -390,6 +642,9 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder vh=list.getChildViewHolder(list.getChildAt(i));
if(!poll.multiple && vh instanceof PollOptionStatusDisplayItem.Holder item){
if(item!=holder) item.itemView.setSelected(false);
}
if(vh instanceof PollFooterStatusDisplayItem.Holder footer){
if(footer.getItemID().equals(holder.getItemID())){
footer.rebind();
@@ -397,9 +652,9 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
}
}
}else{
submitPollVote(holder.getItemID(), poll.id, Collections.singletonList(poll.options.indexOf(option)));
}
// }else{
// submitPollVote(holder.getItemID(), poll.id, Collections.singletonList(poll.options.indexOf(option)));
// }
}
public void onPollVoteButtonClick(PollFooterStatusDisplayItem.Holder holder){
@@ -407,6 +662,14 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
submitPollVote(holder.getItemID(), poll.id, poll.selectedOptions.stream().map(opt->poll.options.indexOf(opt)).collect(Collectors.toList()));
}
public void onPollViewResultsButtonClick(PollFooterStatusDisplayItem.Holder holder, boolean shown){
for(int i=0;i<list.getChildCount();i++){
if(list.getChildViewHolder(list.getChildAt(i)) instanceof PollOptionStatusDisplayItem.Holder item && item.getItemID().equals(holder.getItemID())){
item.showResults(shown);
}
}
}
protected void submitPollVote(String parentID, String pollID, List<Integer> choices){
if(refreshing)
return;
@@ -428,15 +691,39 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
public void onRevealSpoilerClick(SpoilerStatusDisplayItem.Holder holder){
Status status=holder.getItem().status;
toggleSpoiler(status, holder.getItemID());
boolean isForQuote=holder.getItem().isForQuote;
toggleSpoiler(status, isForQuote, holder.getItemID());
}
protected void toggleSpoiler(Status status, String itemID){
public void onVisibilityIconClick(HeaderStatusDisplayItem.Holder holder) {
Status status = holder.getItem().status;
if(holder.getItem().hasVisibilityToggle) holder.animateVisibilityToggle(false);
MediaGridStatusDisplayItem.Holder mediaGrid=findHolderOfType(holder.getItemID(), MediaGridStatusDisplayItem.Holder.class);
if(mediaGrid!=null){
if(!status.sensitiveRevealed) mediaGrid.revealSensitive();
else mediaGrid.hideSensitive();
}else{
status.sensitiveRevealed=false;
notifyItemChangedAfter(holder.getItem(), MediaGridStatusDisplayItem.class);
}
}
public void onSensitiveRevealed(MediaGridStatusDisplayItem.Holder holder) {
HeaderStatusDisplayItem.Holder header=findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class);
if(header!=null && header.getItem().hasVisibilityToggle) header.animateVisibilityToggle(true);
else notifyItemChangedBefore(holder.getItem(), HeaderStatusDisplayItem.class);
}
protected void toggleSpoiler(Status status, boolean isForQuote, String itemID){
status.spoilerRevealed=!status.spoilerRevealed;
SpoilerStatusDisplayItem.Holder spoiler=findHolderOfType(itemID, SpoilerStatusDisplayItem.Holder.class);
if(spoiler!=null)
spoiler.rebind();
SpoilerStatusDisplayItem spoilerItem=Objects.requireNonNull(findItemOfType(itemID, SpoilerStatusDisplayItem.class));
if (!status.spoilerRevealed && !AccountSessionManager.get(accountID).getLocalPreferences().revealCWs)
status.sensitiveRevealed = false;
List<SpoilerStatusDisplayItem.Holder> spoilers=findAllHoldersOfType(itemID, SpoilerStatusDisplayItem.Holder.class);
SpoilerStatusDisplayItem.Holder spoiler=spoilers.size() > 1 && isForQuote ? spoilers.get(1) : spoilers.get(0);
if(spoiler!=null) spoiler.rebind();
else notifyItemChanged(itemID, SpoilerStatusDisplayItem.class);
SpoilerStatusDisplayItem spoilerItem=Objects.requireNonNull(spoiler.getItem());
int index=displayItems.indexOf(spoilerItem);
if(status.spoilerRevealed){
@@ -446,11 +733,44 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
displayItems.subList(index+1, index+1+spoilerItem.contentItems.size()).clear();
adapter.notifyItemRangeRemoved(index+1, spoilerItem.contentItems.size());
}
notifyItemChanged(itemID, TextStatusDisplayItem.class);
HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class);
if(header!=null) header.rebind();
else notifyItemChanged(itemID, HeaderStatusDisplayItem.class);
list.invalidateItemDecorations();
}
public void onGapClick(GapStatusDisplayItem.Holder item){}
public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable) {
Status s=holder.getItem().status;
if(s.textExpandable!=expandable && list!=null) {
s.textExpandable=expandable;
HeaderStatusDisplayItem.Holder header=findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class);
if(header!=null) header.bindCollapseButton();
}
}
public void onToggleExpanded(Status status, String itemID) {
status.textExpanded = !status.textExpanded;
notifyItemChanged(itemID, TextStatusDisplayItem.class);
HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class);
if(header!=null) header.animateExpandToggle();
else notifyItemChanged(itemID, HeaderStatusDisplayItem.class);
}
public void onGapClick(GapStatusDisplayItem.Holder item, boolean downwards){}
public void onWarningClick(WarningFilteredStatusDisplayItem.Holder warning){
int startPos = warning.getAbsoluteAdapterPosition();
displayItems.remove(startPos);
displayItems.addAll(startPos, warning.filteredItems);
adapter.notifyItemRangeInserted(startPos, warning.filteredItems.size() - 1);
if (startPos == 0) scrollToTop();
warning.getItem().status.filterRevealed = true;
}
@Override
public String getAccountID(){
return accountID;
}
@@ -498,9 +818,61 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return null;
}
/**
* Use this as a fallback if findHolderOfType fails to find the ViewHolder.
* It might still be bound but off-screen and therefore not a child of the RecyclerView -
* resulting in the ViewHolder displaying an outdated state once scrolled back into view.
*/
protected <I extends StatusDisplayItem> int notifyItemChanged(String id, Class<I> type){
boolean encounteredParent=false;
for(int i=0; i<displayItems.size(); i++){
StatusDisplayItem item=displayItems.get(i);
boolean idEquals=id.equals(item.parentID);
if(!encounteredParent && idEquals) encounteredParent=true; // reached top of the parent
else if(encounteredParent && !idEquals) break; // passed by bottom of the parent. man muss ja wissen wann schluss is
if(idEquals && type.isInstance(item)){
adapter.notifyItemChanged(i);
return i;
}
}
return -1;
}
protected <I extends StatusDisplayItem> int notifyItemChangedAfter(StatusDisplayItem afterThis, Class<I> type){
int startIndex=displayItems.indexOf(afterThis);
if(startIndex == -1) throw new IllegalStateException("notifyItemChangedAfter didn't find the passed StatusDisplayItem");
String parentID=afterThis.parentID;
for(int i=startIndex; i<displayItems.size(); i++){
StatusDisplayItem item=displayItems.get(i);
if(!parentID.equals(item.parentID)) break; // didn't find anything
if(type.isInstance(item)){
// found it
adapter.notifyItemChanged(i);
return i;
}
}
return -1;
}
protected <I extends StatusDisplayItem> int notifyItemChangedBefore(StatusDisplayItem beforeThis, Class<I> type){
int startIndex=displayItems.indexOf(beforeThis);
if(startIndex == -1) throw new IllegalStateException("notifyItemChangedBefore didn't find the passed StatusDisplayItem");
String parentID=beforeThis.parentID;
for(int i=startIndex; i>=0; i--){
StatusDisplayItem item=displayItems.get(i);
if(!parentID.equals(item.parentID)) break; // didn't find anything
if(type.isInstance(item)){
// found it
adapter.notifyItemChanged(i);
return i;
}
}
return -1;
}
@Nullable
protected <I extends StatusDisplayItem, H extends StatusDisplayItem.Holder<I>> H findHolderOfType(String id, Class<H> type){
for(int i=0;i<list.getChildCount();i++){
for(int i=0; i<list.getChildCount(); i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof StatusDisplayItem.Holder<?> itemHolder && itemHolder.getItemID().equals(id) && type.isInstance(holder))
return type.cast(holder);
@@ -523,6 +895,11 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
smoothScrollRecyclerViewToTop(list);
}
@Override
public boolean isOnTop() {
return isRecyclerViewOnTop(list);
}
protected int getListWidthForMediaLayout(){
return list.getWidth();
}
@@ -569,14 +946,37 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
currentPhotoViewer.onPause();
}
public void onFabClick(View v){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), ComposeFragment.class, args);
}
public boolean onFabLongClick(View v) {
return UiUtils.pickAccountForCompose(getActivity(), accountID);
}
private MediaAttachmentViewController makeNewMediaAttachmentView(MediaGridStatusDisplayItem.GridItemType type){
return new MediaAttachmentViewController(getActivity(), type);
}
private PreviewlessMediaAttachmentViewController makeNewPreviewlessMediaAttachmentView(MediaGridStatusDisplayItem.GridItemType type){
return new PreviewlessMediaAttachmentViewController(getActivity(), type);
}
public TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, MediaAttachmentViewController> getAttachmentViewsPool(){
return attachmentViewsPool;
}
public TypedObjectPool<MediaGridStatusDisplayItem.GridItemType, PreviewlessMediaAttachmentViewController> getPreviewlessAttachmentViewsPool(){
return previewlessAttachmentViewsPool;
}
@Override
public void onProvideAssistContent(AssistContent assistContent) {
assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon()));
}
public void togglePostTranslation(Status status, String itemID){
switch(status.translationState){
case LOADING -> {
@@ -590,44 +990,64 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
status.translationState=Status.TranslationState.SHOWN;
}else{
status.translationState=Status.TranslationState.LOADING;
new TranslateStatus(status.getContentStatus().id, Locale.getDefault().getLanguage())
.setCallback(new Callback<>(){
Consumer<Translation> successCallback=(result)->{
status.translation=result;
status.translationState=Status.TranslationState.SHOWN;
updateTranslation(itemID);
};
MastodonAPIRequest<?> req=isInstanceAkkoma()
? new AkkomaTranslateStatus(status.getContentStatus().id, Locale.getDefault().getLanguage()).setCallback(new Callback<>(){
@Override
public void onSuccess(AkkomaTranslation result){
if(getActivity()!=null) successCallback.accept(result.toTranslation());
}
@Override
public void onError(ErrorResponse error){
if(getActivity()!=null) translationCallbackError(status, itemID);
}
})
: new TranslateStatus(status.getContentStatus().id, Locale.getDefault().getLanguage()).setCallback(new Callback<>(){
@Override
public void onSuccess(Translation result){
if(getActivity()==null)
return;
status.translation=result;
status.translationState=Status.TranslationState.SHOWN;
updateTranslation(itemID);
if(getActivity()!=null) successCallback.accept(result);
}
@Override
public void onError(ErrorResponse error){
if(getActivity()==null)
return;
status.translationState=Status.TranslationState.HIDDEN;
updateTranslation(itemID);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.error)
.setMessage(R.string.translation_failed)
.setPositiveButton(R.string.ok, null)
.show();
if(getActivity()!=null) translationCallbackError(status, itemID);
}
})
.exec(accountID);
});
// 1 minute
req.setTimeout(60000).exec(accountID);
}
}
}
updateTranslation(itemID);
}
private void translationCallbackError(Status status, String itemID) {
status.translationState=Status.TranslationState.HIDDEN;
updateTranslation(itemID);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.error)
.setMessage(R.string.translation_failed)
.setPositiveButton(R.string.ok, null)
.show();
}
private void updateTranslation(String itemID) {
TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class);
if(text!=null){
text.updateTranslation(true);
imgLoader.bindViewHolder((ImageLoaderRecyclerAdapter) list.getAdapter(), text, text.getAbsoluteAdapterPosition());
}else{
notifyItemChanged(itemID, TextStatusDisplayItem.class);
}
if(isInstanceAkkoma())
return;
SpoilerStatusDisplayItem.Holder spoiler=findHolderOfType(itemID, SpoilerStatusDisplayItem.Holder.class);
if(spoiler!=null){
spoiler.rebind();
@@ -638,6 +1058,11 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
media.rebind();
}
PreviewlessMediaGridStatusDisplayItem.Holder previewLessMedia=findHolderOfType(itemID, PreviewlessMediaGridStatusDisplayItem.Holder.class);
if (previewLessMedia!=null) {
previewLessMedia.rebind();
}
for(int i=0;i<list.getChildCount();i++){
if(list.getChildViewHolder(list.getChildAt(i)) instanceof PollOptionStatusDisplayItem.Holder item){
item.rebind();
@@ -675,6 +1100,20 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
protected void onModifyItemViewHolder(BindableViewHolder<StatusDisplayItem> holder){}
@Override
protected void onDataLoaded(List<T> d, boolean more) {
if(getContext()==null) return;
super.onDataLoaded(d, more);
// more available, but the page isn't even full yet? seems wrong, let's load some more
if(more && data.size() < itemsPerPage){
preloader.onScrolledToLastItem();
}
}
public void scrollBy(int x, int y) {
list.scrollBy(x, y);
}
protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{
public DisplayItemsAdapter(){
@@ -720,9 +1159,9 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
private Paint dividerPaint=new Paint();
{
dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OutlineVariant));
dividerPaint.setColor(UiUtils.getThemeColor(getActivity(), GlobalUserPreferences.showDividers ? R.attr.colorM3OutlineVariant : R.attr.colorM3Surface));
dividerPaint.setStyle(Paint.Style.STROKE);
dividerPaint.setStrokeWidth(V.dp(0.5f));
dividerPaint.setStrokeWidth(V.dp(1f));
}
@Override
@@ -745,7 +1184,8 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
// Do not draw dividers between hashtag and/or account rows
if((ih instanceof HashtagStatusDisplayItem.Holder || ih instanceof AccountStatusDisplayItem.Holder) && (sh instanceof HashtagStatusDisplayItem.Holder || sh instanceof AccountStatusDisplayItem.Holder))
return false;
return !ih.getItemID().equals(sh.getItemID()) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP;
if (!ih.getItem().isMainStatus && ih.getItem().hasDescendantNeighbor) return false;
return (!ih.getItemID().equals(sh.getItemID()) || sh instanceof ExtendedFooterStatusDisplayItem.Holder) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP;
}
return false;
}

View File

@@ -1,10 +1,12 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.net.Uri;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetBookmarkedStatuses;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.Status;
@@ -26,6 +28,7 @@ public class BookmarkedStatusListFragment extends StatusListFragment{
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Status> result){
if(getActivity()==null) return;
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
@@ -40,4 +43,14 @@ public class BookmarkedStatusListFragment extends StatusListFragment{
protected void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){
// no-op
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.ACCOUNT;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.path("/bookmarks").build();
}
}

View File

@@ -20,13 +20,15 @@ import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.ImageView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
import org.joinmastodon.android.ui.utils.ColorPalette;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.FixedAspectRatioImageView;
import java.util.Collections;
@@ -42,22 +44,24 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp
private String accountID, attachmentID;
private EditText edit;
private FixedAspectRatioImageView image;
private ImageView image;
private ContextThemeWrapper themeWrapper;
private PhotoViewer photoViewer;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
attachmentID=getArguments().getString("attachment");
setHasOptionsMenu(true);
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
accountID=getArguments().getString("account");
attachmentID=getArguments().getString("attachment");
themeWrapper=new ContextThemeWrapper(activity, R.style.Theme_Mastodon_Dark);
ColorPalette.palettes.get(AccountSessionManager.get(accountID).getLocalPreferences().getCurrentColor())
.apply(themeWrapper, GlobalUserPreferences.ThemePreference.DARK);
setTitle(R.string.add_alt_text);
}
@@ -75,7 +79,7 @@ public class ComposeImageDescriptionFragment extends MastodonToolbarFragment imp
int width=getArguments().getInt("width", 0);
int height=getArguments().getInt("height", 0);
if(width>0 && height>0){
image.setAspectRatio(Math.max(1f, (float)width/height));
// image.setAspectRatio(Math.max(1f, (float)width/height));
}
image.setOnClickListener(v->openPhotoViewer());
Uri uri=getArguments().getParcelable("uri");

View File

@@ -0,0 +1,97 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.net.Uri;
import android.view.Menu;
import android.view.MenuInflater;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
public class CustomLocalTimelineFragment extends PinnableStatusListFragment implements ProvidesAssistContent.ProvidesWebUri{
// private String name;
private String domain;
private String maxID;
@Override
protected boolean wantsComposeButton() {
return false;
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
domain=getArguments().getString("domain");
updateTitle(domain);
setHasOptionsMenu(true);
}
private void updateTitle(String domain) {
this.domain = domain;
setTitle(this.domain);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, null, count, null, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
if (getActivity() == null) return;
result=result.stream().filter(new StatusFilterPredicate(accountID, FilterContext.PUBLIC)).collect(Collectors.toList());
result.stream().forEach(status -> {
status.account.acct += "@"+domain;
status.mentions.forEach(mention -> mention.id = null);
status.isRemote = true;
});
onDataLoaded(result, !result.isEmpty());
}
})
.execNoAuth(domain);
}
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
loadData();
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.custom_local_timelines, menu);
super.onCreateOptionsMenu(menu, inflater);
UiUtils.enableOptionsMenuIcons(getContext(), menu, R.id.pin);
}
@Override
protected FilterContext getFilterContext() {
return null;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return Uri.parse(domain);
}
@Override
protected TimelineDefinition makeTimelineDefinition() {
return TimelineDefinition.ofCustomLocalTimeline(domain);
}
}

View File

@@ -0,0 +1,516 @@
package org.joinmastodon.android.fragments;
import static android.view.Menu.NONE;
import static com.hootsuite.nachos.terminator.ChipTerminatorHandler.BEHAVIOR_CHIPIFY_ALL;
import static org.joinmastodon.android.ui.utils.UiUtils.makeBackItem;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.Context;
import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.PopupMenu;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import com.hootsuite.nachos.NachoTextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.GetLists;
import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.CustomLocalTimeline;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.ListTimeline;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.TextInputFrameLayout;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class EditTimelinesFragment extends MastodonRecyclerFragment<TimelineDefinition> implements ScrollableToTop{
private String accountID;
private TimelinesAdapter adapter;
private final ItemTouchHelper itemTouchHelper;
private Menu optionsMenu;
private boolean updated;
private final Map<MenuItem, TimelineDefinition> timelineByMenuItem=new HashMap<>();
private final List<ListTimeline> listTimelines=new ArrayList<>();
private final List<Hashtag> hashtags=new ArrayList<>();
private MenuItem addHashtagItem;
private final List<CustomLocalTimeline> localTimelines = new ArrayList<>();
public EditTimelinesFragment(){
super(10);
ItemTouchHelper.SimpleCallback itemTouchCallback=new ItemTouchHelperCallback();
itemTouchHelper=new ItemTouchHelper(itemTouchCallback);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
setTitle(R.string.sk_timelines);
accountID=getArguments().getString("account");
new GetLists().setCallback(new Callback<>(){
@Override
public void onSuccess(List<ListTimeline> result){
listTimelines.addAll(result);
updateOptionsMenu();
}
@Override
public void onError(ErrorResponse error){
error.showToast(getContext());
}
}).exec(accountID);
new GetFollowedHashtags().setCallback(new Callback<>(){
@Override
public void onSuccess(HeaderPaginationList<Hashtag> result){
hashtags.addAll(result);
updateOptionsMenu();
}
@Override
public void onError(ErrorResponse error){
error.showToast(getContext());
}
}).exec(accountID);
}
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) loadData();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
itemTouchHelper.attachToRecyclerView(list);
refreshLayout.setEnabled(false);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16));
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
this.optionsMenu=menu;
updateOptionsMenu();
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
if(item.getItemId()==R.id.menu_back){
updateOptionsMenu();
optionsMenu.performIdentifierAction(R.id.menu_add_timeline, 0);
return true;
}
if (item.getItemId() == R.id.menu_add_local_timelines) {
addNewLocalTimeline();
return true;
}
TimelineDefinition tl = timelineByMenuItem.get(item);
if (tl != null) {
addTimeline(tl);
} else if (item == addHashtagItem) {
makeTimelineEditor(null, (hashtag) -> {
if (hashtag != null) addTimeline(hashtag);
}, null);
}
return true;
}
private void addTimeline(TimelineDefinition tl){
data.add(tl.copy());
adapter.notifyItemInserted(data.size());
saveTimelines();
updateOptionsMenu();
}
private void addNewLocalTimeline() {
FrameLayout inputWrap = new FrameLayout(getContext());
EditText input = new EditText(getContext());
input.setHint(R.string.sk_example_domain);
input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
params.setMargins(V.dp(16), V.dp(4), V.dp(16), V.dp(16));
input.setLayoutParams(params);
inputWrap.addView(input);
new M3AlertDialogBuilder(getContext()).setTitle(R.string.mo_add_custom_server_local_timeline).setView(inputWrap)
.setPositiveButton(R.string.save, (d, which) -> {
TimelineDefinition tl = TimelineDefinition.ofCustomLocalTimeline(input.getText().toString().trim());
data.add(tl);
saveTimelines();
})
.setNegativeButton(R.string.cancel, (d, which) -> {
})
.show();
}
private void addTimelineToOptions(TimelineDefinition tl, Menu menu) {
if (data.contains(tl)) return;
MenuItem item = addOptionsItem(menu, tl.getTitle(getContext()), tl.getIcon().iconRes);
timelineByMenuItem.put(item, tl);
}
private MenuItem addOptionsItem(Menu menu, String name, @DrawableRes int icon){
MenuItem item=menu.add(0, View.generateViewId(), Menu.NONE, name);
item.setIcon(icon);
return item;
}
private void updateOptionsMenu(){
if(getActivity()==null) return;
optionsMenu.clear();
timelineByMenuItem.clear();
SubMenu menu=optionsMenu.addSubMenu(0, R.id.menu_add_timeline, NONE, R.string.sk_timelines_add);
menu.getItem().setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
menu.getItem().setIcon(R.drawable.ic_fluent_add_24_regular);
SubMenu timelinesMenu=menu.addSubMenu(R.string.sk_timeline);
timelinesMenu.getItem().setIcon(R.drawable.ic_fluent_timeline_24_regular);
SubMenu listsMenu=menu.addSubMenu(R.string.sk_list);
listsMenu.getItem().setIcon(R.drawable.ic_fluent_people_24_regular);
SubMenu hashtagsMenu=menu.addSubMenu(R.string.sk_hashtag);
hashtagsMenu.getItem().setIcon(R.drawable.ic_fluent_number_symbol_24_regular);
MenuItem addLocalTimelines = menu.add(0, R.id.menu_add_local_timelines, NONE, R.string.local_timeline);
addLocalTimelines.setIcon(R.drawable.ic_fluent_add_24_regular);
makeBackItem(timelinesMenu);
makeBackItem(listsMenu);
makeBackItem(hashtagsMenu);
TimelineDefinition.getAllTimelines(accountID).stream().forEach(tl->addTimelineToOptions(tl, timelinesMenu));
listTimelines.stream().map(TimelineDefinition::ofList).forEach(tl->addTimelineToOptions(tl, listsMenu));
addHashtagItem=addOptionsItem(hashtagsMenu, getContext().getString(R.string.sk_timelines_add), R.drawable.ic_fluent_add_24_regular);
hashtags.stream().map(TimelineDefinition::ofHashtag).forEach(tl->addTimelineToOptions(tl, hashtagsMenu));
timelinesMenu.getItem().setVisible(timelinesMenu.size()>0);
listsMenu.getItem().setVisible(listsMenu.size()>0);
hashtagsMenu.getItem().setVisible(hashtagsMenu.size()>0);
UiUtils.enableOptionsMenuIcons(getContext(), optionsMenu, R.id.menu_add_timeline);
}
private void saveTimelines(){
updated=true;
AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences();
if(data.isEmpty()) data.add(TimelineDefinition.HOME_TIMELINE);
prefs.timelines=data;
prefs.save();
}
private void removeTimeline(int position){
data.remove(position);
adapter.notifyItemRemoved(position);
saveTimelines();
updateOptionsMenu();
}
@Override
protected void doLoadData(int offset, int count){
onDataLoaded(AccountSessionManager.get(accountID).getLocalPreferences().timelines);
updateOptionsMenu();
}
@Override
protected RecyclerView.Adapter<TimelineViewHolder> getAdapter(){
return adapter=new TimelinesAdapter();
}
@Override
public void scrollToTop(){
smoothScrollRecyclerViewToTop(list);
}
@Override
public void onDestroy(){
super.onDestroy();
if(updated) UiUtils.restartApp();
}
private boolean setTagListContent(NachoTextView editText, @Nullable List<String> tags){
if(tags==null || tags.isEmpty()) return false;
editText.setText(tags);
editText.chipifyAllUnterminatedTokens();
return true;
}
private NachoTextView prepareChipTextView(NachoTextView nacho){
//Ill Be Back
nacho.setChipTerminators(
Map.of(
',', BEHAVIOR_CHIPIFY_ALL,
'\n', BEHAVIOR_CHIPIFY_ALL,
' ', BEHAVIOR_CHIPIFY_ALL,
';', BEHAVIOR_CHIPIFY_ALL
)
);
nacho.enableEditChipOnTouch(true, true);
nacho.setOnFocusChangeListener((v, hasFocus)->nacho.chipifyAllUnterminatedTokens());
return nacho;
}
@SuppressLint("ClickableViewAccessibility")
protected void makeTimelineEditor(@Nullable TimelineDefinition item, Consumer<TimelineDefinition> onSave, Runnable onRemove){
Context ctx=getContext();
View view=getActivity().getLayoutInflater().inflate(R.layout.edit_timeline, list, false);
View divider=view.findViewById(R.id.divider);
Button advancedBtn=view.findViewById(R.id.advanced);
EditText editText=view.findViewById(R.id.input);
if(item!=null) editText.setText(item.getCustomTitle());
editText.setHint(item!=null ? item.getDefaultTitle(ctx) : ctx.getString(R.string.sk_hashtag));
LinearLayout tagWrap=view.findViewById(R.id.tag_wrap);
boolean hashtagOptionsAvailable=item==null || item.getType()==TimelineDefinition.TimelineType.HASHTAG;
advancedBtn.setVisibility(hashtagOptionsAvailable ? View.VISIBLE : View.GONE);
advancedBtn.setOnClickListener(l->{
advancedBtn.setSelected(!advancedBtn.isSelected());
advancedBtn.setText(advancedBtn.isSelected() ? R.string.sk_advanced_options_hide : R.string.sk_advanced_options_show);
divider.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE);
tagWrap.setVisibility(advancedBtn.isSelected() ? View.VISIBLE : View.GONE);
UiUtils.beginLayoutTransition((ViewGroup) view);
});
Switch localOnlySwitch=view.findViewById(R.id.local_only_switch);
view.findViewById(R.id.local_only).setOnClickListener(l->localOnlySwitch.setChecked(!localOnlySwitch.isChecked()));
EditText tagMain=view.findViewById(R.id.tag_main);
NachoTextView tagsAny=prepareChipTextView(view.findViewById(R.id.tags_any));
NachoTextView tagsAll=prepareChipTextView(view.findViewById(R.id.tags_all));
NachoTextView tagsNone=prepareChipTextView(view.findViewById(R.id.tags_none));
if(item!=null && hashtagOptionsAvailable){
tagMain.setText(item.getHashtagName());
boolean hasAdvanced=!TextUtils.isEmpty(item.getCustomTitle()) && !Objects.equals(item.getHashtagName(), item.getCustomTitle());
hasAdvanced=setTagListContent(tagsAny, item.getHashtagAny()) || hasAdvanced;
hasAdvanced=setTagListContent(tagsAll, item.getHashtagAll()) || hasAdvanced;
hasAdvanced=setTagListContent(tagsNone, item.getHashtagNone()) || hasAdvanced;
if(item.isHashtagLocalOnly()){
localOnlySwitch.setChecked(true);
hasAdvanced=true;
}
if(hasAdvanced){
advancedBtn.setSelected(true);
advancedBtn.setText(R.string.sk_advanced_options_hide);
tagWrap.setVisibility(View.VISIBLE);
divider.setVisibility(View.VISIBLE);
}
}
ImageButton btn=view.findViewById(R.id.button);
PopupMenu popup=new PopupMenu(ctx, btn);
TimelineDefinition.Icon currentIcon=item!=null ? item.getIcon() : TimelineDefinition.Icon.HASHTAG;
btn.setImageResource(currentIcon.iconRes);
btn.setTag(currentIcon.ordinal());
btn.setContentDescription(ctx.getString(currentIcon.nameRes));
btn.setOnTouchListener(popup.getDragToOpenListener());
btn.setOnClickListener(l->popup.show());
Menu menu=popup.getMenu();
TimelineDefinition.Icon defaultIcon=item!=null ? item.getDefaultIcon() : TimelineDefinition.Icon.HASHTAG;
menu.add(0, currentIcon.ordinal(), NONE, currentIcon.nameRes).setIcon(currentIcon.iconRes);
if(!currentIcon.equals(defaultIcon)){
menu.add(0, defaultIcon.ordinal(), NONE, defaultIcon.nameRes).setIcon(defaultIcon.iconRes);
}
for(TimelineDefinition.Icon icon : TimelineDefinition.Icon.values()){
if(icon.hidden || icon.ordinal()==(int) btn.getTag()) continue;
menu.add(0, icon.ordinal(), NONE, icon.nameRes).setIcon(icon.iconRes);
}
UiUtils.enablePopupMenuIcons(ctx, popup);
popup.setOnMenuItemClickListener(menuItem->{
TimelineDefinition.Icon icon=TimelineDefinition.Icon.values()[menuItem.getItemId()];
btn.setImageResource(icon.iconRes);
btn.setTag(menuItem.getItemId());
btn.setContentDescription(ctx.getString(icon.nameRes));
return true;
});
AlertDialog.Builder builder=new M3AlertDialogBuilder(ctx)
.setTitle(item==null ? R.string.sk_add_timeline : R.string.sk_edit_timeline)
.setView(view)
.setPositiveButton(R.string.save, (d, which)->{
String name=editText.getText().toString().trim();
String mainHashtag=tagMain.getText().toString().trim();
if(item != null && item.getType()==TimelineDefinition.TimelineType.HASHTAG){
tagsAny.chipifyAllUnterminatedTokens();
tagsAll.chipifyAllUnterminatedTokens();
tagsNone.chipifyAllUnterminatedTokens();
if(TextUtils.isEmpty(mainHashtag)){
mainHashtag=name;
name=null;
}
if(TextUtils.isEmpty(mainHashtag) && (item!=null && item.getType()==TimelineDefinition.TimelineType.HASHTAG)){
Toast.makeText(ctx, R.string.sk_add_timeline_tag_error_empty, Toast.LENGTH_SHORT).show();
onSave.accept(null);
return;
}
}
TimelineDefinition tl=item!=null ? item : TimelineDefinition.ofHashtag(name);
TimelineDefinition.Icon icon=TimelineDefinition.Icon.values()[(int) btn.getTag()];
tl.setIcon(icon);
tl.setTitle(name);
if(item == null || item.getType()==TimelineDefinition.TimelineType.HASHTAG){
tl.setTagOptions(
mainHashtag,
tagsAny.getChipValues(),
tagsAll.getChipValues(),
tagsNone.getChipValues(),
localOnlySwitch.isChecked()
);
}
onSave.accept(tl);
})
.setNegativeButton(R.string.cancel, (d, which)->{});
if(onRemove!=null) builder.setNeutralButton(R.string.sk_remove, (d, which)->onRemove.run());
builder.show();
btn.requestFocus();
}
private class TimelinesAdapter extends RecyclerView.Adapter<TimelineViewHolder>{
@NonNull
@Override
public TimelineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new TimelineViewHolder();
}
@Override
public void onBindViewHolder(@NonNull TimelineViewHolder holder, int position){
holder.bind(data.get(position));
}
@Override
public int getItemCount(){
return data.size();
}
}
private class TimelineViewHolder extends BindableViewHolder<TimelineDefinition> implements UsableRecyclerView.Clickable{
private final TextView title;
private final ImageView dragger;
public TimelineViewHolder(){
super(getActivity(), R.layout.item_text, list);
title=findViewById(R.id.title);
dragger=findViewById(R.id.dragger_thingy);
}
@SuppressLint("ClickableViewAccessibility")
@Override
public void onBind(TimelineDefinition item){
title.setText(item.getTitle(getContext()));
title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(item.getIcon().iconRes), null, null, null);
dragger.setVisibility(View.VISIBLE);
dragger.setOnTouchListener((View v, MotionEvent event)->{
if(event.getAction()==MotionEvent.ACTION_DOWN){
itemTouchHelper.startDrag(this);
return true;
}
return false;
});
}
private void onSave(TimelineDefinition tl){
saveTimelines();
rebind();
}
private void onRemove(){
removeTimeline(getAbsoluteAdapterPosition());
}
@SuppressLint("ClickableViewAccessibility")
@Override
public void onClick(){
makeTimelineEditor(item, this::onSave, this::onRemove);
}
}
private class ItemTouchHelperCallback extends ItemTouchHelper.SimpleCallback{
public ItemTouchHelperCallback(){
super(ItemTouchHelper.UP|ItemTouchHelper.DOWN, ItemTouchHelper.LEFT|ItemTouchHelper.RIGHT);
}
@Override
public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target){
int fromPosition=viewHolder.getAbsoluteAdapterPosition();
int toPosition=target.getAbsoluteAdapterPosition();
if(Math.max(fromPosition, toPosition)>=data.size() || Math.min(fromPosition, toPosition)<0){
return false;
}else{
Collections.swap(data, fromPosition, toPosition);
adapter.notifyItemMoved(fromPosition, toPosition);
saveTimelines();
return true;
}
}
@Override
public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState){
if(actionState==ItemTouchHelper.ACTION_STATE_DRAG && viewHolder!=null){
viewHolder.itemView.animate().alpha(0.65f);
}
}
@Override
public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder){
super.clearView(recyclerView, viewHolder);
viewHolder.itemView.animate().alpha(1f);
}
@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction){
int position=viewHolder.getAbsoluteAdapterPosition();
removeTimeline(position);
}
}
}

View File

@@ -1,9 +1,11 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.net.Uri;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetFavoritedStatuses;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.Status;
@@ -25,6 +27,7 @@ public class FavoritedStatusListFragment extends StatusListFragment{
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Status> result){
if(getActivity()==null) return;
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
@@ -34,4 +37,16 @@ public class FavoritedStatusListFragment extends StatusListFragment{
})
.exec(accountID);
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.ACCOUNT;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.encodedPath(isInstanceAkkoma()
? '/' + getSession().self.username + "#favorites"
: "/favourites").build();
}
}

View File

@@ -2,6 +2,7 @@ package org.joinmastodon.android.fragments;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
@@ -55,4 +56,9 @@ public class FeaturedHashtagsListFragment extends BaseStatusListFragment<Hashtag
protected void drawDivider(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder, RecyclerView parent, Canvas c, Paint paint){
// no-op
}
@Override
public Uri getWebUri(Uri.Builder base){
return null; // TODO
}
}

View File

@@ -0,0 +1,375 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.graphics.Rect;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.accounts.GetFollowRequests;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ProgressBarButton;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.parceler.Parcels;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class FollowRequestsListFragment extends MastodonRecyclerFragment<FollowRequestsListFragment.AccountWrapper> implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri {
private String accountID;
private Map<String, Relationship> relationships=Collections.emptyMap();
private GetAccountRelationships relationshipsRequest;
private String nextMaxID;
public FollowRequestsListFragment(){
super(20);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
loadData();
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
setTitle(R.string.sk_follow_requests);
}
@Override
protected void doLoadData(int offset, int count){
if(relationshipsRequest!=null){
relationshipsRequest.cancel();
relationshipsRequest=null;
}
currentRequest=new GetFollowRequests(offset==0 ? null : nextMaxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
if(getActivity()==null) return;
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result.stream().map(AccountWrapper::new).collect(Collectors.toList()), false);
loadRelationships();
}
})
.exec(accountID);
}
@Override
protected RecyclerView.Adapter getAdapter(){
return new AccountsAdapter();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
outRect.bottom=outRect.left=outRect.right=V.dp(16);
if(parent.getChildAdapterPosition(view)==0)
outRect.top=V.dp(16);
}
});
((UsableRecyclerView)list).setDrawSelectorOnTop(true);
}
private void loadRelationships(){
relationships=Collections.emptyMap();
relationshipsRequest=new GetAccountRelationships(data.stream().map(fs->fs.account.id).collect(Collectors.toList()));
relationshipsRequest.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Relationship> result){
relationshipsRequest=null;
relationships=result.stream().collect(Collectors.toMap(rel->rel.id, Function.identity()));
if(list==null)
return;
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof AccountViewHolder avh)
avh.rebind();
}
}
@Override
public void onError(ErrorResponse error){
relationshipsRequest=null;
}
}).exec(accountID);
}
@Override
public void onDestroyView(){
super.onDestroyView();
if(relationshipsRequest!=null){
relationshipsRequest.cancel();
relationshipsRequest=null;
}
}
@Override
public void scrollToTop(){
smoothScrollRecyclerViewToTop(list);
}
@Override
public String getAccountID() {
return accountID;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.path(isInstanceAkkoma() ? "/friend-requests" : "/follow_requests").build();
}
private class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
public AccountsAdapter(){
super(imgLoader);
}
@Override
public void onBindViewHolder(AccountViewHolder holder, int position){
holder.bind(data.get(position));
super.onBindViewHolder(holder, position);
}
@NonNull
@Override
public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new AccountViewHolder();
}
@Override
public int getItemCount(){
return data.size();
}
@Override
public int getImageCountForItem(int position){
return 2+data.get(position).emojiHelper.getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
AccountWrapper item=data.get(position);
if(image==0)
return item.avaRequest;
else if(image==1)
return item.coverRequest;
else
return item.emojiHelper.getImageRequest(image-2);
}
}
// literally the same as AccountCardStatusDisplayItem and DiscoverAccountsFragment. code should be generalized
private class AccountViewHolder extends BindableViewHolder<AccountWrapper> implements ImageLoaderViewHolder, UsableRecyclerView.DisableableClickable{
private final ImageView cover, avatar;
private final TextView name, username, bio, followersCount, followingCount, postsCount, followersLabel, followingLabel, postsLabel;
private final ProgressBarButton actionButton, acceptButton, rejectButton;
private final ProgressBar actionProgress, acceptProgress, rejectProgress;
private final View actionWrap, acceptWrap, rejectWrap;
private Relationship relationship;
public AccountViewHolder(){
super(getActivity(), R.layout.item_discover_account, list);
cover=findViewById(R.id.cover);
avatar=findViewById(R.id.avatar);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
bio=findViewById(R.id.bio);
followersCount=findViewById(R.id.followers_count);
followersLabel=findViewById(R.id.followers_label);
followingCount=findViewById(R.id.following_count);
followingLabel=findViewById(R.id.following_label);
postsCount=findViewById(R.id.posts_count);
postsLabel=findViewById(R.id.posts_label);
actionButton=findViewById(R.id.action_btn);
actionProgress=findViewById(R.id.action_progress);
actionWrap=findViewById(R.id.action_btn_wrap);
acceptButton=findViewById(R.id.accept_btn);
acceptProgress=findViewById(R.id.accept_progress);
acceptWrap=findViewById(R.id.accept_btn_wrap);
rejectButton=findViewById(R.id.reject_btn);
rejectProgress=findViewById(R.id.reject_progress);
rejectWrap=findViewById(R.id.reject_btn_wrap);
avatar.setOutlineProvider(OutlineProviders.roundedRect(15));
avatar.setClipToOutline(true);
View border=findViewById(R.id.avatar_border);
border.setOutlineProvider(OutlineProviders.roundedRect(17));
border.setClipToOutline(true);
cover.setOutlineProvider(OutlineProviders.roundedRect(9));
cover.setClipToOutline(true);
itemView.setOutlineProvider(OutlineProviders.roundedRect(12));
itemView.setClipToOutline(true);
actionButton.setOnClickListener(this::onActionButtonClick);
acceptButton.setOnClickListener(this::onFollowRequestButtonClick);
rejectButton.setOnClickListener(this::onFollowRequestButtonClick);
itemView.setOnClickListener(v->this.onClick());
}
@Override
public boolean isEnabled(){
return false;
}
@Override
public void onBind(AccountWrapper item){
name.setText(item.parsedName);
username.setText('@'+item.account.acct);
bio.setText(item.parsedBio);
followersCount.setText(UiUtils.abbreviateNumber(item.account.followersCount));
followingCount.setText(UiUtils.abbreviateNumber(item.account.followingCount));
postsCount.setText(UiUtils.abbreviateNumber(item.account.statusesCount));
followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, item.account.followersCount)));
followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, item.account.followingCount)));
postsLabel.setText(getResources().getQuantityString(R.plurals.sk_posts_count_label, (int)(item.account.statusesCount%1000), item.account.statusesCount));
followersCount.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followersLabel.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followingCount.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE);
followingLabel.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE);
relationship=relationships.get(item.account.id);
UiUtils.setExtraTextInfo(getContext(), null, true, false, false, item.account);
if(relationship==null || !relationship.followedBy){
actionWrap.setVisibility(View.GONE);
acceptWrap.setVisibility(View.VISIBLE);
rejectWrap.setVisibility(View.VISIBLE);
acceptButton.setCompoundDrawableTintList(acceptButton.getTextColors());
acceptProgress.setIndeterminateTintList(acceptButton.getTextColors());
rejectButton.setCompoundDrawableTintList(rejectButton.getTextColors());
rejectProgress.setIndeterminateTintList(rejectButton.getTextColors());
}else{
actionWrap.setVisibility(View.VISIBLE);
acceptWrap.setVisibility(View.GONE);
rejectWrap.setVisibility(View.GONE);
UiUtils.setRelationshipToActionButtonM3(relationship, actionButton);
}
}
@Override
public void setImage(int index, Drawable image){
if(index==0){
avatar.setImageDrawable(image);
}else if(index==1){
cover.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-2, image);
name.invalidate();
bio.invalidate();
}
if(image instanceof Animatable a && !a.isRunning())
a.start();
}
@Override
public void clearImage(int index){
setImage(index, null);
}
@Override
public void onClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(item.account));
Nav.go(getActivity(), ProfileFragment.class, args);
}
private void onFollowRequestButtonClick(View v) {
itemView.setHasTransientState(true);
UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, accountID, null, v == acceptButton, relationship, rel -> {
if(getContext()==null) return;
itemView.setHasTransientState(false);
relationships.put(item.account.id, rel);
RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter = getBindingAdapter();
if (!rel.requested && !rel.followedBy && adapter != null) {
data.remove(item);
adapter.notifyItemRemoved(getLayoutPosition());
} else {
rebind();
}
});
}
private void onActionButtonClick(View v){
itemView.setHasTransientState(true);
UiUtils.performAccountAction(getActivity(), item.account, accountID, relationship, actionButton, this::setActionProgressVisible, rel->{
if(getContext()==null) return;
itemView.setHasTransientState(false);
relationships.put(item.account.id, rel);
rebind();
});
}
private void setActionProgressVisible(boolean visible){
actionButton.setTextVisible(!visible);
actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE);
actionButton.setClickable(!visible);
}
}
protected class AccountWrapper{
public Account account;
public ImageLoaderRequest avaRequest, coverRequest;
public CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
public CharSequence parsedName, parsedBio;
public AccountWrapper(Account account){
this.account=account;
avaRequest=new UrlImageLoaderRequest(
TextUtils.isEmpty(account.avatar) ? AccountSessionManager.get(getAccountID()).getDefaultAvatarUrl() : account.avatar,
V.dp(50), V.dp(50));
if(!TextUtils.isEmpty(account.header))
coverRequest=new UrlImageLoaderRequest(account.header, 1000, 1000);
parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
if(account.emojis.isEmpty()){
parsedName= account.getDisplayName();
}else{
parsedName=HtmlParser.parseCustomEmoji(account.getDisplayName(), account.emojis);
emojiHelper.setText(new SpannableStringBuilder(parsedName).append(parsedBio));
}
}
}
}

View File

@@ -0,0 +1,128 @@
package org.joinmastodon.android.fragments;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.views.UsableRecyclerView;
public class FollowedHashtagsFragment extends MastodonRecyclerFragment<Hashtag> implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri {
private String nextMaxID;
private String accountID;
public FollowedHashtagsFragment() {
super(20);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Bundle args=getArguments();
accountID=args.getString("account");
setTitle(R.string.sk_hashtags_you_follow);
}
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
loadData();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16));
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetFollowedHashtags(offset==0 ? null : nextMaxID, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Hashtag> result){
if(getActivity()==null) return;
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result, nextMaxID!=null);
}
})
.exec(accountID);
}
@Override
protected RecyclerView.Adapter getAdapter() {
return new HashtagsAdapter();
}
@Override
public void scrollToTop() {
smoothScrollRecyclerViewToTop(list);
}
@Override
public String getAccountID() {
return accountID;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return isInstanceAkkoma() ? null : base.path("/followed_tags").build();
}
private class HashtagsAdapter extends RecyclerView.Adapter<HashtagViewHolder>{
@NonNull
@Override
public HashtagViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new HashtagViewHolder();
}
@Override
public void onBindViewHolder(@NonNull HashtagViewHolder holder, int position) {
holder.bind(data.get(position));
}
@Override
public int getItemCount() {
return data.size();
}
}
private class HashtagViewHolder extends BindableViewHolder<Hashtag> implements UsableRecyclerView.Clickable{
private final TextView title;
public HashtagViewHolder(){
super(getActivity(), R.layout.item_text, list);
title=findViewById(R.id.title);
}
@Override
public void onBind(Hashtag item) {
title.setText(item.name);
title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(R.drawable.ic_fluent_number_symbol_24_regular), null, null, null);
}
@Override
public void onClick() {
UiUtils.openHashtagTimeline(getActivity(), accountID, item.name);
}
}
}

View File

@@ -0,0 +1,32 @@
package org.joinmastodon.android.fragments;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Instance;
import java.util.Optional;
public interface HasAccountID {
String getAccountID();
default AccountSession getSession() {
return AccountSessionManager.getInstance().getAccount(getAccountID());
}
default boolean isInstanceAkkoma() {
return getInstance().map(Instance::isAkkoma).orElse(false);
}
default boolean isInstancePixelfed() {
return getInstance().map(Instance::isPixelfed).orElse(false);
}
default Optional<Instance> getInstance() {
return getSession().getInstance();
}
default AccountLocalPreferences getLocalPrefs() {
return AccountSessionManager.get(getAccountID()).getLocalPreferences();
}
}

View File

@@ -0,0 +1,7 @@
package org.joinmastodon.android.fragments;
import org.joinmastodon.android.utils.ElevationOnScrollListener;
public interface HasElevationOnScrollListener {
ElevationOnScrollListener getElevationOnScrollListener();
}

View File

@@ -0,0 +1,10 @@
package org.joinmastodon.android.fragments;
import android.view.View;
public interface HasFab {
View getFab();
void showFab();
void hideFab();
boolean isScrolling();
}

View File

@@ -2,8 +2,10 @@ package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.content.res.TypedArray;
import android.net.Uri;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.view.HapticFeedbackConstants;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
@@ -12,52 +14,82 @@ import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.requests.filters.CreateFilter;
import org.joinmastodon.android.api.requests.filters.DeleteFilter;
import org.joinmastodon.android.api.requests.filters.GetFilters;
import org.joinmastodon.android.api.requests.tags.GetTag;
import org.joinmastodon.android.api.requests.tags.SetTagFollowed;
import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterKeyword;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.text.SpacerSpan;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ProgressBarButton;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class HashtagTimelineFragment extends StatusListFragment{
public class HashtagTimelineFragment extends PinnableStatusListFragment{
private Hashtag hashtag;
private String hashtagName;
private ImageButton fab;
private TextView headerTitle, headerSubtitle;
private ProgressBarButton followButton;
private ProgressBar followProgress;
private MenuItem followMenuItem;
private MenuItem followMenuItem, pinMenuItem, muteMenuItem;
private boolean followRequestRunning;
private boolean toolbarContentVisible;
private String maxID;
public HashtagTimelineFragment(){
setListLayoutId(R.layout.recycler_fragment_with_fab);
private List<String> any;
private List<String> all;
private List<String> none;
private boolean following;
private boolean localOnly;
private Menu optionsMenu;
private MenuInflater optionsMenuInflater;
private Optional<Filter> filter = Optional.empty();
@Override
protected boolean wantsComposeButton() {
return true;
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
following=getArguments().getBoolean("following", false);
localOnly=getArguments().getBoolean("localOnly", false);
any=getArguments().getStringArrayList("any");
all=getArguments().getStringArrayList("all");
none=getArguments().getStringArrayList("none");
if(getArguments().containsKey("hashtag")){
hashtag=Parcels.unwrap(getArguments().getParcelable("hashtag"));
hashtagName=hashtag.name;
@@ -68,16 +100,71 @@ public class HashtagTimelineFragment extends StatusListFragment{
setHasOptionsMenu(true);
}
private void updateMuteState(boolean newMute) {
muteMenuItem.setTitle(getString(newMute ? R.string.unmute_user : R.string.mute_user, "#" + hashtag));
muteMenuItem.setIcon(newMute ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_off_24_regular);
}
private void showMuteDialog(boolean mute) {
UiUtils.showConfirmationAlert(getContext(),
mute ? R.string.mo_unmute_hashtag : R.string.mo_mute_hashtag,
mute ? R.string.mo_confirm_to_unmute_hashtag : R.string.mo_confirm_to_mute_hashtag,
mute ? R.string.do_unmute : R.string.do_mute,
mute ? R.drawable.ic_fluent_speaker_2_28_regular : R.drawable.ic_fluent_speaker_off_28_regular,
mute ? this::unmuteHashtag : this::muteHashtag
);
}
private void unmuteHashtag() {
//safe to get, this only called if filter is present
new DeleteFilter(filter.get().id).setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
filter=Optional.empty();
updateMuteState(false);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getContext());
}
}).exec(accountID);
}
private void muteHashtag() {
FilterKeyword hashtagFilter=new FilterKeyword();
hashtagFilter.wholeWord=true;
hashtagFilter.keyword="#"+hashtagName;
new CreateFilter("#"+hashtagName, EnumSet.of(FilterContext.HOME), FilterAction.HIDE, 0 , List.of(hashtagFilter)).setCallback(new Callback<>(){
@Override
public void onSuccess(Filter result){
filter=Optional.of(result);
updateMuteState(true);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getContext());
}
}).exec(accountID);
}
@Override
protected TimelineDefinition makeTimelineDefinition() {
return TimelineDefinition.ofHashtag(hashtagName);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetHashtagTimeline(hashtagName, offset==0 ? null : maxID, null, count)
currentRequest=new GetHashtagTimeline(hashtagName, getMaxID(), null, count, any, all, none, localOnly, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
onDataLoaded(result, !result.isEmpty());
if(getActivity()==null) return;
boolean more=applyMaxID(result);
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext());
onDataLoaded(result, more);
}
})
.exec(accountID);
@@ -102,6 +189,8 @@ public class HashtagTimelineFragment extends StatusListFragment{
fab=view.findViewById(R.id.fab);
fab.setOnClickListener(this::onFabClick);
if(getParentFragment() instanceof HomeTabFragment) return;
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
@@ -112,14 +201,19 @@ public class HashtagTimelineFragment extends StatusListFragment{
boolean newToolbarVisibility=newAlpha>0.5f;
if(newToolbarVisibility!=toolbarContentVisible){
toolbarContentVisible=newToolbarVisibility;
if(followMenuItem!=null)
followMenuItem.setVisible(toolbarContentVisible);
createOptionsMenu();
}
}
});
}
private void onFabClick(View v){
@Override
public boolean onFabLongClick(View v) {
return UiUtils.pickAccountForCompose(getActivity(), accountID, '#'+hashtagName+' ');
}
@Override
public void onFabClick(View v){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putString("prefilledText", '#'+hashtagName+' ');
@@ -131,6 +225,16 @@ public class HashtagTimelineFragment extends StatusListFragment{
((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+inset;
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.PUBLIC;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.path((isInstanceAkkoma() ? "/tag/" : "/tags/") + hashtag).build();
}
@Override
protected RecyclerView.Adapter getAdapter(){
View header=getActivity().getLayoutInflater().inflate(R.layout.header_hashtag_timeline, list, false);
@@ -146,10 +250,33 @@ public class HashtagTimelineFragment extends StatusListFragment{
return;
setFollowed(!hashtag.following);
});
followButton.setOnLongClickListener(v->{
if(hashtag==null) return false;
UiUtils.pickAccount(getActivity(), accountID, R.string.sk_follow_as, R.drawable.ic_fluent_person_add_28_regular, session -> {
new SetTagFollowed(hashtagName, true).setCallback(new Callback<>(){
@Override
public void onSuccess(Hashtag hashtag) {
Toast.makeText(
getActivity(),
getString(R.string.sk_followed_as, session.self.getShortUsername()),
Toast.LENGTH_SHORT
).show();
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getActivity());
}
}).exec(session.getID());
}, null);
return true;
});
updateHeader();
MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(header));
if(!(getParentFragment() instanceof HomeTabFragment)){
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(header));
}
mergeAdapter.addAdapter(super.getAdapter());
return mergeAdapter;
}
@@ -159,16 +286,55 @@ public class HashtagTimelineFragment extends StatusListFragment{
return 1;
}
private void createOptionsMenu(){
optionsMenu.clear();
optionsMenuInflater.inflate(R.menu.hashtag_timeline, optionsMenu);
followMenuItem=optionsMenu.findItem(R.id.follow_hashtag);
pinMenuItem=optionsMenu.findItem(R.id.pin);
followMenuItem.setVisible(toolbarContentVisible);
// pinMenuItem.setShowAsAction(toolbarContentVisible ? MenuItem.SHOW_AS_ACTION_NEVER : MenuItem.SHOW_AS_ACTION_ALWAYS);
super.updatePinButton(pinMenuItem);
muteMenuItem = optionsMenu.findItem(R.id.mute_hashtag);
updateMuteState(filter.isPresent());
new GetFilters().setCallback(new Callback<>() {
@Override
public void onSuccess(List<Filter> filters) {
if (getActivity() == null) return;
filter=filters.stream().filter(filter->filter.title.equals("#"+hashtagName)).findAny();
updateMuteState(filter.isPresent());
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getActivity());
}
}).exec(accountID);
}
@Override
public void updatePinButton(MenuItem pin){
super.updatePinButton(pin);
if(toolbarContentVisible) UiUtils.insetPopupMenuIcon(getContext(), pin);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
followMenuItem=menu.add(getString(hashtag!=null && hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName));
followMenuItem.setVisible(toolbarContentVisible);
inflater.inflate(R.menu.hashtag_timeline, menu);
super.onCreateOptionsMenu(menu, inflater);
optionsMenu=menu;
optionsMenuInflater=inflater;
createOptionsMenu();
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
if(hashtag!=null){
if (super.onOptionsItemSelected(item)) return true;
if (item.getItemId() == R.id.follow_hashtag && hashtag!=null) {
setFollowed(!hashtag.following);
} else if (item.getItemId() == R.id.mute_hashtag) {
showMuteDialog(filter.isPresent());
return true;
}
return true;
}
@@ -177,8 +343,7 @@ public class HashtagTimelineFragment extends StatusListFragment{
protected void onUpdateToolbar(){
super.onUpdateToolbar();
toolbarTitleView.setAlpha(toolbarContentVisible ? 1f : 0f);
if(followMenuItem!=null)
followMenuItem.setVisible(toolbarContentVisible);
createOptionsMenu();
}
private void updateHeader(){
@@ -224,6 +389,11 @@ public class HashtagTimelineFragment extends StatusListFragment{
followProgress.setVisibility(View.GONE);
if(followMenuItem!=null){
followMenuItem.setTitle(getString(hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName));
followMenuItem.setIcon(hashtag.following ? R.drawable.ic_fluent_person_delete_24_filled : R.drawable.ic_fluent_person_add_24_regular);
}
if(muteMenuItem!=null){
muteMenuItem.setTitle(getString(filter.isPresent() ? R.string.unmute_user : R.string.mute_user, "#" + hashtag));
muteMenuItem.setIcon(filter.isPresent() ? R.drawable.ic_fluent_speaker_2_24_regular : R.drawable.ic_fluent_speaker_off_24_regular);
}
}
@@ -247,6 +417,7 @@ public class HashtagTimelineFragment extends StatusListFragment{
private void setFollowed(boolean followed){
if(followRequestRunning)
return;
getToolbar().performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK);
followButton.setTextVisible(false);
followProgress.setVisibility(View.VISIBLE);
followRequestRunning=true;

View File

@@ -3,8 +3,13 @@ package org.joinmastodon.android.fragments;
import android.annotation.SuppressLint;
import android.app.Fragment;
import android.app.NotificationManager;
import android.app.assist.AssistContent;
import android.graphics.drawable.RippleDrawable;
import android.content.Intent;
import android.graphics.Outline;
import android.os.Build;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -15,11 +20,13 @@ import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.IdRes;
import androidx.annotation.Nullable;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.PushNotificationReceiver;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
@@ -30,18 +37,17 @@ import org.joinmastodon.android.fragments.onboarding.OnboardingFollowSuggestions
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.TabBar;
import org.joinmastodon.android.utils.ObjectIdComparator;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.IdRes;
import androidx.annotation.Nullable;
import me.grishka.appkit.FragmentStackActivity;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
@@ -54,11 +60,11 @@ import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class HomeFragment extends AppKitFragment implements OnBackPressedListener{
public class HomeFragment extends AppKitFragment implements OnBackPressedListener, ProvidesAssistContent, HasAccountID {
private FragmentRootLinearLayout content;
private HomeTimelineFragment homeTimelineFragment;
private NotificationsListFragment notificationsFragment;
private DiscoverFragment searchFragment;
private HomeTabFragment homeTabFragment;
private NotificationsFragment notificationsFragment;
private DiscoverFragment discoverFragment;
private ProfileFragment profileFragment;
private TabBar tabBar;
private View tabBarWrap;
@@ -73,7 +79,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
setTitle(R.string.app_name);
setTitle(R.string.mo_app_name);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
@@ -81,13 +87,13 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
if(savedInstanceState==null){
Bundle args=new Bundle();
args.putString("account", accountID);
homeTimelineFragment=new HomeTimelineFragment();
homeTimelineFragment.setArguments(args);
homeTabFragment=new HomeTabFragment();
homeTabFragment.setArguments(args);
args=new Bundle(args);
args.putBoolean("noAutoLoad", true);
searchFragment=new DiscoverFragment();
searchFragment.setArguments(args);
notificationsFragment=new NotificationsListFragment();
discoverFragment=new DiscoverFragment();
discoverFragment.setArguments(args);
notificationsFragment=new NotificationsFragment();
notificationsFragment.setArguments(args);
args=new Bundle(args);
args.putParcelable("profileAccount", Parcels.wrap(AccountSessionManager.getInstance().getAccount(accountID).self));
@@ -112,7 +118,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
content.setOrientation(LinearLayout.VERTICAL);
FrameLayout fragmentContainer=new FrameLayout(getActivity());
fragmentContainer.setId(R.id.fragment_wrap);
fragmentContainer.setId(me.grishka.appkit.R.id.fragment_wrap);
content.addView(fragmentContainer, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
inflater.inflate(R.layout.tab_bar, content);
@@ -120,6 +126,34 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
tabBar.setListeners(this::onTabSelected, this::onTabLongClick);
tabBarWrap=content.findViewById(R.id.tabbar_wrap);
// this one's for the pill haters (https://m3.material.io/components/navigation-bar/overview)
if(GlobalUserPreferences.disableM3PillActiveIndicator){
tabBar.findViewById(R.id.tab_home_pill).setBackground(null);
tabBar.findViewById(R.id.tab_search_pill).setBackground(null);
tabBar.findViewById(R.id.tab_notifications_pill).setBackground(null);
tabBar.findViewById(R.id.tab_profile_pill).setBackgroundResource(R.drawable.bg_tab_profile);
View[] tabs={
tabBar.findViewById(R.id.tab_home),
tabBar.findViewById(R.id.tab_search),
tabBar.findViewById(R.id.tab_notifications),
tabBar.findViewById(R.id.tab_profile)
};
for(View tab : tabs){
tab.setBackgroundResource(R.drawable.bg_tabbar_tab_ripple);
((RippleDrawable) tab.getBackground())
.setRadius(V.dp(GlobalUserPreferences.showNavigationLabels ? 56 : 42));
}
}
if(!GlobalUserPreferences.showNavigationLabels){
tabBar.findViewById(R.id.tab_home_label).setVisibility(View.GONE);
tabBar.findViewById(R.id.tab_search_label).setVisibility(View.GONE);
tabBar.findViewById(R.id.tab_notifications_label).setVisibility(View.GONE);
tabBar.findViewById(R.id.tab_profile_label).setVisibility(View.GONE);
}
tabBarAvatar=tabBar.findViewById(R.id.tab_profile_ava);
tabBarAvatar.setOutlineProvider(OutlineProviders.OVAL);
tabBarAvatar.setClipToOutline(true);
@@ -131,10 +165,10 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
if(savedInstanceState==null){
getChildFragmentManager().beginTransaction()
.add(R.id.fragment_wrap, homeTimelineFragment)
.add(R.id.fragment_wrap, searchFragment).hide(searchFragment)
.add(R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment)
.add(R.id.fragment_wrap, profileFragment).hide(profileFragment)
.add(me.grishka.appkit.R.id.fragment_wrap, homeTabFragment)
.add(me.grishka.appkit.R.id.fragment_wrap, discoverFragment).hide(discoverFragment)
.add(me.grishka.appkit.R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment)
.add(me.grishka.appkit.R.id.fragment_wrap, profileFragment).hide(profileFragment)
.commit();
String defaultTab=getArguments().getString("tab");
@@ -158,18 +192,17 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
@Override
public void onViewStateRestored(Bundle savedInstanceState){
super.onViewStateRestored(savedInstanceState);
if(savedInstanceState==null || homeTimelineFragment!=null)
return;
homeTimelineFragment=(HomeTimelineFragment) getChildFragmentManager().getFragment(savedInstanceState, "homeTimelineFragment");
searchFragment=(DiscoverFragment) getChildFragmentManager().getFragment(savedInstanceState, "searchFragment");
notificationsFragment=(NotificationsListFragment) getChildFragmentManager().getFragment(savedInstanceState, "notificationsFragment");
if(savedInstanceState==null) return;
homeTabFragment=(HomeTabFragment) getChildFragmentManager().getFragment(savedInstanceState, "homeTabFragment");
discoverFragment=(DiscoverFragment) getChildFragmentManager().getFragment(savedInstanceState, "searchFragment");
notificationsFragment=(NotificationsFragment) getChildFragmentManager().getFragment(savedInstanceState, "notificationsFragment");
profileFragment=(ProfileFragment) getChildFragmentManager().getFragment(savedInstanceState, "profileFragment");
currentTab=savedInstanceState.getInt("selectedTab");
tabBar.selectTab(currentTab);
Fragment current=fragmentForTab(currentTab);
getChildFragmentManager().beginTransaction()
.hide(homeTimelineFragment)
.hide(searchFragment)
.hide(homeTabFragment)
.hide(discoverFragment)
.hide(notificationsFragment)
.hide(profileFragment)
.show(current)
@@ -203,17 +236,17 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()));
}
WindowInsets topOnlyInsets=insets.replaceSystemWindowInsets(0, insets.getSystemWindowInsetTop(), 0, 0);
homeTimelineFragment.onApplyWindowInsets(topOnlyInsets);
searchFragment.onApplyWindowInsets(topOnlyInsets);
homeTabFragment.onApplyWindowInsets(topOnlyInsets);
discoverFragment.onApplyWindowInsets(topOnlyInsets);
notificationsFragment.onApplyWindowInsets(topOnlyInsets);
profileFragment.onApplyWindowInsets(topOnlyInsets);
}
private Fragment fragmentForTab(@IdRes int tab){
if(tab==R.id.tab_home){
return homeTimelineFragment;
return homeTabFragment;
}else if(tab==R.id.tab_search){
return searchFragment;
return discoverFragment;
}else if(tab==R.id.tab_notifications){
return notificationsFragment;
}else if(tab==R.id.tab_profile){
@@ -232,12 +265,15 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
private void onTabSelected(@IdRes int tab){
Fragment newFragment=fragmentForTab(tab);
if(tab==currentTab){
if(newFragment instanceof ScrollableToTop scrollable)
if (tab == R.id.tab_search && GlobalUserPreferences.doubleTapToSearch)
discoverFragment.openSearch();
else if(newFragment instanceof ScrollableToTop scrollable)
scrollable.scrollToTop();
return;
}
getChildFragmentManager().beginTransaction().hide(fragmentForTab(currentTab)).show(newFragment).commit();
maybeTriggerLoading(newFragment);
if (newFragment instanceof HasFab fabulous && !fabulous.isScrolling()) fabulous.showFab();
currentTab=tab;
((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this);
}
@@ -248,10 +284,14 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
lf.loadData();
}else if(newFragment instanceof DiscoverFragment){
((DiscoverFragment) newFragment).loadData();
}
if(newFragment instanceof NotificationsListFragment){
}else if(newFragment instanceof NotificationsFragment){
((NotificationsFragment) newFragment).loadData();
NotificationManager nm=getActivity().getSystemService(NotificationManager.class);
nm.cancel(accountID, PushNotificationReceiver.NOTIFICATION_ID);
for (StatusBarNotification notification : nm.getActiveNotifications()) {
if (accountID.equals(notification.getTag())) {
nm.cancel(accountID, notification.getId());
}
}
}
}
@@ -259,12 +299,20 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
if(tab==R.id.tab_profile){
ArrayList<String> options=new ArrayList<>();
for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){
options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")");
options.add(session.self.getDisplayName()+"\n("+session.self.username+"@"+session.domain+")");
}
new AccountSwitcherSheet(getActivity(), this).show();
return true;
}
if(tab==R.id.tab_home && BuildConfig.DEBUG){
if(tab==R.id.tab_search){
if(currentTab!=R.id.tab_search){
onTabSelected(R.id.tab_search);
tabBar.selectTab(R.id.tab_search);
}
discoverFragment.openSearch();
return true;
}
if(tab==R.id.tab_home){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), OnboardingFollowSuggestionsFragment.class, args);
@@ -275,20 +323,26 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
@Override
public boolean onBackPressed(){
if(currentTab==R.id.tab_profile)
return profileFragment.onBackPressed();
if (profileFragment.onBackPressed()) return true;
if(currentTab==R.id.tab_search)
return searchFragment.onBackPressed();
return false;
if (discoverFragment.onBackPressed()) return true;
if (currentTab!=R.id.tab_home) {
tabBar.selectTab(R.id.tab_home);
onTabSelected(R.id.tab_home);
return true;
} else {
return homeTabFragment.onBackPressed();
}
}
@Override
public void onSaveInstanceState(Bundle outState){
super.onSaveInstanceState(outState);
outState.putInt("selectedTab", currentTab);
getChildFragmentManager().putFragment(outState, "homeTimelineFragment", homeTimelineFragment);
getChildFragmentManager().putFragment(outState, "searchFragment", searchFragment);
getChildFragmentManager().putFragment(outState, "notificationsFragment", notificationsFragment);
getChildFragmentManager().putFragment(outState, "profileFragment", profileFragment);
if (homeTabFragment.isAdded()) getChildFragmentManager().putFragment(outState, "homeTabFragment", homeTabFragment);
if (discoverFragment.isAdded()) getChildFragmentManager().putFragment(outState, "searchFragment", discoverFragment);
if (notificationsFragment.isAdded()) getChildFragmentManager().putFragment(outState, "notificationsFragment", notificationsFragment);
if (profileFragment.isAdded()) getChildFragmentManager().putFragment(outState, "profileFragment", profileFragment);
}
@Override
@@ -297,7 +351,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
reloadNotificationsForUnreadCount();
}
private void reloadNotificationsForUnreadCount(){
public void reloadNotificationsForUnreadCount(){
List<Notification>[] notifications=new List[]{null};
String[] marker={null};
@@ -308,7 +362,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
}
});
AccountSessionManager.get(accountID).getCacheController().getNotifications(null, 40, false, true, new Callback<>(){
AccountSessionManager.get(accountID).getCacheController().getNotifications(null, 40, false, false, true, new Callback<>(){
@Override
public void onSuccess(PaginatedResponse<List<Notification>> result){
notifications[0]=result.items;
@@ -324,9 +378,9 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
@SuppressLint("DefaultLocale")
private void updateUnreadCount(List<Notification> notifications, String marker){
if(notifications.isEmpty() || ObjectIdComparator.INSTANCE.compare(notifications.get(0).id, marker)<=0){
notificationsBadge.setVisibility(View.GONE);
V.setVisibilityAnimated(notificationsBadge, View.GONE);
}else{
notificationsBadge.setVisibility(View.VISIBLE);
V.setVisibilityAnimated(notificationsBadge, View.VISIBLE);
if(ObjectIdComparator.INSTANCE.compare(notifications.get(notifications.size()-1).id, marker)>0){
notificationsBadge.setText(String.format("%d+", notifications.size()));
}else{
@@ -346,16 +400,28 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
if(!ev.accountID.equals(accountID))
return;
if(ev.clearUnread)
notificationsBadge.setVisibility(View.GONE);
V.setVisibilityAnimated(notificationsBadge, View.GONE);
}
@Subscribe
public void onStatusDisplaySettingsChanged(StatusDisplaySettingsChangedEvent ev){
if(!ev.accountID.equals(accountID))
return;
if(homeTimelineFragment.loaded)
if(homeTabFragment.getCurrentFragment() instanceof LoaderFragment lf && lf.loaded
&& lf instanceof BaseStatusListFragment<?> homeTimelineFragment)
homeTimelineFragment.rebuildAllDisplayItems();
if(notificationsFragment.loaded)
notificationsFragment.rebuildAllDisplayItems();
if(notificationsFragment.getCurrentFragment() instanceof LoaderFragment lf && lf.loaded
&& lf instanceof BaseStatusListFragment<?> l)
l.rebuildAllDisplayItems();
}
@Override
public String getAccountID() {
return accountID;
}
@Override
public void onProvideAssistContent(AssistContent assistContent) {
callFragmentToProvideAssistContent(fragmentForTab(currentTab), assistContent);
}
}

View File

@@ -0,0 +1,777 @@
package org.joinmastodon.android.fragments;
import static org.joinmastodon.android.GlobalUserPreferences.reduceMotion;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Fragment;
import android.app.FragmentTransaction;
import android.app.assist.AssistContent;
import android.content.Context;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.ViewTreeObserver;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toolbar;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.announcements.GetAnnouncements;
import org.joinmastodon.android.api.requests.lists.GetLists;
import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.HashtagUpdatedEvent;
import org.joinmastodon.android.events.ListDeletedEvent;
import org.joinmastodon.android.events.ListUpdatedCreatedEvent;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.Announcement;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.ListTimeline;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.SimpleViewHolder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import org.joinmastodon.android.utils.ElevationOnScrollListener;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.function.Supplier;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class HomeTabFragment extends MastodonToolbarFragment implements ScrollableToTop, OnBackPressedListener, HasFab, ProvidesAssistContent, HasElevationOnScrollListener {
private static final int ANNOUNCEMENTS_RESULT = 654;
private String accountID;
private MenuItem announcements, announcementsAction, settings, settingsAction;
// private ImageView toolbarLogo;
private Button toolbarShowNewPostsBtn;
private boolean newPostsBtnShown;
private AnimatorSet currentNewPostsAnim;
private ViewPager2 pager;
private View switcher;
private FrameLayout toolbarFrame;
private ImageView timelineIcon;
private ImageView collapsedChevron;
private TextView timelineTitle;
private PopupMenu switcherPopup;
private final Map<Integer, ListTimeline> listItems = new HashMap<>();
private final Map<Integer, Hashtag> hashtagsItems = new HashMap<>();
private List<TimelineDefinition> timelinesList;
private int count;
private Fragment[] fragments;
private FrameLayout[] tabViews;
private TimelineDefinition[] timelines;
private final Map<Integer, TimelineDefinition> timelinesByMenuItem = new HashMap<>();
private SubMenu hashtagsMenu, listsMenu;
private PopupMenu overflowPopup;
private View overflowActionView = null;
private boolean announcementsBadged, settingsBadged;
private ImageButton fab;
private ElevationOnScrollListener elevationOnScrollListener;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
E.register(this);
accountID = getArguments().getString("account");
timelinesList=AccountSessionManager.get(accountID).getLocalPreferences().timelines;
assert timelinesList!=null;
if(timelinesList.isEmpty()) timelinesList=List.of(TimelineDefinition.HOME_TIMELINE);
count=timelinesList.size();
fragments=new Fragment[count];
tabViews=new FrameLayout[count];
timelines=new TimelineDefinition[count];
if(GlobalUserPreferences.toolbarMarquee){
setTitleMarqueeEnabled(false);
setSubtitleMarqueeEnabled(false);
}
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
setHasOptionsMenu(true);
}
@Override
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle bundle) {
FragmentRootLinearLayout rootView = new FragmentRootLinearLayout(getContext());
rootView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
FrameLayout view = new FrameLayout(getContext());
view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
rootView.addView(view);
inflater.inflate(R.layout.compose_fab, view);
fab = view.findViewById(R.id.fab);
fab.setOnClickListener(this::onFabClick);
fab.setOnLongClickListener(this::onFabLongClick);
pager = new ViewPager2(getContext());
toolbarFrame = (FrameLayout) LayoutInflater.from(getContext()).inflate(R.layout.home_toolbar, getToolbar(), false);
if (fragments[0] == null) {
Bundle args = new Bundle();
args.putString("account", accountID);
args.putBoolean("__is_tab", true);
args.putBoolean("__disable_fab", true);
args.putBoolean("onlyPosts", true);
for (int i=0; i < timelinesList.size(); i++) {
TimelineDefinition tl = timelinesList.get(i);
fragments[i] = tl.getFragment();
timelines[i] = tl;
}
FragmentTransaction transaction = getChildFragmentManager().beginTransaction();
for (int i = 0; i < count; i++) {
fragments[i].setArguments(timelines[i].populateArguments(new Bundle(args)));
FrameLayout tabView = new FrameLayout(getActivity());
tabView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
tabView.setVisibility(View.GONE);
tabView.setId(i + 1);
transaction.add(i + 1, fragments[i]);
view.addView(tabView);
tabViews[i] = tabView;
}
transaction.commit();
}
view.addView(pager, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
overflowActionView = UiUtils.makeOverflowActionView(getContext());
overflowPopup = new PopupMenu(getContext(), overflowActionView);
overflowPopup.setOnMenuItemClickListener(this::onOptionsItemSelected);
overflowActionView.setOnClickListener(l -> overflowPopup.show());
overflowActionView.setOnTouchListener(overflowPopup.getDragToOpenListener());
return rootView;
}
@SuppressLint("ClickableViewAccessibility")
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
timelineIcon = toolbarFrame.findViewById(R.id.timeline_icon);
timelineTitle = toolbarFrame.findViewById(R.id.timeline_title);
collapsedChevron = toolbarFrame.findViewById(R.id.collapsed_chevron);
switcher = toolbarFrame.findViewById(R.id.switcher_btn);
switcherPopup = new PopupMenu(getContext(), switcher);
switcherPopup.setOnMenuItemClickListener(this::onSwitcherItemSelected);
UiUtils.enablePopupMenuIcons(getContext(), switcherPopup);
switcher.setOnClickListener(v->switcherPopup.show());
switcher.setOnTouchListener(switcherPopup.getDragToOpenListener());
updateSwitcherMenu();
UiUtils.reduceSwipeSensitivity(pager);
pager.setUserInputEnabled(!GlobalUserPreferences.disableSwipe);
pager.setAdapter(new HomePagerAdapter());
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position){
if (!reduceMotion) {
// setting this here because page transformer appears to fire too late so the
// animation can appear bumpy, especially when navigating to a further-away tab
switcher.setScaleY(0.85f);
switcher.setScaleX(0.85f);
switcher.setAlpha(0.65f);
}
updateSwitcherIcon(position);
if (!timelines[position].equals(TimelineDefinition.HOME_TIMELINE)) hideNewPostsButton();
if (fragments[position] instanceof BaseRecyclerFragment<?> page){
if(!page.loaded && !page.isDataLoading()) page.loadData();
}
}
});
if (!reduceMotion) {
pager.setPageTransformer((v, pos) -> {
if (reduceMotion || tabViews[pager.getCurrentItem()] != v) return;
float scaleFactor = Math.max(0.85f, 1 - Math.abs(pos) * 0.06f);
switcher.setScaleY(scaleFactor);
switcher.setScaleX(scaleFactor);
switcher.setAlpha(Math.max(0.65f, 1 - Math.abs(pos)));
});
}
updateToolbarLogo();
ViewTreeObserver vto = getToolbar().getViewTreeObserver();
if (vto.isAlive()) {
vto.addOnGlobalLayoutListener(()->{
Toolbar t=getToolbar();
if(t==null) return;
int toolbarWidth=t.getWidth();
if(toolbarWidth==0) return;
int toolbarFrameWidth=toolbarFrame.getWidth();
int actionsWidth=toolbarWidth-toolbarFrameWidth;
// margin (4) + padding (12) + icon (24) + margin (8) + chevron (16) + padding (12)
int switcherWidth=V.dp(76);
FrameLayout parent=((FrameLayout) toolbarShowNewPostsBtn.getParent());
if(actionsWidth==parent.getPaddingStart()) return;
int paddingMax=Math.max(actionsWidth, switcherWidth);
int paddingEnd=(Math.max(0, switcherWidth-actionsWidth));
// toolbar frame goes from screen edge to beginning of right-aligned option buttons.
// centering button by applying the same space on the left
parent.setPaddingRelative(paddingMax, 0, paddingEnd, 0);
toolbarShowNewPostsBtn.setMaxWidth(toolbarWidth-paddingMax*2);
switcher.setPivotX(V.dp(28)); // padding + half of icon
switcher.setPivotY(switcher.getHeight() / 2f);
});
}
elevationOnScrollListener = new ElevationOnScrollListener((FragmentRootLinearLayout) view, getToolbar());
if(GithubSelfUpdater.needSelfUpdating()){
updateUpdateState(GithubSelfUpdater.getInstance().getState());
}
new GetLists().setCallback(new Callback<>() {
@Override
public void onSuccess(List<ListTimeline> lists) {
updateList(lists, listItems);
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getContext());
}
}).exec(accountID);
new GetFollowedHashtags().setCallback(new Callback<>() {
@Override
public void onSuccess(HeaderPaginationList<Hashtag> hashtags) {
updateList(hashtags, hashtagsItems);
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getContext());
}
}).exec(accountID);
new GetAnnouncements(false).setCallback(new Callback<>() {
@Override
public void onSuccess(List<Announcement> result) {
if(getActivity()==null) return;
if (result.stream().anyMatch(a -> !a.read)) {
announcementsBadged = true;
announcements.setVisible(false);
announcementsAction.setVisible(true);
}
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getActivity());
}
}).exec(accountID);
}
public ElevationOnScrollListener getElevationOnScrollListener() {
return elevationOnScrollListener;
}
private void onFabClick(View v){
if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment<?> l) {
l.onFabClick(v);
}
}
private boolean onFabLongClick(View v) {
if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment<?> l) {
return l.onFabLongClick(v);
} else {
return false;
}
}
private void addListsToOverflowMenu() {
Context ctx = getContext();
listsMenu.clear();
listsMenu.getItem().setVisible(listItems.size() > 0);
UiUtils.insetPopupMenuIcon(ctx, UiUtils.makeBackItem(listsMenu));
listItems.forEach((id, list) -> {
MenuItem item = listsMenu.add(Menu.NONE, id, Menu.NONE, list.title);
item.setIcon(R.drawable.ic_fluent_people_24_regular);
UiUtils.insetPopupMenuIcon(ctx, item);
});
}
private void addHashtagsToOverflowMenu() {
Context ctx = getContext();
hashtagsMenu.clear();
hashtagsMenu.getItem().setVisible(hashtagsItems.size() > 0);
UiUtils.insetPopupMenuIcon(ctx, UiUtils.makeBackItem(hashtagsMenu));
hashtagsItems.entrySet().stream()
.sorted(Comparator.comparing(x -> x.getValue().name, String.CASE_INSENSITIVE_ORDER))
.forEach(entry -> {
MenuItem item = hashtagsMenu.add(Menu.NONE, entry.getKey(), Menu.NONE, entry.getValue().name);
item.setIcon(R.drawable.ic_fluent_number_symbol_24_regular);
UiUtils.insetPopupMenuIcon(ctx, item);
});
}
public void updateToolbarLogo(){
Toolbar toolbar = getToolbar();
ViewParent parentView = toolbarFrame.getParent();
if (parentView == toolbar) return;
if (parentView instanceof Toolbar parentToolbar) parentToolbar.removeView(toolbarFrame);
toolbar.addView(toolbarFrame, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
toolbar.setOnClickListener(v->scrollToTop());
toolbar.setNavigationContentDescription(R.string.back);
toolbar.setContentInsetsAbsolute(0, toolbar.getContentInsetRight());
updateSwitcherIcon(pager.getCurrentItem());
toolbarShowNewPostsBtn=toolbarFrame.findViewById(R.id.show_new_posts_btn);
toolbarShowNewPostsBtn.setCompoundDrawableTintList(toolbarShowNewPostsBtn.getTextColors());
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.N) UiUtils.fixCompoundDrawableTintOnAndroid6(toolbarShowNewPostsBtn);
toolbarShowNewPostsBtn.setOnClickListener(this::onNewPostsBtnClick);
if(newPostsBtnShown){
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
collapsedChevron.setVisibility(View.VISIBLE);
collapsedChevron.setAlpha(1f);
timelineTitle.setVisibility(View.GONE);
timelineTitle.setAlpha(0f);
}else{
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
toolbarShowNewPostsBtn.setAlpha(0f);
collapsedChevron.setVisibility(View.GONE);
collapsedChevron.setAlpha(0f);
toolbarShowNewPostsBtn.setScaleX(.8f);
toolbarShowNewPostsBtn.setScaleY(.8f);
timelineTitle.setVisibility(View.VISIBLE);
}
}
private void updateOverflowMenu() {
if(getActivity()==null) return;
Menu m = overflowPopup.getMenu();
m.clear();
overflowPopup.inflate(R.menu.home_overflow);
announcements = m.findItem(R.id.announcements);
settings = m.findItem(R.id.settings);
hashtagsMenu = m.findItem(R.id.hashtags).getSubMenu();
listsMenu = m.findItem(R.id.lists).getSubMenu();
announcements.setVisible(!announcementsBadged);
announcementsAction.setVisible(announcementsBadged);
settings.setVisible(!settingsBadged);
settingsAction.setVisible(settingsBadged);
UiUtils.enablePopupMenuIcons(getContext(), overflowPopup);
addListsToOverflowMenu();
addHashtagsToOverflowMenu();
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P && !UiUtils.isEMUI())
m.setGroupDividerEnabled(true);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.home, menu);
menu.findItem(R.id.overflow).setActionView(overflowActionView);
announcementsAction = menu.findItem(R.id.announcements_action);
settingsAction = menu.findItem(R.id.settings_action);
updateOverflowMenu();
}
private <T> void updateList(List<T> addItems, Map<Integer, T> items) {
if (addItems.size() == 0 || getActivity() == null) return;
for (int i = 0; i < addItems.size(); i++) items.put(View.generateViewId(), addItems.get(i));
updateOverflowMenu();
}
private void updateSwitcherMenu() {
Menu switcherMenu = switcherPopup.getMenu();
switcherMenu.clear();
timelinesByMenuItem.clear();
for (TimelineDefinition tl : timelines) {
int menuItemId = View.generateViewId();
timelinesByMenuItem.put(menuItemId, tl);
MenuItem item = switcherMenu.add(0, menuItemId, 0, tl.getTitle(getContext()));
item.setIcon(tl.getIcon().iconRes);
}
UiUtils.enablePopupMenuIcons(getContext(), switcherPopup);
}
private boolean onSwitcherItemSelected(MenuItem item) {
int id = item.getItemId();
Bundle args = new Bundle();
args.putString("account", accountID);
if (id == R.id.menu_back) {
switcher.post(() -> switcherPopup.show());
return true;
}
TimelineDefinition tl = timelinesByMenuItem.get(id);
if (tl != null) {
for (int i = 0; i < timelines.length; i++) {
if (timelines[i] == tl) {
navigateTo(i);
return true;
}
}
}
return false;
}
private void navigateTo(int i) {
navigateTo(i, !reduceMotion);
}
private void navigateTo(int i, boolean smooth) {
pager.setCurrentItem(i, smooth);
updateSwitcherIcon(i);
}
@Override
public void showFab() {
if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment<?> l) l.showFab();
}
@Override
public void hideFab() {
if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment<?> l) l.hideFab();
}
@Override
public boolean isScrolling() {
return (fragments[pager.getCurrentItem()] instanceof HasFab fabulous)
&& fabulous.isScrolling();
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (elevationOnScrollListener != null) elevationOnScrollListener.setViews(getToolbar());
}
private void updateSwitcherIcon(int i) {
timelineIcon.setImageResource(timelines[i].getIcon().iconRes);
timelineTitle.setText(timelines[i].getTitle(getContext()));
showFab();
if (elevationOnScrollListener != null && getCurrentFragment() instanceof IsOnTop f) {
elevationOnScrollListener.handleScroll(getContext(), f.isOnTop());
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
Bundle args=new Bundle();
args.putString("account", accountID);
int id = item.getItemId();
ListTimeline list;
Hashtag hashtag;
if (item.getItemId() == R.id.menu_back) {
getToolbar().post(() -> overflowPopup.show());
return true;
} else if (id == R.id.settings || id == R.id.settings_action) {
Nav.go(getActivity(), SettingsMainFragment.class, args);
} else if (id == R.id.announcements || id == R.id.announcements_action) {
Nav.goForResult(getActivity(), AnnouncementsFragment.class, args, ANNOUNCEMENTS_RESULT, this);
} else if (id == R.id.edit_timelines) {
Nav.go(getActivity(), EditTimelinesFragment.class, args);
} else if ((list = listItems.get(id)) != null) {
args.putString("listID", list.id);
args.putString("listTitle", list.title);
args.putBoolean("listIsExclusive", list.exclusive);
if (list.repliesPolicy != null) args.putInt("repliesPolicy", list.repliesPolicy.ordinal());
Nav.go(getActivity(), ListTimelineFragment.class, args);
} else if ((hashtag = hashtagsItems.get(id)) != null) {
UiUtils.openHashtagTimeline(getContext(), accountID, hashtag);
}
return true;
}
@Override
public void scrollToTop(){
if (((IsOnTop) fragments[pager.getCurrentItem()]).isOnTop() &&
GlobalUserPreferences.doubleTapToSwipe && !newPostsBtnShown) {
int nextPage = (pager.getCurrentItem() + 1) % count;
navigateTo(nextPage);
return;
}
((ScrollableToTop) fragments[pager.getCurrentItem()]).scrollToTop();
}
public void hideNewPostsButton(){
if(!newPostsBtnShown)
return;
newPostsBtnShown=false;
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
timelineTitle.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(timelineTitle, View.ALPHA, 1f),
ObjectAnimator.ofFloat(timelineTitle, View.SCALE_X, 1f),
ObjectAnimator.ofFloat(timelineTitle, View.SCALE_Y, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 0f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, .8f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, .8f),
ObjectAnimator.ofFloat(collapsedChevron, View.ALPHA, 0f)
);
set.setDuration(reduceMotion ? 0 : 300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
toolbarShowNewPostsBtn.setVisibility(View.INVISIBLE);
collapsedChevron.setVisibility(View.GONE);
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
public void showNewPostsButton(){
if(newPostsBtnShown || pager == null || !timelines[pager.getCurrentItem()].equals(TimelineDefinition.HOME_TIMELINE))
return;
newPostsBtnShown=true;
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
toolbarShowNewPostsBtn.setVisibility(View.VISIBLE);
collapsedChevron.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(timelineTitle, View.ALPHA, 0f),
ObjectAnimator.ofFloat(timelineTitle, View.SCALE_X, .8f),
ObjectAnimator.ofFloat(timelineTitle, View.SCALE_Y, .8f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.ALPHA, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_X, 1f),
ObjectAnimator.ofFloat(toolbarShowNewPostsBtn, View.SCALE_Y, 1f),
ObjectAnimator.ofFloat(collapsedChevron, View.ALPHA, 1f)
);
set.setDuration(reduceMotion ? 0 : 300);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
timelineTitle.setVisibility(View.GONE);
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
public boolean isNewPostsBtnShown() {
return newPostsBtnShown;
}
private void onNewPostsBtnClick(View view) {
if(newPostsBtnShown){
scrollToTop();
hideNewPostsButton();
}
}
@Override
public void onFragmentResult(int reqCode, boolean success, Bundle result){
if (reqCode == ANNOUNCEMENTS_RESULT && success) {
announcementsBadged = false;
announcements.setVisible(true);
announcementsAction.setVisible(false);
}
}
private void updateUpdateState(GithubSelfUpdater.UpdateState state){
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING) {
settingsBadged = true;
settingsAction.setVisible(true);
settings.setVisible(false);
}
}
@Subscribe
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
updateUpdateState(ev.state);
}
@Override
public boolean onBackPressed(){
if(pager.getCurrentItem() > 0){
navigateTo(0);
return true;
}
return false;
}
@Override
public void onDestroyView(){
super.onDestroyView();
if (overflowPopup != null) {
overflowPopup.dismiss();
overflowPopup = null;
}
if (switcherPopup != null) {
switcherPopup.dismiss();
switcherPopup = null;
}
if(GithubSelfUpdater.needSelfUpdating()){
E.unregister(this);
}
}
@Override
protected void onShown() {
super.onShown();
Object timelines = AccountSessionManager.get(accountID).getLocalPreferences().timelines;
if (timelines != null && timelinesList!= timelines) UiUtils.restartApp();
}
@Override
public void onViewStateRestored(Bundle savedInstanceState) {
super.onViewStateRestored(savedInstanceState);
if (savedInstanceState == null) return;
navigateTo(savedInstanceState.getInt("selectedTab"), false);
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putInt("selectedTab", pager.getCurrentItem());
}
@Subscribe
public void onHashtagUpdatedEvent(HashtagUpdatedEvent event) {
handleListEvent(hashtagsItems, h -> h.name.equalsIgnoreCase(event.name), event.following, () -> {
Hashtag hashtag = new Hashtag();
hashtag.name = event.name;
hashtag.following = true;
return hashtag;
});
}
@Subscribe
public void onListDeletedEvent(ListDeletedEvent event) {
handleListEvent(listItems, l -> l.id.equals(event.id), false, null);
}
@Subscribe
public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) {
handleListEvent(listItems, l -> l.id.equals(event.id), true, () -> {
ListTimeline list = new ListTimeline();
list.id = event.id;
list.title = event.title;
list.repliesPolicy = event.repliesPolicy;
return list;
});
}
private <T> void handleListEvent(
Map<Integer, T> existingThings,
Predicate<T> matchExisting,
boolean shouldBeInList,
Supplier<T> makeNewThing
) {
Optional<Map.Entry<Integer, T>> existingThing = existingThings.entrySet().stream()
.filter(e -> matchExisting.test(e.getValue())).findFirst();
if (shouldBeInList) {
existingThings.put(existingThing.isPresent()
? existingThing.get().getKey() : View.generateViewId(), makeNewThing.get());
updateOverflowMenu();
} else if (existingThing.isPresent() && !shouldBeInList) {
existingThings.remove(existingThing.get().getKey());
updateOverflowMenu();
}
}
public Collection<Hashtag> getHashtags() {
return hashtagsItems.values();
}
public Fragment getCurrentFragment() {
return fragments[pager.getCurrentItem()];
}
public ImageButton getFab() {
return fab;
}
@Override
public void onProvideAssistContent(AssistContent assistContent) {
callFragmentToProvideAssistContent(fragments[pager.getCurrentItem()], assistContent);
}
private class HomePagerAdapter extends RecyclerView.Adapter<SimpleViewHolder> {
@NonNull
@Override
public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
FrameLayout tabView = tabViews[viewType % getItemCount()];
ViewGroup tabParent = (ViewGroup) tabView.getParent();
if (tabParent != null) tabParent.removeView(tabView);
tabView.setVisibility(View.VISIBLE);
return new SimpleViewHolder(tabView);
}
@Override
public void onBindViewHolder(@NonNull SimpleViewHolder holder, int position){}
@Override
public int getItemCount(){
return count;
}
@Override
public int getItemViewType(int position){
return position;
}
}
}

View File

@@ -1,270 +1,84 @@
package org.joinmastodon.android.fragments;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.content.res.Configuration;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.animation.AnimationUtils;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toolbar;
import com.squareup.otto.Subscribe;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.api.requests.markers.SaveMarkers;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.requests.timelines.GetListTimeline;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FollowList;
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.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.viewcontrollers.HomeTimelineMenuController;
import org.joinmastodon.android.ui.viewcontrollers.ToolbarDropdownMenuController;
import org.joinmastodon.android.ui.views.FixedAspectRatioImageView;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class HomeTimelineFragment extends StatusListFragment implements ToolbarDropdownMenuController.HostFragment{
private ImageButton fab;
private LinearLayout listsDropdown;
private FixedAspectRatioImageView listsDropdownArrow;
private TextView listsDropdownText;
private Button newPostsBtn;
private View newPostsBtnWrap;
private boolean newPostsBtnShown;
private AnimatorSet currentNewPostsAnim;
private ToolbarDropdownMenuController dropdownController;
private HomeTimelineMenuController dropdownMainMenuController;
private List<FollowList> lists=List.of();
private ListMode listMode=ListMode.FOLLOWING;
private FollowList currentList;
private MergeRecyclerAdapter mergeAdapter;
private DiscoverInfoBannerHelper localTimelineBannerHelper;
public class HomeTimelineFragment extends StatusListFragment {
private HomeTabFragment parent;
private String maxID;
private String lastSavedMarkerID;
public HomeTimelineFragment(){
setListLayoutId(R.layout.fragment_timeline);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
localTimelineBannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE, accountID);
protected boolean wantsComposeButton() {
return true;
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
dropdownController=new ToolbarDropdownMenuController(this);
dropdownMainMenuController=new HomeTimelineMenuController(dropdownController, new HomeTimelineMenuController.Callback(){
@Override
public void onFollowingSelected(){
if(listMode==ListMode.FOLLOWING)
return;
listMode=ListMode.FOLLOWING;
reload();
}
@Override
public void onLocalSelected(){
if(listMode==ListMode.LOCAL)
return;
listMode=ListMode.LOCAL;
reload();
}
@Override
public List<FollowList> getLists(){
return lists;
}
@Override
public void onListSelected(FollowList list){
if(listMode==ListMode.LIST && currentList==list)
return;
listMode=ListMode.LIST;
currentList=list;
reload();
}
});
setHasOptionsMenu(true);
if (getParentFragment() instanceof HomeTabFragment home) parent = home;
loadData();
AccountSessionManager.get(accountID).getCacheController().getLists(new Callback<>(){
@Override
public void onSuccess(List<FollowList> result){
lists=result;
}
@Override
public void onError(ErrorResponse error){}
});
}
@Override
protected void doLoadData(int offset, int count){
switch(listMode){
case FOLLOWING -> {
AccountSessionManager.getInstance()
.getAccount(accountID).getCacheController()
.getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){
@Override
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
if(getActivity()==null || listMode!=ListMode.FOLLOWING)
return;
if(refreshing)
list.scrollToPosition(0);
onDataLoaded(result.items, !result.items.isEmpty());
maxID=result.maxID;
if(result.isFromCache())
loadNewPosts();
}
@Override
public void onError(ErrorResponse error){
if(listMode!=ListMode.FOLLOWING)
return;
super.onError(error);
}
});
}
case LOCAL -> {
currentRequest=new GetPublicTimeline(true, false, offset>0 ? maxID : null, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(refreshing)
list.scrollToPosition(0);
maxID=result.isEmpty() ? null : result.get(result.size()-1).id;
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
case LIST -> {
currentRequest=new GetListTimeline(currentList.id, offset>0 ? maxID : null, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(refreshing)
list.scrollToPosition(0);
maxID=result.isEmpty() ? null : result.get(result.size()-1).id;
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME);
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
}
AccountSessionManager.getInstance()
.getAccount(accountID).getCacheController()
.getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){
@Override
public void onSuccess(CacheablePaginatedResponse<List<Status>> result){
if(getActivity()==null) return;
boolean empty=result.items.isEmpty();
maxID=result.maxID;
AccountSessionManager.get(accountID).filterStatuses(result.items, getFilterContext());
onDataLoaded(result.items, !empty);
if(result.isFromCache() && GlobalUserPreferences.loadNewPosts)
loadNewPosts();
}
});
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
fab=view.findViewById(R.id.fab);
fab.setOnClickListener(this::onFabClick);
newPostsBtn=view.findViewById(R.id.new_posts_btn);
newPostsBtn.setOnClickListener(this::onNewPostsBtnClick);
newPostsBtnWrap=view.findViewById(R.id.new_posts_btn_wrap);
if(newPostsBtnShown){
newPostsBtnWrap.setVisibility(View.VISIBLE);
}else{
newPostsBtnWrap.setVisibility(View.GONE);
newPostsBtnWrap.setScaleX(0.9f);
newPostsBtnWrap.setScaleY(0.9f);
newPostsBtnWrap.setAlpha(0f);
newPostsBtnWrap.setTranslationY(V.dp(-56));
}
updateToolbarLogo();
list.addOnScrollListener(new RecyclerView.OnScrollListener(){
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
if(newPostsBtnShown && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){
hideNewPostsButton();
if(parent!=null && parent.isNewPostsBtnShown() && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){
parent.hideNewPostsButton();
}
}
});
if(GithubSelfUpdater.needSelfUpdating()){
E.register(this);
updateUpdateState(GithubSelfUpdater.getInstance().getState());
}
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.home, menu);
menu.findItem(R.id.edit_list).setVisible(listMode==ListMode.LIST);
GithubSelfUpdater.UpdateState state=GithubSelfUpdater.UpdateState.NO_UPDATE;
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
if(updater!=null)
state=updater.getState();
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING)
getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_updateready_24px);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
Bundle args=new Bundle();
args.putString("account", accountID);
int id=item.getItemId();
if(id==R.id.settings){
Nav.go(getActivity(), SettingsMainFragment.class, args);
}else if(id==R.id.edit_list){
args.putParcelable("list", Parcels.wrap(currentList));
Nav.go(getActivity(), EditListFragment.class, args);
}
return true;
}
@Override
public void onConfigurationChanged(Configuration newConfig){
super.onConfigurationChanged(newConfig);
updateToolbarLogo();
}
@Override
@@ -273,7 +87,7 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
if(!getArguments().getBoolean("noAutoLoad")){
if(!loaded && !dataLoading){
loadData();
}else if(!dataLoading){
}else if(!dataLoading && GlobalUserPreferences.loadNewPosts){
loadNewPosts();
}
}
@@ -282,7 +96,7 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
@Override
protected void onHidden(){
super.onHidden();
if(!data.isEmpty() && listMode==ListMode.FOLLOWING){
if(!data.isEmpty()){
String topPostID=displayItems.get(Math.max(0, list.getChildAdapterPosition(list.getChildAt(0))-getMainAdapterOffset())).parentID;
if(!topPostID.equals(lastSavedMarkerID)){
lastSavedMarkerID=topPostID;
@@ -303,44 +117,46 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
}
public void onStatusCreated(Status status){
if(status.reblog!=null) return;
prependItems(Collections.singletonList(status), true);
}
private void onFabClick(View v){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), ComposeFragment.class, args);
}
private void loadNewPosts(){
dataLoading=true;
// we only care about the data that was actually retrieved from the timeline api since
// user-created statuses are probably in the wrong position
List<Status> dataFromTimeline=data.stream().filter(s->!s.fromStatusCreated).collect(Collectors.toList());
// The idea here is that we request the timeline such that if there are fewer than `limit` posts,
// we'll get the currently topmost post as last in the response. This way we know there's no gap
// between the existing and newly loaded parts of the timeline.
String sinceID=data.size()>1 ? data.get(1).id : "1";
boolean needCache=listMode==ListMode.FOLLOWING;
loadAdditionalPosts(null, null, 20, sinceID, new Callback<>(){
String sinceID=dataFromTimeline.size()>1 ? dataFromTimeline.get(1).id : "1";
currentRequest=new GetHomeTimeline(null, null, 20, sinceID, getLocalPrefs().timelineReplyVisibility)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
dataLoading=false;
refreshDone();
if(result.isEmpty() || getActivity()==null)
return;
Status last=result.get(result.size()-1);
List<Status> toAdd;
if(!data.isEmpty() && last.id.equals(data.get(0).id)){ // This part intersects with the existing one
toAdd=result.subList(0, result.size()-1); // Remove the already known last post
if(!dataFromTimeline.isEmpty() && last.id.equals(dataFromTimeline.get(0).id)){ // This part intersects with the existing one
toAdd=new ArrayList<>(result.subList(0, result.size()-1)); // Remove the already known last post
}else{
result.get(result.size()-1).hasGapAfter=true;
last.hasGapAfter=last.id;
toAdd=result;
}
if(needCache)
AccountSessionManager.get(accountID).filterStatuses(toAdd, FilterContext.HOME);
if(!toAdd.isEmpty())
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(new ArrayList<>(toAdd), false);
// removing statuses that come up as duplicates (hopefully only posts and boosts that were locally created
// and thus were already prepended to the timeline earlier)
List<String> existingIds=data.stream().map(Status::getID).collect(Collectors.toList());
toAdd.removeIf(s->existingIds.contains(s.getID()));
AccountSessionManager.get(accountID).filterStatuses(toAdd, getFilterContext());
if(!toAdd.isEmpty()){
prependItems(toAdd, true);
showNewPostsButton();
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false);
if(parent != null && GlobalUserPreferences.showNewPostsButton) parent.showNewPostsButton();
}
}
@@ -348,24 +164,37 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
public void onError(ErrorResponse error){
currentRequest=null;
dataLoading=false;
refreshDone();
}
});
})
.exec(accountID);
if (parent.getParentFragment() instanceof HomeFragment homeFragment) {
homeFragment.reloadNotificationsForUnreadCount();
}
}
@Override
public void onGapClick(GapStatusDisplayItem.Holder item){
public void onGapClick(GapStatusDisplayItem.Holder item, boolean downwards){
if(dataLoading)
return;
item.getItem().loading=true;
V.setVisibilityAnimated(item.progress, View.VISIBLE);
V.setVisibilityAnimated(item.text, View.GONE);
GapStatusDisplayItem gap=item.getItem();
gap.loading=true;
dataLoading=true;
boolean needCache=listMode==ListMode.FOLLOWING;
loadAdditionalPosts(item.getItemID(), null, 20, null, new Callback<>(){
String maxID=null;
String minID=null;
if (downwards) {
maxID=item.getItem().getMaxID();
} else {
int gapPos=displayItems.indexOf(gap);
StatusDisplayItem nextItem=displayItems.get(gapPos + 1);
minID=nextItem.parentID;
}
currentRequest=new GetHomeTimeline(maxID, minID, 20, null, getLocalPrefs().timelineReplyVisibility)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Status> result){
currentRequest=null;
dataLoading=false;
if(getActivity()==null)
@@ -373,62 +202,108 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
int gapPos=displayItems.indexOf(gap);
if(gapPos==-1)
return;
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext());
if(result.isEmpty()){
displayItems.remove(gapPos);
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
Status gapStatus=getStatusByID(gap.parentID);
if(gapStatus!=null){
gapStatus.hasGapAfter=false;
if(needCache)
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false);
gapStatus.hasGapAfter=null;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false);
}
}else{
Set<String> idsBelowGap=new HashSet<>();
boolean belowGap=false;
int gapPostIndex=0;
for(Status s:data){
if(belowGap){
idsBelowGap.add(s.id);
}else if(s.id.equals(gap.parentID)){
belowGap=true;
s.hasGapAfter=false;
if(needCache)
// TODO: refactor this code. it's too long. incomprehensible, even
if(downwards) {
Set<String> idsBelowGap=new HashSet<>();
boolean belowGap=false;
int gapPostIndex=0;
for(Status s:data){
if(belowGap){
idsBelowGap.add(s.id);
}else if(s.id.equals(gap.parentID)){
belowGap=true;
s.hasGapAfter=null;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false);
}else{
gapPostIndex++;
}else{
gapPostIndex++;
}
}
int endIndex=0;
for(Status s:result){
endIndex++;
if(idsBelowGap.contains(s.id))
break;
}
if(endIndex==result.size()){
Status last=result.get(result.size()-1);
last.hasGapAfter=last.id;
}else{
result=result.subList(0, endIndex);
}
}
int endIndex=0;
for(Status s:result){
endIndex++;
if(idsBelowGap.contains(s.id))
break;
}
if(endIndex==result.size()){
result.get(result.size()-1).hasGapAfter=true;
}else{
result=result.subList(0, endIndex);
}
if(needCache)
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.HOME);
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
targetList.clear();
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
for(Status s:result){
if(idsBelowGap.contains(s.id))
break;
targetList.addAll(buildDisplayItems(s));
insertedPosts.add(s);
}
if(targetList.isEmpty()){
// oops. We didn't add new posts, but at least we know there are none.
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
}else{
adapter.notifyItemChanged(getMainAdapterOffset()+gapPos);
adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1);
}
if(needCache)
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
targetList.clear();
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
for(Status s:result){
if(idsBelowGap.contains(s.id))
break;
targetList.addAll(buildDisplayItems(s));
insertedPosts.add(s);
}
if(targetList.isEmpty()){
// oops. We didn't add new posts, but at least we know there are none.
adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos);
}else{
adapter.notifyItemChanged(getMainAdapterOffset()+gapPos);
adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1);
}
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false);
} else {
String aboveGapID = gap.parentID;
int gapPostIndex = 0;
for (;gapPostIndex<data.size();gapPostIndex++){
if (Objects.equals(aboveGapID, data.get(gapPostIndex).id)) {
break;
}
}
// find if there's an overlap between the new data and the current data
int indexOfGapInResponse = 0;
for (;indexOfGapInResponse<result.size();indexOfGapInResponse++){
if (Objects.equals(aboveGapID, result.get(indexOfGapInResponse).id)) {
break;
}
}
// there is an overlap between new and current data
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
if(indexOfGapInResponse<result.size()){
result=result.subList(indexOfGapInResponse+1,result.size());
Optional<Status> gapStatus=data.stream()
.filter(s->Objects.equals(s.id, gap.parentID))
.findFirst();
if (gapStatus.isPresent()) {
gapStatus.get().hasGapAfter=null;
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus.get()), false);
}
targetList.clear();
} else {
gap.loading=false;
}
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
for(Status s:result){
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);
}else{
adapter.notifyItemChanged(getMainAdapterOffset()+gapPos);
adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1);
}
list.scrollToPosition(getMainAdapterOffset()+gapPos+targetList.size());
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false);
}
}
}
@@ -445,17 +320,9 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
adapter.notifyItemChanged(gapPos);
}
}
});
}
})
.exec(accountID);
private void loadAdditionalPosts(String maxID, String minID, int limit, String sinceID, Callback<List<Status>> callback){
MastodonAPIRequest<List<Status>> req=switch(listMode){
case FOLLOWING -> new GetHomeTimeline(maxID, minID, limit, sinceID);
case LOCAL -> new GetPublicTimeline(true, false, maxID, minID, limit, sinceID);
case LIST -> new GetListTimeline(currentList.id, maxID, minID, limit, sinceID);
};
currentRequest=req;
req.setCallback(callback).exec(accountID);
}
@Override
@@ -465,205 +332,22 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
currentRequest=null;
dataLoading=false;
}
if(parent!=null) parent.hideNewPostsButton();
super.onRefresh();
}
private void updateToolbarLogo(){
listsDropdown=new LinearLayout(getActivity());
listsDropdown.setOnClickListener(this::onListsDropdownClick);
listsDropdown.setBackgroundResource(R.drawable.bg_button_m3_text);
listsDropdown.setAccessibilityDelegate(new View.AccessibilityDelegate(){
@Override
public void onInitializeAccessibilityNodeInfo(@NonNull View host, @NonNull AccessibilityNodeInfo info){
super.onInitializeAccessibilityNodeInfo(host, info);
info.setClassName("android.widget.Spinner");
}
});
listsDropdownArrow=new FixedAspectRatioImageView(getActivity());
listsDropdownArrow.setUseHeight(true);
listsDropdownArrow.setImageResource(R.drawable.ic_arrow_drop_down_24px);
listsDropdownArrow.setScaleType(ImageView.ScaleType.CENTER);
listsDropdownArrow.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
listsDropdown.addView(listsDropdownArrow, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
listsDropdownText=new TextView(getActivity());
listsDropdownText.setTextAppearance(R.style.action_bar_title);
listsDropdownText.setSingleLine();
listsDropdownText.setEllipsize(TextUtils.TruncateAt.END);
listsDropdownText.setGravity(Gravity.START | Gravity.CENTER_VERTICAL);
listsDropdownText.setPaddingRelative(V.dp(4), 0, V.dp(16), 0);
listsDropdownText.setText(getCurrentListTitle());
listsDropdownArrow.setImageTintList(listsDropdownText.getTextColors());
listsDropdown.setBackgroundTintList(listsDropdownText.getTextColors());
listsDropdown.addView(listsDropdownText, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
FrameLayout logoWrap=new FrameLayout(getActivity());
FrameLayout.LayoutParams ddlp=new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.START);
ddlp.topMargin=ddlp.bottomMargin=V.dp(8);
logoWrap.addView(listsDropdown, ddlp);
Toolbar toolbar=getToolbar();
toolbar.addView(logoWrap, new Toolbar.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
toolbar.setContentInsetsRelative(V.dp(16), 0);
}
private void showNewPostsButton(){
if(newPostsBtnShown)
return;
newPostsBtnShown=true;
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
newPostsBtnWrap.setVisibility(View.VISIBLE);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(newPostsBtnWrap, View.ALPHA, 1f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_X, 1f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_Y, 1f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.TRANSLATION_Y, 0f)
);
set.setDuration(getResources().getInteger(R.integer.m3_sys_motion_duration_medium3));
set.setInterpolator(AnimationUtils.loadInterpolator(getActivity(), R.interpolator.m3_sys_motion_easing_standard_decelerate));
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
private void hideNewPostsButton(){
if(!newPostsBtnShown)
return;
newPostsBtnShown=false;
if(currentNewPostsAnim!=null){
currentNewPostsAnim.cancel();
}
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(newPostsBtnWrap, View.ALPHA, 0f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_X, .9f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.SCALE_Y, .9f),
ObjectAnimator.ofFloat(newPostsBtnWrap, View.TRANSLATION_Y, V.dp(-56))
);
set.setDuration(getResources().getInteger(R.integer.m3_sys_motion_duration_medium3));
set.setInterpolator(AnimationUtils.loadInterpolator(getActivity(), R.interpolator.m3_sys_motion_easing_standard_accelerate));
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
newPostsBtnWrap.setVisibility(View.GONE);
currentNewPostsAnim=null;
}
});
currentNewPostsAnim=set;
set.start();
}
private void onNewPostsBtnClick(View v){
if(newPostsBtnShown){
hideNewPostsButton();
scrollToTop();
}
}
private void onListsDropdownClick(View v){
listsDropdownArrow.animate().rotation(-180f).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
dropdownController.show(dropdownMainMenuController);
AccountSessionManager.get(accountID).getCacheController().reloadLists(new Callback<>(){
@Override
public void onSuccess(java.util.List<FollowList> result){
lists=result;
}
@Override
public void onError(ErrorResponse error){}
});
}
@Override
public void onDestroyView(){
super.onDestroyView();
if(GithubSelfUpdater.needSelfUpdating()){
E.unregister(this);
}
}
private void updateUpdateState(GithubSelfUpdater.UpdateState state){
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING)
getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_updateready_24px);
}
@Subscribe
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
updateUpdateState(ev.state);
}
@Override
protected boolean shouldRemoveAccountPostsWhenUnfollowing(){
return true;
}
@Override
public Toolbar getToolbar(){
return super.getToolbar();
protected FilterContext getFilterContext() {
return FilterContext.HOME;
}
@Override
public void onDropdownWillDismiss(){
listsDropdownArrow.animate().rotation(0f).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
}
@Override
public void onDropdownDismissed(){
}
@Override
public void reload(){
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
refreshing=true;
showProgress();
loadData();
listsDropdownText.setText(getCurrentListTitle());
invalidateOptionsMenu();
}
@Override
protected RecyclerView.Adapter getAdapter(){
mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(super.getAdapter());
return mergeAdapter;
}
@Override
protected void onDataLoaded(List<Status> d, boolean more){
if(refreshing){
if(listMode==ListMode.LOCAL){
localTimelineBannerHelper.maybeAddBanner(list, mergeAdapter);
localTimelineBannerHelper.onBannerBecameVisible();
}else{
localTimelineBannerHelper.removeBanner(mergeAdapter);
}
}
super.onDataLoaded(d, more);
}
private String getCurrentListTitle(){
return switch(listMode){
case FOLLOWING -> getString(R.string.timeline_following);
case LOCAL -> getString(R.string.local_timeline);
case LIST -> currentList.title;
};
}
private enum ListMode{
FOLLOWING,
LOCAL,
LIST
public Uri getWebUri(Uri.Builder base) {
return base.path("/").build();
}
}

View File

@@ -0,0 +1,13 @@
package org.joinmastodon.android.fragments;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
public interface IsOnTop {
boolean isOnTop();
default boolean isRecyclerViewOnTop(@Nullable RecyclerView list) {
if (list == null) return true;
return !list.canScrollVertically(-1);
}
}

View File

@@ -1,61 +1,178 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.net.Uri;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.Nullable;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.GetList;
import org.joinmastodon.android.api.requests.lists.UpdateList;
import org.joinmastodon.android.api.requests.timelines.GetListTimeline;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.ListDeletedEvent;
import org.joinmastodon.android.events.ListUpdatedCreatedEvent;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.ListTimeline;
import org.joinmastodon.android.model.Status;
import org.parceler.Parcels;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ListEditor;
import java.util.List;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.V;
public class ListTimelineFragment extends StatusListFragment{
private FollowList followList;
public class ListTimelineFragment extends PinnableStatusListFragment {
private String listID;
private String listTitle;
@Nullable
private ListTimeline.RepliesPolicy repliesPolicy;
private boolean exclusive;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
followList=Parcels.unwrap(getArguments().getParcelable("list"));
setTitle(followList.title);
setHasOptionsMenu(true);
loadData();
}
@Override
protected boolean wantsComposeButton() {
return true;
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetListTimeline(followList.id, offset>0 ? getMaxID() : null, null, count, null)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
Bundle args = getArguments();
listID = args.getString("listID");
listTitle = args.getString("listTitle");
exclusive = args.getBoolean("listIsExclusive");
repliesPolicy = ListTimeline.RepliesPolicy.values()[args.getInt("repliesPolicy", 0)];
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.standalone_list_timeline, menu);
}
setTitle(listTitle);
setHasOptionsMenu(true);
@Override
public boolean onOptionsItemSelected(MenuItem item){
int id=item.getItemId();
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("list", Parcels.wrap(followList));
if(id==R.id.members){
Nav.go(getActivity(), ListMembersFragment.class, args);
}else if(id==R.id.edit_list){
Nav.go(getActivity(), EditListFragment.class, args);
}
return true;
}
new GetList(listID).setCallback(new Callback<>() {
@Override
public void onSuccess(ListTimeline listTimeline) {
if(getActivity()==null) return;
// TODO: save updated info
if (!listTimeline.title.equals(listTitle)) setTitle(listTimeline.title);
if (listTimeline.repliesPolicy != null && !listTimeline.repliesPolicy.equals(repliesPolicy)) {
repliesPolicy = listTimeline.repliesPolicy;
}
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getContext());
}
});
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.list, menu);
super.onCreateOptionsMenu(menu, inflater);
UiUtils.enableOptionsMenuIcons(getContext(), menu, R.id.pin);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (super.onOptionsItemSelected(item)) return true;
if (item.getItemId() == R.id.edit) {
ListEditor editor = new ListEditor(getContext());
editor.applyList(listTitle, exclusive, repliesPolicy);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_edit_list_title)
.setIcon(R.drawable.ic_fluent_people_28_regular)
.setView(editor)
.setPositiveButton(R.string.save, (d, which) -> {
String newTitle = editor.getTitle().trim();
setTitle(newTitle);
new UpdateList(listID, newTitle, editor.isExclusive(), editor.getRepliesPolicy()).setCallback(new Callback<>() {
@Override
public void onSuccess(ListTimeline list) {
if(getActivity()==null) return;
setTitle(list.title);
listTitle = list.title;
repliesPolicy = list.repliesPolicy;
exclusive = list.exclusive;
E.post(new ListUpdatedCreatedEvent(listID, listTitle, exclusive, repliesPolicy));
}
@Override
public void onError(ErrorResponse error) {
setTitle(listTitle);
error.showToast(getContext());
}
}).exec(accountID);
})
.setNegativeButton(R.string.cancel, (d, which) -> {})
.show();
} else if (item.getItemId() == R.id.delete) {
UiUtils.confirmDeleteList(getActivity(), accountID, listID, listTitle, () -> {
E.post(new ListDeletedEvent(listID));
Nav.finish(this);
});
}
return true;
}
@Override
protected TimelineDefinition makeTimelineDefinition() {
return TimelineDefinition.ofList(listID, listTitle, exclusive);
}
@Override
protected void doLoadData(int offset, int count) {
currentRequest=new GetListTimeline(listID, getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this) {
@Override
public void onSuccess(List<Status> result) {
if(getActivity()==null) return;
boolean more=applyMaxID(result);
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext());
onDataLoaded(result, more);
}
})
.exec(accountID);
}
@Override
protected void onShown() {
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
loadData();
}
@Override
public void onFabClick(View v){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), ComposeFragment.class, args);
}
@Override
protected void onSetFabBottomInset(int inset) {
((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24)+inset;
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.HOME;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.path("/lists/" + listID).build();
}
}

View File

@@ -0,0 +1,274 @@
package org.joinmastodon.android.fragments;
import android.net.Uri;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.lists.AddAccountsToList;
import org.joinmastodon.android.api.requests.lists.CreateList;
import org.joinmastodon.android.api.requests.lists.GetLists;
import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList;
import org.joinmastodon.android.events.ListDeletedEvent;
import org.joinmastodon.android.events.ListUpdatedCreatedEvent;
import org.joinmastodon.android.model.ListTimeline;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.views.ListEditor;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.views.UsableRecyclerView;
public class ListsFragment extends MastodonRecyclerFragment<ListTimeline> implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri {
private String accountID;
private String profileAccountId;
private final HashMap<String, Boolean> userInListBefore = new HashMap<>();
private final HashMap<String, Boolean> userInList = new HashMap<>();
private ListsAdapter adapter;
public ListsFragment() {
super(10);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Bundle args = getArguments();
accountID = args.getString("account");
setHasOptionsMenu(true);
E.register(this);
if(args.containsKey("profileAccount")){
profileAccountId=args.getString("profileAccount");
String profileDisplayUsername = args.getString("profileDisplayUsername");
setTitle(getString(R.string.sk_lists_with_user, profileDisplayUsername));
} else {
setTitle(R.string.sk_your_lists);
}
}
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
loadData();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 0.5f, 56, 16));
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.menu_list, menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.create) {
ListEditor editor = new ListEditor(getContext());
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_create_list_title)
.setIcon(R.drawable.ic_fluent_people_add_28_regular)
.setView(editor)
.setPositiveButton(R.string.sk_create, (d, which) ->
new CreateList(editor.getTitle(), editor.isExclusive(), editor.getRepliesPolicy()).setCallback(new Callback<>() {
@Override
public void onSuccess(ListTimeline list) {
data.add(0, list);
adapter.notifyItemRangeInserted(0, 1);
E.post(new ListUpdatedCreatedEvent(list.id, list.title, list.exclusive, list.repliesPolicy));
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getContext());
}
}).exec(accountID)
)
.setNegativeButton(R.string.cancel, (d, which) -> {})
.show();
}
return true;
}
private void saveListMembership(String listId, boolean isMember) {
userInList.put(listId, isMember);
List<String> accountIdList = Collections.singletonList(profileAccountId);
MastodonAPIRequest<Object> req = isMember ? new AddAccountsToList(listId, accountIdList) : new RemoveAccountsFromList(listId, accountIdList);
req.setCallback(new Callback<>() {
@Override
public void onSuccess(Object o) {}
@Override
public void onError(ErrorResponse error) {
error.showToast(getContext());
}
}).exec(accountID);
}
@Override
protected void doLoadData(int offset, int count){
userInListBefore.clear();
userInList.clear();
currentRequest=(profileAccountId != null ? new GetLists(profileAccountId) : new GetLists())
.setCallback(new SimpleCallback<>(this) {
@Override
public void onSuccess(List<ListTimeline> lists) {
if(getActivity()==null) return;
for (ListTimeline l : lists) userInListBefore.put(l.id, true);
userInList.putAll(userInListBefore);
if (profileAccountId == null || !lists.isEmpty()) onDataLoaded(lists, false);
if (profileAccountId == null) return;
currentRequest=new GetLists().setCallback(new SimpleCallback<>(ListsFragment.this) {
@Override
public void onSuccess(List<ListTimeline> allLists) {
if(getActivity()==null) return;
List<ListTimeline> newLists = new ArrayList<>();
for (ListTimeline l : allLists) {
if (lists.stream().noneMatch(e -> e.id.equals(l.id))) newLists.add(l);
if (!userInListBefore.containsKey(l.id)) {
userInListBefore.put(l.id, false);
}
}
userInList.putAll(userInListBefore);
onDataLoaded(newLists, false);
}
}).exec(accountID);
}
})
.exec(accountID);
}
@Subscribe
public void onListDeletedEvent(ListDeletedEvent event) {
for (int i = 0; i < data.size(); i++) {
ListTimeline item = data.get(i);
if (item.id.equals(event.id)) {
data.remove(i);
adapter.notifyItemRemoved(i);
break;
}
}
}
@Subscribe
public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) {
for (int i = 0; i < data.size(); i++) {
ListTimeline item = data.get(i);
if (item.id.equals(event.id)) {
item.title = event.title;
item.repliesPolicy = event.repliesPolicy;
item.exclusive = event.exclusive;
adapter.notifyItemChanged(i);
break;
}
}
}
@Override
protected RecyclerView.Adapter<ListViewHolder> getAdapter() {
return adapter = new ListsAdapter();
}
@Override
public void scrollToTop() {
smoothScrollRecyclerViewToTop(list);
}
@Override
public String getAccountID() {
return accountID;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.path("/lists").build();
}
private class ListsAdapter extends RecyclerView.Adapter<ListViewHolder>{
@NonNull
@Override
public ListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new ListViewHolder();
}
@Override
public void onBindViewHolder(@NonNull ListViewHolder holder, int position) {
holder.bind(data.get(position));
}
@Override
public int getItemCount() {
return data.size();
}
}
private class ListViewHolder extends BindableViewHolder<ListTimeline> implements UsableRecyclerView.Clickable{
private final TextView title;
private final CheckBox listToggle;
public ListViewHolder(){
super(getActivity(), R.layout.item_text, list);
title=findViewById(R.id.title);
listToggle=findViewById(R.id.list_toggle);
}
@Override
public void onBind(ListTimeline item) {
title.setText(item.title);
title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(
item.exclusive ? R.drawable.ic_fluent_rss_24_regular : R.drawable.ic_fluent_people_24_regular
), null, null, null);
if (profileAccountId != null) {
Boolean checked = userInList.get(item.id);
listToggle.setVisibility(View.VISIBLE);
listToggle.setChecked(userInList.containsKey(item.id) && checked != null && checked);
listToggle.setOnClickListener(this::onClickToggle);
} else {
listToggle.setVisibility(View.GONE);
}
}
private void onClickToggle(View view) {
saveListMembership(item.id, listToggle.isChecked());
}
@Override
public void onClick() {
Bundle args=new Bundle();
args.putString("account", accountID);
args.putString("listID", item.id);
args.putString("listTitle", item.title);
args.putBoolean("listIsExclusive", item.exclusive);
if (item.repliesPolicy != null) args.putInt("repliesPolicy", item.repliesPolicy.ordinal());
Nav.go(getActivity(), ListTimelineFragment.class, args);
}
}
}

View File

@@ -2,6 +2,8 @@ package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toolbar;
import org.joinmastodon.android.R;
@@ -9,10 +11,13 @@ import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ElevationOnScrollListener;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import androidx.annotation.CallSuper;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.views.FragmentRootLinearLayout;
@@ -36,21 +41,13 @@ public abstract class MastodonRecyclerFragment<T> extends BaseRecyclerFragment<T
@CallSuper
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
if(wantsElevationOnScrollEffect()){
FragmentRootLinearLayout rootView;
if(view instanceof FragmentRootLinearLayout frl)
rootView=frl;
else
rootView=view.findViewById(R.id.appkit_loader_root);
list.addOnScrollListener(elevationOnScrollListener=new ElevationOnScrollListener(rootView, getViewsForElevationEffect()));
}
list.setItemAnimator(new BetterItemAnimator());
if(refreshLayout!=null){
int colorBackground=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background);
int colorPrimary=UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary);
refreshLayout.setProgressBackgroundColorSchemeColor(UiUtils.alphaBlendColors(colorBackground, colorPrimary, 0.11f));
refreshLayout.setColorSchemeColors(colorPrimary);
}
if (getParentFragment() instanceof HasElevationOnScrollListener elevator)
list.addOnScrollListener(elevator.getElevationOnScrollListener());
else if(wantsElevationOnScrollEffect())
list.addOnScrollListener(elevationOnScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, getViewsForElevationEffect()));
if(refreshLayout!=null)
setRefreshLayoutColors(refreshLayout);
}
@Override
@@ -65,4 +62,30 @@ public abstract class MastodonRecyclerFragment<T> extends BaseRecyclerFragment<T
protected boolean wantsElevationOnScrollEffect(){
return true;
}
public List<T> getData() {
return data;
}
public static void setRefreshLayoutColors(SwipeRefreshLayout l) {
List<Integer> colors = new ArrayList<>(Arrays.asList(
UiUtils.isDarkTheme() ? R.color.primary_200 : R.color.primary_600,
UiUtils.isDarkTheme() ? R.color.red_primary_200 : R.color.red_primary_600,
UiUtils.isDarkTheme() ? R.color.green_primary_200 : R.color.green_primary_600,
UiUtils.isDarkTheme() ? R.color.blue_primary_200 : R.color.blue_primary_600,
UiUtils.isDarkTheme() ? R.color.purple_200 : R.color.purple_600
));
int primary = UiUtils.getThemeColorRes(l.getContext(),
UiUtils.isDarkTheme() ? R.attr.colorPrimary200 : R.attr.colorPrimary600);
if (!colors.contains(primary)) colors.add(0, primary);
int offset = colors.indexOf(primary);
int[] sorted = new int[colors.size()];
for (int i = 0; i < colors.size(); i++) {
sorted[i] = colors.get((i + offset) % colors.size());
}
l.setColorSchemeResources(sorted);
int colorBackground=UiUtils.getThemeColor(l.getContext(), R.attr.colorM3Background);
int colorPrimary=UiUtils.getThemeColor(l.getContext(), R.attr.colorM3Primary);
l.setProgressBackgroundColorSchemeColor(UiUtils.alphaBlendColors(colorBackground, colorPrimary, 0.11f));
}
}

View File

@@ -39,4 +39,9 @@ public abstract class MastodonToolbarFragment extends ToolbarFragment{
toolbar.setNavigationContentDescription(R.string.back);
}
}
@Override
protected boolean wantsToolbarMenuIconsTinted() {
return false; // else, badged icons don't work :(
}
}

View File

@@ -0,0 +1,397 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.app.Fragment;
import android.app.assist.AssistContent;
import android.content.Context;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetFollowRequests;
import org.joinmastodon.android.api.requests.markers.SaveMarkers;
import org.joinmastodon.android.api.requests.notifications.PleromaMarkNotificationsRead;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.FollowRequestHandledEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.PushSubscription;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.SimpleViewHolder;
import org.joinmastodon.android.ui.tabs.TabLayout;
import org.joinmastodon.android.ui.tabs.TabLayoutMediator;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ElevationOnScrollListener;
import org.joinmastodon.android.utils.ObjectIdComparator;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import java.util.Arrays;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class NotificationsFragment extends MastodonToolbarFragment implements ScrollableToTop, ProvidesAssistContent, HasElevationOnScrollListener, HasAccountID {
TabLayout tabLayout;
private ViewPager2 pager;
private FrameLayout[] tabViews;
private View tabsDivider;
private TabLayoutMediator tabLayoutMediator;
String unreadMarker, realUnreadMarker;
private MenuItem markAllReadItem, filterItem;
private NotificationsListFragment allNotificationsFragment, mentionsFragment;
private ElevationOnScrollListener elevationOnScrollListener;
private String accountID;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
accountID=getArguments().getString("account");
E.register(this);
}
@Override
public void onDestroy() {
super.onDestroy();
E.unregister(this);
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setHasOptionsMenu(true);
setTitle(R.string.notifications);
}
@Override
public void onShown() {
super.onShown();
unreadMarker=realUnreadMarker=AccountSessionManager.get(accountID).getLastKnownNotificationsMarker();
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.notifications, menu);
menu.findItem(R.id.clear_notifications).setVisible(GlobalUserPreferences.enableDeleteNotifications);
filterItem=menu.findItem(R.id.filter_notifications).setVisible(true);
markAllReadItem=menu.findItem(R.id.mark_all_read);
updateMarkAllReadButton();
UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.follow_requests, R.id.mark_all_read, R.id.filter_notifications);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.follow_requests) {
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), FollowRequestsListFragment.class, args);
return true;
} else if (item.getItemId() == R.id.clear_notifications) {
UiUtils.confirmDeleteNotification(getActivity(), accountID, null, ()->{
for (int i = 0; i < tabViews.length; i++) {
getFragmentForPage(i).reload();
}
});
return true;
} else if (item.getItemId() == R.id.mark_all_read) {
markAsRead();
if (getCurrentFragment() instanceof NotificationsListFragment nlf) {
nlf.resetUnreadBackground();
}
return true;
} else if (item.getItemId() == R.id.filter_notifications) {
Context ctx = getToolbarContext();
String[] listItems = {
ctx.getString(R.string.notification_type_mentions_and_replies),
ctx.getString(R.string.notification_type_reblog),
ctx.getString(R.string.notification_type_favorite),
ctx.getString(R.string.notification_type_follow),
ctx.getString(R.string.notification_type_poll),
ctx.getString(R.string.sk_notification_type_update),
ctx.getString(R.string.sk_notification_type_posts)
};
boolean[] checkedItems = {
getLocalPrefs().notificationFilters.mention,
getLocalPrefs().notificationFilters.reblog,
getLocalPrefs().notificationFilters.favourite,
getLocalPrefs().notificationFilters.follow,
getLocalPrefs().notificationFilters.poll,
getLocalPrefs().notificationFilters.update,
getLocalPrefs().notificationFilters.status,
};
M3AlertDialogBuilder dialogBuilder = new M3AlertDialogBuilder(ctx);
dialogBuilder.setTitle(R.string.sk_settings_filters);
dialogBuilder.setMultiChoiceItems(listItems, checkedItems, (dialog, which, isChecked) ->checkedItems[which] = isChecked);
dialogBuilder.setPositiveButton(R.string.save, (d, which) -> {
saveFilters(checkedItems);
this.allNotificationsFragment.reload();
}).setNeutralButton(R.string.mo_notification_filter_reset, (d, which) -> {
Arrays.fill(checkedItems, true);
saveFilters(checkedItems);
this.allNotificationsFragment.reload();
}).setNegativeButton(R.string.cancel, (d, which) -> {});
dialogBuilder.create().show();
return true;
}
return false;
}
private void saveFilters(boolean[] checkedItems) {
PushSubscription.Alerts filter = getLocalPrefs().notificationFilters;
filter.mention = checkedItems[0];
filter.reblog = checkedItems[1];
filter.favourite = checkedItems[2];
filter.follow = checkedItems[3];
filter.poll = checkedItems[4];
filter.update = checkedItems[5];
filter.status = checkedItems[6];
getLocalPrefs().save();
}
void markAsRead(){
if(allNotificationsFragment.getData().isEmpty()) return;
String id=allNotificationsFragment.getData().get(0).id;
if(ObjectIdComparator.INSTANCE.compare(id, realUnreadMarker)>0){
new SaveMarkers(null, id).exec(accountID);
if (allNotificationsFragment.isInstanceAkkoma()) {
new PleromaMarkNotificationsRead(id).exec(accountID);
}
AccountSessionManager.get(accountID).setNotificationsMarker(id, true);
realUnreadMarker=id;
updateMarkAllReadButton();
}
}
public void updateMarkAllReadButton(){
markAllReadItem.setVisible(!allNotificationsFragment.getData().isEmpty() && realUnreadMarker!=null && !realUnreadMarker.equals(allNotificationsFragment.getData().get(0).id));
}
@Override
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
LinearLayout view=(LinearLayout) inflater.inflate(R.layout.fragment_notifications, container, false);
tabLayout=view.findViewById(R.id.tabbar);
tabsDivider=view.findViewById(R.id.tabs_divider);
pager=view.findViewById(R.id.pager);
UiUtils.reduceSwipeSensitivity(pager);
tabViews=new FrameLayout[2];
for(int i=0;i<tabViews.length;i++){
FrameLayout tabView=new FrameLayout(getActivity());
tabView.setId(switch(i){
case 0 -> R.id.notifications_all;
case 1 -> R.id.notifications_mentions;
default -> throw new IllegalStateException("Unexpected value: "+i);
});
tabView.setVisibility(View.GONE);
view.addView(tabView); // needed so the fragment manager will have somewhere to restore the tab fragment
tabViews[i]=tabView;
}
tabLayout.setTabTextSize(V.dp(16));
tabLayout.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurfaceVariant), UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary));
tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
@Override
public void onTabSelected(TabLayout.Tab tab) {}
@Override
public void onTabUnselected(TabLayout.Tab tab) {}
@Override
public void onTabReselected(TabLayout.Tab tab) {
scrollToTop();
}
});
pager.setOffscreenPageLimit(4);
pager.setUserInputEnabled(!GlobalUserPreferences.disableSwipe);
pager.setAdapter(new DiscoverPagerAdapter());
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){
@Override
public void onPageSelected(int position){
if (elevationOnScrollListener != null && getCurrentFragment() instanceof IsOnTop f)
elevationOnScrollListener.handleScroll(getContext(), f.isOnTop());
filterItem.setVisible(position==0);
if(position==0)
return;
Fragment _page=getFragmentForPage(position);
if(_page instanceof BaseRecyclerFragment<?> page){
if(!page.loaded && !page.isDataLoading())
page.loadData();
}
}
});
if(allNotificationsFragment==null){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putBoolean("__is_tab", true);
allNotificationsFragment=new NotificationsListFragment();
allNotificationsFragment.setArguments(args);
args=new Bundle(args);
args.putBoolean("onlyMentions", true);
mentionsFragment=new NotificationsListFragment();
mentionsFragment.setArguments(args);
getChildFragmentManager().beginTransaction()
.add(R.id.notifications_all, allNotificationsFragment)
.add(R.id.notifications_mentions, mentionsFragment)
.commit();
}
tabLayoutMediator=new TabLayoutMediator(tabLayout, pager, new TabLayoutMediator.TabConfigurationStrategy(){
@Override
public void onConfigureTab(@NonNull TabLayout.Tab tab, int position){
tab.setText(switch(position){
case 0 -> R.string.all_notifications;
case 1 -> R.string.mentions;
default -> throw new IllegalStateException("Unexpected value: "+position);
});
}
});
tabLayoutMediator.attach();
return view;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
elevationOnScrollListener = new ElevationOnScrollListener((FragmentRootLinearLayout) view, getToolbar(), tabLayout);
elevationOnScrollListener.setDivider(tabsDivider);
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (elevationOnScrollListener == null) return;
elevationOnScrollListener.setViews(getToolbar(), tabLayout);
if (getCurrentFragment() instanceof IsOnTop f) {
elevationOnScrollListener.handleScroll(getContext(), f.isOnTop());
}
}
@Override
public ElevationOnScrollListener getElevationOnScrollListener() {
return elevationOnScrollListener;
}
public void refreshFollowRequestsBadge() {
new GetFollowRequests(null, 1).setCallback(new Callback<>() {
@Override
public void onSuccess(HeaderPaginationList<Account> accounts) {
if(getActivity()==null) return;
getToolbar().getMenu().findItem(R.id.follow_requests).setVisible(!accounts.isEmpty());
}
@Override
public void onError(ErrorResponse errorResponse) {}
}).exec(accountID);
}
@Subscribe
public void onFollowRequestHandled(FollowRequestHandledEvent ev) {
refreshFollowRequestsBadge();
}
@Override
public void scrollToTop(){
if (getFragmentForPage(pager.getCurrentItem()).isOnTop() && GlobalUserPreferences.doubleTapToSwipe) {
int nextPage = (pager.getCurrentItem() + 1) % tabViews.length;
pager.setCurrentItem(nextPage, true);
return;
}
getFragmentForPage(pager.getCurrentItem()).scrollToTop();
}
public void loadData(){
refreshFollowRequestsBadge();
if(allNotificationsFragment!=null && !allNotificationsFragment.loaded && !allNotificationsFragment.dataLoading)
allNotificationsFragment.loadData();
}
@Override
protected void updateToolbar(){
super.updateToolbar();
getToolbar().setOutlineProvider(null);
getToolbar().setOnClickListener(v->scrollToTop());
}
private NotificationsListFragment getFragmentForPage(int page){
return switch(page){
case 0 -> allNotificationsFragment;
case 1 -> mentionsFragment;
default -> throw new IllegalStateException("Unexpected value: "+page);
};
}
public Fragment getCurrentFragment() {
return getFragmentForPage(pager.getCurrentItem());
}
@Override
public void onProvideAssistContent(AssistContent assistContent) {
callFragmentToProvideAssistContent(getFragmentForPage(pager.getCurrentItem()), assistContent);
}
@Override
public String getAccountID(){
return accountID;
}
private class DiscoverPagerAdapter extends RecyclerView.Adapter<SimpleViewHolder>{
@NonNull
@Override
public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
FrameLayout view=tabViews[viewType];
if (view.getParent() != null) ((ViewGroup)view.getParent()).removeView(view);
view.setVisibility(View.VISIBLE);
view.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
return new SimpleViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull SimpleViewHolder holder, int position){}
@Override
public int getItemCount(){
return 2;
}
@Override
public int getItemViewType(int position){
return position;
}
}
}

View File

@@ -4,6 +4,7 @@ import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
@@ -15,48 +16,56 @@ import android.view.View;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.markers.SaveMarkers;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.EmojiReactionsUpdatedEvent;
import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.displayitems.AccountCardStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.EmojiReactionsStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.NotificationHeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.NestedRecyclerScrollView;
import org.joinmastodon.android.utils.ObjectIdComparator;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class NotificationsListFragment extends BaseStatusListFragment<Notification>{
private boolean onlyMentions;
private String maxID;
private View tabBar;
private View mentionsTab, allTab;
private View endMark;
private String unreadMarker, realUnreadMarker;
private MenuItem markAllReadItem;
private boolean reloadingFromCache;
private DiscoverInfoBannerHelper bannerHelper;
@Override
protected boolean wantsComposeButton() {
return false;
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setLayout(R.layout.fragment_notifications);
E.register(this);
onlyMentions=AccountSessionManager.get(accountID).isNotificationsMentionsOnly();
setHasOptionsMenu(true);
@@ -76,19 +85,59 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
@Override
protected List<StatusDisplayItem> buildDisplayItems(Notification n){
if(!onlyMentions){
switch(n.type){
case MENTION -> {
if(!getLocalPrefs().notificationFilters.mention)
return new ArrayList<>();
}
case REBLOG -> {
if(!getLocalPrefs().notificationFilters.reblog)
return new ArrayList<>();
}
case FAVORITE, REACTION -> {
if(!getLocalPrefs().notificationFilters.favourite)
return new ArrayList<>();
}
case FOLLOW, FOLLOW_REQUEST -> {
if(!getLocalPrefs().notificationFilters.follow)
return new ArrayList<>();
}
case POLL -> {
if(!getLocalPrefs().notificationFilters.poll)
return new ArrayList<>();
}
case UPDATE -> {
if(!getLocalPrefs().notificationFilters.update)
return new ArrayList<>();
}
case STATUS -> {
if(!getLocalPrefs().notificationFilters.status)
return new ArrayList<>();
}
default -> {}
}
}
NotificationHeaderStatusDisplayItem titleItem;
if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){
titleItem=null;
}else{
titleItem=new NotificationHeaderStatusDisplayItem(n.id, this, n, accountID);
if(n.status!=null){
n.status.card=null;
n.status.spoilerText=null;
}
}
if (n.type == Notification.Type.FOLLOW_REQUEST || n.type == Notification.Type.FOLLOW) {
ArrayList<StatusDisplayItem> items = new ArrayList<>();
items.add(titleItem);
items.add(new AccountCardStatusDisplayItem(n.id, this, accountID, n.account, n));
return items;
}
if(n.status!=null){
int flags=titleItem==null ? 0 : (StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_HEADER);
ArrayList<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, flags);
int flags=titleItem==null ? 0 : (StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS); // | StatusDisplayItem.FLAG_NO_HEADER);
if (GlobalUserPreferences.spectatorMode)
flags |= StatusDisplayItem.FLAG_NO_FOOTER;
if (!GlobalUserPreferences.showMediaPreview)
flags |= StatusDisplayItem.FLAG_NO_MEDIA_PREVIEW;
ArrayList<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, null, flags);
if(titleItem!=null)
items.add(0, titleItem);
return items;
@@ -98,34 +147,54 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
return Collections.emptyList();
}
}
@Override
protected void addAccountToKnown(Notification s){
if(!knownAccounts.containsKey(s.account.id))
knownAccounts.put(s.account.id, s.account);
if(s.status!=null && !knownAccounts.containsKey(s.status.account.id))
knownAccounts.put(s.status.account.id, s.status.account);
if(s.status!=null && s.status.reblog!=null && !knownAccounts.containsKey(s.status.reblog.account.id))
knownAccounts.put(s.status.reblog.account.id, s.status.reblog.account);
}
@Override
protected void doLoadData(int offset, int count){
if(!refreshing && !reloadingFromCache)
endMark.setVisibility(View.GONE);
AccountSessionManager.getInstance()
.getAccount(accountID).getCacheController()
.getNotifications(offset>0 ? maxID : null, count, onlyMentions, refreshing && !reloadingFromCache, new SimpleCallback<>(this){
.getNotifications(offset>0 ? maxID : null, count, onlyMentions, onlyPosts, refreshing && !reloadingFromCache, new SimpleCallback<>(this){
@Override
public void onSuccess(PaginatedResponse<List<Notification>> result){
if(getActivity()==null)
return;
onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty());
Set<String> needRelationships=result.items.stream()
.filter(ntf->ntf.status==null && !relationships.containsKey(ntf.account.id))
.map(ntf->ntf.account.id)
.collect(Collectors.toSet());
loadRelationships(needRelationships);
maxID=result.maxID;
endMark.setVisibility(result.items.isEmpty() ? View.VISIBLE : View.GONE);
onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty());
if(bannerHelper!=null) bannerHelper.onBannerBecameVisible();
reloadingFromCache=false;
if (getParentFragment() instanceof NotificationsFragment nf) {
nf.updateMarkAllReadButton();
}
}
});
}
@Override
protected void onRelationshipsLoaded(){
if(getActivity()==null)
return;
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof AccountCardStatusDisplayItem.Holder accountHolder)
accountHolder.rebind();
}
}
@Override
protected void onShown(){
super.onShown();
@@ -145,43 +214,15 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
@Override
public void onItemClick(String id){
Notification n=getNotificationByID(id);
if(n.status!=null){
Status status=n.status;
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("status", Parcels.wrap(status.clone()));
if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId))
args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status.inReplyToAccountId)));
Nav.go(getActivity(), ThreadFragment.class, args);
}else{
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(n.account));
Nav.go(getActivity(), ProfileFragment.class, args);
}
Bundle args = new Bundle();
if(n.status != null && n.status.inReplyToAccountId != null && knownAccounts.containsKey(n.status.inReplyToAccountId))
args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(n.status.inReplyToAccountId)));
UiUtils.showFragmentForNotification(getContext(), n, accountID, args);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
tabBar=view.findViewById(R.id.tabbar);
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new InsetStatusItemDecoration(this));
View tabBarItself=view.findViewById(R.id.tabbar_inner);
tabBarItself.setOutlineProvider(OutlineProviders.roundedRect(20));
tabBarItself.setClipToOutline(true);
mentionsTab=view.findViewById(R.id.mentions_tab);
allTab=view.findViewById(R.id.all_tab);
mentionsTab.setOnClickListener(this::onTabClick);
allTab.setOnClickListener(this::onTabClick);
mentionsTab.setSelected(onlyMentions);
allTab.setSelected(!onlyMentions);
NestedRecyclerScrollView scroller=view.findViewById(R.id.scroller);
scroller.setScrollableChildSupplier(()->list);
scroller.setTakePriorityOverChildViews(true);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private Paint paint=new Paint();
private Rect tmpRect=new Rect();
@@ -192,15 +233,17 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
if(TextUtils.isEmpty(unreadMarker))
return;
for(int i=0;i<parent.getChildCount();i++){
View child=parent.getChildAt(i);
if(parent.getChildViewHolder(child) instanceof StatusDisplayItem.Holder<?> holder){
String itemID=holder.getItemID();
if(ObjectIdComparator.INSTANCE.compare(itemID, unreadMarker)>0){
parent.getDecoratedBoundsWithMargins(child, tmpRect);
c.drawRect(tmpRect, paint);
if (getParentFragment() instanceof NotificationsFragment nf) {
if(TextUtils.isEmpty(nf.unreadMarker))
return;
for(int i=0;i<parent.getChildCount();i++){
View child=parent.getChildAt(i);
if(parent.getChildViewHolder(child) instanceof StatusDisplayItem.Holder<?> holder){
String itemID=holder.getItemID();
if(ObjectIdComparator.INSTANCE.compare(itemID, nf.unreadMarker)>0){
parent.getDecoratedBoundsWithMargins(child, tmpRect);
c.drawRect(tmpRect, paint);
}
}
}
}
@@ -210,9 +253,13 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
@Override
protected List<View> getViewsForElevationEffect(){
ArrayList<View> views=new ArrayList<>(super.getViewsForElevationEffect());
views.add(tabBar);
return views;
if (getParentFragment() instanceof NotificationsFragment nf) {
ArrayList<View> views=new ArrayList<>(super.getViewsForElevationEffect());
views.add(nf.tabLayout);
return views;
} else {
return super.getViewsForElevationEffect();
}
}
private Notification getNotificationByID(String id){
@@ -232,7 +279,57 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
continue;
Status contentStatus=ntf.status.getContentStatus();
if(contentStatus.poll!=null && contentStatus.poll.id.equals(ev.poll.id)){
updatePoll(ntf.id, ntf.status, ev.poll);
updatePoll(ntf.id, contentStatus, ev.poll);
}
}
}
// copied from StatusListFragment.EventListener (just like the method above)
// (which assumes this.data to be a list of statuses...)
@Subscribe
public void onStatusCountersUpdated(StatusCountersUpdatedEvent ev){
for(Notification n:data){
if(n.status!=null && n.status.getContentStatus().id.equals(ev.id)){
n.status.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateNotification(n);
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof FooterStatusDisplayItem.Holder footer && footer.getItem().status==n.status.getContentStatus()){
footer.rebind();
}else if(holder instanceof ExtendedFooterStatusDisplayItem.Holder footer && footer.getItem().status==n.status.getContentStatus()){
footer.rebind();
}
}
}
}
for(Notification n:preloadedData){
if(n.status!=null && n.status.getContentStatus().id.equals(ev.id)){
n.status.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateNotification(n);
}
}
}
@Subscribe
public void onEmojiReactionsChanged(EmojiReactionsUpdatedEvent ev){
for(Notification n : data){
if(n.status!=null && n.status.getContentStatus().id.equals(ev.id)){
n.status.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateNotification(n);
for(int i=0; i<list.getChildCount(); i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof EmojiReactionsStatusDisplayItem.Holder reactions && reactions.getItem().status==n.status.getContentStatus() && ev.viewHolder!=holder){
reactions.rebind();
}else if(holder instanceof TextStatusDisplayItem.Holder text && text.getItem().parentID.equals(n.getID())){
text.rebind();
}
}
}
}
for(Notification n : preloadedData){
if(n.status!=null && n.status.getContentStatus().id.equals(ev.id)){
n.status.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateNotification(n);
}
}
}
@@ -249,7 +346,7 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
}
}
private void removeNotification(Notification n){
public void removeNotification(Notification n){
data.remove(n);
preloadedData.remove(n);
int index=-1;
@@ -324,31 +421,47 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
}
}
private void resetUnreadBackground(){
unreadMarker=realUnreadMarker;
list.invalidate();
void resetUnreadBackground(){
if (getParentFragment() instanceof NotificationsFragment nf) {
nf.unreadMarker=nf.realUnreadMarker;
list.invalidate();
}
}
@Override
public void onRefresh(){
super.onRefresh();
if (getParentFragment() instanceof NotificationsFragment nf) {
if (!onlyMentions && !onlyPosts) nf.markAsRead();
else AccountSessionManager.get(accountID).reloadNotificationsMarker(m->{
nf.unreadMarker=nf.realUnreadMarker=m;
nf.updateMarkAllReadButton();
});
}
resetUnreadBackground();
AccountSessionManager.get(accountID).reloadNotificationsMarker(m->{
unreadMarker=realUnreadMarker=m;
});
}
private void updateMarkAllReadButton(){
markAllReadItem.setEnabled(!data.isEmpty() && realUnreadMarker!=null && !realUnreadMarker.equals(data.get(0).id));
}
@Override
public void onAppendItems(List<Notification> items){
super.onAppendItems(items);
if(data.isEmpty() || data.get(0).id.equals(realUnreadMarker))
return;
for(Notification n:items){
if(ObjectIdComparator.INSTANCE.compare(n.id, realUnreadMarker)<=0){
markAsRead();
break;
}
}
protected RecyclerView.Adapter<?> getAdapter(){
if (bannerHelper == null) return super.getAdapter();
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
bannerHelper.maybeAddBanner(list, adapter);
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.path(isInstanceAkkoma()
? "/users/" + getSession().self.username + "/interactions"
: "/notifications").build();
}
private boolean canRefreshWithoutUpsettingUser(){

View File

@@ -0,0 +1,71 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.HapticFeedbackConstants;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.widget.Toast;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.TimelineDefinition;
import java.util.ArrayList;
import java.util.List;
public abstract class PinnableStatusListFragment extends StatusListFragment {
protected List<TimelineDefinition> timelines;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
timelines=new ArrayList<>(AccountSessionManager.get(accountID).getLocalPreferences().timelines);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
updatePinButton(menu.findItem(R.id.pin));
}
protected boolean isPinned() {
return timelines.contains(makeTimelineDefinition());
}
protected void updatePinButton(MenuItem pin) {
boolean pinned = isPinned();
pin.setIcon(pinned ?
R.drawable.ic_fluent_pin_24_filled :
R.drawable.ic_fluent_pin_24_regular);
pin.setTitle(pinned ? R.string.sk_unpin_timeline : R.string.sk_pin_timeline);
}
protected abstract TimelineDefinition makeTimelineDefinition();
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.pin) {
togglePin(item);
return true;
}
return super.onOptionsItemSelected(item);
}
protected void togglePin(MenuItem pin) {
onPinnedUpdated(true);
getToolbar().performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK);
TimelineDefinition def = makeTimelineDefinition();
boolean pinned = isPinned();
if (pinned) timelines.remove(def);
else timelines.add(def);
Toast.makeText(getContext(), pinned ? R.string.sk_unpinned_timeline : R.string.sk_pinned_timeline, Toast.LENGTH_SHORT).show();
AccountLocalPreferences prefs=AccountSessionManager.get(accountID).getLocalPreferences();
prefs.timelines=new ArrayList<>(timelines);
prefs.save();
updatePinButton(pin);
}
public void onPinnedUpdated(boolean pinned) {}
}

View File

@@ -1,10 +1,13 @@
package org.joinmastodon.android.fragments;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.parceler.Parcels;
@@ -15,6 +18,10 @@ import me.grishka.appkit.api.SimpleCallback;
public class PinnedPostsListFragment extends StatusListFragment{
private Account account;
public PinnedPostsListFragment() {
setListLayoutId(R.layout.recycler_fragment_no_refresh);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
@@ -29,8 +36,20 @@ public class PinnedPostsListFragment extends StatusListFragment{
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(getActivity()==null) return;
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext());
onDataLoaded(result, false);
}
}).exec(accountID);
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.ACCOUNT;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return Uri.parse(account.url);
}
}

View File

@@ -44,7 +44,7 @@ import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareFragment{
private static final int MAX_FIELDS=4;
static final int MAX_FIELDS=4;
public UsableRecyclerView list;
private List<AccountField> fields=Collections.emptyList();
@@ -181,13 +181,13 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF
private class AboutViewHolder extends BaseViewHolder implements ImageLoaderViewHolder{
private final TextView title;
private final LinkedTextView value;
private final ImageView verifiedIcon;
// private final ImageView verifiedIcon;
public AboutViewHolder(){
super(R.layout.item_profile_about);
title=findViewById(R.id.title);
value=findViewById(R.id.value);
verifiedIcon=findViewById(R.id.verified_icon);
// verifiedIcon=findViewById(R.id.verified_icon);
}
@Override
@@ -195,7 +195,7 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF
super.onBind(item);
title.setText(item.parsedName);
value.setText(item.parsedValue);
verifiedIcon.setVisibility(item.verifiedAt!=null ? View.VISIBLE : View.GONE);
// verifiedIcon.setVisibility(item.verifiedAt!=null ? View.VISIBLE : View.GONE);
}
@Override
@@ -310,7 +310,7 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF
public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState){
super.onSelectedChanged(viewHolder, actionState);
if(actionState==ItemTouchHelper.ACTION_STATE_DRAG){
viewHolder.itemView.setTag(R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw()
viewHolder.itemView.setTag(me.grishka.appkit.R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw()
viewHolder.itemView.animate().translationZ(V.dp(1)).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
}
}

View File

@@ -1,204 +0,0 @@
package org.joinmastodon.android.fragments;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.Bundle;
import android.os.Parcelable;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountFeaturedHashtags;
import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.SectionHeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
public class ProfileFeaturedFragment extends BaseStatusListFragment<SearchResult>{
private Account profileAccount;
private List<Hashtag> featuredTags;
// private List<Account> endorsedAccounts;
private List<Status> pinnedStatuses;
private boolean tagsLoaded, statusesLoaded;
public ProfileFeaturedFragment(){
setListLayoutId(R.layout.recycler_fragment_no_refresh);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
profileAccount=Parcels.unwrap(getArguments().getParcelable("profileAccount"));
}
@Override
protected List<StatusDisplayItem> buildDisplayItems(SearchResult s){
ArrayList<StatusDisplayItem> items=switch(s.type){
case ACCOUNT -> new ArrayList<>(Collections.singletonList(new AccountStatusDisplayItem(s.id, this, s.account)));
case HASHTAG -> new ArrayList<>(Collections.singletonList(new HashtagStatusDisplayItem(s.id, this, s.hashtag)));
case STATUS -> StatusDisplayItem.buildItems(this, s.status, accountID, s, knownAccounts, false, true);
};
if(s.firstInSection){
items.add(0, new SectionHeaderStatusDisplayItem(this, getString(switch(s.type){
case ACCOUNT -> R.string.profile_endorsed_accounts;
case HASHTAG -> R.string.hashtags;
case STATUS -> R.string.posts;
}), getString(R.string.view_all), switch(s.type){
case ACCOUNT -> (Runnable)this::showAllEndorsedAccounts;
case HASHTAG -> (Runnable)this::showAllFeaturedHashtags;
case STATUS -> (Runnable)this::showAllPinnedPosts;
}));
}
return items;
}
@Override
protected void addAccountToKnown(SearchResult s){
Account acc=switch(s.type){
case ACCOUNT -> s.account;
case STATUS -> s.status.account;
case HASHTAG -> null;
};
if(acc!=null && !knownAccounts.containsKey(acc.id))
knownAccounts.put(acc.id, acc);
}
@Override
public void onItemClick(String id){
SearchResult res=getResultByID(id);
if(res==null)
return;
switch(res.type){
case ACCOUNT -> {
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(res.account));
Nav.go(getActivity(), ProfileFragment.class, args);
}
case HASHTAG -> UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag);
case STATUS -> {
Status status=res.status.getContentStatus();
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("status", Parcels.wrap(status));
if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId))
args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status.inReplyToAccountId)));
Nav.go(getActivity(), ThreadFragment.class, args);
}
}
}
@Override
protected void doLoadData(int offset, int count){
if(!statusesLoaded){
new GetAccountStatuses(profileAccount.id, null, null, 2, GetAccountStatuses.Filter.PINNED)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
pinnedStatuses=result;
statusesLoaded=true;
onOneApiRequestCompleted();
}
})
.exec(accountID);
}
if(!tagsLoaded){
new GetAccountFeaturedHashtags(profileAccount.id)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Hashtag> result){
featuredTags=result;
tagsLoaded=true;
onOneApiRequestCompleted();
}
})
.exec(accountID);
}
}
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
loadData();
}
@Override
public void onRefresh(){
statusesLoaded=false;
tagsLoaded=false;
super.onRefresh();
}
private void onOneApiRequestCompleted(){
if(getActivity()==null)
return;
if(tagsLoaded && statusesLoaded){
ArrayList<SearchResult> results=new ArrayList<>();
for(int i=0;i<Math.min(2, pinnedStatuses.size());i++){
SearchResult res=new SearchResult(pinnedStatuses.get(i));
res.firstInSection=(i==0);
results.add(res);
}
for(int i=0;i<Math.min(5, featuredTags.size());i++){
SearchResult res=new SearchResult(featuredTags.get(i));
res.firstInSection=(i==0);
results.add(res);
}
onDataLoaded(results, false);
}
}
protected SearchResult getResultByID(String id){
for(SearchResult s:data){
if(s.id.equals(id)){
return s;
}
}
return null;
}
@Override
protected void drawDivider(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder, RecyclerView parent, Canvas c, Paint paint){
if(holder instanceof FooterStatusDisplayItem.Holder && siblingHolder instanceof StatusDisplayItem.Holder<?> sdi && sdi.getItemID().startsWith("post_")){
super.drawDivider(child, bottomSibling, holder, siblingHolder, parent, c, paint);
}
}
private void showAllPinnedPosts(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(profileAccount));
Nav.go(getActivity(), PinnedPostsListFragment.class, args);
}
private void showAllFeaturedHashtags(){
Bundle args=new Bundle();
args.putString("account", accountID);
ArrayList<Parcelable> tags=featuredTags.stream().map(Parcels::wrap).collect(Collectors.toCollection(ArrayList::new));
args.putParcelableArrayList("hashtags", tags);
Nav.go(getActivity(), FeaturedHashtagsListFragment.class, args);
}
private void showAllEndorsedAccounts(){
}
}

View File

@@ -0,0 +1,220 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.ImageButton;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
import org.joinmastodon.android.api.requests.statuses.GetScheduledStatuses;
import org.joinmastodon.android.events.ScheduledStatusCreatedEvent;
import org.joinmastodon.android.events.ScheduledStatusDeletedEvent;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.ScheduledStatus;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.Collections;
import java.util.List;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.V;
public class ScheduledStatusListFragment extends BaseStatusListFragment<ScheduledStatus> {
private String nextMaxID;
private static final int SCHEDULED_STATUS_LIST_OPENED = 161;
@Override
protected boolean wantsComposeButton() {
return true;
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
E.register(this);
}
@Override
public void onDestroy(){
super.onDestroy();
E.unregister(this);
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setTitle(R.string.sk_unsent_posts);
loadData();
}
@Override
public void onFabClick(View v) {
Bundle args=new Bundle();
args.putString("account", accountID);
args.putSerializable("scheduledAt", CreateStatus.getDraftInstant());
Nav.go(getActivity(), ComposeFragment.class, args);
}
@Override
public boolean onFabLongClick(View v) {
Bundle args=new Bundle();
args.putString("account", accountID);
args.putSerializable("scheduledAt", CreateStatus.getDraftInstant());
return UiUtils.pickAccountForCompose(getActivity(), accountID, args);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
if (getArguments().getBoolean("hide_fab", false)) fab.setVisibility(View.GONE);
}
@Override
protected List<StatusDisplayItem> buildDisplayItems(ScheduledStatus s) {
return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, null,
StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS |
StatusDisplayItem.FLAG_NO_FOOTER |
StatusDisplayItem.FLAG_NO_TRANSLATE);
}
@Override
protected void addAccountToKnown(ScheduledStatus s) {}
@Override
public void onItemClick(String id) {
final Bundle args=new Bundle();
args.putString("account", accountID);
ScheduledStatus scheduledStatus = getStatusByID(id);
Status status = scheduledStatus.toStatus();
args.putParcelable("scheduledStatus", Parcels.wrap(scheduledStatus));
args.putParcelable("editStatus", Parcels.wrap(status));
args.putString("sourceText", status.text);
args.putString("sourceSpoiler", status.spoilerText);
args.putBoolean("redraftStatus", true);
args.putString("sourceContentType", scheduledStatus.params.contentType != null ?
scheduledStatus.params.contentType.name() : null);
setResult(true, null);
// closing this scheduled status list if another status list is opened from compose fragment
Nav.goForResult(getActivity(), ComposeFragment.class, args, SCHEDULED_STATUS_LIST_OPENED, this);
}
@Override
public void onFragmentResult(int reqCode, boolean success, Bundle result) {
if (reqCode == SCHEDULED_STATUS_LIST_OPENED && success && getActivity() != null) {
Nav.finish(this);
}
}
@Override
protected void onShown(){
super.onShown();
// because, for some reason, when navigating back from compose fragment,
// match_parent would otherwise be incorrect (leaving a gap for the keyboard
// where there is none)
list.post(list::requestLayout);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetScheduledStatuses(offset==0 ? null : nextMaxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<ScheduledStatus> result){
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
if(getActivity()==null) return;
onDataLoaded(result, nextMaxID!=null);
}
})
.exec(accountID);
}
// copied from StatusListFragment.java
@Subscribe
public void onScheduledStatusDeleted(ScheduledStatusDeletedEvent ev){
if(!ev.accountID.equals(accountID)) return;
ScheduledStatus status=getStatusByID(ev.id);
if(status==null) return;
removeStatus(status);
}
// copied from StatusListFragment.java
@Subscribe
public void onScheduledStatusCreated(ScheduledStatusCreatedEvent ev){
if(!ev.accountID.equals(accountID)) return;
prependItems(Collections.singletonList(ev.scheduledStatus), true);
scrollToTop();
}
// copied from StatusListFragment.java
protected void removeStatus(ScheduledStatus status){
data.remove(status);
preloadedData.remove(status);
int index=-1;
for(int i=0;i<displayItems.size();i++){
if(status.id.equals(displayItems.get(i).parentID)){
index=i;
break;
}
}
if(index==-1)
return;
int lastIndex;
for(lastIndex=index;lastIndex<displayItems.size();lastIndex++){
if(!displayItems.get(lastIndex).parentID.equals(status.id))
break;
}
displayItems.subList(index, lastIndex).clear();
adapter.notifyItemRangeRemoved(index, lastIndex-index);
}
// copied from StatusListFragment.java
protected ScheduledStatus getStatusByID(String id){
for(ScheduledStatus s:data){
if(s.id.equals(id)){
return s;
}
}
for(ScheduledStatus s:preloadedData){
if(s.id.equals(id)){
return s;
}
}
return null;
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(contentView!=null){
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){
((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+insets.getSystemWindowInsetBottom();
}else{
((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16);
}
}
super.onApplyWindowInsets(insets);
}
@Override
public Uri getWebUri(Uri.Builder base) {
// TODO: adapt when frontends finally implement a scheduled posts list
return null;
}
}

View File

@@ -3,9 +3,12 @@ package org.joinmastodon.android.fragments;
import android.view.ViewTreeObserver;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.V;
import org.joinmastodon.android.ui.utils.UiUtils;
public interface ScrollableToTop{
// boolean isScrolledToTop();
void scrollToTop();
/**
@@ -21,7 +24,7 @@ public interface ScrollableToTop{
@Override
public boolean onPreDraw(){
list.getViewTreeObserver().removeOnPreDrawListener(this);
list.scrollBy(0, V.dp(300));
list.scrollBy(0, UiUtils.SCROLL_TO_TOP_DELTA);
list.smoothScrollToPosition(0);
return true;
}

View File

@@ -1,35 +1,42 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetStatusEditHistory;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.DummyStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import me.grishka.appkit.api.SimpleCallback;
import name.fraser.neil.plaintext.diff_match_patch;
public class StatusEditHistoryFragment extends StatusListFragment{
private String id;
private String id, url;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
id=getArguments().getString("id");
url=getArguments().getString("url");
loadData();
}
@@ -45,6 +52,7 @@ public class StatusEditHistoryFragment extends StatusListFragment{
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(getActivity()==null) return;
Collections.sort(result, Comparator.comparing((Status s)->s.createdAt).reversed());
onDataLoaded(result, false);
}
@@ -54,7 +62,7 @@ public class StatusEditHistoryFragment extends StatusListFragment{
@Override
protected List<StatusDisplayItem> buildDisplayItems(Status s){
List<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, true, false);
List<StatusDisplayItem> items=new ArrayList<>();
int idx=data.indexOf(s);
if(idx>=0){
String date=UiUtils.DATE_TIME_FORMATTER.format(s.createdAt.atZone(ZoneId.systemDefault()));
@@ -79,8 +87,11 @@ public class StatusEditHistoryFragment extends StatusListFragment{
EnumSet<StatusEditChangeType> changes=EnumSet.noneOf(StatusEditChangeType.class);
Status prev=data.get(idx+1);
if(!Objects.equals(s.content, prev.content)){
// if only formatting was changed, don't even try to create a diff text
if(!Objects.equals(HtmlParser.text(s.content), HtmlParser.text(prev.content))){
changes.add(StatusEditChangeType.TEXT_CHANGED);
//update status content to display a diffs
s.content=createDiffText(prev.content, s.content);
}
if(!Objects.equals(s.spoilerText, prev.spoilerText)){
if(s.spoilerText==null){
@@ -139,19 +150,50 @@ public class StatusEditHistoryFragment extends StatusListFragment{
action=getString(R.string.edit_multiple_changed);
}
}
items.add(0, new ReblogOrReplyLineStatusDisplayItem(s.id, this, action+" · "+date, Collections.emptyList(), 0));
String sep = getString(R.string.sk_separator);
items.add(0, new ReblogOrReplyLineStatusDisplayItem(s.id, this, action+" "+sep+" "+date, Collections.emptyList(), 0, null, null, s));
items.add(1, new DummyStatusDisplayItem(s.id, this));
}
items.addAll(StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, null, StatusDisplayItem.FLAG_NO_FOOTER|StatusDisplayItem.FLAG_INSET|StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS));
return items;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new InsetStatusItemDecoration(this));
}
@Override
public boolean isItemEnabled(String id){
return false;
}
@Override
protected FilterContext getFilterContext() {
return null;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return Uri.parse(url);
}
private String createDiffText(String original, String modified) {
diff_match_patch dmp=new diff_match_patch();
LinkedList<diff_match_patch.Diff> diffs=dmp.diff_main(original, modified);
dmp.diff_cleanupSemantic(diffs);
StringBuilder stringBuilder=new StringBuilder();
for(diff_match_patch.Diff diff : diffs){
switch(diff.operation){
case DELETE->{
stringBuilder.append("<edit-diff-delete>");
stringBuilder.append(diff.text);
stringBuilder.append("</edit-diff-delete>");
}
case INSERT->{
stringBuilder.append("<edit-diff-insert>");
stringBuilder.append(diff.text);
stringBuilder.append("</edit-diff-insert>");
}
default->stringBuilder.append(diff.text);
}
}
return stringBuilder.toString();
}
}

View File

@@ -1,41 +1,77 @@
package org.joinmastodon.android.fragments;
import static org.joinmastodon.android.api.session.AccountLocalPreferences.ShowEmojiReactions.ONLY_OPENED;
import android.content.res.Configuration;
import android.os.Bundle;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.api.CacheController;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusMuteChangedEvent;
import org.joinmastodon.android.events.EmojiReactionsUpdatedEvent;
import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.events.ReblogDeletedEvent;
import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.events.StatusDeletedEvent;
import org.joinmastodon.android.events.StatusUpdatedEvent;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.EmojiReactionsStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.function.BiPredicate;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
public abstract class StatusListFragment extends BaseStatusListFragment<Status> {
protected EventListener eventListener=new EventListener();
protected List<StatusDisplayItem> buildDisplayItems(Status s){
return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, false, true);
boolean isMainThreadStatus = this instanceof ThreadFragment t && s.id.equals(t.mainStatus.id);
int flags = 0;
AccountLocalPreferences lp=getLocalPrefs();
if(GlobalUserPreferences.spectatorMode)
flags |= StatusDisplayItem.FLAG_NO_FOOTER;
if(!lp.emojiReactionsEnabled || lp.showEmojiReactions==ONLY_OPENED)
flags |= StatusDisplayItem.FLAG_NO_EMOJI_REACTIONS;
if(GlobalUserPreferences.translateButtonOpenedOnly)
flags |= StatusDisplayItem.FLAG_NO_TRANSLATE;
if(!GlobalUserPreferences.showMediaPreview)
flags |= StatusDisplayItem.FLAG_NO_MEDIA_PREVIEW;
return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, getFilterContext(), isMainThreadStatus ? 0 : flags);
}
protected abstract FilterContext getFilterContext();
@Override
protected void addAccountToKnown(Status s){
if(!knownAccounts.containsKey(s.account.id))
knownAccounts.put(s.account.id, s.account);
if(s.reblog!=null && !knownAccounts.containsKey(s.reblog.account.id))
knownAccounts.put(s.reblog.account.id, s.reblog.account);
}
@Override
@@ -53,8 +89,20 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
@Override
public void onItemClick(String id){
Status status=getContentStatusByID(id);
if(status==null)
if(status==null || status.preview) return;
if(status.isRemote){
UiUtils.lookupStatus(getContext(), status, accountID, null, status1 -> {
status1.filterRevealed = true;
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("status", Parcels.wrap(status1));
if(status1.inReplyToAccountId!=null && knownAccounts.containsKey(status1.inReplyToAccountId))
args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status1.inReplyToAccountId)));
Nav.go(getActivity(), ThreadFragment.class, args);
});
return;
}
status.filterRevealed=true;
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("status", Parcels.wrap(status.clone()));
@@ -118,12 +166,12 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
}
}
protected Status getContentStatusByID(String id){
public Status getContentStatusByID(String id){
Status s=getStatusByID(id);
return s==null ? null : s.getContentStatus();
}
protected Status getStatusByID(String id){
public Status getStatusByID(String id){
for(Status s:data){
if(s.id.equals(id)){
return s;
@@ -150,25 +198,79 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
}
}
private boolean removeStatusDisplayItems(String parentID, int firstIndex, int ancestorFirstIndex, int ancestorLastIndex){
// did we find an ancestor that is also the status' neighbor?
if(ancestorFirstIndex>=0 && ancestorLastIndex==firstIndex-1){
// update ancestor to have no descendant anymore
displayItems.subList(ancestorFirstIndex, ancestorLastIndex+1).forEach(i->i.hasDescendantNeighbor=false);
adapter.notifyItemRangeChanged(ancestorFirstIndex, ancestorLastIndex-ancestorFirstIndex+1);
}
if(firstIndex==-1) return false;
int lastIndex=firstIndex;
while(lastIndex<displayItems.size()){
StatusDisplayItem item=displayItems.get(lastIndex);
if(!item.parentID.equals(parentID) || item instanceof GapStatusDisplayItem) break;
lastIndex++;
}
int count=lastIndex-firstIndex;
if(count<1) return false;
displayItems.subList(firstIndex, lastIndex).clear();
adapter.notifyItemRangeRemoved(firstIndex, count);
return true;
}
protected void removeStatus(Status status){
data.remove(status);
preloadedData.remove(status);
int index=-1;
final AccountSessionManager asm=AccountSessionManager.getInstance();
final CacheController cache=AccountSessionManager.get(accountID).getCacheController();
final boolean unReblogging=status.reblog!=null && asm.isSelf(accountID, status.account);
final Predicate<Status> isToBeRemovedReblog=item->item!=null && item.reblog!=null
&& item.reblog.id.equals(status.reblog.id)
&& asm.isSelf(accountID, item.account);
final BiPredicate<String, Supplier<String>> isToBeRemovedContent=(parentId, contentIdSupplier)->
parentId.equals(status.id) || contentIdSupplier.get().equals(status.id);
int ancestorFirstIndex=-1, ancestorLastIndex=-1;
for(int i=0;i<displayItems.size();i++){
if(status.id.equals(displayItems.get(i).parentID)){
index=i;
break;
StatusDisplayItem item=displayItems.get(i);
// we found a status that the to-be-removed status replies to!
// storing indices to maybe update its display items
if(item.parentID.equals(status.inReplyToId)){
if(ancestorFirstIndex==-1) ancestorFirstIndex=i;
ancestorLastIndex=i;
}
// if we're un-reblogging, we compare the reblogged status's id with the current status's
if(unReblogging
? isToBeRemovedReblog.test(getStatusByID(item.parentID))
: isToBeRemovedContent.test(item.parentID, item::getContentStatusID)){
// if statuses are removed from index i, the next iteration should be on the same index again
if(removeStatusDisplayItems(item.parentID, i, ancestorFirstIndex, ancestorLastIndex)) i--;
// resetting in case we find another occurrence of the same status that also has ancestors
// (we won't - unless the timeline is being especially weird)
ancestorFirstIndex=-1; ancestorLastIndex=-1;
}
}
if(index==-1)
return;
int lastIndex;
for(lastIndex=index;lastIndex<displayItems.size();lastIndex++){
if(!displayItems.get(lastIndex).parentID.equals(status.id))
break;
}
displayItems.subList(index, lastIndex).clear();
adapter.notifyItemRangeRemoved(index, lastIndex-index);
Consumer<List<Status>> removeStatusFromData=(list)->{
Iterator<Status> it=list.iterator();
while(it.hasNext()){
Status s=it.next();
if(unReblogging
? isToBeRemovedReblog.test(s)
: isToBeRemovedContent.test(s.id, s::getContentStatusID)){
it.remove();
cache.deleteStatus(s.id);
}
}
};
removeStatusFromData.accept(data);
removeStatusFromData.accept(preloadedData);
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (getParentFragment() instanceof HomeTabFragment home) home.updateToolbarLogo();
}
public class EventListener{
@@ -177,7 +279,8 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
public void onStatusCountersUpdated(StatusCountersUpdatedEvent ev){
for(Status s:data){
if(s.getContentStatus().id.equals(ev.id)){
s.update(ev);
s.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateStatus(s);
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof FooterStatusDisplayItem.Holder footer && footer.getItem().status==s.getContentStatus()){
@@ -189,8 +292,55 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
}
}
for(Status s:preloadedData){
if(s.id.equals(ev.id)){
s.update(ev);
if(s.getContentStatus().id.equals(ev.id)){
s.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateStatus(s);
}
}
}
@Subscribe
public void onStatusMuteChaged(StatusMuteChangedEvent ev){
for(Status s:data){
if(s.getContentStatus().id.equals(ev.id)){
s.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateStatus(s);
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof HeaderStatusDisplayItem.Holder header && header.getItem().status==s.getContentStatus()){
header.rebind();
}
}
}
}
for(Status s:preloadedData){
if(s.getContentStatus().id.equals(ev.id)){
s.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateStatus(s);
}
}
}
@Subscribe
public void onEmojiReactionsChanged(EmojiReactionsUpdatedEvent ev){
for(Status s:data){
if(s.getContentStatus().id.equals(ev.id)){
s.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateStatus(s);
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof EmojiReactionsStatusDisplayItem.Holder reactions && reactions.getItem().status==s.getContentStatus() && ev.viewHolder!=holder){
reactions.rebind();
}else if(holder instanceof TextStatusDisplayItem.Holder text && text.getItem().parentID.equals(s.getID())){
text.rebind();
}
}
}
}
for(Status s:preloadedData){
if(s.getContentStatus().id.equals(ev.id)){
s.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateStatus(s);
}
}
}
@@ -205,6 +355,22 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
removeStatus(status);
}
@Subscribe
public void onReblogDeleted(ReblogDeletedEvent ev){
AccountSessionManager asm=AccountSessionManager.getInstance();
if(!ev.accountID.equals(accountID))
return;
for(Status item : data){
boolean itemIsOwnReblog=item.reblog!=null
&& item.getContentStatusID().equals(ev.statusID)
&& asm.isSelf(accountID, item.account);
if(itemIsOwnReblog){
removeStatus(item);
break;
}
}
}
@Subscribe
public void onStatusCreated(StatusCreatedEvent ev){
if(!ev.accountID.equals(accountID))
@@ -225,6 +391,7 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
Status contentStatus=status.getContentStatus();
if(contentStatus.poll!=null && contentStatus.poll.id.equals(ev.poll.id)){
updatePoll(status.id, contentStatus, ev.poll);
AccountSessionManager.get(accountID).getCacheController().updateStatus(contentStatus);
}
}
}

View File

@@ -1,6 +1,6 @@
package org.joinmastodon.android.fragments;
import android.content.res.ColorStateList;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
@@ -11,27 +11,54 @@ import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.GlobalUserPreferences;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.GlobalUserPreferences.AutoRevealMode;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.GetStatusByID;
import org.joinmastodon.android.api.requests.statuses.GetStatusContext;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.events.StatusMuteChangedEvent;
import org.joinmastodon.android.events.StatusUpdatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusContext;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.SpoilerStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.parceler.Parcels;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
@@ -41,9 +68,11 @@ import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class ThreadFragment extends StatusListFragment{
private Status mainStatus;
private ImageView endMark;
public class ThreadFragment extends StatusListFragment implements ProvidesAssistContent {
protected Status mainStatus, updatedStatus, replyTo;
private final HashMap<String, NeighborAncestryInfo> ancestryMap = new HashMap<>();
private StatusContext result;
protected boolean contextInitiallyRendered, transitionFinished, preview;
private FrameLayout replyContainer;
private LinearLayout replyButton;
private ImageView replyButtonAva;
@@ -55,22 +84,74 @@ public class ThreadFragment extends StatusListFragment{
super.onCreate(savedInstanceState);
setLayout(R.layout.fragment_thread);
mainStatus=Parcels.unwrap(getArguments().getParcelable("status"));
replyTo=Parcels.unwrap(getArguments().getParcelable("inReplyTo"));
Account inReplyToAccount=Parcels.unwrap(getArguments().getParcelable("inReplyToAccount"));
refreshing=contextInitiallyRendered=getArguments().getBoolean("refresh", false);
if(inReplyToAccount!=null)
knownAccounts.put(inReplyToAccount.id, inReplyToAccount);
data.add(mainStatus);
onAppendItems(Collections.singletonList(mainStatus));
if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames)
setTitle(HtmlParser.parseCustomEmoji(getString(R.string.post_from_user, mainStatus.account.displayName), mainStatus.account.emojis));
else
setTitle(getString(R.string.post_from_user, mainStatus.account.displayName));
preview=mainStatus.preview;
if(preview) setRefreshEnabled(false);
setTitle(preview ? getString(R.string.sk_post_preview) : HtmlParser.parseCustomEmoji(getString(R.string.post_from_user, mainStatus.account.getDisplayName()), mainStatus.account.emojis));
transitionFinished = getArguments().getBoolean("noTransition", false);
E.register(this);
}
@Override
public void onDestroy(){
super.onDestroy();
E.unregister(this);
}
@Subscribe
public void onStatusMuteChanged(StatusMuteChangedEvent ev){
for(Status s:data){
s.getContentStatus().update(ev);
AccountSessionManager.get(accountID).getCacheController().updateStatus(s);
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof HeaderStatusDisplayItem.Holder header && header.getItem().status==s.getContentStatus()){
header.rebind();
}
}
}
}
@Override
protected List<StatusDisplayItem> buildDisplayItems(Status s){
List<StatusDisplayItem> items=super.buildDisplayItems(s);
if(s.id.equals(mainStatus.id)){
for(StatusDisplayItem item:items){
// "what the fuck is a deque"? yes
// (it's just so the last-added item automatically comes first when looping over it)
Deque<Integer> deleteTheseItems = new ArrayDeque<>();
// modifying hidden filtered items if status is displayed as a warning
List<StatusDisplayItem> itemsToModify =
(items.get(0) instanceof WarningFilteredStatusDisplayItem warning)
? warning.filteredItems
: items;
for(int i = 0; i < itemsToModify.size(); i++){
StatusDisplayItem item = itemsToModify.get(i);
NeighborAncestryInfo ancestryInfo = ancestryMap.get(s.id);
if (ancestryInfo != null) {
item.setAncestryInfo(
ancestryInfo.descendantNeighbor != null,
ancestryInfo.ancestoringNeighbor != null,
s.id.equals(mainStatus.id),
Optional.ofNullable(ancestryInfo.ancestoringNeighbor)
.map(ancestor -> ancestor.id.equals(mainStatus.id))
.orElse(false)
);
}
if (item instanceof ReblogOrReplyLineStatusDisplayItem &&
(!item.isDirectDescendant && item.hasAncestoringNeighbor)) {
deleteTheseItems.add(i);
}
if(s.id.equals(mainStatus.id)){
if(item instanceof TextStatusDisplayItem text)
text.textSelectable=true;
else if(item instanceof FooterStatusDisplayItem footer)
@@ -82,49 +163,237 @@ public class ThreadFragment extends StatusListFragment{
}
}
}
items.add(items.size()-1, new ExtendedFooterStatusDisplayItem(s.id, this, s.getContentStatus()));
}
for (int deleteThisItem : deleteTheseItems) itemsToModify.remove(deleteThisItem);
if(s.id.equals(mainStatus.id)) {
items.add(new ExtendedFooterStatusDisplayItem(s.id, this, accountID, s.getContentStatus()));
}
return items;
}
@Override
public void onTransitionFinished() {
transitionFinished = true;
maybeApplyContext();
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetStatusContext(mainStatus.id)
if(preview && replyTo==null){
result=new StatusContext();
result.descendants=Collections.emptyList();
result.ancestors=Collections.emptyList();
return;
}
if(refreshing && !preview) loadMainStatus();
currentRequest=new GetStatusContext(preview ? replyTo.id : mainStatus.id)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(StatusContext result){
if(getActivity()==null)
return;
if(refreshing){
data.clear();
displayItems.clear();
data.add(mainStatus);
onAppendItems(Collections.singletonList(mainStatus));
if(preview){
result.descendants=Collections.emptyList();
result.ancestors.add(replyTo);
}
filterStatuses(result.descendants);
filterStatuses(result.ancestors);
if(footerProgress!=null)
footerProgress.setVisibility(View.GONE);
data.addAll(result.descendants);
int prevCount=displayItems.size();
onAppendItems(result.descendants);
int count=displayItems.size();
if(!refreshing)
adapter.notifyItemRangeInserted(prevCount, count-prevCount);
prependItems(result.ancestors, !refreshing);
dataLoaded();
if(refreshing){
refreshDone();
adapter.notifyDataSetChanged();
}
list.scrollToPosition(displayItems.size()-count);
ThreadFragment.this.result = result;
maybeApplyContext();
}
})
.exec(accountID);
}
private void loadMainStatus() {
new GetStatusByID(mainStatus.id)
.setCallback(new Callback<>() {
@Override
public void onSuccess(Status status) {
if (getContext() == null || status == null) return;
updatedStatus = status;
// for the case that the context has already loaded (and the animation has
// already finished), falling back to applying it ourselves:
maybeApplyMainStatus();
}
@Override
public void onError(ErrorResponse error) {}
}).exec(accountID);
}
private void restoreStatusStates(List<Status> newData, Map<String, Status> oldData) {
for (Status s : newData) {
if (s == mainStatus) continue;
Status oldStatus = oldData == null ? null : oldData.get(s.id);
// restore previous spoiler/filter revealed states when refreshing
if (oldStatus != null) {
s.spoilerRevealed = oldStatus.spoilerRevealed;
s.sensitiveRevealed = oldStatus.sensitiveRevealed;
s.filterRevealed = oldStatus.filterRevealed;
}
if (GlobalUserPreferences.autoRevealEqualSpoilers != AutoRevealMode.NEVER &&
s.spoilerText != null &&
s.spoilerText.equals(mainStatus.spoilerText)) {
if (GlobalUserPreferences.autoRevealEqualSpoilers == AutoRevealMode.DISCUSSIONS || Objects.equals(mainStatus.account.id, s.account.id)) {
s.spoilerRevealed = mainStatus.spoilerRevealed;
}
}
}
}
protected void maybeApplyContext() {
if (!transitionFinished || result == null || getContext() == null) return;
Map<String, Status> oldData = null;
if(refreshing){
oldData = new HashMap<>(data.size());
for (Status s : data) oldData.put(s.id, s);
data.clear();
ancestryMap.clear();
displayItems.clear();
data.add(mainStatus);
onAppendItems(Collections.singletonList(mainStatus));
}
// TODO: figure out how this code works
if (isInstanceAkkoma()) sortStatusContext(mainStatus, result);
filterStatuses(result.descendants);
filterStatuses(result.ancestors);
restoreStatusStates(result.descendants, oldData);
restoreStatusStates(result.ancestors, oldData);
for (NeighborAncestryInfo i : mapNeighborhoodAncestry(mainStatus, result)) {
ancestryMap.put(i.status.id, i);
}
if(footerProgress!=null)
footerProgress.setVisibility(View.GONE);
data.addAll(result.descendants);
int prevCount=displayItems.size();
onAppendItems(result.descendants);
int count=displayItems.size();
if(!refreshing)
adapter.notifyItemRangeInserted(prevCount, count-prevCount);
int prependedCount = prependItems(result.ancestors, !refreshing);
if (prependedCount > 0 && displayItems.get(prependedCount) instanceof ReblogOrReplyLineStatusDisplayItem) {
displayItems.remove(prependedCount);
adapter.notifyItemRemoved(prependedCount);
count--;
}
dataLoaded();
if(refreshing){
refreshDone();
adapter.notifyDataSetChanged();
}
list.scrollToPosition(displayItems.size()-count);
// no animation is going to happen, so proceeding to apply right now
if (data.size() == 1) {
contextInitiallyRendered = true;
// for the case that the main status has already finished loading
maybeApplyMainStatus();
}
result = null;
}
protected Object maybeApplyMainStatus() {
if (updatedStatus == null || !contextInitiallyRendered) return null;
// restore revealed states for main status because it gets updated after doLoadData
updatedStatus.filterRevealed = mainStatus.filterRevealed;
updatedStatus.spoilerRevealed = mainStatus.spoilerRevealed;
updatedStatus.sensitiveRevealed = mainStatus.sensitiveRevealed;
// returning fired event object to facilitate testing
Object event;
if (updatedStatus.editedAt != null &&
(mainStatus.editedAt == null ||
updatedStatus.editedAt.isAfter(mainStatus.editedAt))) {
event = new StatusUpdatedEvent(updatedStatus);
} else {
event = new StatusCountersUpdatedEvent(updatedStatus);
}
mainStatus = updatedStatus;
updatedStatus = null;
E.post(event);
return event;
}
public static List<NeighborAncestryInfo> mapNeighborhoodAncestry(Status mainStatus, StatusContext context) {
List<NeighborAncestryInfo> ancestry = new ArrayList<>();
List<Status> statuses = new ArrayList<>(context.ancestors);
statuses.add(mainStatus);
statuses.addAll(context.descendants);
int count = statuses.size();
for (int index = 0; index < count; index++) {
Status current = statuses.get(index);
ancestry.add(new NeighborAncestryInfo(
current,
// descendant neighbor
Optional
.ofNullable(count > index + 1 ? statuses.get(index + 1) : null)
.filter(s -> current.id.equals(s.inReplyToId))
.orElse(null),
// ancestoring neighbor
Optional.ofNullable(index > 0 ? ancestry.get(index - 1) : null)
.filter(ancestor -> Optional.ofNullable(ancestor.descendantNeighbor)
.map(ancestorsDescendant -> current.id.equals(ancestorsDescendant.id))
.orElse(false))
.map(a -> a.status)
.orElse(null)
));
}
return ancestry;
}
public static void sortStatusContext(Status mainStatus, StatusContext context) {
List<String> threadIds=new ArrayList<>();
threadIds.add(mainStatus.id);
for(Status s:context.descendants){
if(threadIds.contains(s.inReplyToId)){
threadIds.add(s.id);
}
}
threadIds.add(mainStatus.inReplyToId);
for(int i=context.ancestors.size()-1; i >= 0; i--){
Status s=context.ancestors.get(i);
if(s.inReplyToId != null && threadIds.contains(s.id)){
threadIds.add(s.inReplyToId);
}
}
context.ancestors=context.ancestors.stream().filter(s -> threadIds.contains(s.id)).collect(Collectors.toList());
context.descendants=getDescendantsOrdered(mainStatus.id,
context.descendants.stream()
.filter(s -> threadIds.contains(s.id))
.collect(Collectors.toList()));
}
private static List<Status> getDescendantsOrdered(String id, List<Status> statuses){
List<Status> out=new ArrayList<>();
for(Status s:getDirectDescendants(id, statuses)){
out.add(s);
getDirectDescendants(s.id, statuses).forEach(d ->{
out.add(d);
out.addAll(getDescendantsOrdered(d.id, statuses));
});
}
return out;
}
private static List<Status> getDirectDescendants(String id, List<Status> statuses){
return statuses.stream()
.filter(s -> id.equals(s.inReplyToId))
.collect(Collectors.toList());
}
private void filterStatuses(List<Status> statuses){
AccountSessionManager.get(accountID).filterStatuses(statuses, FilterContext.THREAD);
AccountSessionManager.get(accountID).filterStatuses(statuses, getFilterContext());
}
@Override
@@ -157,38 +426,128 @@ public class ThreadFragment extends StatusListFragment{
showContent();
if(!loaded)
footerProgress.setVisibility(View.VISIBLE);
list.setItemAnimator(new BetterItemAnimator() {
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
super.onAnimationFinished(viewHolder);
contextInitiallyRendered = true;
// for the case that both requests are already done (and thus won't apply it)
maybeApplyMainStatus();
}
});
}
protected void onStatusCreated(Status status){
if(status.inReplyToId!=null && getStatusByID(status.inReplyToId)!=null){
onAppendItems(Collections.singletonList(status));
data.add(status);
if (status.inReplyToId == null) return;
Status repliedToStatus = getStatusByID(status.inReplyToId);
if (repliedToStatus == null) return;
NeighborAncestryInfo ancestry = ancestryMap.get(repliedToStatus.id);
int nextDisplayItemsIndex = -1, indexOfPreviousDisplayItem = -1;
if (ancestry != null) for (int i = 0; i < displayItems.size(); i++) {
StatusDisplayItem item = displayItems.get(i);
if (repliedToStatus.id.equals(item.parentID)) {
// saving the replied-to status' display items index to eventually reach the last one
indexOfPreviousDisplayItem = i;
item.hasDescendantNeighbor = true;
} else if (indexOfPreviousDisplayItem >= 0 && nextDisplayItemsIndex == -1) {
// previous display item was the replied-to status' display items
nextDisplayItemsIndex = i;
// nothing left to do if there's no other reply to that status
if (ancestry.descendantNeighbor == null) break;
}
if (ancestry.descendantNeighbor != null && item.parentID.equals(ancestry.descendantNeighbor.id)) {
// existing reply shall no longer have the replied-to status as its neighbor
item.hasAncestoringNeighbor = false;
}
}
// fall back to inserting the item at the end
nextDisplayItemsIndex = nextDisplayItemsIndex >= 0 ? nextDisplayItemsIndex : displayItems.size();
int nextDataIndex = data.indexOf(repliedToStatus) + 1;
// if replied-to status already has another reply...
if (ancestry != null && ancestry.descendantNeighbor != null) {
// update the reply's ancestry to remove its ancestoring neighbor (as we did above)
ancestryMap.get(ancestry.descendantNeighbor.id).ancestoringNeighbor = null;
// make sure the existing reply has a reply line
if (nextDataIndex < data.size() &&
!(displayItems.get(nextDisplayItemsIndex) instanceof ReblogOrReplyLineStatusDisplayItem)) {
Status nextStatus = data.get(nextDataIndex);
if (!nextStatus.account.id.equals(repliedToStatus.account.id)) {
// create reply line manually since we're not building that status' items
displayItems.add(nextDisplayItemsIndex, StatusDisplayItem.buildReplyLine(
this, nextStatus, accountID, nextStatus, repliedToStatus.account, false
));
}
}
}
// update replied-to status' ancestry
if (ancestry != null) ancestry.descendantNeighbor = status;
// add ancestry for newly created status before building its display items
ancestryMap.put(status.id, new NeighborAncestryInfo(status, null, repliedToStatus));
displayItems.addAll(nextDisplayItemsIndex, buildDisplayItems(status));
data.add(nextDataIndex, status);
adapter.notifyDataSetChanged();
}
public Status getMainStatus(){
return mainStatus;
}
@Override
public boolean isItemEnabled(String id){
return !id.equals(mainStatus.id);
return !id.equals(mainStatus.id) || !mainStatus.filterRevealed;
}
@Override
protected RecyclerView.Adapter getAdapter(){
MergeRecyclerAdapter a=new MergeRecyclerAdapter();
a.addAdapter(super.getAdapter());
endMark=new ImageView(getActivity());
endMark.setScaleType(ImageView.ScaleType.CENTER);
endMark.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OutlineVariant)));
endMark.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(25)));
endMark.setImageResource(R.drawable.thread_end_mark);
a.addAdapter(new SingleViewRecyclerAdapter(endMark));
return a;
public boolean wantsLightStatusBar(){
return !UiUtils.isDarkTheme();
}
@Override
protected boolean needDividerForExtraItem(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder){
return bottomSibling==endMark;
public boolean wantsLightNavigationBar(){
return !UiUtils.isDarkTheme();
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.THREAD;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return Uri.parse(mainStatus.url);
}
protected static class NeighborAncestryInfo {
protected Status status, descendantNeighbor, ancestoringNeighbor;
protected NeighborAncestryInfo(@NonNull Status status, Status descendantNeighbor, Status ancestoringNeighbor) {
this.status = status;
this.descendantNeighbor = descendantNeighbor;
this.ancestoringNeighbor = ancestoringNeighbor;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
NeighborAncestryInfo that = (NeighborAncestryInfo) o;
return status.equals(that.status)
&& Objects.equals(descendantNeighbor, that.descendantNeighbor)
&& Objects.equals(ancestoringNeighbor, that.ancestoringNeighbor);
}
@Override
public int hashCode() {
return Objects.hash(status, descendantNeighbor, ancestoringNeighbor);
}
}
@Override

View File

@@ -1,17 +1,68 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountByHandle;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.parceler.Parcels;
public abstract class AccountRelatedAccountListFragment extends PaginatedAccountListFragment{
import java.util.Optional;
public abstract class AccountRelatedAccountListFragment extends PaginatedAccountListFragment<Account> {
protected Account account;
protected String initialSubtitle = "";
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
account=Parcels.unwrap(getArguments().getParcelable("targetAccount"));
if (getArguments().containsKey("remoteAccount")) {
remoteInfo = Parcels.unwrap(getArguments().getParcelable("remoteAccount"));
}
setTitle("@"+account.acct);
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.path(isInstanceAkkoma()
? "/users/" + account.id
: '@' + account.acct).build();
}
@Override
public String getRemoteDomain() {
return account.getDomainFromURL();
}
@Override
public Account getCurrentInfo() {
return doneWithHomeInstance && remoteInfo != null ? remoteInfo : account;
}
@Override
protected MastodonAPIRequest<Account> loadRemoteInfo() {
return new GetAccountByHandle(account.acct);
}
@Override
protected AccountSession getRemoteSession() {
return Optional.ofNullable(remoteInfo)
.map(AccountSessionManager.getInstance()::tryGetAccount)
.orElse(null);
}
@Override
protected void onRemoteLoadingFailed() {
super.onRemoteLoadingFailed();
String prefix = initialSubtitle == null ? "" :
initialSubtitle + " " + getContext().getString(R.string.sk_separator) + " ";
String str = prefix +
getContext().getString(R.string.sk_no_remote_info_hint, getSession().domain);
setSubtitle(str);
}
}

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
@@ -106,4 +107,9 @@ public class AccountSearchFragment extends BaseAccountListFragment{
if(!TextUtils.isEmpty(currentQuery))
loadData();
}
@Override
public Uri getWebUri(Uri.Builder base) {
return null;
}
}

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.fragments.account_list;
import android.app.assist.AssistContent;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
@@ -13,9 +14,9 @@ import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import java.util.ArrayList;
import java.util.HashMap;
@@ -34,7 +35,7 @@ import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class BaseAccountListFragment extends MastodonRecyclerFragment<AccountViewModel>{
public abstract class BaseAccountListFragment extends MastodonRecyclerFragment<AccountViewModel> implements ProvidesAssistContent.ProvidesWebUri {
protected HashMap<String, Relationship> relationships=new HashMap<>();
protected String accountID;
protected ArrayList<APIRequest<?>> relationshipsRequests=new ArrayList<>();
@@ -85,6 +86,7 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment<A
for(Relationship rel:result){
relationships.put(rel.id, rel);
}
if(getActivity()==null) return;
if(list==null)
return;
for(int i=0;i<list.getChildCount();i++){
@@ -141,6 +143,16 @@ public abstract class BaseAccountListFragment extends MastodonRecyclerFragment<A
super.onApplyWindowInsets(insets);
}
@Override
public String getAccountID() {
return accountID;
}
@Override
public void onProvideAssistContent(AssistContent assistContent) {
assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon()));
}
protected void onConfigureViewHolder(AccountViewHolder holder){}
protected void onBindViewHolder(AccountViewHolder holder){}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountBlocks;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
public class BlockedAccountsListFragment extends AccountRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.sk_blocked_accounts);
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetAccountBlocks(maxID, count);
}
@Override
protected void onConfigureViewHolder(AccountViewHolder holder){
super.onConfigureViewHolder(holder);
holder.setStyle(AccountViewHolder.AccessoryType.NONE, false);
}
@Override
public Uri getWebUri(Uri.Builder base) {
return super.getWebUri(base).buildUpon()
.appendPath("/blocks").build();
}
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountBlocks;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
public class BlocksListFragment extends AccountRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.mo_blocked_accounts);
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetAccountBlocks(maxID, count);
}
@Override
protected void onConfigureViewHolder(AccountViewHolder holder){
super.onConfigureViewHolder(holder);
holder.setStyle(AccountViewHolder.AccessoryType.NONE, false);
}
@Override
public Uri getWebUri(Uri.Builder base) {
return super.getWebUri(base).buildUpon()
.appendPath("/blocks").build();
}
}

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.R;
@@ -12,11 +13,17 @@ public class FollowerListFragment extends AccountRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setSubtitle(getResources().getQuantityString(R.plurals.x_followers, (int)(account.followersCount%1000), account.followersCount));
setSubtitle(initialSubtitle = getResources().getQuantityString(R.plurals.x_followers, (int)(account.followersCount%1000), account.followersCount));
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetAccountFollowers(account.id, maxID, count);
return new GetAccountFollowers(getCurrentInfo().id, maxID, count);
}
@Override
public Uri getWebUri(Uri.Builder base) {
return super.getWebUri(base).buildUpon()
.appendPath(isInstanceAkkoma() ? "#followers" : "/followers").build();
}
}

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.R;
@@ -12,11 +13,17 @@ public class FollowingListFragment extends AccountRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setSubtitle(getResources().getQuantityString(R.plurals.x_following, (int)(account.followingCount%1000), account.followingCount));
setSubtitle(initialSubtitle = getResources().getQuantityString(R.plurals.x_following, (int)(account.followingCount%1000), account.followingCount));
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetAccountFollowing(account.id, maxID, count);
return new GetAccountFollowing(getCurrentInfo().id, maxID, count);
}
@Override
public Uri getWebUri(Uri.Builder base) {
return super.getWebUri(base).buildUpon()
.appendPath(isInstanceAkkoma() ? "#followees" : "/following").build();
}
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountMutes;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
public class MutedAccountsListFragment extends AccountRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.sk_muted_accounts);
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetAccountMutes(maxID, count);
}
@Override
protected void onConfigureViewHolder(AccountViewHolder holder){
super.onConfigureViewHolder(holder);
holder.setStyle(AccountViewHolder.AccessoryType.NONE, false);
}
@Override
public Uri getWebUri(Uri.Builder base) {
return super.getWebUri(base).buildUpon()
.appendPath("/mutes").build();
}
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.accounts.GetAccountMutes;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
public class MutesListFragment extends AccountRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.mo_muted_accounts);
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetAccountMutes(maxID, count);
}
@Override
protected void onConfigureViewHolder(AccountViewHolder holder){
super.onConfigureViewHolder(holder);
holder.setStyle(AccountViewHolder.AccessoryType.NONE, false);
}
@Override
public Uri getWebUri(Uri.Builder base) {
return super.getWebUri(base).buildUpon()
.appendPath("/mutes").build();
}
}

View File

@@ -1,33 +1,179 @@
package org.joinmastodon.android.fragments.account_list;
import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
public abstract class PaginatedAccountListFragment extends BaseAccountListFragment{
public abstract class PaginatedAccountListFragment<T> extends BaseAccountListFragment{
private String nextMaxID;
private MastodonAPIRequest<T> remoteInfoRequest;
protected boolean doneWithHomeInstance, remoteRequestFailed, startedRemoteLoading, remoteDisabled;
protected int localOffset;
protected T remoteInfo;
public abstract HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count);
protected abstract MastodonAPIRequest<T> loadRemoteInfo();
public abstract T getCurrentInfo();
public abstract String getRemoteDomain();
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// already have remote info (e.g. from arguments), so no need to fetch it again
if (remoteInfo != null) {
onRemoteInfoLoaded(remoteInfo);
return;
}
remoteDisabled = !GlobalUserPreferences.allowRemoteLoading
|| getSession().domain.equals(getRemoteDomain());
if (!remoteDisabled) {
remoteInfoRequest = loadRemoteInfo().setCallback(new Callback<>() {
@Override
public void onSuccess(T result) {
if (getContext() == null) return;
onRemoteInfoLoaded(result);
}
@Override
public void onError(ErrorResponse error) {
if (getContext() == null) return;
onRemoteLoadingFailed();
}
});
remoteInfoRequest.execRemote(getRemoteDomain(), getRemoteSession());
}
}
/**
* override to provide an ideal account session (e.g. if you're logged into the author's remote
* account) to make the remote request from. if null is provided, will try to get any session
* on the remote domain, or tries the request without authentication.
*/
protected AccountSession getRemoteSession() {
return null;
}
protected void onRemoteInfoLoaded(T info) {
this.remoteInfo = info;
this.remoteInfoRequest = null;
maybeStartLoadingRemote();
}
protected void onRemoteLoadingFailed() {
this.remoteRequestFailed = true;
this.remoteInfo = null;
this.remoteInfoRequest = null;
if (doneWithHomeInstance) dataLoaded();
}
@Override
public void dataLoaded() {
super.dataLoaded();
footerProgress.setVisibility(View.GONE);
}
private void maybeStartLoadingRemote() {
if (startedRemoteLoading || remoteDisabled) return;
if (!remoteRequestFailed) {
if (data.size() == 0) showProgress();
else footerProgress.setVisibility(View.VISIBLE);
}
if (doneWithHomeInstance && remoteInfo != null) {
startedRemoteLoading = true;
loadData(localOffset, itemsPerPage * 2);
}
}
@Override
public void onRefresh() {
localOffset = 0;
doneWithHomeInstance = false;
startedRemoteLoading = false;
super.onRefresh();
}
@Override
public void loadData(int offset, int count) {
// always subtract the amount loaded through the home instance once loading from remote
// since loadData gets called with data.size() (data includes both local and remote)
if (doneWithHomeInstance) offset -= localOffset;
super.loadData(offset, count);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=onCreateRequest(offset==0 ? null : nextMaxID, count)
MastodonAPIRequest<?> request = onCreateRequest(offset==0 ? null : nextMaxID, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(HeaderPaginationList<Account> result){
boolean justRefreshed = !doneWithHomeInstance && offset == 0;
Collection<AccountViewModel> d = justRefreshed ? List.of() : data;
if(result.nextPageUri!=null)
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), nextMaxID!=null);
if(getActivity()==null) return;
List<AccountViewModel> items = result.stream()
.filter(a -> d.size() > 1000 || d.stream()
.noneMatch(i -> i.account.url.equals(a.url)))
.peek(account ->{
if (account.getDomainFromURL().equals(getRemoteDomain()))
account.acct=account.getFullyQualifiedName();
})
.map(a->new AccountViewModel(a, accountID))
.collect(Collectors.toList());
boolean hasMore = nextMaxID != null;
if (!hasMore && !doneWithHomeInstance) {
// only runs last time data was fetched from the home instance
localOffset = d.size() + items.size();
doneWithHomeInstance = true;
}
onDataLoaded(items, hasMore);
if (doneWithHomeInstance) maybeStartLoadingRemote();
}
})
.exec(accountID);
@Override
public void onError(ErrorResponse error) {
if (doneWithHomeInstance) {
onRemoteLoadingFailed();
onDataLoaded(Collections.emptyList(), false);
return;
}
super.onError(error);
}
});
if (doneWithHomeInstance && remoteInfo == null) return; // we are waiting
if (doneWithHomeInstance && remoteInfo != null) {
request.execRemote(getRemoteDomain(), getRemoteSession());
} else {
request.exec(accountID);
}
currentRequest = request;
}
@Override

View File

@@ -0,0 +1,97 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.statuses.PleromaGetStatusReactions;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiReaction;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
public class StatusEmojiReactionsListFragment extends BaseAccountListFragment {
private String id;
private String emojiName;
private String url;
private int count;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
id = getArguments().getString("statusID");
emojiName = getArguments().getString("emoji");
url = getArguments().getString("url");
count = getArguments().getInt("count");
SpannableStringBuilder title = new SpannableStringBuilder(getResources().getQuantityString(R.plurals.sk_users_reacted_with, count,
count, url == null ? emojiName : ":"+emojiName+":"));
if (url != null) {
Emoji emoji = new Emoji();
emoji.shortcode = emojiName;
emoji.url = url;
HtmlParser.parseCustomEmoji(title, Collections.singletonList(emoji));
}
setTitle(title);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
if (url != null) {
UiUtils.loadCustomEmojiInTextView(toolbarTitleView);
}
}
@Override
public void dataLoaded() {
super.dataLoaded();
footerProgress.setVisibility(View.GONE);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest = new PleromaGetStatusReactions(id, emojiName)
.setCallback(new SimpleCallback<>(StatusEmojiReactionsListFragment.this){
@Override
public void onSuccess(List<EmojiReaction> result) {
if (getActivity() == null)
return;
List<AccountViewModel> items = result.get(0).accounts.stream()
.map(a -> new AccountViewModel(a, accountID))
.collect(Collectors.toList());
onDataLoaded(items);
}
@Override
public void onError(ErrorResponse error) {
super.onError(error);
}
})
.exec(accountID);
}
@Override
public void onResume(){
super.onResume();
if(!loaded && !dataLoading)
loadData();
}
@Override
public Uri getWebUri(Uri.Builder base) {
return null;
}
}

View File

@@ -1,21 +1,36 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.statuses.GetStatusFavorites;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Status;
public class StatusFavoritesListFragment extends StatusRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
updateTitle(status);
}
@Override
protected void updateTitle(Status status) {
setTitle(getResources().getQuantityString(R.plurals.x_favorites, (int)(status.favouritesCount%1000), status.favouritesCount));
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetStatusFavorites(status.id, maxID, count);
return new GetStatusFavorites(getCurrentInfo().id, maxID, count);
}
@Override
public Uri getWebUri(Uri.Builder base) {
Uri statusUri = super.getWebUri(base);
return isInstanceAkkoma()
? statusUri
: statusUri.buildUpon().appendPath("favourites").build();
}
}

View File

@@ -1,21 +1,36 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.HeaderPaginationRequest;
import org.joinmastodon.android.api.requests.statuses.GetStatusReblogs;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Status;
public class StatusReblogsListFragment extends StatusRelatedAccountListFragment{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
updateTitle(status);
}
@Override
protected void updateTitle(Status status) {
setTitle(getResources().getQuantityString(R.plurals.x_reblogs, (int)(status.reblogsCount%1000), status.reblogsCount));
}
@Override
public HeaderPaginationRequest<Account> onCreateRequest(String maxID, int count){
return new GetStatusReblogs(status.id, maxID, count);
return new GetStatusReblogs(getCurrentInfo().id, maxID, count);
}
@Override
public Uri getWebUri(Uri.Builder base) {
Uri statusUri = super.getWebUri(base);
return isInstanceAkkoma()
? statusUri
: statusUri.buildUpon().appendPath("reblogs").build();
}
}

View File

@@ -1,17 +1,77 @@
package org.joinmastodon.android.fragments.account_list;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.statuses.GetStatusByID;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Status;
import org.parceler.Parcels;
public abstract class StatusRelatedAccountListFragment extends PaginatedAccountListFragment{
import java.util.Optional;
public abstract class StatusRelatedAccountListFragment extends PaginatedAccountListFragment<Status> {
protected Status status;
protected abstract void updateTitle(Status status);
protected MastodonAPIRequest<Status> loadRemoteInfo() {
String[] parts = status.url.split("/");
if (parts.length == 0) return null;
return new GetStatusByID(parts[parts.length - 1]);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
status=Parcels.unwrap(getArguments().getParcelable("status"));
}
@Override
protected boolean hasSubtitle(){
return remoteRequestFailed;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base
.encodedPath(isInstanceAkkoma()
? "/notice/" + status.id
: '@' + status.account.acct + '/' + status.id)
.build();
}
@Override
public String getRemoteDomain() {
return Uri.parse(status.url).getHost();
}
@Override
public Status getCurrentInfo() {
return doneWithHomeInstance && remoteInfo != null ? remoteInfo : status;
}
@Override
protected AccountSession getRemoteSession() {
return Optional.ofNullable(remoteInfo)
.map(s -> s.account)
.map(AccountSessionManager.getInstance()::tryGetAccount)
.orElse(null);
}
@Override
protected void onRemoteInfoLoaded(Status info) {
super.onRemoteInfoLoaded(info);
updateTitle(remoteInfo);
}
@Override
protected void onRemoteLoadingFailed() {
super.onRemoteLoadingFailed();
setSubtitle(getContext().getString(R.string.sk_no_remote_info_hint, getSession().domain));
updateToolbar();
}
}

View File

@@ -0,0 +1,68 @@
package org.joinmastodon.android.fragments.discover;
import android.net.Uri;
import android.os.Bundle;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.api.requests.timelines.GetBubbleTimeline;
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 java.util.List;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class BubbleTimelineFragment extends StatusListFragment {
private DiscoverInfoBannerHelper bannerHelper;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.BUBBLE_TIMELINE, accountID);
}
@Override
protected boolean wantsComposeButton() {
return true;
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetBubbleTimeline(getMaxID(), count, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(getActivity()==null) return;
boolean more=applyMaxID(result);
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext());
onDataLoaded(result, more);
bannerHelper.onBannerBecameVisible();
}
})
.exec(accountID);
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
bannerHelper.maybeAddBanner(list, adapter);
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.PUBLIC;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return isInstanceAkkoma() ? base.path("/main/bubble").build() : null;
}
}

View File

@@ -1,39 +1,88 @@
package org.joinmastodon.android.fragments.discover;
import android.graphics.Rect;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.accounts.GetFollowSuggestions;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.IsOnTop;
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.fragments.account_list.BaseAccountListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FollowSuggestion;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ProgressBarButton;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.parceler.Parcels;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class DiscoverAccountsFragment extends BaseAccountListFragment implements ScrollableToTop{
private DiscoverInfoBannerHelper bannerHelper;
public class DiscoverAccountsFragment extends MastodonRecyclerFragment<DiscoverAccountsFragment.AccountWrapper> implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri {
private String accountID;
private Map<String, Relationship> relationships=Collections.emptyMap();
private GetAccountRelationships relationshipsRequest;
public DiscoverAccountsFragment(){
super(20);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.ACCOUNTS, accountID);
accountID=getArguments().getString("account");
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
}
@Override
protected void doLoadData(int offset, int count){
if(relationshipsRequest!=null){
relationshipsRequest.cancel();
relationshipsRequest=null;
}
currentRequest=new GetFollowSuggestions(count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowSuggestion> result){
List<AccountViewModel> accounts=result.stream().map(fs->new AccountViewModel(fs.account, accountID)).collect(Collectors.toList());
onDataLoaded(accounts, false);
bannerHelper.onBannerBecameVisible();
if(getActivity()==null) return;
onDataLoaded(result.stream().map(fs->new AccountWrapper(fs.account)).collect(Collectors.toList()), false);
loadRelationships();
}
})
.exec(accountID);
@@ -41,14 +90,253 @@ public class DiscoverAccountsFragment extends BaseAccountListFragment implements
@Override
protected RecyclerView.Adapter getAdapter(){
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
bannerHelper.maybeAddBanner(list, adapter);
adapter.addAdapter(super.getAdapter());
return adapter;
return new AccountsAdapter();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
outRect.bottom=outRect.left=outRect.right=V.dp(16);
if(parent.getChildAdapterPosition(view)==0)
outRect.top=V.dp(16);
}
});
((UsableRecyclerView)list).setDrawSelectorOnTop(true);
}
private void loadRelationships(){
relationships=Collections.emptyMap();
relationshipsRequest=new GetAccountRelationships(data.stream().map(fs->fs.account.id).collect(Collectors.toList()));
relationshipsRequest.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Relationship> result){
relationshipsRequest=null;
relationships=result.stream().collect(Collectors.toMap(rel->rel.id, Function.identity()));
if(getActivity()==null) return;
if(list==null)
return;
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof AccountViewHolder avh)
avh.rebind();
}
}
@Override
public void onError(ErrorResponse error){
relationshipsRequest=null;
}
}).exec(accountID);
}
@Override
public void onDestroyView(){
super.onDestroyView();
if(relationshipsRequest!=null){
relationshipsRequest.cancel();
relationshipsRequest=null;
}
}
@Override
public void scrollToTop(){
smoothScrollRecyclerViewToTop(list);
}
@Override
public boolean isOnTop() {
return isRecyclerViewOnTop(list);
}
@Override
public String getAccountID() {
return accountID;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return isInstanceAkkoma() ? null : base.path("/explore/suggestions").build();
}
private class AccountsAdapter extends UsableRecyclerView.Adapter<AccountViewHolder> implements ImageLoaderRecyclerAdapter{
public AccountsAdapter(){
super(imgLoader);
}
@Override
public void onBindViewHolder(AccountViewHolder holder, int position){
holder.bind(data.get(position));
super.onBindViewHolder(holder, position);
}
@NonNull
@Override
public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new AccountViewHolder();
}
@Override
public int getItemCount(){
return data.size();
}
@Override
public int getImageCountForItem(int position){
return 2+data.get(position).emojiHelper.getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
AccountWrapper item=data.get(position);
if(image==0)
return item.avaRequest;
else if(image==1)
return item.coverRequest;
else
return item.emojiHelper.getImageRequest(image-2);
}
}
private class AccountViewHolder extends BindableViewHolder<AccountWrapper> implements ImageLoaderViewHolder, UsableRecyclerView.DisableableClickable{
private final ImageView cover, avatar;
private final TextView name, username, bio, followersCount, followingCount, postsCount, followersLabel, followingLabel, postsLabel;
private final ProgressBarButton actionButton;
private final ProgressBar actionProgress;
private final View actionWrap;
private Relationship relationship;
public AccountViewHolder(){
super(getActivity(), R.layout.item_discover_account, list);
cover=findViewById(R.id.cover);
avatar=findViewById(R.id.avatar);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
bio=findViewById(R.id.bio);
followersCount=findViewById(R.id.followers_count);
followersLabel=findViewById(R.id.followers_label);
followingCount=findViewById(R.id.following_count);
followingLabel=findViewById(R.id.following_label);
postsCount=findViewById(R.id.posts_count);
postsLabel=findViewById(R.id.posts_label);
actionButton=findViewById(R.id.action_btn);
actionProgress=findViewById(R.id.action_progress);
actionWrap=findViewById(R.id.action_btn_wrap);
avatar.setOutlineProvider(OutlineProviders.roundedRect(15));
avatar.setClipToOutline(true);
View border=findViewById(R.id.avatar_border);
border.setOutlineProvider(OutlineProviders.roundedRect(17));
border.setClipToOutline(true);
cover.setOutlineProvider(OutlineProviders.roundedRect(9));
cover.setClipToOutline(true);
itemView.setOutlineProvider(OutlineProviders.roundedRect(12));
itemView.setClipToOutline(true);
actionButton.setOnClickListener(this::onActionButtonClick);
itemView.setOnClickListener(v->this.onClick());
}
@Override
public boolean isEnabled(){
return false;
}
@Override
public void onBind(AccountWrapper item){
name.setText(item.parsedName);
username.setText('@'+item.account.acct);
bio.setText(item.parsedBio);
followersCount.setText(UiUtils.abbreviateNumber(item.account.followersCount));
followingCount.setText(UiUtils.abbreviateNumber(item.account.followingCount));
postsCount.setText(UiUtils.abbreviateNumber(item.account.statusesCount));
followersLabel.setText(getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, item.account.followersCount)));
followingLabel.setText(getResources().getQuantityString(R.plurals.following, (int)Math.min(999, item.account.followingCount)));
postsLabel.setText(getResources().getQuantityString(R.plurals.sk_posts_count_label, (int)(item.account.statusesCount%1000), item.account.statusesCount));
followersCount.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followersLabel.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followingCount.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE);
followingLabel.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE);
relationship=relationships.get(item.account.id);
UiUtils.setExtraTextInfo(getContext(), null, true, false, false, item.account);
if(relationship==null){
actionWrap.setVisibility(View.GONE);
}else{
actionWrap.setVisibility(View.VISIBLE);
UiUtils.setRelationshipToActionButtonM3(relationship, actionButton);
}
}
@Override
public void setImage(int index, Drawable image){
if(index==0){
avatar.setImageDrawable(image);
}else if(index==1){
cover.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-2, image);
name.invalidate();
bio.invalidate();
}
if(image instanceof Animatable a && !a.isRunning())
a.start();
}
@Override
public void clearImage(int index){
setImage(index, null);
}
@Override
public void onClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(item.account));
Nav.go(getActivity(), ProfileFragment.class, args);
}
private void onActionButtonClick(View v){
itemView.setHasTransientState(true);
UiUtils.performAccountAction(getActivity(), item.account, accountID, relationship, actionButton, this::setActionProgressVisible, rel->{
itemView.setHasTransientState(false);
relationships.put(item.account.id, rel);
rebind();
});
}
private void setActionProgressVisible(boolean visible){
actionButton.setTextVisible(!visible);
actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE);
if(visible)
actionProgress.setIndeterminateTintList(actionButton.getTextColors());
actionButton.setClickable(!visible);
}
}
protected class AccountWrapper{
public Account account;
public ImageLoaderRequest avaRequest, coverRequest;
public CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
public CharSequence parsedName, parsedBio;
public AccountWrapper(Account account){
this.account=account;
avaRequest=new UrlImageLoaderRequest(
TextUtils.isEmpty(account.avatar) ? AccountSessionManager.getInstance().getAccount(accountID).getDefaultAvatarUrl() : account.avatar,
V.dp(50), V.dp(50));
if(!TextUtils.isEmpty(account.header))
coverRequest=new UrlImageLoaderRequest(account.header, 1000, 1000);
parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
if(account.emojis.isEmpty()){
parsedName= account.getDisplayName();
}else{
parsedName=HtmlParser.parseCustomEmoji(account.getDisplayName(), account.emojis);
emojiHelper.setText(new SpannableStringBuilder(parsedName).append(parsedBio));
}
}
}
}

View File

@@ -12,8 +12,12 @@ import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.IsOnTop;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.SimpleViewHolder;
@@ -31,7 +35,7 @@ import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.utils.V;
public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener{
public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener, IsOnTop {
private static final int QUERY_RESULT=937;
private TabLayout tabLayout;
@@ -53,6 +57,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
private String accountID;
private String currentQuery;
private boolean disableDiscover;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
@@ -74,8 +80,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
for(int i=0;i<tabViews.length;i++){
FrameLayout tabView=new FrameLayout(getActivity());
tabView.setId(switch(i){
case 0 -> R.id.discover_posts;
case 1 -> R.id.discover_hashtags;
case 0 -> R.id.discover_hashtags;
case 1 -> R.id.discover_posts;
case 2 -> R.id.discover_news;
case 3 -> R.id.discover_users;
default -> throw new IllegalStateException("Unexpected value: "+i);
@@ -93,8 +99,6 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){
@Override
public void onPageSelected(int position){
if(position==0)
return;
Fragment _page=getFragmentForPage(position);
if(_page instanceof BaseRecyclerFragment<?> page){
if(!page.loaded && !page.isDataLoading())
@@ -103,7 +107,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
}
});
if(postsFragment==null){
if(hashtagsFragment==null){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putBoolean("__is_tab", true);
@@ -121,8 +125,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
accountsFragment.setArguments(args);
getChildFragmentManager().beginTransaction()
.add(R.id.discover_posts, postsFragment)
.add(R.id.discover_hashtags, hashtagsFragment)
.add(R.id.discover_posts, postsFragment)
.add(R.id.discover_news, newsFragment)
.add(R.id.discover_users, accountsFragment)
.commit();
@@ -132,8 +136,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
@Override
public void onConfigureTab(@NonNull TabLayout.Tab tab, int position){
tab.setText(switch(position){
case 0 -> R.string.posts;
case 1 -> R.string.hashtags;
case 0 -> R.string.hashtags;
case 1 -> R.string.posts;
case 2 -> R.string.news;
case 3 -> R.string.for_you;
default -> throw new IllegalStateException("Unexpected value: "+position);
@@ -154,6 +158,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
}
});
disableDiscover=AccountSessionManager.get(accountID).getInstance().map(Instance::isAkkoma).orElse(false);
searchView=view.findViewById(R.id.search_fragment);
if(searchFragment==null){
searchFragment=new SearchFragment();
@@ -165,11 +170,13 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
searchBack=view.findViewById(R.id.search_back);
searchText=view.findViewById(R.id.search_text);
searchBack.setEnabled(searchActive);
searchBack.setImportantForAccessibility(searchActive ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO);
searchBack.setOnClickListener(v->exitSearch());
if(searchActive){
searchBack.setImageResource(R.drawable.ic_arrow_back);
searchBack.setOnClickListener(v->{
if(searchActive) exitSearch(); else openSearch();
});
if(searchActive) searchBack.setImageResource(R.drawable.ic_fluent_arrow_left_24_regular);
else searchBack.setEnabled(false);
if(searchActive || disableDiscover){
pager.setVisibility(View.GONE);
tabLayout.setVisibility(View.GONE);
searchView.setVisibility(View.VISIBLE);
@@ -178,22 +185,35 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
View searchWrap=view.findViewById(R.id.search_wrap);
searchWrap.setOutlineProvider(OutlineProviders.roundedRect(28));
searchWrap.setClipToOutline(true);
searchText.setOnClickListener(v->{
Bundle args=new Bundle();
args.putString("account", accountID);
if(!TextUtils.isEmpty(currentQuery)){
args.putString("query", currentQuery);
}
Nav.goForResult(getActivity(), SearchQueryFragment.class, args, QUERY_RESULT, DiscoverFragment.this);
});
searchText.setOnClickListener(v->openSearch());
tabsDivider=view.findViewById(R.id.tabs_divider);
return view;
}
@Override
public boolean isOnTop() {
return searchActive ? searchFragment.isOnTop()
: ((IsOnTop)getFragmentForPage(pager.getCurrentItem())).isOnTop();
}
public void openSearch() {
Bundle args=new Bundle();
args.putString("account", accountID);
if(!TextUtils.isEmpty(currentQuery)){
args.putString("query", currentQuery);
}
Nav.goForResult(getActivity(), SearchQueryFragment.class, args, QUERY_RESULT, DiscoverFragment.this);
}
@Override
public void scrollToTop(){
if(!searchActive){
if (((IsOnTop)getFragmentForPage(pager.getCurrentItem())).isOnTop() && GlobalUserPreferences.doubleTapToSwipe){
int nextPage=(pager.getCurrentItem()+1)%tabViews.length;
pager.setCurrentItem(nextPage, true);
return;
}
((ScrollableToTop)getFragmentForPage(pager.getCurrentItem())).scrollToTop();
}else{
searchFragment.scrollToTop();
@@ -201,8 +221,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
}
public void loadData(){
if(postsFragment!=null && !postsFragment.loaded && !postsFragment.dataLoading)
postsFragment.loadData();
if(hashtagsFragment!=null && !hashtagsFragment.loaded && !hashtagsFragment.dataLoading)
hashtagsFragment.loadData();
}
private void enterSearch(){
@@ -211,7 +231,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
pager.setVisibility(View.GONE);
tabLayout.setVisibility(View.GONE);
searchView.setVisibility(View.VISIBLE);
searchBack.setImageResource(R.drawable.ic_arrow_back);
searchBack.setImageResource(R.drawable.ic_fluent_arrow_left_24_regular);
searchBack.setEnabled(true);
searchBack.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
tabsDivider.setVisibility(View.GONE);
@@ -222,21 +242,24 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop,
if(!searchActive)
return;
searchActive=false;
searchText.setText(R.string.sk_search_fediverse);
searchBack.setImageResource(R.drawable.ic_fluent_search_24_regular);
searchBack.setEnabled(false);
searchBack.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
currentQuery=null;
searchFragment.clear();
if(disableDiscover) return;
pager.setVisibility(View.VISIBLE);
tabLayout.setVisibility(View.VISIBLE);
searchView.setVisibility(View.GONE);
searchText.setText(R.string.search_mastodon);
searchBack.setImageResource(R.drawable.ic_search_24px);
searchBack.setEnabled(false);
searchBack.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
tabsDivider.setVisibility(View.VISIBLE);
currentQuery=null;
}
private Fragment getFragmentForPage(int page){
return switch(page){
case 0 -> postsFragment;
case 1 -> hashtagsFragment;
case 0 -> hashtagsFragment;
case 1 -> postsFragment;
case 2 -> newsFragment;
case 3 -> accountsFragment;
default -> throw new IllegalStateException("Unexpected value: "+page);

View File

@@ -2,8 +2,8 @@ package org.joinmastodon.android.fragments.discover;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
@@ -11,17 +11,16 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.trends.GetTrendingLinks;
import org.joinmastodon.android.fragments.IsOnTop;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.Card;
import org.joinmastodon.android.model.viewmodel.CardViewModel;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@@ -32,18 +31,16 @@ import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.ListImageLoaderAdapter;
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
import me.grishka.appkit.imageloader.RecyclerViewDelegate;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> implements ScrollableToTop{
public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> implements ScrollableToTop, IsOnTop{
private String accountID;
private DiscoverInfoBannerHelper bannerHelper;
private MergeRecyclerAdapter mergeAdapter;
@@ -60,6 +57,8 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> im
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_LINKS, accountID);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
}
@Override
@@ -111,6 +110,11 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment<CardViewModel> im
smoothScrollRecyclerViewToTop(list);
}
@Override
public boolean isOnTop(){
return isRecyclerViewOnTop(list);
}
private class LinksAdapter extends UsableRecyclerView.Adapter<BaseLinkViewHolder> implements ImageLoaderRecyclerAdapter{
private final List<CardViewModel> data;

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.fragments.discover;
import android.net.Uri;
import android.os.Bundle;
import org.joinmastodon.android.api.requests.trends.GetTrendingStatuses;
@@ -31,8 +32,9 @@ public class DiscoverPostsFragment extends StatusListFragment{
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(getActivity()==null) return;
realOffset+=result.size();
AccountSessionManager.get(accountID).filterStatuses(result, FilterContext.PUBLIC);
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext());
onDataLoaded(result, !result.isEmpty());
bannerHelper.onBannerBecameVisible();
}
@@ -46,4 +48,14 @@ public class DiscoverPostsFragment extends StatusListFragment{
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.PUBLIC;
}
@Override
public Uri getWebUri(Uri.Builder base){
return isInstanceAkkoma() ? null : base.path("/explore/posts").build();
}
}

View File

@@ -0,0 +1,63 @@
package org.joinmastodon.android.fragments.discover;
import android.net.Uri;
import android.os.Bundle;
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 java.util.List;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class FederatedTimelineFragment extends StatusListFragment{
private DiscoverInfoBannerHelper bannerHelper;
private String maxID;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.FEDERATED_TIMELINE, accountID);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetPublicTimeline(false, false, getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(getActivity()==null) return;
boolean more=applyMaxID(result);
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext());
onDataLoaded(result, more);
bannerHelper.onBannerBecameVisible();
}
})
.exec(accountID);
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
bannerHelper.maybeAddBanner(list, adapter);
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.PUBLIC;
}
@Override
public Uri getWebUri(Uri.Builder base) {
return base.path(isInstanceAkkoma() ? "/main/all" : "/public").build();
}
}

View File

@@ -0,0 +1,63 @@
package org.joinmastodon.android.fragments.discover;
import android.net.Uri;
import android.os.Bundle;
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 java.util.List;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class LocalTimelineFragment extends StatusListFragment{
private DiscoverInfoBannerHelper bannerHelper;
private String maxID;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE, accountID);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetPublicTimeline(true, false, getMaxID(), null, count, null, getLocalPrefs().timelineReplyVisibility)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(getActivity()==null) return;
boolean more=applyMaxID(result);
AccountSessionManager.get(accountID).filterStatuses(result, getFilterContext());
onDataLoaded(result, more);
bannerHelper.onBannerBecameVisible();
}
})
.exec(accountID);
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
bannerHelper.maybeAddBanner(list, adapter);
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected FilterContext getFilterContext() {
return FilterContext.PUBLIC;
}
@Override
public Uri getWebUri(Uri.Builder base){
return base.path(isInstanceAkkoma() ? "/main/public" : "/public/local").build();
}
}

View File

@@ -1,10 +1,13 @@
package org.joinmastodon.android.fragments.discover;
import android.app.Activity;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.search.GetSearchResults;
import org.joinmastodon.android.api.session.AccountSessionManager;
@@ -12,6 +15,7 @@ import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.SearchResults;
@@ -33,6 +37,7 @@ import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.V;
public class SearchFragment extends BaseStatusListFragment<SearchResult>{
private String currentQuery;
@@ -50,7 +55,7 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
super.onCreate(savedInstanceState);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
setEmptyText(R.string.no_search_results);
setEmptyText(R.string.sk_recent_searches_placeholder);
loadData();
}
@@ -65,7 +70,7 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
return switch(s.type){
case ACCOUNT -> Collections.singletonList(new AccountStatusDisplayItem(s.id, this, s.account));
case HASHTAG -> Collections.singletonList(new HashtagStatusDisplayItem(s.id, this, s.hashtag));
case STATUS -> StatusDisplayItem.buildItems(this, s.status, accountID, s, knownAccounts, false, true);
case STATUS -> StatusDisplayItem.buildItems(this, s.status, accountID, s, knownAccounts, FilterContext.PUBLIC, 0);
};
}
@@ -165,6 +170,7 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
list.scrollToPosition(0);
}
})
.setTimeout(180000) // 3 minutes (searches can take a long time)
.exec(accountID);
}
@@ -180,13 +186,16 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
}
public void setQuery(String q, SearchResult.Type filter){
if(q.isBlank())
if(q.isBlank()) {
setEmptyText(R.string.sk_recent_searches_placeholder);
return;
}
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
currentQuery=q;
setEmptyText(R.string.no_search_results);
if(filter==null)
currentFilter=EnumSet.allOf(SearchResult.Type.class);
else
@@ -228,6 +237,21 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
}
}
public void clear() {
data.clear();
preloadedData.clear();
adapter.notifyDataSetChanged();
V.setVisibilityAnimated(content, View.GONE);
}
@Override
public Uri getWebUri(Uri.Builder base) {
Uri.Builder searchUri = base.path("/search");
return isInstanceAkkoma()
? searchUri.appendQueryParameter("query", currentQuery).build()
: searchUri.build();
}
@FunctionalInterface
public interface ProgressVisibilityListener{
void onProgressVisibilityChanged(boolean visible);

View File

@@ -4,12 +4,12 @@ import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.app.Fragment;
import android.graphics.Outline;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
@@ -22,19 +22,22 @@ import android.view.animation.AnimationUtils;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import android.widget.Toolbar;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.search.GetSearchResults;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.model.viewmodel.SearchResultViewModel;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.SearchViewHelper;
import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
@@ -91,11 +94,11 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
setRefreshEnabled(false);
setEmptyText("");
openUrlItem=new ListItem<>(R.string.search_open_url, 0, R.drawable.ic_link_24px, this::onOpenURLClick);
goToHashtagItem=new ListItem<>("", null, R.drawable.ic_tag_24px, this::onGoToHashtagClick);
goToAccountItem=new ListItem<>("", null, R.drawable.ic_person_24px, this::onGoToAccountClick);
goToStatusSearchItem=new ListItem<>("", null, R.drawable.ic_search_24px, this::onGoToStatusSearchClick);
goToAccountSearchItem=new ListItem<>("", null, R.drawable.ic_group_24px, this::onGoToAccountSearchClick);
openUrlItem=new ListItem<>(R.string.search_open_url, 0, R.drawable.ic_fluent_link_24_regular, this::onOpenURLClick);
goToHashtagItem=new ListItem<>("", null, R.drawable.ic_fluent_number_symbol_24_regular, this::onGoToHashtagClick);
goToAccountItem=new ListItem<>("", null, R.drawable.ic_fluent_person_24_regular, this::onGoToAccountClick);
goToStatusSearchItem=new ListItem<>("", null, R.drawable.ic_fluent_search_24_regular, this::onGoToStatusSearchClick);
goToAccountSearchItem=new ListItem<>("", null, R.drawable.ic_fluent_people_24_regular, this::onGoToAccountSearchClick);
currentQuery=getArguments().getString("query");
dataLoaded();
@@ -124,7 +127,11 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(SearchResults result){
onDataLoaded(Stream.of(result.hashtags.stream().map(SearchResult::new), result.accounts.stream().map(SearchResult::new))
onDataLoaded(Stream
.of(
result.hashtags.stream().filter(hashtag -> !hashtag.name.isEmpty()).map(SearchResult::new),
result.accounts.stream().map(SearchResult::new)
)
.flatMap(Function.identity())
.map(sr->{
SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, false);
@@ -159,14 +166,14 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getString(R.string.search_mastodon));
searchViewHelper=new SearchViewHelper(getActivity(), getToolbarContext(), getString(R.string.sk_search_fediverse));
searchViewHelper.setListeners(this::onQueryChanged, this::onQueryChangedNoDebounce);
searchViewHelper.addDivider(contentView);
searchViewHelper.setEnterCallback(this::onSearchViewEnter);
navigationIcon=new LayerDrawable(new Drawable[]{
searchIcon=getToolbarContext().getResources().getDrawable(R.drawable.ic_search_24px, getToolbarContext().getTheme()).mutate(),
backIcon=getToolbarContext().getResources().getDrawable(R.drawable.ic_arrow_back, getToolbarContext().getTheme()).mutate()
searchIcon=getToolbarContext().getResources().getDrawable(R.drawable.ic_fluent_search_24_regular, getToolbarContext().getTheme()).mutate(),
backIcon=getToolbarContext().getResources().getDrawable(R.drawable.ic_fluent_arrow_left_24_regular, getToolbarContext().getTheme()).mutate()
}){
@Override
public Drawable mutate(){
@@ -176,8 +183,8 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
super.onViewCreated(view, savedInstanceState);
view.setBackgroundResource(R.drawable.bg_m3_surface3);
int color=UiUtils.alphaBlendThemeColors(getActivity(), R.attr.colorM3Surface, R.attr.colorM3Primary, 0.11f);
view.setBackgroundColor(color);
setStatusBarColor(color);
setNavigationBarColor(color);
if(currentQuery!=null){
@@ -331,7 +338,6 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
anims.add(ObjectAnimator.ofFloat(outlineProvider, "radius", enter ? buttonRadius : screenRadius, enter ? screenRadius : buttonRadius));
anims.add(ObjectAnimator.ofFloat(toolbar, View.TRANSLATION_X, enter ? toolbarTX : 0, enter ? 0 : toolbarTX));
anims.add(ObjectAnimator.ofFloat(toolbar, View.TRANSLATION_Y, enter ? toolbarTY : 0, enter ? 0 : toolbarTY));
anims.add(ObjectAnimator.ofFloat(searchViewHelper.getSearchLayout(), View.TRANSLATION_X, enter ? V.dp(-4) : 0, enter ? 0 : V.dp(-4)));
anims.add(ObjectAnimator.ofFloat(searchViewHelper.getDivider(), View.ALPHA, enter ? 0 : 1, enter ? 1 : 0));
View parentContent=prev.findViewById(R.id.discover_content);
View parentContentParent=(View) parentContent.getParent();
@@ -375,29 +381,69 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
}
private void openHashtag(SearchResult res){
UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag);
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putRecentSearch(res);
wrapSuicideDialog(()->{
UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag);
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putRecentSearch(res);
});
}
private boolean isInRecentMode(){
return TextUtils.isEmpty(currentQuery);
}
private void wrapSuicideDialog(Runnable r){
if(!GlobalUserPreferences.showSuicideHelp || currentQuery==null){
r.run();
return;
}
String[] terms=getContext().getString(R.string.sk_suicide_search_terms).toLowerCase().split(",");
String query=currentQuery.trim().toLowerCase();
boolean termMatches=false;
for(String term : terms){
if(query.contains(term)){
termMatches=true;
break;
}
}
if(!termMatches){
r.run();
return;
}
String url=getContext().getString(R.string.sk_suicide_helplines_url);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_search_suicide_title)
.setMessage(R.string.sk_search_suicide_message)
.setNegativeButton(R.string.sk_do_not_show_again, (dialog, which)->{
GlobalUserPreferences.showSuicideHelp = false;
GlobalUserPreferences.save();
r.run();
})
.setNeutralButton(R.string.sk_search_suicide_hotlines, (dialog, which)->UiUtils.launchWebBrowser(getContext(), url))
.setPositiveButton(R.string.ok, (dialog, which)->r.run())
.setOnDismissListener((dialog)->{})
.show();
}
private void onSearchViewEnter(){
if(TextUtils.isEmpty(currentQuery) || currentQuery.trim().isEmpty())
return;
deliverResult(currentQuery, null);
wrapSuicideDialog(()->deliverResult(currentQuery, null));
}
private void onOpenURLClick(ListItem<?> item_){
((MainActivity)getActivity()).handleURL(Uri.parse(searchViewHelper.getQuery()), accountID);
UiUtils.openURL(getContext(), accountID, searchViewHelper.getQuery(), false);
}
private void onGoToHashtagClick(ListItem<?> item_){
String q=searchViewHelper.getQuery();
if(q.startsWith("#"))
q=q.substring(1);
UiUtils.openHashtagTimeline(getActivity(), accountID, q);
wrapSuicideDialog(()->{
String q=searchViewHelper.getQuery();
if(q.startsWith("#"))
q=q.substring(1);
UiUtils.openHashtagTimeline(getActivity(), accountID, q);
});
}
private void onGoToAccountClick(ListItem<?> item_){
@@ -408,15 +454,21 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
if(q.lastIndexOf('@')==0){
q+="@"+AccountSessionManager.get(accountID).domain;
}
((MainActivity)getActivity()).openSearchQuery(q, accountID, R.string.loading, true, GetSearchResults.Type.ACCOUNTS);
UiUtils.lookupAccountHandle(getContext(), accountID, q, (clazz, args) -> {
if (!args.containsKey("profileAccount")) {
Toast.makeText(getContext(), R.string.no_search_results, Toast.LENGTH_SHORT).show();
return;
}
Nav.go((Activity) getContext(), ProfileFragment.class, args);
}).ifPresent(progress -> progress.wrapProgress((Activity) getContext(), R.string.loading, true));
}
private void onGoToStatusSearchClick(ListItem<?> item_){
deliverResult(searchViewHelper.getQuery(), SearchResult.Type.STATUS);
wrapSuicideDialog(()->deliverResult(searchViewHelper.getQuery(), SearchResult.Type.STATUS));
}
private void onGoToAccountSearchClick(ListItem<?> item_){
deliverResult(searchViewHelper.getQuery(), SearchResult.Type.ACCOUNT);
wrapSuicideDialog(()->deliverResult(searchViewHelper.getQuery(), SearchResult.Type.ACCOUNT));
}
private void onClearRecentClick(){
@@ -429,6 +481,8 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
}
private void deliverResult(String query, SearchResult.Type typeFilter){
if(query.isEmpty())
return;
Bundle res=new Bundle();
res.putString("query", query);
if(typeFilter!=null)

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.fragments.discover;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
@@ -7,10 +8,9 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.trends.GetTrendingHashtags;
import org.joinmastodon.android.fragments.IsOnTop;
import org.joinmastodon.android.fragments.ScrollableToTop;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.HashtagChartView;
@@ -23,7 +23,7 @@ import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.views.UsableRecyclerView;
public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> implements ScrollableToTop{
public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> implements ScrollableToTop, IsOnTop{
private String accountID;
public TrendingHashtagsFragment(){
@@ -34,6 +34,8 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> impl
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
}
@Override
@@ -58,6 +60,11 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> impl
smoothScrollRecyclerViewToTop(list);
}
@Override
public boolean isOnTop(){
return isRecyclerViewOnTop(list);
}
private class HashtagsAdapter extends RecyclerView.Adapter<HashtagViewHolder>{
@NonNull
@Override

View File

@@ -24,7 +24,7 @@ import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;

View File

@@ -0,0 +1,244 @@
package org.joinmastodon.android.fragments.onboarding;
import android.content.Context;
import android.content.res.ColorStateList;
import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.RadioButton;
import android.widget.Space;
import android.widget.TextView;
import android.widget.Toolbar;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.catalog.CatalogInstance;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.ArrayList;
import java.util.Objects;
import me.grishka.appkit.FragmentStackActivity;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class CustomWelcomeFragment extends InstanceCatalogFragment {
private View headerView;
public CustomWelcomeFragment() {
super(R.layout.fragment_welcome_custom, 1);
}
@Override
public void onAttach(Context context){
super.onAttach(context);
setRefreshEnabled(false);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
dataLoaded();
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
if (!canGoBack()) {
ImageView toolbarLogo=new ImageView(getActivity());
toolbarLogo.setScaleType(ImageView.ScaleType.CENTER);
toolbarLogo.setImageResource(R.drawable.logo);
toolbarLogo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)));
FrameLayout logoWrap=new FrameLayout(getActivity());
FrameLayout.LayoutParams logoParams=new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER);
logoParams.setMargins(0, V.dp(2), 0, 0);
logoWrap.addView(toolbarLogo, logoParams);
getToolbar().addView(logoWrap, new Toolbar.LayoutParams(Gravity.CENTER));
} else {
setTitle(R.string.add_account);
}
}
@Override
protected void proceedWithAuthOrSignup(Instance instance) {
AccountSessionManager.getInstance().authenticate(getActivity(), instance);
}
@Override
protected void updateFilteredList(){
String query=getCurrentSearchQuery();
boolean addFakeInstance=query.length()>0 && query.matches("^\\S+\\.[^\\.]+$");
if(addFakeInstance){
fakeInstance.domain=fakeInstance.normalizedDomain=query;
fakeInstance.description=getString(R.string.loading_instance);
if(filteredData.size()>0 && filteredData.get(0)==fakeInstance){
if(list.findViewHolderForAdapterPosition(1) instanceof InstanceViewHolder ivh){
ivh.rebind();
}
}
if(filteredData.isEmpty()){
filteredData.add(fakeInstance);
adapter.notifyItemInserted(0);
}
}
ArrayList<CatalogInstance> prevData=new ArrayList<>(filteredData);
filteredData.clear();
if(query.length()>0){
boolean foundExactMatch=false;
for(CatalogInstance inst:data){
if(inst.normalizedDomain.contains(query)){
filteredData.add(inst);
if(inst.normalizedDomain.equals(query))
foundExactMatch=true;
}
}
if(!foundExactMatch && addFakeInstance) {
filteredData.add(0, fakeInstance);
adapter.notifyItemChanged(0);
}
}
UiUtils.updateList(prevData, filteredData, list, adapter, Objects::equals);
for(int i=0;i<list.getChildCount();i++){
list.getChildAt(i).invalidateOutline();
}
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Surface));
list.setItemAnimator(new BetterItemAnimator());
((UsableRecyclerView) list).setSelector(null);
}
@Override
protected void doLoadData(int offset, int count) {}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
headerView=getActivity().getLayoutInflater().inflate(R.layout.header_welcome_custom, list, false);
searchEdit=headerView.findViewById(R.id.search_edit);
searchEdit.setOnEditorActionListener(this::onSearchEnterPressed);
headerView.findViewById(R.id.more).setVisibility(View.GONE);
headerView.findViewById(R.id.visibility).setVisibility(View.GONE);
headerView.findViewById(R.id.unread_indicator).setVisibility(View.GONE);
headerView.findViewById(R.id.separator).setVisibility(View.GONE);
headerView.findViewById(R.id.time).setVisibility(View.GONE);
((TextView) headerView.findViewById(R.id.username)).setText(R.string.mo_app_username);
((TextView) headerView.findViewById(R.id.name)).setText(R.string.mo_app_name);
((ImageView) headerView.findViewById(R.id.avatar)).setImageDrawable(getActivity().getDrawable(R.mipmap.ic_launcher));
((FragmentStackActivity) getActivity()).invalidateSystemBarColors(this);
searchEdit.addTextChangedListener(new TextWatcher(){
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after){}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count){
nextButton.setEnabled(false);
chosenInstance = null;
searchEdit.removeCallbacks(searchDebouncer);
searchEdit.postDelayed(searchDebouncer, 300);
}
@Override
public void afterTextChanged(Editable s){}
});
mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(headerView));
mergeAdapter.addAdapter(adapter=new InstancesAdapter());
View spacer = new Space(getActivity());
spacer.setMinimumHeight(V.dp(8));
mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(spacer));
return mergeAdapter;
}
private class InstancesAdapter extends UsableRecyclerView.Adapter<InstanceViewHolder> {
public InstancesAdapter(){
super(imgLoader);
}
@NonNull
@Override
public InstanceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new InstanceViewHolder();
}
@Override
public void onBindViewHolder(InstanceViewHolder holder, int position){
holder.bind(filteredData.get(position));
chosenInstance = filteredData.get(position);
if (chosenInstance != fakeInstance) nextButton.setEnabled(true);
super.onBindViewHolder(holder, position);
}
@Override
public int getItemCount(){
return filteredData.size();
}
@Override
public int getItemViewType(int position){
return -1;
}
}
private class InstanceViewHolder extends BindableViewHolder<CatalogInstance> implements UsableRecyclerView.Clickable{
private final TextView title, description, userCount, lang;
public InstanceViewHolder(){
super(getActivity(), R.layout.item_instance_custom, list);
title=findViewById(R.id.title);
description=findViewById(R.id.description);
userCount=findViewById(R.id.user_count);
lang=findViewById(R.id.lang);
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.N){
UiUtils.fixCompoundDrawableTintOnAndroid6(userCount);
UiUtils.fixCompoundDrawableTintOnAndroid6(lang);
}
}
@Override
public void onBind(CatalogInstance item){
title.setText(item.normalizedDomain);
description.setText(item.description);
if (item == fakeInstance) {
userCount.setVisibility(View.GONE);
lang.setVisibility(View.GONE);
} else {
userCount.setVisibility(View.VISIBLE);
lang.setVisibility(View.VISIBLE);
userCount.setText(UiUtils.abbreviateNumber(item.totalUsers));
lang.setText(item.language.toUpperCase());
}
}
@Override
public void onClick(){
if(chosenInstance==null)
nextButton.setEnabled(true);
chosenInstance=item;
loadInstanceInfo(chosenInstance.domain, false);
onNextClick(null);
}
}
}

View File

@@ -16,6 +16,7 @@ import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.requests.instance.GetInstance;
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.catalog.CatalogInstance;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
@@ -45,14 +46,13 @@ import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import okhttp3.Call;
import okhttp3.Request;
import okhttp3.Response;
abstract class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstance>{
abstract class InstanceCatalogFragment extends MastodonRecyclerFragment<CatalogInstance> {
protected RecyclerView.Adapter adapter;
protected MergeRecyclerAdapter mergeAdapter;
protected CatalogInstance chosenInstance;
@@ -76,13 +76,13 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInsta
private static final double DUNBAR=Math.log(800);
public InstanceCatalogFragment(int layout, int perPage){
super(layout, perPage);
super(layout, perPage);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
isSignup=getArguments().getBoolean("signup");
isSignup=getArguments() != null && getArguments().getBoolean("signup");
}
protected abstract void proceedWithAuthOrSignup(Instance instance);
@@ -94,10 +94,10 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInsta
currentSearchQueryButWithCasePreserved=searchEdit.getText().toString().trim();
updateFilteredList();
searchEdit.removeCallbacks(searchDebouncer);
Instance instance=instancesCache.get(normalizeInstanceDomain(currentSearchQuery));
Instance instance=instancesCache.get(normalizeInstanceDomain(getCurrentSearchQuery()));
if(instance==null){
showProgressDialog();
loadInstanceInfo(currentSearchQuery, false);
loadInstanceInfo(getCurrentSearchQuery(), false);
}else{
proceedWithAuthOrSignup(instance);
}
@@ -108,7 +108,7 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInsta
currentSearchQuery=searchEdit.getText().toString().toLowerCase().trim();
currentSearchQueryButWithCasePreserved=searchEdit.getText().toString().trim();
updateFilteredList();
loadInstanceInfo(currentSearchQuery, false);
loadInstanceInfo(getCurrentSearchQuery(), false);
}
protected List<CatalogInstance> sortInstances(List<CatalogInstance> result){
@@ -128,9 +128,16 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInsta
instanceProgressDialog.show();
}
protected String getCurrentSearchQuery(){
String[] parts=currentSearchQuery.split("@");
return parts.length>0 ? parts[parts.length-1] : "";
}
protected String normalizeInstanceDomain(String _domain){
if(TextUtils.isEmpty(_domain))
return null;
String[] parts=_domain.split("@");
_domain=parts[parts.length - 1];
if(_domain.contains(":")){
try{
_domain=Uri.parse(_domain).getAuthority();
@@ -208,7 +215,7 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInsta
instanceProgressDialog.dismiss();
instanceProgressDialog=null;
}
if(Objects.equals(domain, currentSearchQuery) || Objects.equals(currentSearchQuery, redirects.get(domain)) || Objects.equals(currentSearchQuery, redirectsInverse.get(domain))){
if(Objects.equals(domain, getCurrentSearchQuery()) || Objects.equals(getCurrentSearchQuery(), redirects.get(domain)) || Objects.equals(getCurrentSearchQuery(), redirectsInverse.get(domain))){
boolean found=false;
for(CatalogInstance ci:filteredData){
if(ci.domain.equals(domain) && ci!=fakeInstance){

View File

@@ -106,13 +106,13 @@ public class InstanceChooserLoginFragment extends InstanceCatalogFragment{
.execNoAuth("");
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
Toolbar toolbar=getToolbar();
toolbar.setElevation(0);
toolbar.setBackground(null);
}
// @Override
// protected void onUpdateToolbar(){
// super.onUpdateToolbar();
// Toolbar toolbar=getToolbar();
// toolbar.setElevation(0);
// toolbar.setBackground(null);
// }
@Override
protected RecyclerView.Adapter getAdapter(){

View File

@@ -1,32 +1,47 @@
package org.joinmastodon.android.fragments.onboarding;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.assist.AssistContent;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.Html;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.adapters.InstanceRulesAdapter;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ElevationOnScrollListener;
import org.joinmastodon.android.utils.ProvidesAssistContent;
import org.parceler.Parcels;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.FragmentRootLinearLayout;
import me.grishka.appkit.views.UsableRecyclerView;
public class InstanceRulesFragment extends ToolbarFragment{
public class InstanceRulesFragment extends ToolbarFragment implements ProvidesAssistContent {
private UsableRecyclerView list;
private MergeRecyclerAdapter adapter;
private Button btn;
@@ -62,8 +77,9 @@ public class InstanceRulesFragment extends ToolbarFragment{
adapter=new MergeRecyclerAdapter();
adapter.addAdapter(new SingleViewRecyclerAdapter(headerView));
adapter.addAdapter(new InstanceRulesAdapter(instance.rules));
adapter.addAdapter(new ItemsAdapter());
list.setAdapter(adapter);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 1, 56, 0, DividerItemDecoration.NOT_FIRST));
btn=view.findViewById(R.id.btn_next);
btn.setOnClickListener(v->onButtonClick());
@@ -77,12 +93,16 @@ public class InstanceRulesFragment extends ToolbarFragment{
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
// setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
// view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background));
list.addOnScrollListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar()));
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
getToolbar().setBackgroundResource(R.drawable.bg_onboarding_panel);
getToolbar().setElevation(0);
if(onScrollListener!=null){
onScrollListener.setViews(buttonBar, getToolbar());
}
@@ -107,6 +127,60 @@ public class InstanceRulesFragment extends ToolbarFragment{
@Override
public void onApplyWindowInsets(WindowInsets insets){
super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets));
if(Build.VERSION.SDK_INT>=27){
int inset=insets.getSystemWindowInsetBottom();
buttonBar.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0);
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()));
}else{
super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom()));
}
}
@Override
public void onProvideAssistContent(AssistContent assistContent) {
assistContent.setWebUri(new Uri.Builder()
.scheme("https")
.authority(instance.normalizedUri)
.path("/about")
.build());
}
private class ItemsAdapter extends RecyclerView.Adapter<ItemViewHolder>{
@NonNull
@Override
public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new ItemViewHolder();
}
@Override
public void onBindViewHolder(@NonNull ItemViewHolder holder, int position){
holder.bind(instance.rules.get(position));
}
@Override
public int getItemCount(){
return instance.rules.size();
}
}
private class ItemViewHolder extends BindableViewHolder<Instance.Rule>{
private final TextView text, number;
public ItemViewHolder(){
super(getActivity(), R.layout.item_server_rule, list);
text=findViewById(R.id.text);
number=findViewById(R.id.number);
}
@SuppressLint("DefaultLocale")
@Override
public void onBind(Instance.Rule item){
if(item.parsedText==null){
item.parsedText=HtmlParser.parseLinks(item.text);
}
text.setText(item.parsedText);
number.setText(String.format("%d", getAbsoluteAdapterPosition()));
}
}
}

View File

@@ -1,6 +1,7 @@
package org.joinmastodon.android.fragments.onboarding;
import android.app.ProgressDialog;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.view.WindowInsets;
@@ -55,7 +56,7 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment
buttonBar=view.findViewById(R.id.button_bar);
view.findViewById(R.id.btn_next).setOnClickListener(UiUtils.rateLimitedClickListener(this::onFollowAllClick));
view.findViewById(R.id.btn_skip).setOnClickListener(UiUtils.rateLimitedClickListener(v->proceed()));
// view.findViewById(R.id.btn_skip).setOnClickListener(UiUtils.rateLimitedClickListener(v->proceed()));
}
@Override
@@ -160,9 +161,9 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment
}
private void proceed(){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), OnboardingProfileSetupFragment.class, args);
// Bundle args=new Bundle();
// args.putString("account", accountID);
// Nav.go(getActivity(), OnboardingProfileSetupFragment.class, args);
}
@Override
@@ -171,4 +172,9 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment
holder.setStyle(AccountViewHolder.AccessoryType.BUTTON, true);
holder.avatar.setOutlineProvider(OutlineProviders.roundedRect(8));
}
@Override
public Uri getWebUri(Uri.Builder base){
return null;
}
}

View File

@@ -3,7 +3,6 @@ package org.joinmastodon.android.fragments.onboarding;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;

View File

@@ -1,9 +1,8 @@
package org.joinmastodon.android.fragments.report;
import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.view.WindowInsets;
@@ -19,10 +18,12 @@ import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses;
import org.joinmastodon.android.events.FinishReportFragmentsEvent;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.displayitems.AudioStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.CheckableHeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.DummyStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
@@ -81,10 +82,11 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetAccountStatuses(reportAccount.id, offset>0 ? getMaxID() : null, null, count, GetAccountStatuses.Filter.OWN_POSTS_AND_REPLIES)
currentRequest=new GetAccountStatuses(reportAccount.id, getMaxID(), null, count, GetAccountStatuses.Filter.OWN_POSTS_AND_REPLIES)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
if(getActivity()==null) return;
for(Status s:result){
s.sensitive=true;
}
@@ -94,15 +96,14 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
.exec(accountID);
}
@Override
public void onItemClick(String id){
public void onToggleItem(String id){
if(selectedIDs.contains(id))
selectedIDs.remove(id);
else
selectedIDs.add(id);
CheckableHeaderStatusDisplayItem.Holder holder=findHolderOfType(id, CheckableHeaderStatusDisplayItem.Holder.class);
if(holder!=null)
holder.rebind();
if(holder!=null) holder.rebind();
else notifyItemChanged(id, CheckableHeaderStatusDisplayItem.class);
}
@Override
@@ -118,13 +119,20 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
if(holder.getAbsoluteAdapterPosition()==0 || holder instanceof CheckableHeaderStatusDisplayItem.Holder)
return;
outRect.left=V.dp(40);
boolean isRTL=parent.getLayoutDirection()==View.LAYOUT_DIRECTION_RTL;
if(isRTL) outRect.right=V.dp(40);
else outRect.left=V.dp(40);
if(holder instanceof AudioStatusDisplayItem.Holder){
outRect.bottom=V.dp(16);
}else if(holder instanceof LinkCardStatusDisplayItem.Holder || holder instanceof MediaGridStatusDisplayItem.Holder){
outRect.bottom=V.dp(16);
outRect.left+=V.dp(16);
outRect.right=V.dp(16);
outRect.bottom=V.dp(8);
if(isRTL){
outRect.right+=V.dp(16);
outRect.left=V.dp(16);
}else{
outRect.left+=V.dp(16);
outRect.right=V.dp(16);
}
}
}
});
@@ -152,9 +160,6 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
return adapter;
}
protected void drawDivider(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder, RecyclerView parent, Canvas c, Paint paint){
}
private void onButtonClick(View v){
Bundle args=new Bundle();
args.putString("account", accountID);
@@ -198,7 +203,14 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
@Override
protected List<StatusDisplayItem> buildDisplayItems(Status s){
return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_CHECKABLE | StatusDisplayItem.FLAG_MEDIA_FORCE_HIDDEN);
List<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, getFilterContext(), StatusDisplayItem.FLAG_NO_FOOTER | StatusDisplayItem.FLAG_CHECKABLE | StatusDisplayItem.FLAG_MEDIA_FORCE_HIDDEN);
items.add(new DummyStatusDisplayItem(s.getID(), this));
return items;
}
@Override
protected FilterContext getFilterContext(){
return null;
}
@Override
@@ -218,4 +230,9 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{
private boolean isChecked(CheckableHeaderStatusDisplayItem.Holder holder){
return selectedIDs.contains(holder.getItem().parentID);
}
@Override
public Uri getWebUri(Uri.Builder base){
return null;
}
}

View File

@@ -16,6 +16,7 @@ import android.widget.TextView;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.reports.SendReport;
import org.joinmastodon.android.api.session.AccountSessionManager;
@@ -99,6 +100,7 @@ public class ReportCommentFragment extends MastodonToolbarFragment{
ProgressBar topProgress=view.findViewById(R.id.top_progress);
topProgress.setProgress(getArguments().containsKey("ruleIDs") ? 75 : 66);
forwardSwitch.setChecked(GlobalUserPreferences.forwardReportDefault);
}
@Override

View File

@@ -34,7 +34,6 @@ import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class ReportDoneFragment extends MastodonToolbarFragment{
@@ -131,9 +130,9 @@ public class ReportDoneFragment extends MastodonToolbarFragment{
unfollowTitle.setText(getString(R.string.unfollow_user, '@'+reportAccount.acct));
muteTitle.setText(getString(R.string.mute_user, '@'+reportAccount.acct));
blockTitle.setText(getString(R.string.block_user, '@'+reportAccount.acct));
setIconToButton(R.drawable.ic_person_remove_20px, unfollowTitle);
setIconToButton(R.drawable.ic_block_20px, blockTitle);
setIconToButton(R.drawable.ic_volume_off_20px, muteTitle);
setIconToButton(R.drawable.ic_fluent_person_delete_20_filled, unfollowTitle);
setIconToButton(R.drawable.ic_fluent_person_prohibited_20_filled, blockTitle);
setIconToButton(R.drawable.ic_fluent_speaker_0_20_filled, muteTitle);
unfollowBtn.setOnClickListener(v->onUnfollowClick());
muteBtn.setOnClickListener(v->onMuteClick());
@@ -184,7 +183,7 @@ public class ReportDoneFragment extends MastodonToolbarFragment{
E.post(new RemoveAccountPostsEvent(accountID, reportAccount.id, true));
unfollowTitle.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSecondaryContainer));
unfollowTitle.setText(getString(R.string.unfollowed_user, '@'+reportAccount.acct));
setIconToButton(R.drawable.ic_check_24px, unfollowTitle);
setIconToButton(R.drawable.ic_fluent_checkmark_24_regular, unfollowTitle);
unfollowBtn.setBackgroundResource(R.drawable.bg_button_m3_tonal);
unfollowBtn.setClickable(false);
unfollowBtn.setFocusable(false);
@@ -203,7 +202,7 @@ public class ReportDoneFragment extends MastodonToolbarFragment{
UiUtils.confirmToggleMuteUser(getActivity(), accountID, reportAccount, false, rel->{
muteTitle.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSecondaryContainer));
muteTitle.setText(getString(R.string.muted_user, '@'+reportAccount.acct));
setIconToButton(R.drawable.ic_check_24px, muteTitle);
setIconToButton(R.drawable.ic_fluent_checkmark_24_regular, muteTitle);
muteBtn.setBackgroundResource(R.drawable.bg_button_m3_tonal);
muteBtn.setClickable(false);
muteBtn.setFocusable(false);
@@ -214,7 +213,7 @@ public class ReportDoneFragment extends MastodonToolbarFragment{
UiUtils.confirmToggleBlockUser(getActivity(), accountID, reportAccount, false, rel->{
blockTitle.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSecondaryContainer));
blockTitle.setText(getString(R.string.blocked_user, '@'+reportAccount.acct));
setIconToButton(R.drawable.ic_check_24px, blockTitle);
setIconToButton(R.drawable.ic_fluent_checkmark_24_regular, blockTitle);
blockBtn.setBackgroundResource(R.drawable.bg_button_m3_tonal);
blockBtn.setClickable(false);
blockBtn.setFocusable(false);
@@ -227,7 +226,7 @@ public class ReportDoneFragment extends MastodonToolbarFragment{
@Override
protected int getNavigationIconDrawableResource(){
return R.drawable.ic_baseline_close_24;
return R.drawable.ic_fluent_dismiss_24_regular;
}
@Override

View File

@@ -4,6 +4,7 @@ import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
@@ -21,6 +22,7 @@ import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.FinishReportFragmentsEvent;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.ReportReason;
@@ -81,7 +83,7 @@ public class ReportReasonChoiceFragment extends StatusListFragment{
reportStatus=Parcels.unwrap(getArguments().getParcelable("status"));
if(reportStatus!=null){
Status hiddenStatus=reportStatus.clone();
hiddenStatus.spoilerText=getString(R.string.post_hidden);
if(hiddenStatus.spoilerText==null) hiddenStatus.spoilerText=getString(R.string.post_hidden);
onDataLoaded(Collections.singletonList(hiddenStatus));
setTitle(R.string.report_title_post);
}else{
@@ -166,17 +168,6 @@ public class ReportReasonChoiceFragment extends StatusListFragment{
((UsableRecyclerView)list).setIncludeMarginsInItemHitbox(false);
if(reportStatus!=null){
list.addItemDecoration(new RecyclerView.ItemDecoration(){
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
if(holder instanceof LinkCardStatusDisplayItem.Holder || holder instanceof MediaGridStatusDisplayItem.Holder){
outRect.left=V.dp(16);
outRect.right=V.dp(16);
}
}
});
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
{
@@ -213,18 +204,6 @@ public class ReportReasonChoiceFragment extends StatusListFragment{
float off=paint.getStrokeWidth()/2f;
c.drawRoundRect(V.dp(16)-off, top-off, parent.getWidth()-V.dp(16)+off, bottom+off, V.dp(12), V.dp(12), paint);
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
if(holder instanceof StatusDisplayItem.Holder<?>){
outRect.left=outRect.right=V.dp(16);
}
int index=holder.getAbsoluteAdapterPosition()-mergeAdapter.getPositionForAdapter(adapter);
if(index==displayItems.size()){
outRect.top=V.dp(32);
}
}
});
}
}
@@ -241,19 +220,12 @@ public class ReportReasonChoiceFragment extends StatusListFragment{
@Override
protected List<StatusDisplayItem> buildDisplayItems(Status s){
return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_FOOTER);
return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, getFilterContext(), StatusDisplayItem.FLAG_INSET | StatusDisplayItem.FLAG_NO_FOOTER);
}
@Override
protected void onModifyItemViewHolder(BindableViewHolder<StatusDisplayItem> holder){
if((Object)holder instanceof MediaGridStatusDisplayItem.Holder h){
View layout=h.getLayout();
layout.setOutlineProvider(OutlineProviders.roundedRect(8));
layout.setClipToOutline(true);
View overlay=h.getSensitiveOverlay();
overlay.setOutlineProvider(OutlineProviders.roundedRect(8));
overlay.setClipToOutline(true);
}
protected FilterContext getFilterContext(){
return null;
}
@Override
@@ -262,4 +234,9 @@ public class ReportReasonChoiceFragment extends StatusListFragment{
if(id.equals(reportAccount.id))
relationship=rel;
}
@Override
public Uri getWebUri(Uri.Builder base){
return null;
}
}

View File

@@ -49,7 +49,7 @@ public abstract class BaseSettingsFragment<T> extends MastodonRecyclerFragment<L
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 1, 0, 0, vh->vh instanceof SimpleListItemViewHolder ivh && ivh.getItem().dividerAfter));
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 1, 0, 0, vh->vh instanceof ListItemViewHolder<?> ivh && ivh.getItem().dividerAfter));
list.setItemAnimator(new BetterItemAnimator());
}

View File

@@ -68,6 +68,10 @@ public class EditFilterFragment extends BaseSettingsFragment<Void> implements On
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
filter=Parcels.unwrap(getArguments().getParcelable("filter"));
ArrayList<Parcelable> words=getArguments().getParcelableArrayList("words");
if (words != null) {
words.stream().map(p->(FilterKeyword)Parcels.unwrap(p)).forEach(keywords::add);
}
setTitle(filter==null ? R.string.settings_add_filter : R.string.settings_edit_filter);
onDataLoaded(List.of(
durationItem=new ListItem<>(R.string.settings_filter_duration, 0, this::onDurationClick),

View File

@@ -92,7 +92,7 @@ public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> imp
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
fab=view.findViewById(R.id.fab);
fab.setImageResource(R.drawable.ic_add_24px);
fab.setImageResource(R.drawable.ic_fluent_add_24_regular);
fab.setContentDescription(getString(R.string.add_muted_word));
fab.setOnClickListener(v->onFabClick());
}

View File

@@ -5,20 +5,42 @@ import android.content.ClipData;
import android.content.ClipboardManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.Gravity;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.HasAccountID;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.Snackbar;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.ArrayList;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
@@ -27,25 +49,53 @@ import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class SettingsAboutAppFragment extends BaseSettingsFragment<Void>{
private ListItem<Void> mediaCacheItem;
public class SettingsAboutAppFragment extends BaseSettingsFragment<Void> implements HasAccountID{
private static final String TAG="SettingsAboutAppFragment";
private ListItem<Void> mediaCacheItem, copyCrashLogItem;
private CheckableListItem<Void> enablePreReleasesItem;
private AccountSession session;
private boolean timelineCacheCleared=false;
private File crashLogFile=new File(MastodonApp.context.getFilesDir(), "crash.log");
// MOSHIDON
private ListItem<Void> clearRecentEmojisItem;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(getString(R.string.about_app, getString(R.string.app_name)));
AccountSession s=AccountSessionManager.get(accountID);
onDataLoaded(List.of(
new ListItem<>(R.string.settings_even_more, 0, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/auth/edit")),
new ListItem<>(R.string.settings_contribute, 0, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.github_url))),
new ListItem<>(R.string.settings_tos, 0, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/terms")),
new ListItem<>(R.string.settings_privacy_policy, 0, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true),
mediaCacheItem=new ListItem<>(R.string.settings_clear_cache, 0, this::onClearMediaCacheClick)
setTitle(getString(R.string.about_app, getString(R.string.mo_app_name)));
session=AccountSessionManager.get(accountID);
String lastModified=crashLogFile.exists()
? DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT).withZone(ZoneId.systemDefault()).format(Instant.ofEpochMilli(crashLogFile.lastModified()))
: getString(R.string.sk_settings_crash_log_unavailable);
List<ListItem<Void>> items=new ArrayList<>(List.of(
new ListItem<>(R.string.sk_settings_donate, 0, R.drawable.ic_fluent_heart_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.mo_donate_url))),
new ListItem<>(R.string.mo_settings_contribute, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.mo_repo_url))),
new ListItem<>(R.string.settings_tos, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms")),
new ListItem<>(R.string.settings_privacy_policy, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true),
clearRecentEmojisItem=new ListItem<>(R.string.mo_clear_recent_emoji, 0, this::onClearRecentEmojisClick),
mediaCacheItem=new ListItem<>(R.string.settings_clear_cache, 0, this::onClearMediaCacheClick),
new ListItem<>(getString(R.string.sk_settings_clear_timeline_cache), session.domain, this::onClearTimelineCacheClick),
copyCrashLogItem=new ListItem<>(getString(R.string.sk_settings_copy_crash_log), lastModified, 0, this::onCopyCrashLog)
));
if(GithubSelfUpdater.needSelfUpdating()){
items.add(enablePreReleasesItem=new CheckableListItem<>(R.string.sk_updater_enable_pre_releases, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.enablePreReleases, i->toggleCheckableItem(enablePreReleasesItem)));
}
copyCrashLogItem.isEnabled=crashLogFile.exists();
onDataLoaded(items);
updateMediaCacheItem();
}
@Override
protected void onHidden(){
super.onHidden();
GlobalUserPreferences.enablePreReleases=enablePreReleasesItem!=null && enablePreReleasesItem.checked;
GlobalUserPreferences.save();
if(timelineCacheCleared) getActivity().recreate();
}
@Override
protected void doLoadData(int offset, int count){}
@@ -55,12 +105,11 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment<Void>{
adapter.addAdapter(super.getAdapter());
TextView versionInfo=new TextView(getActivity());
versionInfo.setSingleLine();
versionInfo.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(32)));
versionInfo.setTextAppearance(R.style.m3_label_medium);
versionInfo.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Outline));
versionInfo.setGravity(Gravity.CENTER);
versionInfo.setText(getString(R.string.settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE));
versionInfo.setText(getString(R.string.mo_settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE));
versionInfo.setOnClickListener(v->{
getActivity().getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText("", BuildConfig.VERSION_NAME+" ("+BuildConfig.VERSION_CODE+")"));
if(Build.VERSION.SDK_INT<=Build.VERSION_CODES.S_V2){
@@ -85,10 +134,40 @@ public class SettingsAboutAppFragment extends BaseSettingsFragment<Void>{
});
}
private void onClearTimelineCacheClick(ListItem<?> item){
session.getCacheController().putHomeTimeline(List.of(), true);
Toast.makeText(getContext(), R.string.sk_timeline_cache_cleared, Toast.LENGTH_SHORT).show();
timelineCacheCleared=true;
}
private void onClearRecentEmojisClick(ListItem<?> item){
getLocalPrefs().recentCustomEmoji=new ArrayList<>();
getLocalPrefs().save();
Toast.makeText(getContext(), R.string.mo_recent_emoji_cleared, Toast.LENGTH_SHORT).show();
}
private void updateMediaCacheItem(){
long size=ImageCache.getInstance(getActivity()).getDiskCache().size();
mediaCacheItem.subtitle=UiUtils.formatFileSize(getActivity(), size, false);
mediaCacheItem.isEnabled=size>0;
rebindItem(mediaCacheItem);
}
@Override
public String getAccountID(){
return accountID;
}
private void onCopyCrashLog(ListItem<?> item){
if(!crashLogFile.exists()) return;
try(InputStream is=new FileInputStream(crashLogFile)){
BufferedReader reader=new BufferedReader(new InputStreamReader(is));
StringBuilder sb=new StringBuilder();
String line;
while ((line=reader.readLine())!=null) sb.append(line).append("\n");
UiUtils.copyText(list, sb.toString());
} catch(IOException e){
Log.e(TAG, "Error reading crash log", e);
}
}
}

View File

@@ -2,59 +2,115 @@ package org.joinmastodon.android.fragments.settings;
import android.os.Bundle;
import androidx.annotation.StringRes;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.HasAccountID;
import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.viewcontrollers.ComposeLanguageAlertViewController;
import org.joinmastodon.android.utils.MastodonLanguage;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.stream.IntStream;
import java.util.stream.Stream;
public class SettingsBehaviorFragment extends BaseSettingsFragment<Void>{
public class SettingsBehaviorFragment extends BaseSettingsFragment<Void> implements HasAccountID{
private ListItem<Void> languageItem;
private CheckableListItem<Void> altTextItem, playGifsItem, customTabsItem, confirmUnfollowItem, confirmBoostItem, confirmDeleteItem;
private Locale postLanguage;
private MastodonLanguage postLanguage;
private ComposeLanguageAlertViewController.SelectedOption newPostLanguage;
// MEGALODON
private MastodonLanguage.LanguageResolver languageResolver;
private ListItem<Void> prefixRepliesItem, replyVisibilityItem;
private CheckableListItem<Void> forwardReportsItem, remoteLoadingItem, showBoostsItem, showRepliesItem, loadNewPostsItem, seeNewPostsBtnItem, overlayMediaItem;
// MOSHIDON
private CheckableListItem<Void> mentionRebloggerAutomaticallyItem, hapticFeedbackItem, showPostsWithoutAltItem;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.settings_behavior);
AccountSession s=AccountSessionManager.get(accountID);
if(s.preferences!=null && s.preferences.postingDefaultLanguage!=null){
postLanguage=Locale.forLanguageTag(s.preferences.postingDefaultLanguage);
}
AccountLocalPreferences lp=getLocalPrefs();
languageResolver = s.getInstance().map(MastodonLanguage.LanguageResolver::new).orElse(null);
postLanguage=s.preferences==null || s.preferences.postingDefaultLanguage==null ? null :
languageResolver.from(s.preferences.postingDefaultLanguage).orElse(null);
onDataLoaded(List.of(
languageItem=new ListItem<>(getString(R.string.default_post_language), postLanguage!=null ? postLanguage.getDisplayName(Locale.getDefault()) : null, R.drawable.ic_language_24px, this::onDefaultLanguageClick),
altTextItem=new CheckableListItem<>(R.string.settings_alt_text_reminders, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.altTextReminders, R.drawable.ic_alt_24px, this::toggleCheckableItem),
playGifsItem=new CheckableListItem<>(R.string.settings_gif, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_animation_24px, this::toggleCheckableItem),
customTabsItem=new CheckableListItem<>(R.string.settings_custom_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.useCustomTabs, R.drawable.ic_open_in_browser_24px, this::toggleCheckableItem),
confirmUnfollowItem=new CheckableListItem<>(R.string.settings_confirm_unfollow, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmUnfollow, R.drawable.ic_person_remove_24px, this::toggleCheckableItem),
confirmBoostItem=new CheckableListItem<>(R.string.settings_confirm_boost, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmBoost, R.drawable.ic_repeat_24px, this::toggleCheckableItem),
confirmDeleteItem=new CheckableListItem<>(R.string.settings_confirm_delete_post, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmDeletePost, R.drawable.ic_delete_24px, this::toggleCheckableItem)
List<ListItem<Void>> items = new ArrayList<>(List.of(
languageItem=new ListItem<>(getString(R.string.default_post_language), postLanguage!=null ? postLanguage.getDisplayName(getContext()) : null, R.drawable.ic_fluent_local_language_24_regular, this::onDefaultLanguageClick),
altTextItem=new CheckableListItem<>(R.string.settings_alt_text_reminders, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.altTextReminders, R.drawable.ic_fluent_image_alt_text_24_regular, i->toggleCheckableItem(altTextItem)),
showPostsWithoutAltItem=new CheckableListItem<>(R.string.mo_settings_show_posts_without_alt, R.string.mo_settings_show_posts_without_alt_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showPostsWithoutAlt, R.drawable.ic_fluent_eye_tracking_on_24_regular, i->toggleCheckableItem(showPostsWithoutAltItem)),
playGifsItem=new CheckableListItem<>(R.string.settings_gif, R.string.mo_setting_play_gif_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_fluent_gif_24_regular, i->toggleCheckableItem(playGifsItem)),
overlayMediaItem=new CheckableListItem<>(R.string.sk_settings_continues_playback, R.string.sk_settings_continues_playback_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.overlayMedia, R.drawable.ic_fluent_play_circle_hint_24_regular, i->toggleCheckableItem(overlayMediaItem)),
customTabsItem=new CheckableListItem<>(R.string.settings_custom_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.useCustomTabs, R.drawable.ic_fluent_link_24_regular, i->toggleCheckableItem(customTabsItem)),
confirmUnfollowItem=new CheckableListItem<>(R.string.settings_confirm_unfollow, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmUnfollow, R.drawable.ic_fluent_person_delete_24_regular, i->toggleCheckableItem(confirmUnfollowItem)),
confirmBoostItem=new CheckableListItem<>(R.string.settings_confirm_boost, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmBoost, R.drawable.ic_fluent_arrow_repeat_all_24_regular, i->toggleCheckableItem(confirmBoostItem)),
confirmDeleteItem=new CheckableListItem<>(R.string.settings_confirm_delete_post, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmDeletePost, R.drawable.ic_fluent_delete_24_regular, i->toggleCheckableItem(confirmDeleteItem)),
prefixRepliesItem=new ListItem<>(R.string.sk_settings_prefix_reply_cw_with_re, getPrefixWithRepliesString(), R.drawable.ic_fluent_arrow_reply_24_regular, this::onPrefixRepliesClick),
forwardReportsItem=new CheckableListItem<>(R.string.sk_settings_forward_report_default, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.forwardReportDefault, R.drawable.ic_fluent_arrow_forward_24_regular, i->toggleCheckableItem(forwardReportsItem)),
loadNewPostsItem=new CheckableListItem<>(R.string.sk_settings_load_new_posts, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.loadNewPosts, R.drawable.ic_fluent_arrow_sync_24_regular, i->onLoadNewPostsClick()),
seeNewPostsBtnItem=new CheckableListItem<>(R.string.sk_settings_see_new_posts_button, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showNewPostsButton, R.drawable.ic_fluent_arrow_up_24_regular, i->toggleCheckableItem(seeNewPostsBtnItem)),
remoteLoadingItem=new CheckableListItem<>(R.string.sk_settings_allow_remote_loading, R.string.sk_settings_allow_remote_loading_explanation, CheckableListItem.Style.SWITCH, GlobalUserPreferences.allowRemoteLoading, R.drawable.ic_fluent_communication_24_regular, i->toggleCheckableItem(remoteLoadingItem)),
mentionRebloggerAutomaticallyItem=new CheckableListItem<>(R.string.mo_mention_reblogger_automatically, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.mentionRebloggerAutomatically, R.drawable.ic_fluent_comment_mention_24_regular, i->toggleCheckableItem(mentionRebloggerAutomaticallyItem)),
hapticFeedbackItem=new CheckableListItem<>(R.string.mo_haptic_feedback, R.string.mo_setting_haptic_feedback_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.hapticFeedback, R.drawable.ic_fluent_phone_vibrate_24_regular, i->toggleCheckableItem(hapticFeedbackItem), true),
showBoostsItem=new CheckableListItem<>(R.string.sk_settings_show_boosts, 0, CheckableListItem.Style.SWITCH, lp.showBoosts, R.drawable.ic_fluent_arrow_repeat_all_24_regular, i->toggleCheckableItem(showBoostsItem)),
showRepliesItem=new CheckableListItem<>(R.string.sk_settings_show_replies, 0, CheckableListItem.Style.SWITCH, lp.showReplies, R.drawable.ic_fluent_arrow_reply_24_regular, i->toggleCheckableItem(showRepliesItem))
));
if(isInstanceAkkoma()) items.add(
replyVisibilityItem=new ListItem<>(R.string.sk_settings_reply_visibility, getReplyVisibilityString(), R.drawable.ic_fluent_chat_24_regular, this::onReplyVisibilityClick)
);
loadNewPostsItem.checkedChangeListener=checked->onLoadNewPostsClick();
seeNewPostsBtnItem.isEnabled=loadNewPostsItem.checked;
onDataLoaded(items);
}
private @StringRes int getPrefixWithRepliesString(){
return switch(GlobalUserPreferences.prefixReplies){
case NEVER -> R.string.sk_settings_prefix_replies_never;
case ALWAYS -> R.string.sk_settings_prefix_replies_always;
case TO_OTHERS -> R.string.sk_settings_prefix_replies_to_others;
};
}
private @StringRes int getReplyVisibilityString(){
AccountLocalPreferences lp=getLocalPrefs();
if (lp.timelineReplyVisibility==null) return R.string.sk_settings_reply_visibility_all;
return switch(lp.timelineReplyVisibility){
case "following" -> R.string.sk_settings_reply_visibility_following;
case "self" -> R.string.sk_settings_reply_visibility_self;
default -> R.string.sk_settings_reply_visibility_all;
};
}
@Override
protected void doLoadData(int offset, int count){}
private void onDefaultLanguageClick(ListItem<?> item){
ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), null, new ComposeLanguageAlertViewController.SelectedOption(-1, postLanguage), null);
if (languageResolver == null) return;
ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), null, new ComposeLanguageAlertViewController.SelectedOption(postLanguage), null, languageResolver);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.default_post_language)
.setView(vc.getView())
.setPositiveButton(R.string.ok, (dlg, which)->{
ComposeLanguageAlertViewController.SelectedOption opt=vc.getSelectedOption();
if(!opt.locale.equals(postLanguage)){
if(!opt.language.equals(postLanguage)){
newPostLanguage=opt;
languageItem.subtitle=newPostLanguage.locale.getDisplayLanguage(Locale.getDefault());
postLanguage=newPostLanguage.language;
languageItem.subtitle=newPostLanguage.language.getDefaultName();
rebindItem(languageItem);
}
})
@@ -62,22 +118,90 @@ public class SettingsBehaviorFragment extends BaseSettingsFragment<Void>{
.show();
}
private void onPrefixRepliesClick(ListItem<?> item){
int selected=GlobalUserPreferences.prefixReplies.ordinal();
int[] newSelected={selected};
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_settings_prefix_reply_cw_with_re)
.setSingleChoiceItems((String[]) IntStream.of(R.string.sk_settings_prefix_replies_never, R.string.sk_settings_prefix_replies_always, R.string.sk_settings_prefix_replies_to_others).mapToObj(this::getString).toArray(String[]::new),
selected, (dlg, which)->newSelected[0]=which)
.setPositiveButton(R.string.ok, (dlg, which)->{
GlobalUserPreferences.prefixReplies=GlobalUserPreferences.PrefixRepliesMode.values()[newSelected[0]];
prefixRepliesItem.subtitleRes=getPrefixWithRepliesString();
rebindItem(prefixRepliesItem);
})
.setNegativeButton(R.string.cancel, null)
.show();
}
private void onReplyVisibilityClick(ListItem<?> item){
AccountLocalPreferences lp=getLocalPrefs();
int selected=lp.timelineReplyVisibility==null ? 2 : switch(lp.timelineReplyVisibility){
case "following" -> 0;
case "self" -> 1;
default -> 2;
};
int[] newSelected={selected};
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_settings_prefix_reply_cw_with_re)
.setSingleChoiceItems((String[]) IntStream.of(R.string.sk_settings_reply_visibility_following, R.string.sk_settings_reply_visibility_self, R.string.sk_settings_reply_visibility_all).mapToObj(this::getString).toArray(String[]::new),
selected, (dlg, which)->newSelected[0]=which)
.setPositiveButton(R.string.ok, (dlg, which)->{
lp.timelineReplyVisibility=switch(newSelected[0]){
case 0 -> "following";
case 1 -> "self";
default -> null;
};
replyVisibilityItem.subtitleRes=getReplyVisibilityString();
rebindItem(replyVisibilityItem);
})
.setNegativeButton(R.string.cancel, null)
.show();
}
private void onLoadNewPostsClick(){
toggleCheckableItem(loadNewPostsItem);
seeNewPostsBtnItem.checked=loadNewPostsItem.checked;
seeNewPostsBtnItem.isEnabled=loadNewPostsItem.checked;
rebindItem(seeNewPostsBtnItem);
}
@Override
protected void onHidden(){
super.onHidden();
GlobalUserPreferences.playGifs=playGifsItem.checked;
GlobalUserPreferences.overlayMedia=overlayMediaItem.checked;
GlobalUserPreferences.useCustomTabs=customTabsItem.checked;
GlobalUserPreferences.altTextReminders=altTextItem.checked;
GlobalUserPreferences.confirmUnfollow=customTabsItem.checked;
GlobalUserPreferences.confirmUnfollow=confirmUnfollowItem.checked;
GlobalUserPreferences.confirmBoost=confirmBoostItem.checked;
GlobalUserPreferences.confirmDeletePost=confirmDeleteItem.checked;
GlobalUserPreferences.forwardReportDefault=forwardReportsItem.checked;
GlobalUserPreferences.loadNewPosts=loadNewPostsItem.checked;
GlobalUserPreferences.showNewPostsButton=seeNewPostsBtnItem.checked;
GlobalUserPreferences.allowRemoteLoading=remoteLoadingItem.checked;
GlobalUserPreferences.mentionRebloggerAutomatically=mentionRebloggerAutomaticallyItem.checked;
GlobalUserPreferences.hapticFeedback=hapticFeedbackItem.checked;
GlobalUserPreferences.showPostsWithoutAlt=showPostsWithoutAltItem.checked;
GlobalUserPreferences.save();
AccountLocalPreferences lp=getLocalPrefs();
boolean restartPlease=lp.showBoosts!=showBoostsItem.checked
|| lp.showReplies!=showRepliesItem.checked;
lp.showBoosts=showBoostsItem.checked;
lp.showReplies=showRepliesItem.checked;
lp.save();
if(newPostLanguage!=null){
AccountSession s=AccountSessionManager.get(accountID);
if(s.preferences==null)
s.preferences=new Preferences();
s.preferences.postingDefaultLanguage=newPostLanguage.locale.toLanguageTag();
s.preferences.postingDefaultLanguage=newPostLanguage.language.getLanguage();
s.savePreferencesLater();
}
if(restartPlease) getActivity().recreate();
}
@Override
public String getAccountID(){
return accountID;
}
}

View File

@@ -1,27 +1,37 @@
package org.joinmastodon.android.fragments.settings;
import android.app.Activity;
import android.app.AlertDialog;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.text.TextUtils;
import android.view.View;
import android.view.WindowManager;
import android.widget.ImageView;
import androidx.annotation.StringRes;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountLocalPreferences.ColorPreference;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusDisplaySettingsChangedEvent;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.views.TextInputFrameLayout;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import me.grishka.appkit.FragmentStackActivity;
@@ -29,21 +39,57 @@ import me.grishka.appkit.FragmentStackActivity;
public class SettingsDisplayFragment extends BaseSettingsFragment<Void>{
private ImageView themeTransitionWindowView;
private ListItem<Void> themeItem;
private CheckableListItem<Void> showCWsItem, hideSensitiveMediaItem, interactionCountsItem, emojiInNamesItem;
private CheckableListItem<Void> revealCWsItem, hideSensitiveMediaItem, interactionCountsItem, emojiInNamesItem;
// MEGALODON
private CheckableListItem<Void> trueBlackModeItem, marqueeItem, disableSwipeItem, reduceMotionItem, altIndicatorItem, noAltIndicatorItem, collapsePostsItem, spectatorModeItem, hideFabItem, translateOpenedItem, disablePillItem, showNavigationLabelsItem, likeIconItem, underlinedLinksItem;
private ListItem<Void> colorItem, publishTextItem, autoRevealCWsItem;
private CheckableListItem<Void> pronounsInUserListingsItem, pronounsInTimelinesItem, pronounsInThreadsItem;
// MOSHIDON
private CheckableListItem<Void> enableDoubleTapToSwipeItem, relocatePublishButtonItem, showPostDividersItem, enableDoubleTapToSearchItem, showMediaPreviewItem;
private AccountLocalPreferences lp;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.settings_display);
AccountSession s=AccountSessionManager.get(accountID);
AccountLocalPreferences lp=s.getLocalPreferences();
lp=s.getLocalPreferences();
onDataLoaded(List.of(
themeItem=new ListItem<>(R.string.settings_theme, getAppearanceValue(), R.drawable.ic_dark_mode_24px, this::onAppearanceClick),
showCWsItem=new CheckableListItem<>(R.string.settings_show_cws, 0, CheckableListItem.Style.SWITCH, lp.showCWs, R.drawable.ic_warning_24px, this::toggleCheckableItem),
hideSensitiveMediaItem=new CheckableListItem<>(R.string.settings_hide_sensitive_media, 0, CheckableListItem.Style.SWITCH, lp.hideSensitiveMedia, R.drawable.ic_no_adult_content_24px, this::toggleCheckableItem),
interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, 0, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_social_leaderboard_24px, this::toggleCheckableItem),
emojiInNamesItem=new CheckableListItem<>(R.string.settings_show_emoji_in_names, 0, CheckableListItem.Style.SWITCH, lp.customEmojiInNames, R.drawable.ic_emoticon_24px, this::toggleCheckableItem)
themeItem=new ListItem<>(R.string.settings_theme, getAppearanceValue(), R.drawable.ic_fluent_weather_moon_24_regular, this::onAppearanceClick),
colorItem=new ListItem<>(getString(R.string.sk_settings_color_palette), getColorPaletteValue(), R.drawable.ic_fluent_color_24_regular, this::onColorClick),
trueBlackModeItem=new CheckableListItem<>(R.string.sk_settings_true_black, R.string.mo_setting_true_black_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.trueBlackTheme, R.drawable.ic_fluent_dark_theme_24_regular, i->onTrueBlackModeClick(), true),
publishTextItem=new ListItem<>(getString(R.string.sk_settings_publish_button_text), getPublishButtonText(), R.drawable.ic_fluent_send_24_regular, this::onPublishTextClick),
autoRevealCWsItem=new ListItem<>(R.string.sk_settings_auto_reveal_equal_spoilers, getAutoRevealSpoilersText(), R.drawable.ic_fluent_eye_24_regular, this::onAutoRevealSpoilersClick),
relocatePublishButtonItem=new CheckableListItem<>(R.string.mo_relocate_publish_button, R.string.mo_setting_relocate_publish_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.relocatePublishButton, R.drawable.ic_fluent_arrow_autofit_down_24_regular, i->toggleCheckableItem(relocatePublishButtonItem)),
revealCWsItem=new CheckableListItem<>(R.string.sk_settings_always_reveal_content_warnings, 0, CheckableListItem.Style.SWITCH, lp.revealCWs, R.drawable.ic_fluent_chat_warning_24_regular, i->toggleCheckableItem(revealCWsItem)),
hideSensitiveMediaItem=new CheckableListItem<>(R.string.settings_hide_sensitive_media, 0, CheckableListItem.Style.SWITCH, lp.hideSensitiveMedia, R.drawable.ic_fluent_flag_24_regular, i->toggleCheckableItem(hideSensitiveMediaItem)),
showMediaPreviewItem=new CheckableListItem<>(R.string.mo_show_media_preview, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showMediaPreview, R.drawable.ic_fluent_image_24_regular, i->toggleCheckableItem(showMediaPreviewItem)),
interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, R.string.mo_setting_interaction_count_summary, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_fluent_number_row_24_regular, i->toggleCheckableItem(interactionCountsItem)),
emojiInNamesItem=new CheckableListItem<>(R.string.settings_show_emoji_in_names, 0, CheckableListItem.Style.SWITCH, lp.customEmojiInNames, R.drawable.ic_fluent_emoji_24_regular, i->toggleCheckableItem(emojiInNamesItem)),
marqueeItem=new CheckableListItem<>(R.string.sk_settings_enable_marquee, R.string.mo_setting_marquee_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.toolbarMarquee, R.drawable.ic_fluent_text_more_24_regular, i->toggleCheckableItem(marqueeItem)),
reduceMotionItem=new CheckableListItem<>(R.string.sk_settings_reduce_motion, R.string.mo_setting_reduced_motion_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.reduceMotion, R.drawable.ic_fluent_star_emphasis_24_regular, i->toggleCheckableItem(reduceMotionItem)),
enableDoubleTapToSearchItem=new CheckableListItem<>(R.string.mo_double_tap_to_search, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.doubleTapToSearch, R.drawable.ic_fluent_search_24_regular, i->toggleCheckableItem(enableDoubleTapToSearchItem)),
disableSwipeItem=new CheckableListItem<>(R.string.sk_settings_tabs_disable_swipe, R.string.mo_setting_disable_swipe_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.disableSwipe, R.drawable.ic_fluent_swipe_right_24_regular, i->toggleCheckableItem(disableSwipeItem)),
enableDoubleTapToSwipeItem=new CheckableListItem<>(R.string.mo_double_tap_to_swipe_between_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.doubleTapToSwipe, R.drawable.ic_fluent_double_tap_swipe_right_24_regular, i->toggleCheckableItem(enableDoubleTapToSwipeItem)),
altIndicatorItem=new CheckableListItem<>(R.string.sk_settings_show_alt_indicator, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showAltIndicator, R.drawable.ic_fluent_scan_text_24_regular, i->toggleCheckableItem(altIndicatorItem)),
noAltIndicatorItem=new CheckableListItem<>(R.string.sk_settings_show_no_alt_indicator, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showNoAltIndicator, R.drawable.ic_fluent_important_24_regular, i->toggleCheckableItem(noAltIndicatorItem)),
collapsePostsItem=new CheckableListItem<>(R.string.sk_settings_collapse_long_posts, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.collapseLongPosts, R.drawable.ic_fluent_chevron_down_24_regular, i->toggleCheckableItem(collapsePostsItem)),
spectatorModeItem=new CheckableListItem<>(R.string.sk_settings_hide_interaction, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.spectatorMode, R.drawable.ic_fluent_star_off_24_regular, i->toggleCheckableItem(spectatorModeItem)),
hideFabItem=new CheckableListItem<>(R.string.sk_settings_hide_fab, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.autoHideFab, R.drawable.ic_fluent_edit_24_regular, i->toggleCheckableItem(hideFabItem)),
translateOpenedItem=new CheckableListItem<>(R.string.sk_settings_translate_only_opened, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.translateButtonOpenedOnly, R.drawable.ic_fluent_translate_24_regular, i->toggleCheckableItem(translateOpenedItem)),
likeIconItem=new CheckableListItem<>(R.string.sk_settings_like_icon, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.likeIcon, R.drawable.ic_fluent_heart_24_regular, i->toggleCheckableItem(likeIconItem)),
underlinedLinksItem=new CheckableListItem<>(R.string.sk_settings_underlined_links, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.underlinedLinks, R.drawable.ic_fluent_text_underline_24_regular, i->toggleCheckableItem(underlinedLinksItem)),
showPostDividersItem=new CheckableListItem<>(R.string.mo_enable_dividers, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showDividers, R.drawable.ic_fluent_timeline_24_regular, i->toggleCheckableItem(showPostDividersItem)),
disablePillItem=new CheckableListItem<>(R.string.sk_disable_pill_shaped_active_indicator, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.disableM3PillActiveIndicator, R.drawable.ic_fluent_pill_24_regular, i->toggleCheckableItem(disablePillItem)),
showNavigationLabelsItem=new CheckableListItem<>(R.string.sk_settings_show_labels_in_navigation_bar, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.showNavigationLabels, R.drawable.ic_fluent_tag_24_regular, i->toggleCheckableItem(showNavigationLabelsItem), true),
pronounsInTimelinesItem=new CheckableListItem<>(R.string.sk_settings_display_pronouns_in_timelines, 0, CheckableListItem.Style.CHECKBOX, GlobalUserPreferences.displayPronounsInTimelines, 0, i->toggleCheckableItem(pronounsInTimelinesItem)),
pronounsInThreadsItem=new CheckableListItem<>(R.string.sk_settings_display_pronouns_in_threads, 0, CheckableListItem.Style.CHECKBOX, GlobalUserPreferences.displayPronounsInThreads, 0, i->toggleCheckableItem(pronounsInThreadsItem)),
pronounsInUserListingsItem=new CheckableListItem<>(R.string.sk_settings_display_pronouns_in_user_listings, 0, CheckableListItem.Style.CHECKBOX, GlobalUserPreferences.displayPronounsInUserListings, 0, i->toggleCheckableItem(pronounsInUserListingsItem))
));
trueBlackModeItem.checkedChangeListener=checked->onTrueBlackModeClick();
}
@Override
@@ -62,17 +108,45 @@ public class SettingsDisplayFragment extends BaseSettingsFragment<Void>{
@Override
protected void onHidden(){
super.onHidden();
AccountSession s=AccountSessionManager.get(accountID);
AccountLocalPreferences lp=s.getLocalPreferences();
lp.showCWs=showCWsItem.checked;
boolean restartPlease=GlobalUserPreferences.disableM3PillActiveIndicator!=disablePillItem.checked
|| GlobalUserPreferences.showNavigationLabels!=showNavigationLabelsItem.checked
|| GlobalUserPreferences.showMediaPreview!=showMediaPreviewItem.checked
|| GlobalUserPreferences.showDividers!=showPostDividersItem.checked
|| GlobalUserPreferences.likeIcon!=likeIconItem.checked;
lp.revealCWs=revealCWsItem.checked;
lp.hideSensitiveMedia=hideSensitiveMediaItem.checked;
lp.showInteractionCounts=interactionCountsItem.checked;
lp.customEmojiInNames=emojiInNamesItem.checked;
lp.save();
E.post(new StatusDisplaySettingsChangedEvent(accountID));
GlobalUserPreferences.toolbarMarquee=marqueeItem.checked;
GlobalUserPreferences.relocatePublishButton=relocatePublishButtonItem.checked;
GlobalUserPreferences.reduceMotion=reduceMotionItem.checked;
GlobalUserPreferences.disableSwipe=disableSwipeItem.checked;
GlobalUserPreferences.doubleTapToSearch=enableDoubleTapToSearchItem.checked;
GlobalUserPreferences.doubleTapToSwipe=enableDoubleTapToSwipeItem.checked;
GlobalUserPreferences.showAltIndicator=altIndicatorItem.checked;
GlobalUserPreferences.showNoAltIndicator=noAltIndicatorItem.checked;
GlobalUserPreferences.collapseLongPosts=collapsePostsItem.checked;
GlobalUserPreferences.spectatorMode=spectatorModeItem.checked;
GlobalUserPreferences.autoHideFab=hideFabItem.checked;
GlobalUserPreferences.translateButtonOpenedOnly=translateOpenedItem.checked;
GlobalUserPreferences.likeIcon=likeIconItem.checked;
GlobalUserPreferences.underlinedLinks=underlinedLinksItem.checked;
GlobalUserPreferences.showDividers=showPostDividersItem.checked;
GlobalUserPreferences.disableM3PillActiveIndicator=disablePillItem.checked;
GlobalUserPreferences.showNavigationLabels=showNavigationLabelsItem.checked;
GlobalUserPreferences.displayPronounsInTimelines=pronounsInTimelinesItem.checked;
GlobalUserPreferences.displayPronounsInThreads=pronounsInThreadsItem.checked;
GlobalUserPreferences.displayPronounsInUserListings=pronounsInUserListingsItem.checked;
GlobalUserPreferences.showMediaPreview=showMediaPreviewItem.checked;
GlobalUserPreferences.save();
if(restartPlease) restartActivityToApplyNewTheme();
else E.post(new StatusDisplaySettingsChangedEvent(accountID));
}
private int getAppearanceValue(){
private @StringRes int getAppearanceValue(){
return switch(GlobalUserPreferences.theme){
case AUTO -> R.string.theme_auto;
case LIGHT -> R.string.theme_light;
@@ -80,6 +154,34 @@ public class SettingsDisplayFragment extends BaseSettingsFragment<Void>{
};
}
private String getColorPaletteValue(){
ColorPreference color=AccountSessionManager.get(accountID).getLocalPreferences().color;
return color==null
? getString(R.string.sk_settings_color_palette_default, getString(GlobalUserPreferences.color.getName()))
: getString(color.getName());
}
private String getPublishButtonText() {
return TextUtils.isEmpty(AccountSessionManager.get(accountID).getLocalPreferences().publishButtonText)
? getContext().getString(R.string.publish)
: AccountSessionManager.get(accountID).getLocalPreferences().publishButtonText;
}
private @StringRes int getAutoRevealSpoilersText() {
return switch(GlobalUserPreferences.autoRevealEqualSpoilers){
case THREADS -> R.string.sk_settings_auto_reveal_author;
case DISCUSSIONS -> R.string.sk_settings_auto_reveal_anyone;
default -> R.string.sk_settings_auto_reveal_nobody;
};
}
private void onTrueBlackModeClick(){
toggleCheckableItem(trueBlackModeItem);
boolean prev=GlobalUserPreferences.trueBlackTheme;
GlobalUserPreferences.trueBlackTheme=trueBlackModeItem.checked;
maybeApplyNewThemeRightNow(null, null, prev);
}
private void onAppearanceClick(ListItem<?> item_){
int selected=switch(GlobalUserPreferences.theme){
case LIGHT -> 0;
@@ -104,19 +206,100 @@ public class SettingsDisplayFragment extends BaseSettingsFragment<Void>{
GlobalUserPreferences.save();
themeItem.subtitleRes=getAppearanceValue();
rebindItem(themeItem);
maybeApplyNewThemeRightNow(prev);
maybeApplyNewThemeRightNow(prev, null, null);
}
})
.setNegativeButton(R.string.cancel, null)
.show();
}
private void maybeApplyNewThemeRightNow(GlobalUserPreferences.ThemePreference prev){
boolean isCurrentDark=prev==GlobalUserPreferences.ThemePreference.DARK ||
(prev==GlobalUserPreferences.ThemePreference.AUTO && Build.VERSION.SDK_INT>=30 && getResources().getConfiguration().isNightModeActive());
private void onColorClick(ListItem<?> item_){
boolean multiple=AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1;
int indexOffset=multiple ? 1 : 0;
int selected=lp.color==null ? 0 : lp.color.ordinal() + indexOffset;
int[] newSelected={selected};
List<String> items=Arrays.stream(ColorPreference.values()).map(ColorPreference::getName).map(this::getString).collect(Collectors.toList());
if(multiple)
items.add(0, getString(R.string.sk_settings_color_palette_default, items.get(GlobalUserPreferences.color.ordinal())));
Consumer<Boolean> save=(asDefault)->{
boolean defaultSelected=multiple && newSelected[0]==0;
ColorPreference pref=defaultSelected ? null : ColorPreference.values()[newSelected[0]-indexOffset];
if(pref!=lp.color){
ColorPreference prev=lp.color;
lp.color=asDefault ? null : pref;
lp.save();
if((asDefault || !multiple) && pref!=null){
GlobalUserPreferences.color=pref;
GlobalUserPreferences.save();
}
colorItem.subtitle=getColorPaletteValue();
rebindItem(colorItem);
if(prev==null && pref!=null) restartActivityToApplyNewTheme();
else maybeApplyNewThemeRightNow(null, prev, null);
}
};
AlertDialog.Builder alert=new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_settings_color_palette)
.setSingleChoiceItems(items.stream().toArray(String[]::new),
selected, (dlg, item)->newSelected[0]=item)
.setPositiveButton(R.string.ok, (dlg, item)->save.accept(false))
.setNegativeButton(R.string.cancel, null);
if(multiple) alert.setNeutralButton(R.string.sk_set_as_default, (dlg, item)->save.accept(true));
alert.show();
}
private void onPublishTextClick(ListItem<?> item_){
TextInputFrameLayout input = new TextInputFrameLayout(
getContext(),
getString(R.string.publish),
TextUtils.isEmpty(lp.publishButtonText) ? "" : lp.publishButtonText.trim()
);
new M3AlertDialogBuilder(getContext()).setTitle(R.string.sk_settings_publish_button_text_title).setView(input)
.setPositiveButton(R.string.save, (d, which) -> {
lp.publishButtonText=input.getEditText().getText().toString().trim();
lp.save();
publishTextItem.subtitle=getPublishButtonText();
rebindItem(publishTextItem);
})
.setNeutralButton(R.string.clear, (d, which) -> {
lp.publishButtonText=null;
lp.save();
publishTextItem.subtitle=getPublishButtonText();
rebindItem(publishTextItem);
})
.setNegativeButton(R.string.cancel, (d, which) -> {})
.show();
}
private void onAutoRevealSpoilersClick(ListItem<?> item_){
int selected=GlobalUserPreferences.autoRevealEqualSpoilers.ordinal();
int[] newSelected={selected};
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_settings_auto_reveal_equal_spoilers)
.setSingleChoiceItems((String[])IntStream.of(R.string.sk_settings_auto_reveal_nobody, R.string.sk_settings_auto_reveal_author, R.string.sk_settings_auto_reveal_anyone).mapToObj(this::getString).toArray(String[]::new),
selected, (dlg, item)->newSelected[0]=item)
.setPositiveButton(R.string.ok, (dlg, item)->{
GlobalUserPreferences.autoRevealEqualSpoilers=GlobalUserPreferences.AutoRevealMode.values()[newSelected[0]];
autoRevealCWsItem.subtitleRes=getAutoRevealSpoilersText();
rebindItem(autoRevealCWsItem);
})
.setNegativeButton(R.string.cancel, null)
.show();
}
private void maybeApplyNewThemeRightNow(GlobalUserPreferences.ThemePreference prevTheme, ColorPreference prevColor, Boolean prevTrueBlack){
if(prevTheme==null) prevTheme=GlobalUserPreferences.theme;
if(prevTrueBlack==null) prevTrueBlack=GlobalUserPreferences.trueBlackTheme;
if(prevColor==null) prevColor=lp.getCurrentColor();
boolean isCurrentDark=prevTheme==GlobalUserPreferences.ThemePreference.DARK ||
(prevTheme==GlobalUserPreferences.ThemePreference.AUTO && Build.VERSION.SDK_INT>=30 && getResources().getConfiguration().isNightModeActive());
boolean isNewDark=GlobalUserPreferences.theme==GlobalUserPreferences.ThemePreference.DARK ||
(GlobalUserPreferences.theme==GlobalUserPreferences.ThemePreference.AUTO && Build.VERSION.SDK_INT>=30 && getResources().getConfiguration().isNightModeActive());
if(isCurrentDark!=isNewDark){
boolean isNewBlack=GlobalUserPreferences.trueBlackTheme;
if(isCurrentDark!=isNewDark || prevColor!=lp.getCurrentColor() || (isNewDark && prevTrueBlack!=isNewBlack)){
restartActivityToApplyNewTheme();
}
}

View File

@@ -55,7 +55,7 @@ public class SettingsFiltersFragment extends BaseSettingsFragment<Filter>{
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(super.getAdapter());
adapter.addAdapter(new GenericListItemsAdapter<>(Collections.singletonList(
new ListItem<Void>(R.string.settings_add_filter, 0, R.drawable.ic_add_24px, this::onAddFilterClick)
new ListItem<Void>(R.string.settings_add_filter, 0, R.drawable.ic_fluent_add_24_regular, this::onAddFilterClick)
)));
return adapter;
}

View File

@@ -0,0 +1,157 @@
package org.joinmastodon.android.fragments.settings;
import android.os.Bundle;
import androidx.annotation.StringRes;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusDisplaySettingsChangedEvent;
import org.joinmastodon.android.fragments.HasAccountID;
import org.joinmastodon.android.model.ContentType;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import me.grishka.appkit.Nav;
public class SettingsInstanceFragment extends BaseSettingsFragment<Void> implements HasAccountID{
private CheckableListItem<Void> contentTypesItem, emojiReactionsItem, localOnlyItem, glitchModeItem;
private ListItem<Void> defaultContentTypeItem, showEmojiReactionsItem;
private AccountLocalPreferences lp;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.sk_settings_instance);
AccountSession s=AccountSessionManager.get(accountID);
lp=s.getLocalPreferences();
onDataLoaded(List.of(
new ListItem<>(AccountSessionManager.get(accountID).domain, getString(R.string.settings_server_explanation), R.drawable.ic_fluent_server_24_regular, this::onServerClick),
new ListItem<>(R.string.sk_settings_profile, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/settings/profile")),
new ListItem<>(R.string.sk_settings_posting, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/settings/preferences/other")),
new ListItem<>(R.string.sk_settings_auth, 0, R.drawable.ic_fluent_open_24_regular, i->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/auth/edit"), 0, true),
contentTypesItem=new CheckableListItem<>(R.string.sk_settings_content_types, R.string.sk_settings_content_types_explanation, CheckableListItem.Style.SWITCH, lp.contentTypesEnabled, R.drawable.ic_fluent_text_edit_style_24_regular, i->onContentTypeClick()),
defaultContentTypeItem=new ListItem<>(R.string.sk_settings_default_content_type, lp.defaultContentType.getName(), R.drawable.ic_fluent_text_bold_24_regular, this::onDefaultContentTypeClick, 0, true),
emojiReactionsItem=new CheckableListItem<>(R.string.sk_settings_emoji_reactions, R.string.sk_settings_emoji_reactions_explanation, CheckableListItem.Style.SWITCH, lp.emojiReactionsEnabled, R.drawable.ic_fluent_emoji_laugh_24_regular, i->onEmojiReactionsClick()),
showEmojiReactionsItem=new ListItem<>(R.string.sk_settings_show_emoji_reactions, getShowEmojiReactionsString(), R.drawable.ic_fluent_emoji_24_regular, this::onShowEmojiReactionsClick, 0, true),
localOnlyItem=new CheckableListItem<>(R.string.sk_settings_support_local_only, R.string.sk_settings_local_only_explanation, CheckableListItem.Style.SWITCH, lp.localOnlySupported, R.drawable.ic_fluent_eye_24_regular, i->onLocalOnlyClick()),
glitchModeItem=new CheckableListItem<>(R.string.sk_settings_glitch_instance, R.string.sk_settings_glitch_mode_explanation, CheckableListItem.Style.SWITCH, lp.glitchInstance, R.drawable.ic_fluent_eye_24_filled, i->toggleCheckableItem(glitchModeItem))
));
contentTypesItem.checkedChangeListener=checked->onContentTypeClick();
defaultContentTypeItem.isEnabled=contentTypesItem.checked;
emojiReactionsItem.checkedChangeListener=checked->onEmojiReactionsClick();
showEmojiReactionsItem.isEnabled=emojiReactionsItem.checked;
localOnlyItem.checkedChangeListener=checked->onLocalOnlyClick();
glitchModeItem.isEnabled=localOnlyItem.checked;
}
@Override
protected void doLoadData(int offset, int count){}
@Override
protected void onHidden(){
super.onHidden();
lp.contentTypesEnabled=contentTypesItem.checked;
lp.emojiReactionsEnabled=emojiReactionsItem.checked;
lp.localOnlySupported=localOnlyItem.checked;
lp.glitchInstance=glitchModeItem.checked;
lp.save();
E.post(new StatusDisplaySettingsChangedEvent(accountID));
}
private void onServerClick(ListItem<?> item){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), SettingsServerFragment.class, args);
}
private void onContentTypeClick(){
toggleCheckableItem(contentTypesItem);
defaultContentTypeItem.isEnabled=contentTypesItem.checked;
resetDefaultContentType();
rebindItem(defaultContentTypeItem);
}
private void resetDefaultContentType(){
lp.defaultContentType=defaultContentTypeItem.isEnabled
? ContentType.PLAIN : ContentType.UNSPECIFIED;
defaultContentTypeItem.subtitleRes=lp.defaultContentType.getName();
}
private void onDefaultContentTypeClick(ListItem<?> item_){
List<ContentType> supportedContentTypes=Arrays.stream(ContentType.values())
.filter(t->t.supportedByInstance(getInstance().orElse(null)))
.collect(Collectors.toList());
int selected=supportedContentTypes.indexOf(lp.defaultContentType);
int[] newSelected={selected};
String[] names=supportedContentTypes.stream()
.map(ContentType::getName)
.map(this::getString)
.toArray(String[]::new);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_settings_default_content_type)
.setSingleChoiceItems(names,
selected, (dlg, item)->newSelected[0]=item)
.setPositiveButton(R.string.ok, (dlg, item)->{
ContentType type=supportedContentTypes.get(newSelected[0]);
lp.defaultContentType=type;
defaultContentTypeItem.subtitleRes=type.getName();
rebindItem(defaultContentTypeItem);
})
.setNegativeButton(R.string.cancel, null)
.show();
}
private void onShowEmojiReactionsClick(ListItem<?> item_){
int selected=lp.showEmojiReactions.ordinal();
int[] newSelected={selected};
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_settings_show_emoji_reactions)
.setSingleChoiceItems((String[]) IntStream.of(R.string.sk_settings_show_emoji_reactions_hide_empty, R.string.sk_settings_show_emoji_reactions_only_opened, R.string.sk_settings_show_emoji_reactions_always).mapToObj(this::getString).toArray(String[]::new),
selected, (dlg, item)->newSelected[0]=item)
.setPositiveButton(R.string.ok, (dlg, item)->{
lp.showEmojiReactions=AccountLocalPreferences.ShowEmojiReactions.values()[newSelected[0]];
showEmojiReactionsItem.subtitleRes=getShowEmojiReactionsString();
rebindItem(showEmojiReactionsItem);
})
.setNegativeButton(R.string.cancel, null)
.show();
}
private @StringRes int getShowEmojiReactionsString(){
return switch(lp.showEmojiReactions){
case HIDE_EMPTY -> R.string.sk_settings_show_emoji_reactions_hide_empty;
case ONLY_OPENED -> R.string.sk_settings_show_emoji_reactions_only_opened;
case ALWAYS -> R.string.sk_settings_show_emoji_reactions_always;
};
}
private void onEmojiReactionsClick(){
toggleCheckableItem(emojiReactionsItem);
showEmojiReactionsItem.isEnabled=emojiReactionsItem.checked;
rebindItem(showEmojiReactionsItem);
}
private void onLocalOnlyClick(){
toggleCheckableItem(localOnlyItem);
glitchModeItem.checked=localOnlyItem.checked && !isInstanceAkkoma();
glitchModeItem.isEnabled=localOnlyItem.checked;
rebindItem(glitchModeItem);
}
@Override
public String getAccountID(){
return accountID;
}
}

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.fragments.settings;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
@@ -15,8 +16,9 @@ import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
@@ -29,6 +31,7 @@ import me.grishka.appkit.Nav;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class SettingsMainFragment extends BaseSettingsFragment<Void>{
private AccountSession account;
private boolean loggedOut;
private HideableSingleViewRecyclerAdapter bannerAdapter;
private Button updateButton1, updateButton2;
@@ -47,22 +50,27 @@ public class SettingsMainFragment extends BaseSettingsFragment<Void>{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
account=AccountSessionManager.get(accountID);
setTitle(R.string.settings);
setSubtitle(AccountSessionManager.get(accountID).getFullUsername());
setSubtitle(account.getFullUsername());
onDataLoaded(List.of(
new ListItem<>(R.string.settings_behavior, 0, R.drawable.ic_settings_24px, this::onBehaviorClick),
new ListItem<>(R.string.settings_display, 0, R.drawable.ic_style_24px, this::onDisplayClick),
new ListItem<>(R.string.settings_privacy, 0, R.drawable.ic_privacy_tip_24px, this::onPrivacyClick),
new ListItem<>(R.string.settings_filters, 0, R.drawable.ic_filter_alt_24px, this::onFiltersClick),
new ListItem<>(R.string.settings_notifications, 0, R.drawable.ic_notifications_24px, this::onNotificationsClick),
new ListItem<>(AccountSessionManager.get(accountID).domain, getString(R.string.settings_server_explanation), R.drawable.ic_dns_24px, this::onServerClick),
new ListItem<>(getString(R.string.about_app, getString(R.string.app_name)), null, R.drawable.ic_info_24px, this::onAboutClick, null, 0, true),
new ListItem<>(R.string.manage_accounts, 0, R.drawable.ic_switch_account_24px, this::onManageAccountsClick),
new ListItem<>(R.string.log_out, 0, R.drawable.ic_logout_24px, this::onLogOutClick, R.attr.colorM3Error, false)
new ListItem<>(R.string.settings_behavior, 0, R.drawable.ic_fluent_settings_24_regular, this::onBehaviorClick),
new ListItem<>(R.string.settings_display, 0, R.drawable.ic_fluent_color_24_regular, this::onDisplayClick),
new ListItem<>(R.string.settings_privacy, 0, R.drawable.ic_fluent_shield_24_regular, this::onPrivacyClick),
new ListItem<>(R.string.settings_notifications, 0, R.drawable.ic_fluent_alert_24_regular, this::onNotificationsClick),
new ListItem<>(R.string.sk_settings_instance, 0, R.drawable.ic_fluent_server_24_regular, this::onInstanceClick),
new ListItem<>(getString(R.string.about_app, getString(R.string.mo_app_name)), null, R.drawable.ic_fluent_info_24_regular, this::onAboutClick, null, 0, true),
new ListItem<>(R.string.manage_accounts, 0, R.drawable.ic_fluent_person_swap_24_regular, this::onManageAccountsClick),
new ListItem<>(R.string.log_out, 0, R.drawable.ic_fluent_sign_out_24_regular, this::onLogOutClick, R.attr.colorM3Error, false)
));
Instance instance=AccountSessionManager.getInstance().getInstanceInfo(account.domain);
if(!instance.isAkkoma()){
data.add(3, new ListItem<>(R.string.settings_filters, 0, R.drawable.ic_fluent_filter_24_regular, this::onFiltersClick));
}
if(BuildConfig.DEBUG || BuildConfig.BUILD_TYPE.equals("appcenterPrivateBeta")){
data.add(0, new ListItem<>("Debug settings", null, R.drawable.ic_settings_24px, i->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true));
data.add(0, new ListItem<>("Debug settings", null, R.drawable.ic_fluent_wrench_screwdriver_24_regular, i->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true));
}
AccountSession session=AccountSessionManager.get(accountID);
@@ -84,7 +92,7 @@ public class SettingsMainFragment extends BaseSettingsFragment<Void>{
protected void onHidden(){
super.onHidden();
if(!loggedOut)
AccountSessionManager.get(accountID).savePreferencesIfPending();
account.savePreferencesIfPending();
}
@Override
@@ -101,7 +109,7 @@ public class SettingsMainFragment extends BaseSettingsFragment<Void>{
updateButton2.setOnClickListener(this::onUpdateButtonClick);
bannerTitle.setText(R.string.app_update_ready);
bannerIcon.setImageResource(R.drawable.ic_apk_install_24px);
bannerIcon.setImageResource(R.drawable.ic_fluent_phone_update_24_regular);
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(bannerAdapter);
@@ -143,8 +151,8 @@ public class SettingsMainFragment extends BaseSettingsFragment<Void>{
Nav.go(getActivity(), SettingsNotificationsFragment.class, makeFragmentArgs());
}
private void onServerClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsServerFragment.class, makeFragmentArgs());
private void onInstanceClick(ListItem<?> item_){
Nav.go(getActivity(), SettingsInstanceFragment.class, makeFragmentArgs());
}
private void onAboutClick(ListItem<?> item_){
@@ -152,16 +160,17 @@ public class SettingsMainFragment extends BaseSettingsFragment<Void>{
}
private void onManageAccountsClick(ListItem<?> item){
new AccountSwitcherSheet(getActivity(), null).setOnLoggedOutCallback(()->loggedOut=true).show();
new AccountSwitcherSheet(getActivity(), null).show();
}
private void onLogOutClick(ListItem<?> item_){
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.log_out)
.setMessage(getString(R.string.confirm_log_out, session.getFullUsername()))
.setPositiveButton(R.string.log_out, (dialog, which)->AccountSessionManager.get(accountID).logOut(getActivity(), ()->{
.setPositiveButton(R.string.log_out, (dialog, which)->account.logOut(getActivity(), ()->{
loggedOut=true;
((MainActivity)getActivity()).restartHomeFragment();
((MainActivity)getActivity()).restartActivity();
}))
.setNegativeButton(R.string.cancel, null)
.show();

View File

@@ -1,5 +1,7 @@
package org.joinmastodon.android.fragments.settings;
import static org.unifiedpush.android.connector.UnifiedPush.getDistributor;
import android.app.AlertDialog;
import android.app.NotificationManager;
import android.content.Intent;
@@ -10,10 +12,13 @@ import android.provider.Settings;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.PushSubscriptionManager;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.PushSubscription;
@@ -22,14 +27,17 @@ import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.unifiedpush.android.connector.UnifiedPush;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
private PushSubscription pushSubscription;
@@ -47,26 +55,53 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
private boolean needUpdateNotificationSettings;
private boolean notificationsAllowed=true;
// MEGALODON
private boolean useUnifiedPush = false;
private CheckableListItem<Void> uniformIconItem, deleteItem, onlyLatestItem, unifiedPushItem;
private CheckableListItem<Void> postsItem, updateItem;
// MOSHIDON
private CheckableListItem<Void> swapBookmarkWithReblogItem;
private AccountLocalPreferences lp;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.settings_notifications);
lp=AccountSessionManager.get(accountID).getLocalPreferences();
getPushSubscription();
useUnifiedPush=!getDistributor(getContext()).isEmpty();
onDataLoaded(List.of(
pauseItem=new CheckableListItem<>(getString(R.string.pause_all_notifications), getPauseItemSubtitle(), CheckableListItem.Style.SWITCH, false, R.drawable.ic_notifications_paused_24px, i->onPauseNotificationsClick(false)),
policyItem=new ListItem<>(R.string.settings_notifications_policy, 0, R.drawable.ic_group_24px, this::onNotificationsPolicyClick),
pauseItem=new CheckableListItem<>(getString(R.string.pause_all_notifications), getPauseItemSubtitle(), CheckableListItem.Style.SWITCH, false, R.drawable.ic_fluent_alert_snooze_24_regular, i->onPauseNotificationsClick(false)),
policyItem=new ListItem<>(R.string.settings_notifications_policy, 0, R.drawable.ic_fluent_people_24_regular, this::onNotificationsPolicyClick, 0, true),
mentionsItem=new CheckableListItem<>(R.string.notification_type_mentions_and_replies, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.mention, this::toggleCheckableItem),
boostsItem=new CheckableListItem<>(R.string.notification_type_reblog, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.reblog, this::toggleCheckableItem),
favoritesItem=new CheckableListItem<>(R.string.notification_type_favorite, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.favourite, this::toggleCheckableItem),
followersItem=new CheckableListItem<>(R.string.notification_type_follow, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.follow, this::toggleCheckableItem),
pollsItem=new CheckableListItem<>(R.string.notification_type_poll, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.poll, this::toggleCheckableItem)
mentionsItem=new CheckableListItem<>(R.string.notification_type_mentions_and_replies, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.mention, R.drawable.ic_fluent_mention_24_regular, i->toggleCheckableItem(mentionsItem)),
boostsItem=new CheckableListItem<>(R.string.notification_type_reblog, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.reblog, R.drawable.ic_fluent_arrow_repeat_all_24_regular, i->toggleCheckableItem(boostsItem)),
favoritesItem=new CheckableListItem<>(R.string.notification_type_favorite, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.favourite, R.drawable.ic_fluent_star_24_regular, i->toggleCheckableItem(favoritesItem)),
followersItem=new CheckableListItem<>(R.string.notification_type_follow, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.follow, R.drawable.ic_fluent_person_add_24_regular, i->toggleCheckableItem(followersItem)),
pollsItem=new CheckableListItem<>(R.string.notification_type_poll, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.poll, R.drawable.ic_fluent_poll_24_regular, i->toggleCheckableItem(pollsItem)),
updateItem=new CheckableListItem<>(R.string.sk_notification_type_update, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.update, R.drawable.ic_fluent_history_24_regular, i->toggleCheckableItem(updateItem)),
postsItem=new CheckableListItem<>(R.string.sk_notification_type_posts, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.status, R.drawable.ic_fluent_chat_24_regular, i->toggleCheckableItem(postsItem), true),
uniformIconItem=new CheckableListItem<>(R.string.sk_settings_uniform_icon_for_notifications, R.string.mo_setting_uniform_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.uniformNotificationIcon, R.drawable.ic_ntf_logo, i->toggleCheckableItem(uniformIconItem)),
swapBookmarkWithReblogItem=new CheckableListItem<>(R.string.mo_swap_bookmark_with_reblog, R.string.mo_swap_bookmark_with_reblog_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.swapBookmarkWithBoostAction, R.drawable.ic_boost, i->toggleCheckableItem(swapBookmarkWithReblogItem)),
deleteItem=new CheckableListItem<>(R.string.sk_settings_enable_delete_notifications, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.enableDeleteNotifications, R.drawable.ic_fluent_mail_inbox_dismiss_24_regular, i->toggleCheckableItem(deleteItem)),
onlyLatestItem=new CheckableListItem<>(R.string.sk_settings_single_notification, 0, CheckableListItem.Style.SWITCH, lp.keepOnlyLatestNotification, R.drawable.ic_fluent_convert_range_24_regular, i->toggleCheckableItem(onlyLatestItem), true),
unifiedPushItem=new CheckableListItem<>(R.string.sk_settings_unifiedpush, 0, CheckableListItem.Style.SWITCH, useUnifiedPush, R.drawable.ic_fluent_alert_arrow_up_24_regular, i->onUnifiedPushClick(), true)
));
typeItems=List.of(mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem);
//only enable when distributors, who can receive notifications, are available
unifiedPushItem.isEnabled=!UnifiedPush.getDistributors(getContext(), new ArrayList<>()).isEmpty();
if (!unifiedPushItem.isEnabled) {
unifiedPushItem.subtitleRes=R.string.sk_settings_unifiedpush_no_distributor_body;
}
typeItems=List.of(mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem, updateItem, postsItem);
pauseItem.checkedChangeListener=checked->onPauseNotificationsClick(true);
unifiedPushItem.checkedChangeListener=checked->onUnifiedPushClick();
updatePolicyItem(null);
updatePauseItem();
}
@@ -83,12 +118,20 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
|| favoritesItem.checked!=ps.alerts.favourite
|| followersItem.checked!=ps.alerts.follow
|| pollsItem.checked!=ps.alerts.poll;
GlobalUserPreferences.uniformNotificationIcon=uniformIconItem.checked;
GlobalUserPreferences.enableDeleteNotifications=deleteItem.checked;
GlobalUserPreferences.swapBookmarkWithBoostAction=swapBookmarkWithReblogItem.checked;
GlobalUserPreferences.save();
lp.keepOnlyLatestNotification=onlyLatestItem.checked;
lp.save();
if(needUpdateNotificationSettings && PushSubscriptionManager.arePushNotificationsAvailable()){
ps.alerts.mention=mentionsItem.checked;
ps.alerts.reblog=boostsItem.checked;
ps.alerts.favourite=favoritesItem.checked;
ps.alerts.follow=followersItem.checked;
ps.alerts.poll=pollsItem.checked;
ps.alerts.status=postsItem.checked;
ps.alerts.update=updateItem.checked;
AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().updatePushSettings(pushSubscription);
}
}
@@ -268,13 +311,13 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
long pauseTime=AccountSessionManager.get(accountID).getLocalPreferences().getNotificationsPauseEndTime();
if(!areNotificationsAllowed()){
bannerAdapter.setVisible(true);
bannerIcon.setImageResource(R.drawable.ic_app_badging_24px);
bannerIcon.setImageResource(R.drawable.ic_fluent_alert_badge_24_regular);
bannerText.setText(R.string.notifications_disabled_in_system);
bannerButton.setText(R.string.open_system_notification_settings);
bannerButton.setOnClickListener(v->openSystemNotificationSettings());
}else if(pauseTime>System.currentTimeMillis()){
bannerAdapter.setVisible(true);
bannerIcon.setImageResource(R.drawable.ic_notifications_paused_24px);
bannerIcon.setImageResource(R.drawable.ic_fluent_alert_snooze_24_regular);
bannerText.setText(getString(R.string.pause_notifications_banner, UiUtils.formatRelativeTimestampAsMinutesAgo(getActivity(), Instant.ofEpochMilli(pauseTime), false)));
bannerButton.setText(R.string.resume_notifications_now);
bannerButton.setOnClickListener(v->resumePausedNotifications());
@@ -282,4 +325,42 @@ public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
bannerAdapter.setVisible(false);
}
}
}
private void onUnifiedPushClick(){
if(getDistributor(getContext()).isEmpty()){
List<String> distributors = UnifiedPush.getDistributors(getContext(), new ArrayList<>());
showUnifiedPushRegisterDialog(distributors);
return;
}
for (AccountSession accountSession : AccountSessionManager.getInstance().getLoggedInAccounts()) {
UnifiedPush.unregisterApp(
getContext(),
accountSession.getID()
);
//re-register to fcm
accountSession.getPushSubscriptionManager().registerAccountForPush(getPushSubscription());
}
unifiedPushItem.toggle();
rebindItem(unifiedPushItem);
}
private void showUnifiedPushRegisterDialog(List<String> distributors){
new M3AlertDialogBuilder(getContext()).setTitle(R.string.sk_settings_unifiedpush_choose).setItems(distributors.toArray(String[]::new),
(dialog, which)->{
String userDistrib = distributors.get(which);
UnifiedPush.saveDistributor(getContext(), userDistrib);
for (AccountSession accountSession : AccountSessionManager.getInstance().getLoggedInAccounts()){
UnifiedPush.registerApp(
getContext(),
accountSession.getID(),
new ArrayList<>(),
getContext().getPackageName()
);
}
unifiedPushItem.toggle();
rebindItem(unifiedPushItem);
}).setOnCancelListener(d->rebindItem(unifiedPushItem)).show();
}
}

View File

@@ -2,40 +2,106 @@ package org.joinmastodon.android.fragments.settings;
import android.os.Bundle;
import androidx.annotation.StringRes;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import java.util.ArrayList;
import java.util.List;
public class SettingsPrivacyFragment extends BaseSettingsFragment<Void>{
private CheckableListItem<Void> discoverableItem, indexableItem;
private CheckableListItem<Void> discoverableItem, indexableItem, lockedItem;
private ListItem<Void> privacyItem;
private StatusPrivacy privacy=null;
private Instance instance;
//MOSHIDON
private CheckableListItem<Void> unlistedRepliesItem;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.settings_privacy);
Account self=AccountSessionManager.get(accountID).self;
AccountSession session=AccountSessionManager.get(accountID);
Account self=session.self;
instance=AccountSessionManager.getInstance().getInstanceInfo(session.domain);
privacy=self.source.privacy;
onDataLoaded(List.of(
discoverableItem=new CheckableListItem<>(R.string.settings_discoverable, 0, CheckableListItem.Style.SWITCH, self.discoverable, R.drawable.ic_thumbs_up_down_24px, this::toggleCheckableItem),
indexableItem=new CheckableListItem<>(R.string.settings_indexable, 0, CheckableListItem.Style.SWITCH, self.source.indexable!=null ? self.source.indexable : true, R.drawable.ic_search_24px, this::toggleCheckableItem)
privacyItem=new ListItem<>(R.string.sk_settings_default_visibility, getPrivacyString(privacy), R.drawable.ic_fluent_eye_24_regular, this::onPrivacyClick, 0, false),
unlistedRepliesItem=new CheckableListItem<>(R.string.mo_change_default_reply_visibility_to_unlisted, R.string.mo_setting_default_reply_privacy_summary, CheckableListItem.Style.SWITCH, GlobalUserPreferences.defaultToUnlistedReplies, R.drawable.ic_fluent_lock_open_24_regular, i->toggleCheckableItem(unlistedRepliesItem), true),
lockedItem=new CheckableListItem<>(R.string.sk_settings_lock_account, 0, CheckableListItem.Style.SWITCH, self.locked, R.drawable.ic_fluent_person_available_24_regular, i->toggleCheckableItem(lockedItem))
));
if(self.source.indexable==null)
indexableItem.isEnabled=false;
if(!instance.isAkkoma()){
data.addAll(List.of(
discoverableItem=new CheckableListItem<>(R.string.settings_discoverable, 0, CheckableListItem.Style.SWITCH, self.discoverable, R.drawable.ic_fluent_thumb_like_dislike_24_regular, i->toggleCheckableItem(discoverableItem)),
indexableItem=new CheckableListItem<>(R.string.settings_indexable, 0, CheckableListItem.Style.SWITCH, self.source.indexable!=null ? self.source.indexable : true, R.drawable.ic_fluent_search_24_regular, i->toggleCheckableItem(indexableItem))
));
if(self.source.indexable==null)
indexableItem.isEnabled=false;
}
}
@Override
protected void doLoadData(int offset, int count){}
private @StringRes int getPrivacyString(StatusPrivacy p){
if(p==null) return R.string.visibility_public;
return switch(p){
case PUBLIC -> R.string.visibility_public;
case UNLISTED -> R.string.sk_visibility_unlisted;
case PRIVATE -> R.string.visibility_followers_only;
case DIRECT -> R.string.visibility_private;
case LOCAL -> R.string.sk_local_only;
};
}
private void onPrivacyClick(ListItem<?> item_){
Account self=AccountSessionManager.get(accountID).self;
List<StatusPrivacy> options=new ArrayList<>(List.of(StatusPrivacy.PUBLIC, StatusPrivacy.UNLISTED, StatusPrivacy.PRIVATE, StatusPrivacy.DIRECT));
if(instance.isAkkoma()) options.add(StatusPrivacy.LOCAL);
int selected=options.indexOf(self.source.privacy);
int[] newSelected={selected};
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.sk_settings_default_visibility)
.setSingleChoiceItems(options.stream().map(this::getPrivacyString).map(this::getString).toArray(String[]::new),
selected, (dlg, item)->newSelected[0]=item)
.setPositiveButton(R.string.ok, (dlg, item)->{
privacy=options.get(newSelected[0]);
privacyItem.subtitleRes=getPrivacyString(privacy);
rebindItem(privacyItem);
})
.setNegativeButton(R.string.cancel, null)
.show();
}
@Override
public void onPause(){
super.onPause();
Account self=AccountSessionManager.get(accountID).self;
if(self.discoverable!=discoverableItem.checked || (self.source.indexable!=null && self.source.indexable!=indexableItem.checked)){
self.discoverable=discoverableItem.checked;
self.source.indexable=indexableItem.checked;
AccountSessionManager.get(accountID).savePreferencesLater();
GlobalUserPreferences.defaultToUnlistedReplies=unlistedRepliesItem.checked;
GlobalUserPreferences.save();
AccountSession s=AccountSessionManager.get(accountID);
Account self=s.self;
boolean savePlease=self.locked!=lockedItem.checked
|| self.source.privacy!=privacy
|| (discoverableItem!=null && self.discoverable!=discoverableItem.checked)
|| (indexableItem!=null && self.source.indexable!=null && self.source.indexable!=indexableItem.checked);
if(savePlease){
if(discoverableItem!=null) self.discoverable=discoverableItem.checked;
if(indexableItem!=null) self.source.indexable=indexableItem.checked;
self.locked=lockedItem.checked;
s.preferences.postingDefaultVisibility=privacy;
s.savePreferencesLater();
}
}
}

View File

@@ -8,6 +8,7 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
@@ -34,6 +35,7 @@ import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
import org.joinmastodon.android.ui.views.FixedAspectRatioImageView;
import org.joinmastodon.android.utils.ViewImageLoaderHolderTarget;
import org.parceler.Parcels;
import java.io.BufferedReader;
import java.io.IOException;
@@ -42,6 +44,8 @@ import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.fragments.LoaderFragment;
import me.grishka.appkit.imageloader.ViewImageLoader;
@@ -60,7 +64,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
instance=AccountSessionManager.getInstance().getInstanceInfo(AccountSessionManager.get(accountID).domain);
instance=Parcels.unwrap(getArguments().getParcelable("instance"));
loadData();
}
@@ -124,6 +128,8 @@ public class SettingsServerAboutFragment extends LoaderFragment{
hlp.leftMargin=hlp.rightMargin=V.dp(16);
scrollingLayout.addView(heading, hlp);
// if a remote instance is shown, the account is remote and need to be loaded accordingly when shown
instance.contactAccount.isRemote=!AccountSessionManager.get(accountID).domain.equals(instance.normalizedUri);
AccountViewModel model=new AccountViewModel(instance.contactAccount, accountID);
AccountViewHolder holder=new AccountViewHolder(this, scrollingLayout, null);
holder.setStyle(AccountViewHolder.AccessoryType.NONE, false);
@@ -139,7 +145,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{
if(!TextUtils.isEmpty(instance.email)){
needDivider=true;
SimpleListItemViewHolder holder=new SimpleListItemViewHolder(getActivity(), scrollingLayout);
ListItem<Void> item=new ListItem<>(R.string.send_email_to_server_admin, 0, R.drawable.ic_mail_24px, i->{});
ListItem<Void> item=new ListItem<>(R.string.send_email_to_server_admin, 0, R.drawable.ic_fluent_mail_24_regular, i->{});
holder.bind(item);
holder.itemView.setBackground(UiUtils.getThemeDrawable(getActivity(), android.R.attr.selectableItemBackground));
holder.itemView.setOnClickListener(v->openAdminEmail());
@@ -160,7 +166,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{
@Override
protected void doLoadData(){
new GetInstanceExtendedDescription()
.setCallback(new SimpleCallback<>(this){
.setCallback(new Callback<>(){
@Override
public void onSuccess(GetInstanceExtendedDescription.Response result){
MastodonAPIController.runInBackground(()->{
@@ -196,8 +202,14 @@ public class SettingsServerAboutFragment extends LoaderFragment{
});
});
}
@Override
public void onError(ErrorResponse error){
// probably an akkoma instance where this isn't implemented
dataLoaded();
}
})
.exec(accountID);
.execRemote(instance.normalizedUri);
}
@Override

View File

@@ -1,29 +1,38 @@
package org.joinmastodon.android.fragments.settings;
import android.app.Activity;
import android.app.Fragment;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.WindowInsets;
import android.widget.FrameLayout;
import android.widget.TextView;
import android.view.Menu;
import android.view.MenuInflater;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.CustomLocalTimelineFragment;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.SimpleViewHolder;
import org.joinmastodon.android.ui.tabs.TabLayout;
import org.joinmastodon.android.ui.tabs.TabLayoutMediator;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.NestedRecyclerScrollView;
import org.parceler.Parcels;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import me.grishka.appkit.Nav;
import me.grishka.appkit.fragments.AppKitFragment;
import me.grishka.appkit.utils.V;
@@ -44,11 +53,17 @@ public class SettingsServerFragment extends AppKitFragment{
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
setTitle(AccountSessionManager.get(accountID).domain);
instance=getArguments().containsKey("instance")
? Parcels.unwrap(getArguments().getParcelable("instance"))
: AccountSessionManager.getOptional(accountID)
.map(i->AccountSessionManager.getInstance().getInstanceInfo(i.domain))
.orElseThrow();
setTitle(instance.title);
Bundle args=new Bundle();
args.putString("account", accountID);
args.putBoolean("__is_tab", true);
args.putParcelable("instance", Parcels.wrap(instance));
aboutFragment=new SettingsServerAboutFragment();
aboutFragment.setArguments(args);
rulesFragment=new SettingsServerRulesFragment();
@@ -122,6 +137,41 @@ public class SettingsServerFragment extends AppKitFragment{
};
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
if (instance != null) {
inflater.inflate(R.menu.instance_info, menu);
UiUtils.enableOptionsMenuIcons(getActivity(), menu);
menu.findItem(R.id.share).setTitle(R.string.button_share);
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
int id=item.getItemId();
if(id==R.id.share){
Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TEXT, instance.normalizedUri);
startActivity(Intent.createChooser(intent, item.getTitle()));
} else if (id==R.id.open_timeline) {
Bundle args=new Bundle();
args.putString("account", accountID);
args.putString("domain", instance.normalizedUri);
Nav.go(getActivity(), CustomLocalTimelineFragment.class, args);
} else if (id==R.id.open_in_browser){
UiUtils.launchWebBrowser(getActivity(), new Uri.Builder().scheme("https").authority(instance.uri).appendPath("about").build().toString());
}
return true;
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setHasOptionsMenu(true);
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(contentView!=null){

View File

@@ -1,19 +1,20 @@
package org.joinmastodon.android.fragments.settings;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.api.requests.instance.GetInstance;
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.adapters.InstanceRulesAdapter;
import org.parceler.Parcels;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
public class SettingsServerRulesFragment extends MastodonRecyclerFragment<Instance.Rule>{
private String accountID;
private String domain;
public SettingsServerRulesFragment(){
super(20);
@@ -23,24 +24,32 @@ public class SettingsServerRulesFragment extends MastodonRecyclerFragment<Instan
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
Instance instance=AccountSessionManager.getInstance().getInstanceInfo(AccountSessionManager.get(accountID).domain);
domain=getArguments().getString("domain");
Instance instance=Parcels.unwrap(getArguments().getParcelable("instance"));
onDataLoaded(instance.rules);
setRefreshEnabled(false);
}
@Override
protected void doLoadData(int offset, int count){}
protected void doLoadData(int offset, int count){
new GetInstance().setCallback(new Callback<>(){
@Override
public void onSuccess(Instance instance){
onDataLoaded(instance.rules);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getContext());
}
}).execRemote(domain);
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
return new InstanceRulesAdapter(data);
}
@Override
protected View onCreateFooterView(LayoutInflater inflater){
return inflater.inflate(R.layout.load_more_with_end_mark, null);
}
public RecyclerView getList(){
return list;
}