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 <opensource@connyduck.at>
This commit is contained in:
Nik Clayton 2023-12-11 20:57:11 +01:00 committed by GitHub
parent 60cfa99f17
commit cbecfa3117
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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.Context
import android.content.Intent import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
import android.os.Bundle import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.TextWatcher import android.text.TextWatcher
import android.text.style.StyleSpan
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
@ -33,10 +37,12 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.Px import androidx.annotation.Px
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat 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.Account
import app.pachli.core.network.model.Relationship import app.pachli.core.network.model.Relationship
import app.pachli.core.network.parseAsMastodonHtml import app.pachli.core.network.parseAsMastodonHtml
import app.pachli.core.preferences.AppTheme
import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.PrefKeys
import app.pachli.databinding.ActivityAccountBinding import app.pachli.databinding.ActivityAccountBinding
import app.pachli.db.DraftsAlert import app.pachli.db.DraftsAlert
@ -83,6 +90,7 @@ import app.pachli.util.visible
import app.pachli.view.showMuteAccountDialog import app.pachli.view.showMuteAccountDialog
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.google.android.material.appbar.AppBarLayout 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.color.MaterialColors
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
@ -217,7 +225,7 @@ class AccountActivity :
binding.accountFloatingActionButton.hide() binding.accountFloatingActionButton.hide()
binding.accountFollowButton.hide() binding.accountFollowButton.hide()
binding.accountMuteButton.hide() binding.accountMuteButton.hide()
binding.accountFollowsYouTextView.hide() binding.accountFollowsYouChip.hide()
// setup the RecyclerView for the account fields // setup the RecyclerView for the account fields
accountFieldAdapter = AccountFieldAdapter(this, animateEmojis) accountFieldAdapter = AccountFieldAdapter(this, animateEmojis)
@ -484,10 +492,10 @@ class AccountActivity :
accountFieldAdapter.notifyDataSetChanged() accountFieldAdapter.notifyDataSetChanged()
binding.accountLockedImageView.visible(account.locked) binding.accountLockedImageView.visible(account.locked)
binding.accountBadgeTextView.visible(account.bot)
updateAccountAvatar() updateAccountAvatar()
updateToolbar() updateToolbar()
updateBadges()
updateMovedAccount() updateMovedAccount()
updateRemoteAccount() updateRemoteAccount()
updateAccountJoinedDate() updateAccountJoinedDate()
@ -654,7 +662,7 @@ class AccountActivity :
// If wellbeing mode is enabled, "follows you" text should not be visible // If wellbeing mode is enabled, "follows you" text should not be visible
val wellbeingEnabled = sharedPreferencesRepository.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_PROFILE, false) 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 // 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 // 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) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.account_toolbar, menu) 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 { companion object {
private val argbEvaluator = ArgbEvaluator() private val argbEvaluator = ArgbEvaluator()
} }

View File

@ -44,6 +44,9 @@ class AccountViewModel @Inject constructor(
lateinit var accountId: String lateinit var accountId: String
var isSelf = false var isSelf = false
/** The domain of the viewed account **/
var domain = ""
/** True if the viewed account has the same domain as the active account */ /** True if the viewed account has the same domain as the active account */
var isFromOwnDomain = false var isFromOwnDomain = false
@ -70,11 +73,12 @@ class AccountViewModel @Inject constructor(
mastodonApi.account(accountId) mastodonApi.account(accountId)
.fold( .fold(
{ account -> { account ->
domain = getDomain(account.url)
accountData.postValue(Success(account)) accountData.postValue(Success(account))
isDataLoading = false isDataLoading = false
isRefreshing.postValue(false) isRefreshing.postValue(false)
isFromOwnDomain = getDomain(account.url) == activeAccount.domain isFromOwnDomain = domain == activeAccount.domain
}, },
{ t -> { t ->
Timber.w("failed obtaining account", 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" app:srcCompat="@drawable/ic_reblog_private_24dp"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView <com.google.android.material.chip.Chip
android:id="@+id/accountFollowsYouTextView" android:id="@+id/accountFollowsYouChip"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:background="@drawable/profile_badge_background" 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:text="@string/follows_you"
android:textSize="?attr/status_text_small" android:textSize="?attr/status_text_small"
android:textColor="?colorOnPrimaryContainer"
android:visibility="gone" 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_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountUsernameTextView" app:layout_constraintTop_toBottomOf="@id/accountUsernameTextView"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView <com.google.android.material.chip.ChipGroup
android:id="@+id/accountBadgeTextView" android:id="@+id/accountBadgeContainer"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:background="@drawable/profile_badge_background" app:chipSpacingVertical="4dp"
android:textColor="?colorOnPrimaryContainer" app:layout_constraintStart_toEndOf="@id/accountUsernameTextView"
android:text="@string/profile_badge_bot_text" app:layout_constraintTop_toBottomOf="@id/accountFollowsYouChip"
android:textSize="?attr/status_text_small" app:layout_goneMarginStart="0dp" />
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/accountFollowsYouTextView"
app:layout_constraintTop_toBottomOf="@id/accountUsernameTextView"
app:layout_goneMarginStart="0dp"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier
android:id="@+id/labelBarrier" android:id="@+id/labelBarrier"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:barrierDirection="bottom" app:barrierDirection="bottom"
app:constraint_referenced_ids="accountFollowsYouTextView,accountBadgeTextView" /> app:constraint_referenced_ids="accountFollowsYouChip,accountBadgeContainer" />
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/accountNoteTextInputLayout" android:id="@+id/accountNoteTextInputLayout"

View File

@ -43,6 +43,11 @@
<dimen name="min_report_button_width">160dp</dimen> <dimen name="min_report_button_width">160dp</dimen>
<dimen name="account_avatar_background_radius">14dp</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> <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 emojis: List<Emoji>? = emptyList(), // nullable for backward compatibility
val fields: List<Field>? = emptyList(), // nullable for backward compatibility val fields: List<Field>? = emptyList(), // nullable for backward compatibility
val moved: Account? = null, val moved: Account? = null,
val roles: List<Role>? = emptyList()
) { ) {
val name: String val name: String
@ -69,3 +69,13 @@ data class StringField(
val name: String, val name: String,
val value: 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,
)