Basic status rendering
This commit is contained in:
parent
42d5f52ff5
commit
dfbc1fd2e2
|
@ -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'
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -159,6 +159,7 @@ public class AccountSessionManager{
|
|||
.build();
|
||||
|
||||
new CustomTabsIntent.Builder()
|
||||
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
|
||||
.build()
|
||||
.launchUrl(context, uri);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(ci.domain.equals(currentSearchQuery))
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(!found){
|
||||
CatalogInstance ci=cachedInstance.toCatalogInstance();
|
||||
filteredData.add(0, ci);
|
||||
adapter.notifyItemInserted(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(loadingInstanceDomain!=null){
|
||||
|
|
|
@ -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+
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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+
|
||||
'}';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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+'\''+
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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+
|
||||
'}';
|
||||
}
|
||||
}
|
|
@ -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+
|
||||
'}';
|
||||
}
|
||||
}
|
|
@ -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+'\''+
|
||||
'}';
|
||||
}
|
||||
}
|
|
@ -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+
|
||||
'}';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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+
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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+
|
||||
'}';
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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.setBounds(0, 0, dw, dh);
|
||||
}
|
||||
canvas.save();
|
||||
canvas.translate(x, top);
|
||||
canvas.scale(size/(float)dw, size/(float)dh, 0f, 0f);
|
||||
drawable.draw(canvas);
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
public void setDrawable(Drawable drawable){
|
||||
this.drawable=drawable;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
package org.joinmastodon.android.ui.text;
|
||||
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
|
||||
import org.joinmastodon.android.model.Emoji;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.nodes.Node;
|
||||
import org.jsoup.nodes.TextNode;
|
||||
import org.jsoup.select.NodeVisitor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class HtmlParser{
|
||||
private static final String TAG="HtmlParser";
|
||||
private static Pattern EMOJI_CODE_PATTERN=Pattern.compile(":([\\w]+):");
|
||||
|
||||
private HtmlParser(){}
|
||||
|
||||
/**
|
||||
* Parse HTML and custom emoji into a spanned string for display.
|
||||
* Supported tags: <ul>
|
||||
* <li><a class="hashtag | mention | (none)"></li>
|
||||
* <li><span class="invisible | ellipsis"></li>
|
||||
* <li><br/></li>
|
||||
* <li><p></li>
|
||||
* </ul>
|
||||
* @param source Source HTML
|
||||
* @param emojis Custom emojis that are present in source as <code>:code:</code>
|
||||
* @return a spanned string
|
||||
*/
|
||||
public static SpannableStringBuilder parse(String source, List<Emoji> emojis){
|
||||
class SpanInfo{
|
||||
public Object span;
|
||||
public int start;
|
||||
public Element element;
|
||||
|
||||
public SpanInfo(Object span, int start, Element element){
|
||||
this.span=span;
|
||||
this.start=start;
|
||||
this.element=element;
|
||||
}
|
||||
}
|
||||
|
||||
final SpannableStringBuilder ssb=new SpannableStringBuilder();
|
||||
Jsoup.parseBodyFragment(source).body().traverse(new NodeVisitor(){
|
||||
private final ArrayList<SpanInfo> openSpans=new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void head(@NonNull Node node, int depth){
|
||||
if(node instanceof TextNode){
|
||||
ssb.append(((TextNode) node).text());
|
||||
}else if(node instanceof Element){
|
||||
Element el=(Element)node;
|
||||
switch(el.nodeName()){
|
||||
case "a" -> {
|
||||
LinkSpan.Type linkType;
|
||||
if(el.hasClass("hashtag")){
|
||||
linkType=LinkSpan.Type.HASHTAG;
|
||||
}else if(el.hasClass("mention")){
|
||||
linkType=LinkSpan.Type.MENTION;
|
||||
}else{
|
||||
linkType=LinkSpan.Type.URL;
|
||||
}
|
||||
openSpans.add(new SpanInfo(new LinkSpan(el.attr("href"), null, linkType), ssb.length(), el));
|
||||
}
|
||||
case "br" -> ssb.append('\n');
|
||||
case "span" -> {
|
||||
if(el.hasClass("invisible")){
|
||||
openSpans.add(new SpanInfo(new InvisibleSpan(), ssb.length(), el));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tail(@NonNull Node node, int depth){
|
||||
if(node instanceof Element){
|
||||
Element el=(Element)node;
|
||||
if("span".equals(el.nodeName()) && el.hasClass("ellipsis")){
|
||||
ssb.append('…');
|
||||
}else if("p".equals(el.nodeName())){
|
||||
if(node.nextSibling()!=null)
|
||||
ssb.append("\n\n");
|
||||
}else if(!openSpans.isEmpty()){
|
||||
SpanInfo si=openSpans.get(openSpans.size()-1);
|
||||
if(si.element==el){
|
||||
ssb.setSpan(si.span, si.start, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
openSpans.remove(openSpans.size()-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if(!emojis.isEmpty())
|
||||
parseCustomEmoji(ssb, emojis);
|
||||
return ssb;
|
||||
}
|
||||
|
||||
public static void parseCustomEmoji(SpannableStringBuilder ssb, List<Emoji> emojis){
|
||||
Map<String, Emoji> emojiByCode=emojis.stream().collect(Collectors.toMap(e->e.shortcode, Function.identity()));
|
||||
Matcher matcher=EMOJI_CODE_PATTERN.matcher(ssb);
|
||||
while(matcher.find()){
|
||||
Emoji emoji=emojiByCode.get(matcher.group(1));
|
||||
if(emoji==null)
|
||||
continue;
|
||||
ssb.setSpan(new CustomEmojiSpan(emoji), matcher.start(), matcher.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package org.joinmastodon.android.ui.text;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.text.style.ReplacementSpan;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class InvisibleSpan extends ReplacementSpan{
|
||||
@Override
|
||||
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint){
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package org.joinmastodon.android.ui.text;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextPaint;
|
||||
import android.text.style.CharacterStyle;
|
||||
|
||||
public class LinkSpan extends CharacterStyle {
|
||||
|
||||
private int color=0xFF569ace;
|
||||
private OnLinkClickListener listener;
|
||||
private String link;
|
||||
private Type type;
|
||||
|
||||
public LinkSpan(String link, OnLinkClickListener listener, Type type) {
|
||||
this.listener=listener;
|
||||
this.link=link;
|
||||
this.type=type;
|
||||
}
|
||||
|
||||
public void setColor(int c){
|
||||
color=c;
|
||||
}
|
||||
|
||||
public int getColor(){
|
||||
return color;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDrawState(TextPaint tp) {
|
||||
tp.setColor(color);
|
||||
}
|
||||
|
||||
public void onClick(Context context){
|
||||
if(listener!=null)
|
||||
listener.onLinkClick(this);
|
||||
}
|
||||
|
||||
public String getLink(){
|
||||
return link;
|
||||
}
|
||||
|
||||
public Type getType(){
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setListener(OnLinkClickListener listener){
|
||||
this.listener=listener;
|
||||
}
|
||||
|
||||
public interface OnLinkClickListener{
|
||||
void onLinkClick(LinkSpan span);
|
||||
}
|
||||
|
||||
public enum Type{
|
||||
URL,
|
||||
MENTION,
|
||||
HASHTAG
|
||||
}
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
package org.joinmastodon.android.ui.utils;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.util.SparseArray;
|
||||
|
||||
/**
|
||||
* https://github.com/woltapp/blurhash/blob/master/Kotlin/lib/src/main/java/com/wolt/blurhashkt/BlurHashDecoder.kt
|
||||
* but rewritten in a language that doesn't suck
|
||||
*/
|
||||
public class BlurHashDecoder{
|
||||
private BlurHashDecoder(){}
|
||||
|
||||
// cache Math.cos() calculations to improve performance.
|
||||
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
|
||||
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
|
||||
private static SparseArray<double[]> cacheCosinesX=new SparseArray<>();
|
||||
private static SparseArray<double[]> cacheCosinesY=new SparseArray<>();
|
||||
private static final String CHAR_MAP="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
|
||||
|
||||
/**
|
||||
* Clear calculations stored in memory cache.
|
||||
* The cache is not big, but will increase when many image sizes are used,
|
||||
* if the app needs memory it is recommended to clear it.
|
||||
*/
|
||||
public static void clearCache(){
|
||||
cacheCosinesX.clear();
|
||||
cacheCosinesY.clear();
|
||||
}
|
||||
|
||||
public static Bitmap decode(String blurHash, int width, int height){
|
||||
return decode(blurHash, width, height, 1f, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a blur hash into a new bitmap.
|
||||
*
|
||||
* @param useCache use in memory cache for the calculated math, reused by images with same size.
|
||||
* if the cache does not exist yet it will be created and populated with new calculations.
|
||||
* By default it is true.
|
||||
*/
|
||||
public static Bitmap decode(String blurHash, int width, int height, float punch, boolean useCache){
|
||||
if(blurHash==null || blurHash.length()<6)
|
||||
return null;
|
||||
int numCompEnc=decode83(blurHash, 0, 1);
|
||||
int numCompX=(numCompEnc%9)+1;
|
||||
int numCompY=(numCompEnc/9)+1;
|
||||
if(blurHash.length()!=4+2*numCompX*numCompY)
|
||||
return null;
|
||||
int maxAcEnc=decode83(blurHash, 1, 2);
|
||||
float maxAc=(maxAcEnc+1)/166f;
|
||||
float[][] colors=new float[numCompX*numCompY][];
|
||||
colors[0]=decodeDc(decode83(blurHash, 2, 6));
|
||||
for(int i=1;i<colors.length;i++){
|
||||
int from=4+i*2;
|
||||
int colorEnc=decode83(blurHash, from, from+2);
|
||||
colors[i]=decodeAc(colorEnc, maxAc*punch);
|
||||
}
|
||||
return composeBitmap(width, height, numCompX, numCompY, colors, useCache);
|
||||
}
|
||||
|
||||
private static int decode83(String str, int from, int to){
|
||||
int result=0;
|
||||
for(int i=from;i<to;i++){
|
||||
int index=CHAR_MAP.indexOf(str.charAt(i));
|
||||
if(index!=-1)
|
||||
result=result*83+index;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static float[] decodeDc(int colorEnc){
|
||||
int r=colorEnc >> 16;
|
||||
int g=(colorEnc >> 8) & 255;
|
||||
int b=colorEnc & 255;
|
||||
return new float[]{srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)};
|
||||
}
|
||||
|
||||
private static float srgbToLinear(int colorEnc){
|
||||
float v=colorEnc/255f;
|
||||
return v<=0.4045f ? (v/12.92f) : (float)Math.pow((v + 0.055f) / 1.055f, 2.4f);
|
||||
}
|
||||
|
||||
private static float[] decodeAc(int value, float maxAc){
|
||||
int r=value/(19*19);
|
||||
int g=(value/19)%19;
|
||||
int b=value%19;
|
||||
return new float[]{signedPow2((r-9)/9f)*maxAc, signedPow2((g-9)/9f)*maxAc, signedPow2((b-9)/9f)*maxAc};
|
||||
}
|
||||
|
||||
private static float signedPow2(float value){
|
||||
return value*value*Math.signum(value);
|
||||
}
|
||||
|
||||
private static Bitmap composeBitmap(int width, int height, int numCompX, int numCompY, float[][] colors, boolean useCache){
|
||||
// use an array for better performance when writing pixel colors
|
||||
int[] imageArray=new int[width*height];
|
||||
boolean calculateCosX=!useCache || cacheCosinesX.get(width*numCompX)==null;
|
||||
double[] cosinesX=getArrayForCosinesX(calculateCosX, width, numCompX);
|
||||
boolean calculateCosY=!useCache || cacheCosinesY.get(height*numCompY)==null;
|
||||
double[] cosinesY=getArrayForCosinesY(calculateCosY, height, numCompY);
|
||||
for(int y=0;y<height;y++){
|
||||
for(int x=0;x<width;x++){
|
||||
float r=0f, g=0f, b=0f;
|
||||
for(int j=0;j<numCompY;j++){
|
||||
for(int i=0;i<numCompX;i++){
|
||||
double cosX=calculateCosX ? (cosinesX[i+numCompX*x]=Math.cos(Math.PI*x*i/width)) : cosinesX[i+numCompX*x];
|
||||
double cosY=calculateCosY ? (cosinesY[j+numCompY*y]=Math.cos(Math.PI*y*j/height)) : cosinesY[j+numCompY*y];
|
||||
float basis=(float)(cosX*cosY);
|
||||
float[] color=colors[j*numCompX+i];
|
||||
r+=color[0]*basis;
|
||||
g+=color[1]*basis;
|
||||
b+=color[2]*basis;
|
||||
}
|
||||
}
|
||||
imageArray[x+width*y]=0xFF000000 | linearToSrgb(b) | (linearToSrgb(g) << 8) | (linearToSrgb(r) << 16);
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888);
|
||||
}
|
||||
|
||||
private static double[] getArrayForCosinesY(boolean calculate, int height, int numCompY){
|
||||
if(calculate){
|
||||
double[] res=new double[height*numCompY];
|
||||
cacheCosinesY.put(height*numCompY, res);
|
||||
return res;
|
||||
}else{
|
||||
return cacheCosinesY.get(height*numCompY);
|
||||
}
|
||||
}
|
||||
|
||||
private static double[] getArrayForCosinesX(boolean calculate, int width, int numCompX){
|
||||
if(calculate){
|
||||
double[] res=new double[width*numCompX];
|
||||
cacheCosinesX.put(width*numCompX, res);
|
||||
return res;
|
||||
}else{
|
||||
return cacheCosinesX.get(width*numCompX);
|
||||
}
|
||||
}
|
||||
|
||||
private static int linearToSrgb(float value){
|
||||
float v=Math.max(0f, Math.min(1f, value));
|
||||
return v<=0.0031308f ? (int)(v * 12.92f * 255f + 0.5f) : (int)((1.055f * (float)Math.pow(v, 1 / 2.4f) - 0.055f) * 255 + 0.5f);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package org.joinmastodon.android.ui.utils;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.ColorFilter;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class BlurHashDrawable extends Drawable{
|
||||
private final Bitmap bitmap;
|
||||
private final int width, height;
|
||||
private static final Paint PAINT=new Paint(Paint.FILTER_BITMAP_FLAG);
|
||||
|
||||
public BlurHashDrawable(Bitmap bitmap, int width, int height){
|
||||
this.bitmap=bitmap;
|
||||
this.width=width>0 ? width : bitmap.getWidth();
|
||||
this.height=height>0 ? height : bitmap.getHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas){
|
||||
canvas.drawBitmap(bitmap, null, getBounds(), PAINT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAlpha(int alpha){
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColorFilter(@Nullable ColorFilter colorFilter){
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOpacity(){
|
||||
return PixelFormat.OPAQUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getIntrinsicWidth(){
|
||||
return width;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getIntrinsicHeight(){
|
||||
return height;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package org.joinmastodon.android.ui.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.browser.customtabs.CustomTabsIntent;
|
||||
|
||||
public class UiUtils{
|
||||
private UiUtils(){}
|
||||
|
||||
public static void launchWebBrowser(Context context, String url){
|
||||
// TODO setting for custom tabs
|
||||
new CustomTabsIntent.Builder()
|
||||
.build()
|
||||
.launchUrl(context, Uri.parse(url));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package org.joinmastodon.android.ui.views;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.joinmastodon.android.ui.text.ClickableLinksDelegate;
|
||||
|
||||
public class LinkedTextView extends TextView {
|
||||
|
||||
private ClickableLinksDelegate delegate=new ClickableLinksDelegate(this);
|
||||
private boolean needInvalidate;
|
||||
|
||||
public LinkedTextView(Context context) {
|
||||
super(context);
|
||||
// TODO Auto-generated constructor stub
|
||||
}
|
||||
|
||||
public LinkedTextView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
// TODO Auto-generated constructor stub
|
||||
}
|
||||
|
||||
public LinkedTextView(Context context, AttributeSet attrs, int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
// TODO Auto-generated constructor stub
|
||||
}
|
||||
|
||||
public boolean onTouchEvent(MotionEvent ev){
|
||||
if(delegate.onTouch(ev)) return true;
|
||||
return super.onTouchEvent(ev);
|
||||
}
|
||||
|
||||
public void onDraw(Canvas c){
|
||||
super.onDraw(c);
|
||||
delegate.onDraw(c);
|
||||
if(needInvalidate)
|
||||
invalidate();
|
||||
}
|
||||
|
||||
// a hack to support animated emoji on <9.0
|
||||
public void setInvalidateOnEveryFrame(boolean invalidate){
|
||||
needInvalidate=invalidate;
|
||||
if(invalidate)
|
||||
invalidate();
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/avatar"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignParentTop="true"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_toEndOf="@id/avatar"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end"
|
||||
tools:text="Eugen"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_toEndOf="@id/avatar"
|
||||
android:layout_alignBottom="@id/avatar"
|
||||
tools:text="\@Gargron . 1d"/>
|
||||
|
||||
</RelativeLayout>
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/photo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="250dp"
|
||||
android:scaleType="centerCrop"/>
|
||||
|
||||
</FrameLayout>
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end"/>
|
||||
|
||||
</FrameLayout>
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp">
|
||||
|
||||
<org.joinmastodon.android.ui.views.LinkedTextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
</FrameLayout>
|
|
@ -10,4 +10,6 @@
|
|||
<string name="ok">OK</string>
|
||||
<string name="preparing_auth">Preparing for authentication…</string>
|
||||
<string name="finishing_auth">Finishing authentication…</string>
|
||||
<string name="user_boosted">%s boosted</string>
|
||||
<string name="replied_to">Replied to %s</string>
|
||||
</resources>
|
|
@ -3,6 +3,7 @@ dependencyResolutionManagement {
|
|||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
mavenLocal()
|
||||
}
|
||||
}
|
||||
rootProject.name = "Mastodon"
|
||||
|
|
Loading…
Reference in New Issue