Add support for showing the user's own status over their avatar on the app's Settings button. (#5912)

* Add a common .inverseMask view modifier and use a blend mode for efficiency.

This also fixes the snapshot rendering 🎉

* Add support for the status emoji on the app's Setting button.

* Align the room header decoration icons with the status emoji.

* Add missing snapshots.
This commit is contained in:
Doug
2026-07-22 14:53:32 +01:00
committed by GitHub
parent e4f919e58f
commit b46189d05f
103 changed files with 376 additions and 268 deletions
@@ -0,0 +1,24 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
extension View {
/// Masks this view using the inverted alpha channel of the given view.
///
/// The inverse of `mask(alignment:_:)`.
func inverseMask(alignment: Alignment = .center, @ViewBuilder _ inverseMask: () -> some View) -> some View {
mask {
Rectangle()
.overlay(alignment: alignment) {
inverseMask()
.blendMode(.destinationOut) // erases the rectangle wherever `mask` is opaque.
}
.compositingGroup()
}
}
}
@@ -0,0 +1,86 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct AvatarSettingsButtonLabel: View {
let userProfile: UserProfile
var requiresExtraAccountSetup = false
let mediaProvider: MediaProviderProtocol?
var body: some View {
LoadableAvatarImage(url: userProfile.avatarURL,
name: userProfile.displayName,
contentID: userProfile.id,
avatarSize: .user(on: .chats),
mediaProvider: mediaProvider)
.accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar)
.overlayBadge(10, isBadged: requiresExtraAccountSetup)
.modifier(StatusEmojiModifier(statusEmoji: userProfile.status.displayed?.emoji))
.geometryGroup()
}
}
private struct StatusEmojiModifier: ViewModifier {
let statusEmoji: Character?
func body(content: Content) -> some View {
if let emojiText {
content
.inverseMask(alignment: .bottomTrailing) {
emojiText
.hidden()
.overlay { Circle().inset(by: -3) }
}
.overlay(alignment: .bottomTrailing) {
emojiText
}
} else {
content
}
}
var emojiText: (some View)? {
statusEmoji.map {
Text(String($0))
// All values (inc font) scaled by 0.8 as our Liquid Glass avatar is 32pt and not 40pt.
.font(.compound.bodyXS)
.padding(.vertical, 1) // Match Figma's line height.
.alignmentGuide(.trailing) { $0.width - 6.5 }
.alignmentGuide(.bottom) { $0.height + 1.5 }
}
}
}
struct AvatarSettingsButtonLabel_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
HStack(spacing: 16) {
AvatarSettingsButtonLabel(userProfile: .mockAlice,
requiresExtraAccountSetup: false,
mediaProvider: MediaProviderMock(.init()))
AvatarSettingsButtonLabel(userProfile: .mockAlice,
requiresExtraAccountSetup: true,
mediaProvider: MediaProviderMock(.init()))
AvatarSettingsButtonLabel(userProfile: .init(userID: "",
avatarURL: .mockMXCUserAvatar,
status: .mock(text: "", emoji: "🌴")),
requiresExtraAccountSetup: false,
mediaProvider: MediaProviderMock(.init()))
AvatarSettingsButtonLabel(userProfile: .init(userID: "",
avatarURL: .mockMXCUserAvatar,
status: .mock(text: "", emoji: "🌴")),
requiresExtraAccountSetup: true,
mediaProvider: MediaProviderMock(.init()))
}
.padding(16)
.previewLayout(.sizeThatFits)
}
}
@@ -23,21 +23,15 @@ struct BadgeViewModifier: ViewModifier {
let size: Double
func body(content: Content) -> some View {
content.mask {
Rectangle()
.fill(.white)
.overlay(alignment: .topTrailing) {
Circle()
.fill(.black)
.frame(width: maskSize, height: maskSize)
.offset(maskOffset)
}
.compositingGroup()
.luminanceToAlpha()
}
.overlay(alignment: .topTrailing) {
BadgeView(size: size)
}
content
.inverseMask(alignment: .topTrailing) {
Circle()
.frame(width: maskSize, height: maskSize)
.offset(maskOffset)
}
.overlay(alignment: .topTrailing) {
BadgeView(size: size)
}
}
private var maskSize: Double {
@@ -49,19 +49,12 @@ struct JoinedMembersBadgeView: View {
contentID: hero.id,
avatarSize: .user(on: .spaceHeader),
mediaProvider: mediaProvider)
.mask {
Circle()
.fill(Color.white)
.overlay {
if hero != heroes.first {
Circle()
.inset(by: -2)
.fill(Color.black)
.offset(x: 12)
}
}
.compositingGroup()
.luminanceToAlpha()
.inverseMask {
if hero != heroes.first {
Circle()
.inset(by: -2)
.offset(x: 12)
}
}
}
}
@@ -19,18 +19,12 @@ private struct RemoveItemButtonViewModifier: ViewModifier {
func body(content: Content) -> some View {
content
.mask {
Rectangle()
.fill(.white)
.overlay(alignment: .topTrailing) {
closeButtonLabel
.hidden()
.padding(2)
.overlay { Circle().fill(.black) }
.offset(x: 2, y: -2)
}
.compositingGroup()
.luminanceToAlpha()
.inverseMask(alignment: .topTrailing) {
closeButtonLabel
.hidden()
.padding(2)
.overlay { Circle() }
.offset(x: 2, y: -2)
}
.overlay(alignment: .topTrailing) {
Button(action: action) {
@@ -88,18 +88,11 @@ struct RoomAvatarImage: View {
avatarSize: avatarSize,
mediaProvider: mediaProvider,
onTap: onAvatarTap)
.mask {
Rectangle()
.fill(Color.white)
.overlay {
Circle()
.inset(by: -4)
.fill(Color.black)
.scaledOffset(x: clusterSize - avatarSize.value,
y: -clusterSize + avatarSize.value)
}
.compositingGroup()
.luminanceToAlpha()
.inverseMask {
Circle()
.inset(by: -4)
.scaledOffset(x: clusterSize - avatarSize.value,
y: -clusterSize + avatarSize.value)
}
.scaledFrame(size: clusterSize, alignment: .bottomLeading)
}
@@ -52,39 +52,42 @@ struct RoomHeaderView: View {
avatarImage
.accessibilityHidden(true)
HStack(spacing: 4) {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
Text(roomName)
.lineLimit(1)
.font(.compound.bodyMDSemibold)
.foregroundStyle(.compound.textPrimary)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name)
if let statusEmoji = dmRecipientDetails.statusEmoji {
Text(String(statusEmoji))
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
}
}
if let roomSubtitle {
Text(roomSubtitle)
.lineLimit(1)
.font(.compound.bodyXS)
.foregroundStyle(.compound.textSecondary)
}
}
VStack(alignment: .leading, spacing: 0) {
roomDetails
if let verificationState = dmRecipientDetails.verification {
VerificationBadge(verificationState: verificationState, size: .xSmall, relativeTo: .compound.bodyMDSemibold)
if let roomSubtitle {
Text(roomSubtitle)
.lineLimit(1)
.font(.compound.bodyXS)
.foregroundStyle(.compound.textSecondary)
}
}
}
}
private var roomDetails: some View {
HStack(spacing: 4) {
HStack(spacing: 8) {
Text(roomName)
.lineLimit(1)
.font(.compound.bodyMDSemibold)
.foregroundStyle(.compound.textPrimary)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.name)
if let historySharingIcon {
CompoundIcon(historySharingIcon, size: .xSmall, relativeTo: .compound.bodyMDSemibold)
.foregroundStyle(.compound.iconInfoPrimary)
if let statusEmoji = dmRecipientDetails.statusEmoji {
Text(String(statusEmoji))
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
}
}
if let verificationState = dmRecipientDetails.verification {
VerificationBadge(verificationState: verificationState, size: .xSmall, relativeTo: .compound.bodyMDSemibold)
}
if let historySharingIcon {
CompoundIcon(historySharingIcon, size: .xSmall, relativeTo: .compound.bodyMDSemibold)
.foregroundStyle(.compound.iconInfoPrimary)
}
}
}
@@ -22,6 +22,7 @@ enum TestablePreviewsDictionary {
"AuthenticationClassicAppBackupInstructionsView_Previews" : AuthenticationClassicAppBackupInstructionsView_Previews.self,
"AuthenticationStartScreen_Previews" : AuthenticationStartScreen_Previews.self,
"AvatarHeaderView_Previews" : AvatarHeaderView_Previews.self,
"AvatarSettingsButtonLabel_Previews" : AvatarSettingsButtonLabel_Previews.self,
"BadgeLabel_Previews" : BadgeLabel_Previews.self,
"BlockedUsersScreen_Previews" : BlockedUsersScreen_Previews.self,
"BloomModifier_Previews" : BloomModifier_Previews.self,
@@ -85,15 +85,9 @@ struct HomeScreen: View {
Button {
context.send(viewAction: .showSettings)
} label: {
LoadableAvatarImage(url: context.viewState.userProfile.avatarURL,
name: context.viewState.userProfile.displayName,
contentID: context.viewState.userProfile.id,
avatarSize: .user(on: .chats),
mediaProvider: context.mediaProvider)
.accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar)
.clipShape(.circle)
.overlayBadge(10, isBadged: context.viewState.requiresExtraAccountSetup)
.compositingGroup()
AvatarSettingsButtonLabel(userProfile: context.viewState.userProfile,
requiresExtraAccountSetup: context.viewState.requiresExtraAccountSetup,
mediaProvider: context.mediaProvider)
}
.accessibilityLabel(L10n.commonSettings)
}
@@ -96,13 +96,8 @@ struct SpacesScreen: View {
Button {
context.send(viewAction: .showSettings)
} label: {
LoadableAvatarImage(url: context.viewState.userProfile.avatarURL,
name: context.viewState.userProfile.displayName,
contentID: context.viewState.userProfile.id,
avatarSize: .user(on: .spaces),
mediaProvider: context.mediaProvider)
.accessibilityIdentifier(A11yIdentifiers.homeScreen.userAvatar)
.compositingGroup()
AvatarSettingsButtonLabel(userProfile: context.viewState.userProfile,
mediaProvider: context.mediaProvider)
}
.buttonStyle(.borderless)
.accessibilityLabel(L10n.commonSettings)