feature: Show role badges on profiles

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 <opensource@connyduck.at>
This commit is contained in:
Nik Clayton 2023-12-11 20:41:11 +01:00
parent 60cfa99f17
commit e35fa1dbce
No known key found for this signature in database
GPG Key ID: F95268159C2EC897
6 changed files with 161 additions and 22 deletions

View File

@ -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()
}

View File

@ -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)

View File

@ -0,0 +1,22 @@
<!--
~ Copyright 2023 Pachli Association
~
~ This file is a part of Pachli.
~
~ 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.
~
~ Pachli 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 Pachli; if not,
~ see <http://www.gnu.org/licenses>.
-->
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M20,7h-5V4c0,-1.1 -0.9,-2 -2,-2h-2C9.9,2 9,2.9 9,4v3H4C2.9,7 2,7.9 2,9v11c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V9C22,7.9 21.1,7 20,7zM9,12c0.83,0 1.5,0.67 1.5,1.5S9.83,15 9,15s-1.5,-0.67 -1.5,-1.5S8.17,12 9,12zM12,18H6v-0.75c0,-1 2,-1.5 3,-1.5s3,0.5 3,1.5V18zM13,9h-2V4h2V9zM18,16.5h-4V15h4V16.5zM18,13.5h-4V12h4V13.5z"/>
</vector>

View File

@ -150,42 +150,43 @@
app:srcCompat="@drawable/ic_reblog_private_24dp"
tools:visibility="visible" />
<TextView
android:id="@+id/accountFollowsYouTextView"
<com.google.android.material.chip.Chip
android:id="@+id/accountFollowsYouChip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/profile_badge_background"
android:clickable="false"
android:focusable="false"
android:minHeight="@dimen/profile_badge_min_height"
android:text="@string/follows_you"
android:textSize="?attr/status_text_small"
android:textColor="?colorOnPrimaryContainer"
android:visibility="gone"
app:chipBackgroundColor="#0000"
app:chipMinHeight="@dimen/profile_badge_min_height"
app:chipStrokeColor="?android:attr/textColorTertiary"
app:chipStrokeWidth="@dimen/profile_badge_stroke_width"
app:ensureMinTouchTargetSize="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountUsernameTextView"
tools:visibility="visible" />
<TextView
android:id="@+id/accountBadgeTextView"
android:layout_width="wrap_content"
<com.google.android.material.chip.ChipGroup
android:id="@+id/accountBadgeContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="6dp"
android:background="@drawable/profile_badge_background"
android:textColor="?colorOnPrimaryContainer"
android:text="@string/profile_badge_bot_text"
android:textSize="?attr/status_text_small"
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/accountFollowsYouTextView"
app:layout_constraintTop_toBottomOf="@id/accountUsernameTextView"
app:layout_goneMarginStart="0dp"
tools:visibility="visible" />
app:chipSpacingVertical="4dp"
app:layout_constraintStart_toEndOf="@id/accountUsernameTextView"
app:layout_constraintTop_toBottomOf="@id/accountFollowsYouChip"
app:layout_goneMarginStart="0dp" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/labelBarrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="accountFollowsYouTextView,accountBadgeTextView" />
app:constraint_referenced_ids="accountFollowsYouChip,accountBadgeContainer" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/accountNoteTextInputLayout"

View File

@ -43,6 +43,11 @@
<dimen name="min_report_button_width">160dp</dimen>
<dimen name="account_avatar_background_radius">14dp</dimen>
<dimen name="profile_badge_stroke_width">1dp</dimen>
<dimen name="profile_badge_min_height">24dp</dimen>
<dimen name="profile_badge_icon_size">16dp</dimen>
<dimen name="profile_badge_icon_start_padding">4dp</dimen>
<dimen name="profile_badge_icon_end_padding">0dp</dimen>
<dimen name="card_radius">5dp</dimen>

View File

@ -38,7 +38,7 @@ data class Account(
val emojis: List<Emoji>? = emptyList(), // nullable for backward compatibility
val fields: List<Field>? = emptyList(), // nullable for backward compatibility
val moved: Account? = null,
val roles: List<Role>? = 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,
)