Basic status rendering

This commit is contained in:
Grishka 2022-01-17 13:27:34 +03:00
parent 42d5f52ff5
commit dfbc1fd2e2
41 changed files with 1735 additions and 17 deletions

View File

@ -32,7 +32,8 @@ dependencies {
implementation 'me.grishka.litex:recyclerview:1.2.1'
implementation 'me.grishka.litex:swiperefreshlayout:1.1.0'
implementation 'me.grishka.litex:browser:1.4.0'
implementation 'me.grishka.appkit:appkit:1.0'
implementation 'me.grishka.appkit:appkit:1.1'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'org.jsoup:jsoup:1.14.3'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
}

View File

@ -9,7 +9,8 @@
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Mastodon">
android:theme="@style/Theme.Mastodon"
android:largeHeap="true">
<activity android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize">
<intent-filter>

View File

@ -4,6 +4,9 @@ import android.annotation.SuppressLint;
import android.app.Application;
import android.content.Context;
import me.grishka.appkit.imageloader.ImageCache;
import me.grishka.appkit.utils.NetworkUtils;
public class MastodonApp extends Application{
@SuppressLint("StaticFieldLeak") // it's not a leak
@ -12,6 +15,11 @@ public class MastodonApp extends Application{
@Override
public void onCreate(){
super.onCreate();
ImageCache.Parameters params=new ImageCache.Parameters();
params.diskCacheSize=100*1024*1024;
params.maxMemoryCacheSize=Integer.MAX_VALUE;
ImageCache.setParams(params);
NetworkUtils.setUserAgent("MastodonAndroid/"+BuildConfig.VERSION_NAME);
context=getApplicationContext();
}
}

View File

@ -60,6 +60,8 @@ public class MastodonAPIController{
public <T> void submitRequest(final MastodonAPIRequest<T> req){
thread.postRunnable(()->{
try{
if(req.canceled)
return;
Request.Builder builder=new Request.Builder()
.url(req.getURL().toString())
.method(req.getMethod(), req.getRequestBody())

View File

@ -32,6 +32,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
TypeToken<T> respTypeToken;
Call okhttpCall;
Token token;
boolean canceled;
public MastodonAPIRequest(HttpMethod method, String path, Class<T> respClass){
this.path=path;
@ -47,6 +48,7 @@ public abstract class MastodonAPIRequest<T> extends APIRequest<T>{
@Override
public synchronized void cancel(){
canceled=true;
if(okhttpCall!=null){
okhttpCall.cancel();
}

View File

@ -0,0 +1,20 @@
package org.joinmastodon.android.api.requests.timelines;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Status;
import java.util.List;
public class GetHomeTimeline extends MastodonAPIRequest<List<Status>>{
public GetHomeTimeline(String maxID, String minID, int limit){
super(HttpMethod.GET, "/timelines/home", new TypeToken<>(){});
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(minID!=null)
addQueryParameter("min_id", minID);
if(limit>0)
addQueryParameter("limit", ""+limit);
}
}

View File

@ -159,6 +159,7 @@ public class AccountSessionManager{
.build();
new CustomTabsIntent.Builder()
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
.build()
.launchUrl(context, uri);
}

View File

@ -4,15 +4,43 @@ import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import org.joinmastodon.android.R;
import androidx.annotation.Nullable;
import me.grishka.appkit.fragments.AppKitFragment;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.views.FragmentRootLinearLayout;
public class HomeFragment extends AppKitFragment{
private FragmentRootLinearLayout content;
private HomeTimelineFragment homeTimelineFragment;
private String accountID;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
}
public class HomeFragment extends ToolbarFragment{
@Nullable
@Override
public View onCreateContentView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
return new View(getActivity());
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
content=new FragmentRootLinearLayout(getActivity());
content.setOrientation(LinearLayout.VERTICAL);
FrameLayout fragmentContainer=new FrameLayout(getActivity());
fragmentContainer.setId(R.id.fragment_wrap);
content.addView(fragmentContainer, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
Bundle args=new Bundle();
args.putString("account", accountID);
homeTimelineFragment=new HomeTimelineFragment();
homeTimelineFragment.setArguments(args);
getChildFragmentManager().beginTransaction().add(R.id.fragment_wrap, homeTimelineFragment).commit();
return content;
}
}

View File

@ -0,0 +1,35 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.model.Status;
import java.util.List;
import me.grishka.appkit.api.SimpleCallback;
public class HomeTimelineFragment extends StatusListFragment{
private String accountID;
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setTitle(R.string.app_name);
accountID=getArguments().getString("account");
loadData();
}
@Override
protected void doLoadData(int offset, int count){
new GetHomeTimeline(null, null, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> result){
onDataLoaded(result, false);
}
})
.exec(accountID);
}
}

View File

@ -0,0 +1,83 @@
package org.joinmastodon.android.fragments;
import android.view.ViewGroup;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class StatusListFragment extends BaseRecyclerFragment<Status>{
protected ArrayList<StatusDisplayItem> displayItems=new ArrayList<>();
public StatusListFragment(){
super(20);
}
@Override
protected RecyclerView.Adapter getAdapter(){
return new DisplayItemsAdapter();
}
@Override
public void onAppendItems(List<Status> items){
super.onAppendItems(items);
for(Status s:items){
displayItems.addAll(StatusDisplayItem.buildItems(this, s));
}
}
@Override
public void onClearItems(){
super.onClearItems();
displayItems.clear();
}
protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{
public DisplayItemsAdapter(){
super(imgLoader);
}
@NonNull
@Override
public BindableViewHolder<StatusDisplayItem> onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return (BindableViewHolder<StatusDisplayItem>) StatusDisplayItem.createViewHolder(StatusDisplayItem.Type.values()[viewType], getActivity(), parent);
}
@Override
public void onBindViewHolder(BindableViewHolder<StatusDisplayItem> holder, int position){
holder.bind(displayItems.get(position));
super.onBindViewHolder(holder, position);
}
@Override
public int getItemCount(){
return displayItems.size();
}
@Override
public int getItemViewType(int position){
return displayItems.get(position).getType().ordinal();
}
@Override
public int getImageCountForItem(int position){
return displayItems.get(position).getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
return displayItems.get(position).getImageRequest(image);
}
}
}

View File

@ -127,7 +127,7 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
group.activeUsers+=instance.lastWeekUsers;
}
return group;
}).sorted(Comparator.comparingInt(g->g.activeUsers)).forEachOrdered(ig->sortedList.addAll(ig.instances));
}).sorted(Comparator.comparingInt((InstanceGroup g)->g.activeUsers).reversed()).forEachOrdered(ig->sortedList.addAll(ig.instances));
onDataLoaded(sortedList, false);
updateFilteredList();
}
@ -299,18 +299,13 @@ public class InstanceCatalogFragment extends BaseRecyclerFragment<CatalogInstanc
}
Instance cachedInstance=instancesCache.get(domain);
if(cachedInstance!=null){
boolean found=false;
for(CatalogInstance ci:filteredData){
if(ci.domain.equals(currentSearchQuery)){
found=true;
break;
}
}
if(!found){
CatalogInstance ci=cachedInstance.toCatalogInstance();
filteredData.add(0, ci);
adapter.notifyItemInserted(0);
if(ci.domain.equals(currentSearchQuery))
return;
}
CatalogInstance ci=cachedInstance.toCatalogInstance();
filteredData.add(0, ci);
adapter.notifyItemInserted(0);
return;
}
if(loadingInstanceDomain!=null){

View File

@ -22,4 +22,13 @@ public class AccountField extends BaseModel{
* Timestamp of when the server verified a URL value for a rel="me” link.
*/
public Instant verifiedAt;
@Override
public String toString(){
return "AccountField{"+
"name='"+name+'\''+
", value='"+value+'\''+
", verifiedAt="+verifiedAt+
'}';
}
}

View File

@ -0,0 +1,128 @@
package org.joinmastodon.android.model;
import android.graphics.Bitmap;
import android.graphics.PointF;
import android.graphics.drawable.Drawable;
import com.google.gson.annotations.SerializedName;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
import org.joinmastodon.android.ui.utils.BlurHashDecoder;
import org.joinmastodon.android.ui.utils.BlurHashDrawable;
public class Attachment extends BaseModel{
@RequiredField
public String id;
@RequiredField
public Type type;
@RequiredField
public String url;
@RequiredField
public String previewUrl;
public String remoteUrl;
public String description;
public String blurhash;
public Metadata meta;
public transient Drawable blurhashPlaceholder;
public int getWidth(){
if(meta==null)
return 0;
if(meta.width>0)
return meta.width;
if(meta.original!=null && meta.original.width>0)
return meta.original.width;
if(meta.small!=null && meta.small.width>0)
return meta.small.width;
return 0;
}
public int getHeight(){
if(meta==null)
return 0;
if(meta.height>0)
return meta.height;
if(meta.original!=null && meta.original.height>0)
return meta.original.height;
if(meta.small!=null && meta.small.height>0)
return meta.small.height;
return 0;
}
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
if(blurhash!=null){
Bitmap placeholder=BlurHashDecoder.decode(blurhash, 16, 16);
if(placeholder!=null)
blurhashPlaceholder=new BlurHashDrawable(placeholder, getWidth(), getHeight());
}
}
@Override
public String toString(){
return "Attachment{"+
"id='"+id+'\''+
", type="+type+
", url='"+url+'\''+
", previewUrl='"+previewUrl+'\''+
", remoteUrl='"+remoteUrl+'\''+
", description='"+description+'\''+
", blurhash='"+blurhash+'\''+
", meta="+meta+
'}';
}
public enum Type{
@SerializedName("image")
IMAGE,
@SerializedName("gifv")
GIFV,
@SerializedName("video")
VIDEO,
@SerializedName("audio")
AUDIO,
@SerializedName("unknown")
UNKNOWN
}
public static class Metadata{
public double duration;
public int width;
public int height;
public double aspect;
public PointF focus;
public SizeMetadata original;
public SizeMetadata small;
@Override
public String toString(){
return "Metadata{"+
"duration="+duration+
", width="+width+
", height="+height+
", aspect="+aspect+
", focus="+focus+
", original="+original+
", small="+small+
'}';
}
}
public static class SizeMetadata{
public int width;
public int height;
public double aspect;
@Override
public String toString(){
return "SizeMetadata{"+
"width="+width+
", height="+height+
", aspect="+aspect+
'}';
}
}
}

View File

@ -0,0 +1,71 @@
package org.joinmastodon.android.model;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import com.google.gson.annotations.SerializedName;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
import org.joinmastodon.android.ui.utils.BlurHashDecoder;
import org.joinmastodon.android.ui.utils.BlurHashDrawable;
public class Card extends BaseModel{
@RequiredField
public String url;
@RequiredField
public String description;
@RequiredField
public Type type;
public String authorName;
public String authorUrl;
public String providerName;
public String providerUrl;
// public String html;
public int width;
public int height;
public String image;
public String embedUrl;
public String blurhash;
public transient Drawable blurhashPlaceholder;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
if(blurhash!=null){
Bitmap placeholder=BlurHashDecoder.decode(blurhash, 16, 16);
if(placeholder!=null)
blurhashPlaceholder=new BlurHashDrawable(placeholder, width, height);
}
}
@Override
public String toString(){
return "Card{"+
"url='"+url+'\''+
", description='"+description+'\''+
", type="+type+
", authorName='"+authorName+'\''+
", authorUrl='"+authorUrl+'\''+
", providerName='"+providerName+'\''+
", providerUrl='"+providerUrl+'\''+
", width="+width+
", height="+height+
", image='"+image+'\''+
", embedUrl='"+embedUrl+'\''+
", blurhash='"+blurhash+'\''+
'}';
}
public enum Type{
@SerializedName("link")
LINK,
@SerializedName("photo")
PHOTO,
@SerializedName("video")
VIDEO,
@SerializedName("rich")
RICH
}
}

View File

@ -30,4 +30,15 @@ public class Emoji extends BaseModel{
* Used for sorting custom emoji in the picker.
*/
public String category;
@Override
public String toString(){
return "Emoji{"+
"shortcode='"+shortcode+'\''+
", url='"+url+'\''+
", staticUrl='"+staticUrl+'\''+
", visibleInPicker="+visibleInPicker+
", category='"+category+'\''+
'}';
}
}

View File

@ -0,0 +1,22 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.RequiredField;
import java.util.List;
public class Hashtag extends BaseModel{
@RequiredField
public String name;
@RequiredField
public String url;
public List<History> history;
@Override
public String toString(){
return "Hashtag{"+
"name='"+name+'\''+
", url='"+url+'\''+
", history="+history+
'}';
}
}

View File

@ -0,0 +1,19 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.AllFieldsAreRequired;
@AllFieldsAreRequired
public class History extends BaseModel{
public long day; // unixtime
public int uses;
public int accounts;
@Override
public String toString(){
return "History{"+
"day="+day+
", uses="+uses+
", accounts="+accounts+
'}';
}
}

View File

@ -0,0 +1,21 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.AllFieldsAreRequired;
@AllFieldsAreRequired
public class Mention extends BaseModel{
public String id;
public String username;
public String acct;
public String url;
@Override
public String toString(){
return "Mention{"+
"id='"+id+'\''+
", username='"+username+'\''+
", acct='"+acct+'\''+
", url='"+url+'\''+
'}';
}
}

View File

@ -0,0 +1,56 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.joinmastodon.android.api.ObjectValidationException;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
@AllFieldsAreRequired
public class Poll extends BaseModel{
public String id;
public Instant expiresAt;
public boolean expired;
public boolean multiple;
public int votersCount;
public boolean voted;
public int[] ownVotes;
public List<Option> options;
public List<Emoji> emojis;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
for(Emoji e:emojis)
e.postprocess();
}
@Override
public String toString(){
return "Poll{"+
"id='"+id+'\''+
", expiresAt="+expiresAt+
", expired="+expired+
", multiple="+multiple+
", votersCount="+votersCount+
", voted="+voted+
", ownVotes="+Arrays.toString(ownVotes)+
", options="+options+
", emojis="+emojis+
'}';
}
public static class Option{
public String title;
public Integer votesCount;
@Override
public String toString(){
return "Option{"+
"title='"+title+'\''+
", votesCount="+votesCount+
'}';
}
}
}

View File

@ -42,4 +42,16 @@ public class Source extends BaseModel{
for(AccountField f:fields)
f.postprocess();
}
@Override
public String toString(){
return "Source{"+
"note='"+note+'\''+
", fields="+fields+
", privacy="+privacy+
", sensitive="+sensitive+
", language='"+language+'\''+
", followRequestCount="+followRequestCount+
'}';
}
}

View File

@ -0,0 +1,120 @@
package org.joinmastodon.android.model;
import android.text.Html;
import android.text.TextUtils;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
import org.joinmastodon.android.ui.text.HtmlParser;
import java.time.Instant;
import java.util.List;
public class Status extends BaseModel{
@RequiredField
public String id;
@RequiredField
public String uri;
@RequiredField
public Instant createdAt;
@RequiredField
public Account account;
@RequiredField
public String content;
@RequiredField
public StatusPrivacy visibility;
public boolean sensitive;
@RequiredField
public String spoilerText;
@RequiredField
public List<Attachment> mediaAttachments;
public Application application;
@RequiredField
public List<Mention> mentions;
@RequiredField
public List<Hashtag> tags;
@RequiredField
public List<Emoji> emojis;
public int reblogsCount;
public int favouritesCount;
public int repliesCount;
public String url;
public String inReplyToId;
public String inReplyToAccountId;
public Status reblog;
public Poll poll;
public Card card;
public String language;
public String text;
public boolean favourited;
public boolean reblogged;
public boolean muted;
public boolean bookmarked;
public boolean pinned;
public transient CharSequence processedContent;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
if(application!=null)
application.postprocess();
for(Mention m:mentions)
m.postprocess();
for(Hashtag t:tags)
t.postprocess();
for(Emoji e:emojis)
e.postprocess();
for(Attachment a:mediaAttachments)
a.postprocess();
account.postprocess();
if(poll!=null)
poll.postprocess();
if(card!=null)
card.postprocess();
if(reblog!=null)
reblog.postprocess();
if(!TextUtils.isEmpty(content)){
processedContent=HtmlParser.parse(content, emojis);
}
}
@Override
public String toString(){
return "Status{"+
"id='"+id+'\''+
", uri='"+uri+'\''+
", createdAt="+createdAt+
", account="+account+
", content='"+content+'\''+
", visibility="+visibility+
", sensitive="+sensitive+
", spoilerText='"+spoilerText+'\''+
", mediaAttachments="+mediaAttachments+
", application="+application+
", mentions="+mentions+
", tags="+tags+
", emojis="+emojis+
", reblogsCount="+reblogsCount+
", favouritesCount="+favouritesCount+
", repliesCount="+repliesCount+
", url='"+url+'\''+
", inReplyToId='"+inReplyToId+'\''+
", inReplyToAccountId='"+inReplyToAccountId+'\''+
", reblog="+reblog+
", poll="+poll+
", card="+card+
", language='"+language+'\''+
", text='"+text+'\''+
", favourited="+favourited+
", reblogged="+reblogged+
", muted="+muted+
", bookmarked="+bookmarked+
", pinned="+pinned+
", processedContent="+processedContent+
'}';
}
}

View File

@ -0,0 +1,77 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Status;
import java.time.Instant;
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;
public class HeaderStatusDisplayItem extends StatusDisplayItem{
private Account user;
private Instant createdAt;
private ImageLoaderRequest avaRequest;
public HeaderStatusDisplayItem(Status status, Account user, Instant createdAt){
super(status);
this.user=user;
this.createdAt=createdAt;
avaRequest=new UrlImageLoaderRequest(user.avatar);
}
@Override
public Type getType(){
return Type.HEADER;
}
@Override
public int getImageCount(){
return 1;
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return avaRequest;
}
public static class Holder extends BindableViewHolder<HeaderStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView name, subtitle;
private final ImageView avatar;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_header, parent);
name=findViewById(R.id.name);
subtitle=findViewById(R.id.subtitle);
avatar=findViewById(R.id.avatar);
}
@Override
public void onBind(HeaderStatusDisplayItem item){
name.setText(item.user.displayName);
subtitle.setText('@'+item.user.acct);
}
@Override
public void setImage(int index, Drawable drawable){
avatar.setImageDrawable(drawable);
if(drawable instanceof Animatable)
((Animatable) drawable).start();
}
@Override
public void clearImage(int index){
avatar.setImageBitmap(null);
}
}
}

View File

@ -0,0 +1,64 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.view.ViewGroup;
import android.widget.ImageView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Status;
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;
public class PhotoStatusDisplayItem extends StatusDisplayItem{
private Attachment attachment;
private ImageLoaderRequest request;
public PhotoStatusDisplayItem(Status status, Attachment photo){
super(status);
this.attachment=photo;
request=new UrlImageLoaderRequest(photo.url, 1000, 1000);
}
@Override
public Type getType(){
return Type.PHOTO;
}
@Override
public int getImageCount(){
return 1;
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return request;
}
public static class Holder extends BindableViewHolder<PhotoStatusDisplayItem> implements ImageLoaderViewHolder{
private final ImageView photo;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_photo, parent);
photo=findViewById(R.id.photo);
}
@Override
public void onBind(PhotoStatusDisplayItem item){
}
@Override
public void setImage(int index, Drawable drawable){
photo.setImageDrawable(drawable);
}
@Override
public void clearImage(int index){
photo.setImageDrawable(item.attachment.blurhashPlaceholder);
}
}
}

View File

@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.view.ViewGroup;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Status;
import me.grishka.appkit.utils.BindableViewHolder;
public class ReblogOrReplyLineStatusDisplayItem extends StatusDisplayItem{
public ReblogOrReplyLineStatusDisplayItem(Status status){
super(status);
}
@Override
public Type getType(){
return Type.REBLOG_OR_REPLY_LINE;
}
public static class Holder extends BindableViewHolder<ReblogOrReplyLineStatusDisplayItem>{
private final TextView text;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_reblog_or_reply_line, parent);
text=findViewById(R.id.text);
}
@Override
public void onBind(ReblogOrReplyLineStatusDisplayItem item){
if(item.status.reblog!=null){
text.setText(itemView.getContext().getString(R.string.user_boosted, item.status.account.displayName));
}
}
}
}

View File

@ -0,0 +1,75 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.app.Fragment;
import android.text.TextUtils;
import android.view.ViewGroup;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Status;
import java.util.ArrayList;
import java.util.List;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
public abstract class StatusDisplayItem{
public final Status status;
public StatusDisplayItem(Status status){
this.status=status;
}
public abstract Type getType();
public int getImageCount(){
return 0;
}
public ImageLoaderRequest getImageRequest(int index){
return null;
}
public static BindableViewHolder<? extends StatusDisplayItem> createViewHolder(Type type, Activity activity, ViewGroup parent){
return switch(type){
case HEADER -> new HeaderStatusDisplayItem.Holder(activity, parent);
case REBLOG_OR_REPLY_LINE -> new ReblogOrReplyLineStatusDisplayItem.Holder(activity, parent);
case TEXT -> new TextStatusDisplayItem.Holder(activity, parent);
case PHOTO -> new PhotoStatusDisplayItem.Holder(activity, parent);
default -> throw new UnsupportedOperationException();
};
}
public static List<StatusDisplayItem> buildItems(Fragment fragment, Status status){
ArrayList<StatusDisplayItem> items=new ArrayList<>();
Status statusForContent=status.reblog==null ? status : status.reblog;
if(status.reblog!=null){
items.add(new ReblogOrReplyLineStatusDisplayItem(status));
}
items.add(new HeaderStatusDisplayItem(status, statusForContent.account, statusForContent.createdAt));
if(!TextUtils.isEmpty(statusForContent.content))
items.add(new TextStatusDisplayItem(status, statusForContent.processedContent, fragment));
for(Attachment attachment:statusForContent.mediaAttachments){
if(attachment.type==Attachment.Type.IMAGE){
items.add(new PhotoStatusDisplayItem(status, attachment));
}
}
return items;
}
public enum Type{
HEADER,
REBLOG_OR_REPLY_LINE,
TEXT,
PHOTO,
VIDEO,
GIFV,
AUDIO,
POLL_HEADER,
POLL_OPTION,
POLL_FOOTER,
CARD,
FOOTER,
}
}

View File

@ -0,0 +1,113 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.app.Fragment;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.text.Spanned;
import android.view.ViewGroup;
import android.widget.Toast;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.text.CustomEmojiSpan;
import org.joinmastodon.android.ui.text.LinkSpan;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.LinkedTextView;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.MovieDrawable;
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;
public class TextStatusDisplayItem extends StatusDisplayItem implements LinkSpan.OnLinkClickListener{
private CharSequence text;
private ImageLoaderRequest[] emojiRequests;
private Fragment parentFragment;
public TextStatusDisplayItem(Status status, CharSequence text, Fragment parentFragment){
super(status);
this.text=text;
this.parentFragment=parentFragment;
if(text instanceof Spanned){
CustomEmojiSpan[] emojiSpans=((Spanned) text).getSpans(0, text.length(), CustomEmojiSpan.class);
emojiRequests=new ImageLoaderRequest[emojiSpans.length];
int emojiSize=V.dp(20);
for(int i=0; i<emojiSpans.length; i++){
emojiRequests[i]=new UrlImageLoaderRequest(emojiSpans[i].emoji.url, emojiSize, emojiSize);
}
LinkSpan[] linkSpans=((Spanned) text).getSpans(0, text.length(), LinkSpan.class);
for(LinkSpan span:linkSpans){
span.setListener(this);
}
}else{
emojiRequests=new ImageLoaderRequest[0];
}
}
@Override
public Type getType(){
return Type.TEXT;
}
@Override
public int getImageCount(){
return emojiRequests.length;
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return emojiRequests[index];
}
@Override
public void onLinkClick(LinkSpan span){
switch(span.getType()){
case URL -> UiUtils.launchWebBrowser(parentFragment.getActivity(), span.getLink());
case HASHTAG, MENTION -> Toast.makeText(parentFragment.getActivity(), "Not implemented yet", Toast.LENGTH_SHORT).show();
}
}
public static class Holder extends BindableViewHolder<TextStatusDisplayItem> implements ImageLoaderViewHolder{
private final LinkedTextView text;
private CustomEmojiSpan[] emojiSpans;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_text, parent);
text=findViewById(R.id.text);
}
@Override
public void onBind(TextStatusDisplayItem item){
text.setText(item.text);
if(emojiSpans!=null){
for(CustomEmojiSpan span:emojiSpans){
span.setDrawable(null);
}
}
if(item.text instanceof Spanned)
emojiSpans=((Spanned) item.text).getSpans(0, item.text.length(), CustomEmojiSpan.class);
else
emojiSpans=new CustomEmojiSpan[0];
text.setInvalidateOnEveryFrame(false);
}
@Override
public void setImage(int index, Drawable image){
emojiSpans[index].setDrawable(image);
text.invalidate();
if(image instanceof Animatable){
((Animatable) image).start();
if(image instanceof MovieDrawable)
text.setInvalidateOnEveryFrame(true);
}
}
@Override
public void clearImage(int index){
emojiSpans[index].setDrawable(null);
text.invalidate();
}
}
}

View File

@ -0,0 +1,118 @@
package org.joinmastodon.android.ui.text;
import android.graphics.Canvas;
import android.graphics.CornerPathEffect;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.text.Layout;
import android.text.Spanned;
import android.view.MotionEvent;
import android.view.SoundEffectConstants;
import android.widget.TextView;
import me.grishka.appkit.utils.V;
public class ClickableLinksDelegate {
private Paint hlPaint;
private Path hlPath;
private LinkSpan selectedSpan;
private TextView view;
public ClickableLinksDelegate(TextView view) {
this.view=view;
hlPaint=new Paint();
hlPaint.setAntiAlias(true);
hlPaint.setPathEffect(new CornerPathEffect(V.dp(3)));
// view.setHighlightColor(view.getResources().getColor(android.R.color.holo_blue_light));
}
public boolean onTouch(MotionEvent event) {
if(event.getAction()==MotionEvent.ACTION_DOWN){
int line=-1;
Rect rect=new Rect();
Layout l=view.getLayout();
for(int i=0;i<l.getLineCount();i++){
view.getLineBounds(i, rect);
if(rect.contains((int)event.getX(), (int)event.getY())){
line=i;
break;
}
}
if(line==-1){
return false;
}
CharSequence text=view.getText();
if(text instanceof Spanned){
Spanned s=(Spanned)text;
LinkSpan[] spans=s.getSpans(0, s.length()-1, LinkSpan.class);
if(spans.length>0){
for(LinkSpan span:spans){
int start=s.getSpanStart(span);
int end=s.getSpanEnd(span);
int lstart=l.getLineForOffset(start);
int lend=l.getLineForOffset(end);
if(line>=lstart && line<=lend){
if(line==lstart && event.getX()-view.getPaddingLeft()<l.getPrimaryHorizontal(start)){
continue;
}
if(line==lend && event.getX()-view.getPaddingLeft()>l.getPrimaryHorizontal(end)){
continue;
}
hlPath=new Path();
selectedSpan=span;
hlPaint.setColor((span.getColor() & 0x00FFFFFF) | 0x33000000);
//l.getSelectionPath(start, end, hlPath);
for(int j=lstart;j<=lend;j++){
Rect bounds=new Rect();
l.getLineBounds(j, bounds);
//bounds.left+=view.getPaddingLeft();
if(j==lstart){
bounds.left=Math.round(l.getPrimaryHorizontal(start));
}
if(j==lend){
bounds.right=Math.round(l.getPrimaryHorizontal(end));
}else{
CharSequence lineChars=view.getText().subSequence(l.getLineStart(j), l.getLineEnd(j));
bounds.right=Math.round(view.getPaint().measureText(lineChars.toString()))/*+view.getPaddingRight()*/;
}
bounds.inset(V.dp(-2), V.dp(-2));
hlPath.addRect(new RectF(bounds), Path.Direction.CW);
}
hlPath.offset(view.getPaddingLeft(), 0);
view.invalidate();
return true;
}
}
}
}
}
if(event.getAction()==MotionEvent.ACTION_UP && selectedSpan!=null){
view.playSoundEffect(SoundEffectConstants.CLICK);
selectedSpan.onClick(view.getContext());
hlPath=null;
selectedSpan=null;
view.invalidate();
return false;
}
if(event.getAction()==MotionEvent.ACTION_CANCEL){
hlPath=null;
selectedSpan=null;
view.invalidate();
return false;
}
return false;
}
public void onDraw(Canvas canvas){
if(hlPath!=null){
canvas.save();
canvas.translate(0, view.getPaddingTop());
canvas.drawPath(hlPath, hlPaint);
canvas.restore();
}
}
}

View File

@ -0,0 +1,51 @@
package org.joinmastodon.android.ui.text;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.text.style.ReplacementSpan;
import org.joinmastodon.android.model.Emoji;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class CustomEmojiSpan extends ReplacementSpan{
public final Emoji emoji;
private Drawable drawable;
public CustomEmojiSpan(Emoji emoji){
this.emoji=emoji;
}
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){
return Math.round(paint.descent()-paint.ascent());
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint){
int size=Math.round(paint.descent()-paint.ascent());
if(drawable==null){
canvas.drawRect(x, top, x+size, top+size, paint);
}else{
// AnimatedImageDrawable doesn't like when its bounds don't start at (0, 0)
Rect bounds=drawable.getBounds();
int dw=drawable.getIntrinsicWidth();
int dh=drawable.getIntrinsicHeight();
if(bounds.left!=0 || bounds.top!=0 || bounds.right!=dw || bounds.left!=dh){
drawable