modernize autocomplete (#2510)

* modernize autocomplete

* use @WorkerThread annotation
This commit is contained in:
Konrad Pozniak 2022-05-17 19:55:37 +02:00 committed by GitHub
parent 4c9cd4084b
commit cec8f6dd65
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 314 additions and 412 deletions

View file

@ -78,7 +78,6 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.NewPoll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.ComposeTokenizer
import com.keylesspalace.tusky.util.PickMediaFiles
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.afterTextChanged
@ -307,7 +306,8 @@ class ComposeActivity :
ComposeAutoCompleteAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
)
)
binding.composeEditField.setTokenizer(ComposeTokenizer())

View file

@ -1,320 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.TextView;
import com.bumptech.glide.Glide;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.HashTag;
import com.keylesspalace.tusky.entity.TimelineAccount;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.ImageLoadingHelper;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Created by charlag on 12/11/17.
*/
public class ComposeAutoCompleteAdapter extends BaseAdapter
implements Filterable {
private static final int ACCOUNT_VIEW_TYPE = 1;
private static final int HASHTAG_VIEW_TYPE = 2;
private static final int EMOJI_VIEW_TYPE = 3;
private static final int SEPARATOR_VIEW_TYPE = 0;
private final ArrayList<AutocompleteResult> resultList;
private final AutocompletionProvider autocompletionProvider;
private final boolean animateAvatar;
private final boolean animateEmojis;
public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider, boolean animateAvatar, boolean animateEmojis) {
super();
resultList = new ArrayList<>();
this.autocompletionProvider = autocompletionProvider;
this.animateAvatar = animateAvatar;
this.animateEmojis = animateEmojis;
}
@Override
public int getCount() {
return resultList.size();
}
@Override
public AutocompleteResult getItem(int index) {
return resultList.get(index);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
@NonNull
public Filter getFilter() {
return new Filter() {
@Override
public CharSequence convertResultToString(Object resultValue) {
if (resultValue instanceof AccountResult) {
return formatUsername(((AccountResult) resultValue));
} else if (resultValue instanceof HashtagResult) {
return formatHashtag((HashtagResult) resultValue);
} else if (resultValue instanceof EmojiResult) {
return formatEmoji((EmojiResult) resultValue);
} else {
return "";
}
}
// This method is invoked in a worker thread.
@Override
protected FilterResults performFiltering(CharSequence constraint) {
FilterResults filterResults = new FilterResults();
if (constraint != null) {
List<AutocompleteResult> results =
autocompletionProvider.search(constraint.toString());
filterResults.values = results;
filterResults.count = results.size();
}
return filterResults;
}
@SuppressWarnings("unchecked")
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
if (results != null && results.count > 0) {
resultList.clear();
resultList.addAll((List<AutocompleteResult>) results.values);
notifyDataSetChanged();
} else {
notifyDataSetInvalidated();
}
}
};
}
@Override
@NonNull
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = convertView;
final Context context = parent.getContext();
switch (getItemViewType(position)) {
case ACCOUNT_VIEW_TYPE:
AccountViewHolder accountViewHolder;
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_account, parent, false);
}
if (view.getTag() == null) {
view.setTag(new AccountViewHolder(view));
}
accountViewHolder = (AccountViewHolder) view.getTag();
AccountResult accountResult = ((AccountResult) getItem(position));
if (accountResult != null) {
TimelineAccount account = accountResult.account;
String formattedUsername = context.getString(
R.string.post_username_format,
account.getUsername()
);
accountViewHolder.username.setText(formattedUsername);
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(),
account.getEmojis(), accountViewHolder.displayName, animateEmojis);
accountViewHolder.displayName.setText(emojifiedName);
int avatarRadius = accountViewHolder.avatar.getContext().getResources()
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
ImageLoadingHelper.loadAvatar(
account.getAvatar(),
accountViewHolder.avatar,
avatarRadius,
animateAvatar
);
}
break;
case HASHTAG_VIEW_TYPE:
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_hashtag, parent, false);
}
HashtagResult result = (HashtagResult) getItem(position);
if (result != null) {
((TextView) view).setText(formatHashtag(result));
}
break;
case EMOJI_VIEW_TYPE:
EmojiViewHolder emojiViewHolder;
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_emoji, parent, false);
}
if (view.getTag() == null) {
view.setTag(new EmojiViewHolder(view));
}
emojiViewHolder = (EmojiViewHolder) view.getTag();
EmojiResult emojiResult = ((EmojiResult) getItem(position));
if (emojiResult != null) {
Emoji emoji = emojiResult.emoji;
String formattedShortcode = context.getString(
R.string.emoji_shortcode_format,
emoji.getShortcode()
);
emojiViewHolder.shortcode.setText(formattedShortcode);
Glide.with(emojiViewHolder.preview)
.load(emoji.getUrl())
.into(emojiViewHolder.preview);
}
break;
case SEPARATOR_VIEW_TYPE:
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_divider, parent, false);
}
break;
default:
throw new AssertionError("unknown view type");
}
return view;
}
private static String formatUsername(AccountResult result) {
return String.format("@%s", result.account.getUsername());
}
private static String formatHashtag(HashtagResult result) {
return String.format("#%s", result.hashtag);
}
private static String formatEmoji(EmojiResult result) {
return String.format(":%s:", result.emoji.getShortcode());
}
@Override
public int getViewTypeCount() {
return 4;
}
@Override
public int getItemViewType(int position) {
AutocompleteResult item = getItem(position);
if (item instanceof AccountResult) {
return ACCOUNT_VIEW_TYPE;
} else if (item instanceof HashtagResult) {
return HASHTAG_VIEW_TYPE;
} else if (item instanceof EmojiResult) {
return EMOJI_VIEW_TYPE;
} else {
return SEPARATOR_VIEW_TYPE;
}
}
@Override
public boolean areAllItemsEnabled() {
// there may be separators
return false;
}
@Override
public boolean isEnabled(int position) {
return !(getItem(position) instanceof ResultSeparator);
}
public abstract static class AutocompleteResult {
AutocompleteResult() {
}
}
public final static class AccountResult extends AutocompleteResult {
private final TimelineAccount account;
public AccountResult(TimelineAccount account) {
this.account = account;
}
}
public final static class HashtagResult extends AutocompleteResult {
private final String hashtag;
public HashtagResult(HashTag hashtag) {
this.hashtag = hashtag.getName();
}
}
public final static class EmojiResult extends AutocompleteResult {
private final Emoji emoji;
public EmojiResult(Emoji emoji) {
this.emoji = emoji;
}
}
public final static class ResultSeparator extends AutocompleteResult {}
public interface AutocompletionProvider {
List<AutocompleteResult> search(String mention);
}
private class AccountViewHolder {
final TextView username;
final TextView displayName;
final ImageView avatar;
private AccountViewHolder(View view) {
username = view.findViewById(R.id.username);
displayName = view.findViewById(R.id.display_name);
avatar = view.findViewById(R.id.avatar);
}
}
private class EmojiViewHolder {
final TextView shortcode;
final ImageView preview;
private EmojiViewHolder(View view) {
shortcode = view.findViewById(R.id.shortcode);
preview = view.findViewById(R.id.preview);
}
}
}

View file

@ -0,0 +1,175 @@
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.compose
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.Filter
import android.widget.Filterable
import androidx.annotation.WorkerThread
import com.bumptech.glide.Glide
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
import com.keylesspalace.tusky.databinding.ItemAutocompleteEmojiBinding
import com.keylesspalace.tusky.databinding.ItemAutocompleteHashtagBinding
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.loadAvatar
import com.keylesspalace.tusky.util.visible
class ComposeAutoCompleteAdapter(
private val autocompletionProvider: AutocompletionProvider,
private val animateAvatar: Boolean,
private val animateEmojis: Boolean,
private val showBotBadge: Boolean
) : BaseAdapter(), Filterable {
private var resultList: List<AutocompleteResult> = emptyList()
override fun getCount() = resultList.size
override fun getItem(index: Int): AutocompleteResult {
return resultList[index]
}
override fun getItemId(position: Int): Long {
return position.toLong()
}
override fun getFilter(): Filter {
return object : Filter() {
override fun convertResultToString(resultValue: Any): CharSequence {
return when (resultValue) {
is AutocompleteResult.AccountResult -> formatUsername(resultValue)
is AutocompleteResult.HashtagResult -> formatHashtag(resultValue)
is AutocompleteResult.EmojiResult -> formatEmoji(resultValue)
else -> ""
}
}
@WorkerThread
override fun performFiltering(constraint: CharSequence?): FilterResults {
val filterResults = FilterResults()
if (constraint != null) {
val results = autocompletionProvider.search(constraint.toString())
filterResults.values = results
filterResults.count = results.size
}
return filterResults
}
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
if (results.count > 0) {
resultList = results.values as List<AutocompleteResult>
notifyDataSetChanged()
} else {
notifyDataSetInvalidated()
}
}
}
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val itemViewType = getItemViewType(position)
val context = parent.context
val view: View = convertView ?: run {
val layoutInflater = LayoutInflater.from(context)
val binding = when (itemViewType) {
ACCOUNT_VIEW_TYPE -> ItemAutocompleteAccountBinding.inflate(layoutInflater)
HASHTAG_VIEW_TYPE -> ItemAutocompleteHashtagBinding.inflate(layoutInflater)
EMOJI_VIEW_TYPE -> ItemAutocompleteEmojiBinding.inflate(layoutInflater)
else -> throw AssertionError("unknown view type")
}
binding.root.tag = binding
binding.root
}
when (val binding = view.tag) {
is ItemAutocompleteAccountBinding -> {
val accountResult = getItem(position) as AutocompleteResult.AccountResult
val account = accountResult.account
binding.username.text = context.getString(R.string.post_username_format, account.username)
binding.displayName.text = account.name.emojify(account.emojis, binding.displayName, animateEmojis)
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
loadAvatar(
account.avatar,
binding.avatar,
avatarRadius,
animateAvatar
)
binding.avatarBadge.visible(showBotBadge && account.bot)
}
is ItemAutocompleteHashtagBinding -> {
val result = getItem(position) as AutocompleteResult.HashtagResult
binding.root.text = formatHashtag(result)
}
is ItemAutocompleteEmojiBinding -> {
val emojiResult = getItem(position) as AutocompleteResult.EmojiResult
val (shortcode, url) = emojiResult.emoji
binding.shortcode.text = context.getString(R.string.emoji_shortcode_format, shortcode)
Glide.with(binding.preview)
.load(url)
.into(binding.preview)
}
}
return view
}
override fun getViewTypeCount() = 3
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is AutocompleteResult.AccountResult -> ACCOUNT_VIEW_TYPE
is AutocompleteResult.HashtagResult -> HASHTAG_VIEW_TYPE
is AutocompleteResult.EmojiResult -> EMOJI_VIEW_TYPE
}
}
sealed class AutocompleteResult {
class AccountResult(val account: TimelineAccount) : AutocompleteResult()
class HashtagResult(val hashtag: String) : AutocompleteResult()
class EmojiResult(val emoji: Emoji) : AutocompleteResult()
}
interface AutocompletionProvider {
fun search(token: String): List<AutocompleteResult>
}
companion object {
private const val ACCOUNT_VIEW_TYPE = 0
private const val HASHTAG_VIEW_TYPE = 1
private const val EMOJI_VIEW_TYPE = 2
private fun formatUsername(result: AutocompleteResult.AccountResult): String {
return String.format("@%s", result.account.username)
}
private fun formatHashtag(result: AutocompleteResult.HashtagResult): String {
return String.format("#%s", result.hashtag)
}
private fun formatEmoji(result: AutocompleteResult.EmojiResult): String {
return String.format(":%s:", result.emoji.shortcode)
}
}
}

View file

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util
package com.keylesspalace.tusky.components.compose
import android.text.SpannableString
import android.text.Spanned

View file

@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
import com.keylesspalace.tusky.components.drafts.DraftHelper
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
@ -38,6 +39,7 @@ import com.keylesspalace.tusky.service.ServiceClient
import com.keylesspalace.tusky.service.StatusToSend
import com.keylesspalace.tusky.util.combineLiveData
import com.keylesspalace.tusky.util.randomAlphanumericString
import com.keylesspalace.tusky.util.result
import com.keylesspalace.tusky.util.toLiveData
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.Dispatchers
@ -51,7 +53,6 @@ import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.rxSingle
import kotlinx.coroutines.withContext
import java.util.Locale
import javax.inject.Inject
class ComposeViewModel @Inject constructor(
@ -330,49 +331,40 @@ class ComposeViewModel @Inject constructor(
return true
}
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
when (token[0]) {
'@' -> {
return try {
api.searchAccounts(query = token.substring(1), limit = 10)
.blockingGet()
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
} catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
return api.searchAccountsCall(query = token.substring(1), limit = 10)
.result()
.fold({ accounts ->
accounts.map { AutocompleteResult.AccountResult(it) }
}, { e ->
Log.e(TAG, "Autocomplete search for $token failed.", e)
emptyList()
}
})
}
'#' -> {
return try {
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.blockingGet()
.hashtags
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
} catch (e: Throwable) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
return api.searchCall(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
.result()
.fold({ searchResult ->
searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
}, { e ->
Log.e(TAG, "Autocomplete search for $token failed.", e)
emptyList()
}
})
}
':' -> {
val emojiList = emoji.value ?: return emptyList()
val incomplete = token.substring(1)
val incomplete = token.substring(1).lowercase(Locale.ROOT)
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
for (emoji in emojiList) {
val shortcode = emoji.shortcode.lowercase(Locale.ROOT)
if (shortcode.startsWith(incomplete)) {
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
} else if (shortcode.indexOf(incomplete, 1) != -1) {
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
return emojiList.filter { emoji ->
emoji.shortcode.contains(incomplete, ignoreCase = true)
}.sortedBy { emoji ->
emoji.shortcode.indexOf(incomplete, ignoreCase = true)
}.map { emoji ->
AutocompleteResult.EmojiResult(emoji)
}
}
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
}
results.addAll(resultsInside)
return results
}
else -> {
Log.w(TAG, "Unexpected autocompletion token: $token")
return emptyList()

View file

@ -288,6 +288,14 @@ interface MastodonApi {
@Query("following") following: Boolean? = null
): Single<List<TimelineAccount>>
@GET("api/v1/accounts/search")
fun searchAccountsCall(
@Query("q") query: String,
@Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("following") following: Boolean? = null
): Call<List<TimelineAccount>>
@GET("api/v1/accounts/{id}")
fun account(
@Path("id") accountId: String
@ -593,6 +601,16 @@ interface MastodonApi {
@Query("following") following: Boolean? = null
): Single<SearchResult>
@GET("api/v2/search")
fun searchCall(
@Query("q") query: String?,
@Query("type") type: String? = null,
@Query("resolve") resolve: Boolean? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
@Query("following") following: Boolean? = null
): Call<SearchResult>
@FormUrlEncoded
@POST("api/v1/accounts/{id}/note")
fun updateAccountNote(

View file

@ -0,0 +1,23 @@
package com.keylesspalace.tusky.util
import retrofit2.Call
import retrofit2.HttpException
/**
* Synchronously executes the call and returns the response encapsulated in a kotlin.Result.
* Since Result is an inline class it is not possible to do this with a Retrofit adapter unfortunately.
* More efficient then calling a suspending method with runBlocking
*/
fun <T> Call<T>.result(): Result<T> {
return try {
val response = execute()
val responseBody = response.body()
if (response.isSuccessful && responseBody != null) {
Result.success(responseBody)
} else {
Result.failure(HttpException(response))
}
} catch (e: Exception) {
Result.failure(e)
}
}

View file

@ -1,48 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:padding="8dp">
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<ImageView
android:id="@+id/avatar"
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_centerVertical="true"
android:layout_marginEnd="8dp"
android:contentDescription="@null"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="24dp"
android:foregroundGravity="center_vertical"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/avatar_default" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_toEndOf="@id/avatar"
android:gravity="center_vertical"
android:orientation="vertical">
<ImageView
android:id="@+id/avatarBadge"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/profile_badge_bot_text"
android:src="@drawable/bot_badge"
app:layout_constraintBottom_toBottomOf="@id/avatar"
app:layout_constraintEnd_toEndOf="@id/avatar" />
<TextView
android:id="@+id/display_name"
android:layout_width="wrap_content"
android:id="@+id/displayName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_large"
android:textStyle="normal|bold"
tools:text="Conny Duck" />
app:layout_constraintBottom_toTopOf="@id/username"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="@id/avatar"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Display name" />
<TextView
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
tools:text="\@ConnyDuck" />
app:layout_constraintBottom_toBottomOf="@id/avatar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toBottomOf="@id/displayName"
tools:text="\@username" />
</LinearLayout>
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/status_divider" />

View file

@ -5,24 +5,24 @@
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
tools:ignore="UseCompoundDrawables">
<ImageView
android:id="@+id/preview"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp"
android:layout_marginBottom="4dp"
android:contentDescription="@null"
android:padding="4dp" />
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/shortcode"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorPrimary"

View file

@ -1,11 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/hashtag"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:ellipsize="end"
android:maxLines="1"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:textSize="?attr/status_text_medium"
android:textStyle="normal|bold"
app:drawableStartCompat="@drawable/ic_list"
app:drawableTint="?attr/iconColor" />
tools:text="#Tusky" />

View file

@ -15,7 +15,7 @@
package com.keylesspalace.tusky
import com.keylesspalace.tusky.util.ComposeTokenizer
import com.keylesspalace.tusky.components.compose.ComposeTokenizer
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith