From cbecfa31178401477970cc3a4e312b722004dd5f Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Mon, 11 Dec 2023 20:57:11 +0100 Subject: [PATCH] feat: Show roles on profiles (#312) Roles for the logged in user appeared in Mastodon 4.0.0 and can be displayed on the user's profile screen. Show them as chips, adjusting the display of the existing "Follows you" and "Bot" indicators to make allowances for this. Roles can have a custom colour assigned by the server admin. This is blended with the app colour so it is not too jarring in the display. See also https://github.com/tuskyapp/Tusky/pull/4029 Co-authored-by: Konrad Pozniak --- .../components/account/AccountActivity.kt | 103 +++++++++++++++++- .../components/account/AccountViewModel.kt | 6 +- .../main/res/drawable/profile_role_badge.xml | 22 ++++ app/src/main/res/layout/activity_account.xml | 35 +++--- app/src/main/res/values/dimens.xml | 5 + .../app/pachli/core/network/model/Account.kt | 12 +- 6 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 app/src/main/res/drawable/profile_role_badge.xml diff --git a/app/src/main/java/app/pachli/components/account/AccountActivity.kt b/app/src/main/java/app/pachli/components/account/AccountActivity.kt index ff409e740..2d90f5d18 100644 --- a/app/src/main/java/app/pachli/components/account/AccountActivity.kt +++ b/app/src/main/java/app/pachli/components/account/AccountActivity.kt @@ -22,10 +22,14 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.res.ColorStateList +import android.content.res.Configuration import android.graphics.Color +import android.graphics.Typeface import android.graphics.drawable.LayerDrawable import android.os.Bundle +import android.text.SpannableStringBuilder import android.text.TextWatcher +import android.text.style.StyleSpan import android.view.Menu import android.view.MenuInflater import android.view.MenuItem @@ -33,10 +37,12 @@ import android.view.View import android.view.ViewGroup import androidx.activity.viewModels import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes import androidx.annotation.Px import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.ActivityOptionsCompat +import androidx.core.graphics.ColorUtils import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat @@ -61,6 +67,7 @@ import app.pachli.core.navigation.ViewMediaActivityIntent import app.pachli.core.network.model.Account import app.pachli.core.network.model.Relationship import app.pachli.core.network.parseAsMastodonHtml +import app.pachli.core.preferences.AppTheme import app.pachli.core.preferences.PrefKeys import app.pachli.databinding.ActivityAccountBinding import app.pachli.db.DraftsAlert @@ -83,6 +90,7 @@ import app.pachli.util.visible import app.pachli.view.showMuteAccountDialog import com.bumptech.glide.Glide import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.chip.Chip import com.google.android.material.color.MaterialColors import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.shape.MaterialShapeDrawable @@ -217,7 +225,7 @@ class AccountActivity : binding.accountFloatingActionButton.hide() binding.accountFollowButton.hide() binding.accountMuteButton.hide() - binding.accountFollowsYouTextView.hide() + binding.accountFollowsYouChip.hide() // setup the RecyclerView for the account fields accountFieldAdapter = AccountFieldAdapter(this, animateEmojis) @@ -484,10 +492,10 @@ class AccountActivity : accountFieldAdapter.notifyDataSetChanged() binding.accountLockedImageView.visible(account.locked) - binding.accountBadgeTextView.visible(account.bot) updateAccountAvatar() updateToolbar() + updateBadges() updateMovedAccount() updateRemoteAccount() updateAccountJoinedDate() @@ -654,7 +662,7 @@ class AccountActivity : // If wellbeing mode is enabled, "follows you" text should not be visible val wellbeingEnabled = sharedPreferencesRepository.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_PROFILE, false) - binding.accountFollowsYouTextView.visible(relation.followedBy && !wellbeingEnabled) + binding.accountFollowsYouChip.visible(relation.followedBy && !wellbeingEnabled) // because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field // it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call @@ -753,6 +761,48 @@ class AccountActivity : } } + private fun updateBadges() { + binding.accountBadgeContainer.removeAllViews() + + val isLight = when (AppTheme.from(sharedPreferencesRepository)) { + AppTheme.DAY -> true + AppTheme.NIGHT, AppTheme.BLACK -> false + AppTheme.AUTO, AppTheme.AUTO_SYSTEM -> { + (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO + } + } + + if (loadedAccount?.bot == false) { + val badgeView = getBadge( + MaterialColors.getColor( + binding.accountBadgeContainer, + com.google.android.material.R.attr.colorSurfaceVariant, + ), + R.drawable.ic_bot_24dp, + getString(R.string.profile_badge_bot_text), + isLight, + ) + binding.accountBadgeContainer.addView(badgeView) + } + + // Display badges for any roles. Per the API spec this should only include + // roles with a true `highlighted` property, but the web UI doesn't do that, + // so follow suit for the moment, https://github.com/mastodon/mastodon/issues/28327 + loadedAccount?.roles?.forEach { role -> + val badgeColor = if (role.color.isNotBlank()) { + Color.parseColor(role.color) + } else { + MaterialColors.getColor(binding.accountBadgeContainer, android.R.attr.colorPrimary) + } + + val sb = SpannableStringBuilder("${role.name} ${viewModel.domain}") + sb.setSpan(StyleSpan(Typeface.BOLD), 0, role.name.length, 0) + + val badgeView = getBadge(badgeColor, R.drawable.profile_role_badge, sb, isLight) + binding.accountBadgeContainer.addView(badgeView) + } + } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.account_toolbar, menu) @@ -1004,6 +1054,53 @@ class AccountActivity : } } + private fun getBadge( + @ColorInt baseColor: Int, + @DrawableRes icon: Int, + text: CharSequence, + isLight: Boolean, + ): Chip { + val badge = Chip(this) + + // Text colour is black or white with ~ 70% opacity + // Experiments with the Palette library to extract the colour and pick an + // appropriate text colour showed that although the resulting colour could + // have marginally more contrast you could get a dark text colour when the + // other text colours were light, and vice-versa, making the badge text + // appear to be more prominent/important in the information hierarchy. + val textColor = if (isLight) Color.argb(178, 0, 0, 0) else Color.argb(178, 255, 255, 255) + + // Badge background colour with 50% transparency so it blends in with the theme background + val backgroundColor = Color.argb(128, Color.red(baseColor), Color.green(baseColor), Color.blue(baseColor)) + + // Outline colour blends the two + val outlineColor = ColorUtils.blendARGB(textColor, baseColor, 0.7f) + + // Configure the badge + badge.text = text + badge.setTextColor(textColor) + badge.chipStrokeWidth = resources.getDimension(R.dimen.profile_badge_stroke_width) + badge.chipStrokeColor = ColorStateList.valueOf(outlineColor) + badge.setChipIconResource(icon) + badge.isChipIconVisible = true + badge.chipIconSize = resources.getDimension(R.dimen.profile_badge_icon_size) + badge.chipIconTint = ColorStateList.valueOf(textColor) + badge.chipBackgroundColor = ColorStateList.valueOf(backgroundColor) + + // Badge isn't clickable, so disable all related behavior + badge.isClickable = false + badge.isFocusable = false + badge.setEnsureMinTouchTargetSize(false) + + // Reset some chip defaults so it looks better for our badge usecase + badge.iconStartPadding = resources.getDimension(R.dimen.profile_badge_icon_start_padding) + badge.iconEndPadding = resources.getDimension(R.dimen.profile_badge_icon_end_padding) + badge.minHeight = resources.getDimensionPixelSize(R.dimen.profile_badge_min_height) + badge.chipMinHeight = resources.getDimension(R.dimen.profile_badge_min_height) + badge.updatePadding(top = 0, bottom = 0) + return badge + } + companion object { private val argbEvaluator = ArgbEvaluator() } diff --git a/app/src/main/java/app/pachli/components/account/AccountViewModel.kt b/app/src/main/java/app/pachli/components/account/AccountViewModel.kt index b34b0b660..a3c5c33ee 100644 --- a/app/src/main/java/app/pachli/components/account/AccountViewModel.kt +++ b/app/src/main/java/app/pachli/components/account/AccountViewModel.kt @@ -44,6 +44,9 @@ class AccountViewModel @Inject constructor( lateinit var accountId: String var isSelf = false + /** The domain of the viewed account **/ + var domain = "" + /** True if the viewed account has the same domain as the active account */ var isFromOwnDomain = false @@ -70,11 +73,12 @@ class AccountViewModel @Inject constructor( mastodonApi.account(accountId) .fold( { account -> + domain = getDomain(account.url) accountData.postValue(Success(account)) isDataLoading = false isRefreshing.postValue(false) - isFromOwnDomain = getDomain(account.url) == activeAccount.domain + isFromOwnDomain = domain == activeAccount.domain }, { t -> Timber.w("failed obtaining account", t) diff --git a/app/src/main/res/drawable/profile_role_badge.xml b/app/src/main/res/drawable/profile_role_badge.xml new file mode 100644 index 000000000..7ff0934c6 --- /dev/null +++ b/app/src/main/res/drawable/profile_role_badge.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index 2229ffc78..6e70de0e1 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -150,42 +150,43 @@ app:srcCompat="@drawable/ic_reblog_private_24dp" tools:visibility="visible" /> - - + app:chipSpacingVertical="4dp" + app:layout_constraintStart_toEndOf="@id/accountUsernameTextView" + app:layout_constraintTop_toBottomOf="@id/accountFollowsYouChip" + app:layout_goneMarginStart="0dp" /> + app:constraint_referenced_ids="accountFollowsYouChip,accountBadgeContainer" /> 160dp 14dp + 1dp + 24dp + 16dp + 4dp + 0dp 5dp diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt index 11e7a56ba..2cc6f7c46 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt @@ -38,7 +38,7 @@ data class Account( val emojis: List? = emptyList(), // nullable for backward compatibility val fields: List? = emptyList(), // nullable for backward compatibility val moved: Account? = null, - + val roles: List? = emptyList() ) { val name: String @@ -69,3 +69,13 @@ data class StringField( val name: String, val value: String, ) + +/** [Mastodon Entities: Role](https://docs.joinmastodon.org/entities/Role) */ +data class Role( + /** Displayable name of the role */ + val name: String, + /** Colour to use for the role badge, may be the empty string */ + val color: String, + /** True if the badge should be displayed on the account profile */ + val highlighted: Boolean, +)