diff --git a/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt b/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt index e43a6a0f54d..462f6e5b687 100644 --- a/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt +++ b/app/src/main/java/com/nextcloud/talk/call/components/AvatarWithFallback.kt @@ -25,7 +25,9 @@ import coil.compose.AsyncImage import com.nextcloud.talk.R import com.nextcloud.talk.activities.ParticipantUiState import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.ui.ActorAvatarImage import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.DisplayUtils.isDarkModeOn @Composable @@ -35,21 +37,31 @@ fun AvatarWithFallback(participant: ParticipantUiState, displayName: String, mod .clip(CircleShape), contentAlignment = Alignment.Center ) { - val avatarUrl = getUrlForAvatar( - participant = participant, - displayName = displayName - ) - if (avatarUrl.isNotEmpty()) { - AsyncImage( - model = avatarUrl, - contentDescription = stringResource(R.string.avatar), - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(CircleShape) - ) + // Guests and email participants have no avatar on the server, so theirs is drawn here + val isGuest = Participant.ActorType.GUESTS == participant.actorType || + Participant.ActorType.EMAILS == participant.actorType + val guestAvatar = if (isGuest) { + CharacterAvatarUtils.guestAvatar(displayName, stringResource(R.string.nc_guest)) + } else { + null + } + + if (guestAvatar != null) { + ActorAvatarImage(avatar = guestAvatar, modifier = Modifier.fillMaxSize()) } else { - FallbackAvatar(participant = participant) + val avatarUrl = getUrlForAvatar(participant = participant) + if (avatarUrl.isNotEmpty()) { + AsyncImage( + model = avatarUrl, + contentDescription = stringResource(R.string.avatar), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + ) + } else { + FallbackAvatar(participant = participant) + } } } } @@ -76,31 +88,20 @@ private fun FallbackAvatar(participant: ParticipantUiState) { } @Composable -fun getUrlForAvatar(participant: ParticipantUiState, displayName: String): String { - var url = ApiUtils.getUrlForAvatar( - participant.baseUrl, - participant.actorId, - true, - darkMode = isDarkModeOn(LocalContext.current) - ) - if (Participant.ActorType.GUESTS == participant.actorType || - Participant.ActorType.EMAILS == participant.actorType - ) { - url = ApiUtils.getUrlForGuestAvatar( - participant.baseUrl, - displayName, - true - ) - } +fun getUrlForAvatar(participant: ParticipantUiState): String = if (participant.actorType == Participant.ActorType.FEDERATED) { - val darkTheme = if (isDarkModeOn(LocalContext.current)) 1 else 0 - url = ApiUtils.getUrlForFederatedAvatar( + ApiUtils.getUrlForFederatedAvatar( participant.baseUrl, participant.roomToken, participant.actorId!!, - darkTheme, + if (isDarkModeOn(LocalContext.current)) 1 else 0, true ) + } else { + ApiUtils.getUrlForAvatar( + participant.baseUrl, + participant.actorId, + true, + darkMode = isDarkModeOn(LocalContext.current) + ) } - return url -} diff --git a/app/src/main/java/com/nextcloud/talk/chat/MentionAutocompleteAdapter.kt b/app/src/main/java/com/nextcloud/talk/chat/MentionAutocompleteAdapter.kt index 04b7ef82759..77ecb60b8fe 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MentionAutocompleteAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MentionAutocompleteAdapter.kt @@ -25,7 +25,6 @@ import com.nextcloud.talk.adapters.items.MentionAutocompleteItem.Companion.SOURC import com.nextcloud.talk.adapters.items.MentionAutocompleteItem.Companion.SOURCE_TEAMS import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.RvItemConversationInfoParticipantBinding -import com.nextcloud.talk.extensions.loadDefaultAvatar import com.nextcloud.talk.extensions.loadFederatedUserAvatar import com.nextcloud.talk.extensions.loadGuestAvatar import com.nextcloud.talk.extensions.loadUserAvatar @@ -138,11 +137,7 @@ class MentionAutocompleteAdapter( } SOURCE_GUESTS, SOURCE_EMAILS -> { - if (item.displayName.equals(context.resources.getString(R.string.nc_guest))) { - avatarView.loadDefaultAvatar(viewThemeUtils) - } else { - avatarView.loadGuestAvatar(currentUser, item.displayName!!, false) - } + avatarView.loadGuestAvatar(item.displayName, viewThemeUtils) } SOURCE_TEAMS -> diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/ShowReactionsSheet.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/ShowReactionsSheet.kt index bdc138b7463..616e8b9fcb1 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/ShowReactionsSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/ShowReactionsSheet.kt @@ -54,7 +54,10 @@ import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.json.reactions.ReactionVoter +import com.nextcloud.talk.ui.ActorAvatarImage +import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CharacterAvatarUtils import java.util.Collections private const val TAG = "ShowReactionsSheet" @@ -222,30 +225,24 @@ private fun ReactionVoterRow( val canDelete = hasReactPermission && reactionItem.reactionVoter.actorId == user.userId val guestLabel = stringResource(R.string.nc_guest) - val avatarUrl = remember(reactionItem, isDark) { - when (reactionItem.reactionVoter.actorType) { - ReactionVoter.ReactionActorType.GUESTS -> { - val displayName = reactionItem.reactionVoter.actorDisplayName - ?.takeIf { it.isNotEmpty() } - ?: guestLabel - ApiUtils.getUrlForGuestAvatar(user.baseUrl, displayName, false) - } - - ReactionVoter.ReactionActorType.USERS -> { - ApiUtils.getUrlForAvatar(user.baseUrl, reactionItem.reactionVoter.actorId, false, isDark) - } - - else -> null + // Guests have no avatar on the server, so theirs is drawn from their name here + val guestAvatar = remember(reactionItem, guestLabel) { + if (reactionItem.reactionVoter.actorType == ReactionVoter.ReactionActorType.GUESTS) { + CharacterAvatarUtils.guestAvatar(reactionItem.reactionVoter.actorDisplayName, guestLabel) + } else { + null } } - val avatarRequest = remember(avatarUrl, credentials) { - avatarUrl?.let { + val avatarRequest = remember(reactionItem, isDark, credentials) { + if (reactionItem.reactionVoter.actorType == ReactionVoter.ReactionActorType.USERS) { ImageRequest.Builder(context) - .data(it) + .data(ApiUtils.getUrlForAvatar(user.baseUrl, reactionItem.reactionVoter.actorId, false, isDark)) .transformations(CircleCropTransformation()) .addHeader("Authorization", credentials ?: "") .build() + } else { + null } } @@ -258,13 +255,7 @@ private fun ReactionVoterRow( .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.Companion.CenterVertically ) { - AsyncImage( - model = avatarRequest ?: R.drawable.account_circle_96dp, - contentDescription = null, - placeholder = painterResource(R.drawable.account_circle_96dp), - error = painterResource(R.drawable.account_circle_96dp), - modifier = Modifier.Companion.size(40.dp) - ) + ReactionVoterAvatar(guestAvatar = guestAvatar, avatarRequest = avatarRequest) Spacer(modifier = Modifier.Companion.width(16.dp)) Text( text = reactionItem.reactionVoter.actorDisplayName ?: "", @@ -279,6 +270,27 @@ private fun ReactionVoterRow( } } +/** + * The voter's avatar: the one drawn for a guest, otherwise the avatar loaded from the server. + */ +@Composable +private fun ReactionVoterAvatar(guestAvatar: ActorAvatar?, avatarRequest: ImageRequest?) { + if (guestAvatar != null) { + ActorAvatarImage( + avatar = guestAvatar, + modifier = Modifier.Companion.size(40.dp) + ) + } else { + AsyncImage( + model = avatarRequest ?: R.drawable.account_circle_96dp, + contentDescription = null, + placeholder = painterResource(R.drawable.account_circle_96dp), + error = painterResource(R.drawable.account_circle_96dp), + modifier = Modifier.Companion.size(40.dp) + ) + } +} + private class ReactionComparator(private val activeUser: String?) : Comparator { @Suppress("ReturnCount") override fun compare(item1: ReactionItem?, item2: ReactionItem?): Int { diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt index 8a25f459626..dbd12514dfb 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt @@ -31,6 +31,8 @@ data class ChatMessageUi( val plainMessage: String = "", val renderMarkdown: Boolean, val actorDisplayName: String, + val actorType: String? = null, + val actorId: String? = null, val isThread: Boolean, val threadTitle: String, val threadTitleIconRes: Int = R.drawable.outline_forum_24, @@ -129,6 +131,8 @@ fun ChatMessage.toUiModel( plainMessage = message.orEmpty(), renderMarkdown = renderMarkdown == true, actorDisplayName = actorDisplayName.orEmpty(), + actorType = actorType, + actorId = actorId, threadTitle = threadTitle.orEmpty(), isThread = isThread, threadReplies = threadReplies ?: 0, @@ -180,6 +184,8 @@ fun ChatMessage.toScheduledMessageUiModel( plainMessage = message.orEmpty(), renderMarkdown = renderMarkdown != false, actorDisplayName = actorDisplayName.orEmpty(), + actorType = actorType, + actorId = actorId, isThread = isThread, threadTitle = threadTitle.orEmpty(), threadReplies = threadReplies ?: 0, diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 88d2fa78ad5..9a40d599d53 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -73,6 +73,7 @@ import com.nextcloud.talk.threadsoverview.data.ThreadsRepository import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability +import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.MimetypeUtils @@ -1691,8 +1692,12 @@ class ChatViewModel @AssistedInject constructor( // val timeString = DateUtils.getLocalTimeStringFromTimestamp(message.timestamp) + /** + * Avatar to request from the server for a message, empty for the actors the server has none for + * - those get their avatar drawn on the client instead, see [CharacterAvatarUtils]. + */ fun getAvatarUrl(message: ChatMessage): String = - if (this::currentUser.isInitialized) { + if (this::currentUser.isInitialized && !message.hasClientSideAvatar()) { ApiUtils.getUrlForAvatar( currentUser.baseUrl, message.actorId, @@ -1702,6 +1707,9 @@ class ChatViewModel @AssistedInject constructor( "" } + private fun ChatMessage.hasClientSideAvatar(): Boolean = + CharacterAvatarUtils.avatarFor(actorType, actorId, actorDisplayName, guestLabel = null) != null + fun initData(user: User, credentials: String, urlForChatting: String, threadId: Long?) { currentUser = user diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ParticipantItemAdapter.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ParticipantItemAdapter.kt index cc46c8a3d5a..a83b607ceff 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ParticipantItemAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ParticipantItemAdapter.kt @@ -23,10 +23,9 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA import com.nextcloud.talk.conversationinfo.model.ParticipantModel import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.RvItemConversationInfoParticipantBinding -import com.nextcloud.talk.extensions.loadDefaultAvatar import com.nextcloud.talk.extensions.loadDefaultGroupCallAvatar import com.nextcloud.talk.extensions.loadFederatedUserAvatar -import com.nextcloud.talk.extensions.loadFirstLetterAvatar +import com.nextcloud.talk.extensions.loadGuestAvatar import com.nextcloud.talk.extensions.loadPhoneAvatar import com.nextcloud.talk.extensions.loadTeamAvatar import com.nextcloud.talk.extensions.loadUserAvatar @@ -210,12 +209,7 @@ class ParticipantItemAdapter( } Participant.ActorType.GUESTS, Participant.ActorType.EMAILS -> { - val actorName = model.displayName - if (!actorName.isNullOrBlank()) { - binding.avatarView.loadFirstLetterAvatar(actorName) - } else { - binding.avatarView.loadDefaultAvatar(viewThemeUtils) - } + binding.avatarView.loadGuestAvatar(model.displayName, viewThemeUtils) } Participant.ActorType.FEDERATED -> { diff --git a/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt b/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt index 5b784e3b3fa..c984aeee672 100644 --- a/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt +++ b/app/src/main/java/com/nextcloud/talk/extensions/ImageViewExtensions.kt @@ -10,11 +10,9 @@ package com.nextcloud.talk.extensions -import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapShader import android.graphics.Canvas -import android.graphics.Color import android.graphics.ColorFilter import android.graphics.Matrix import android.graphics.Paint @@ -28,11 +26,9 @@ import android.graphics.drawable.LayerDrawable import android.util.Log import android.widget.ImageView import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.toBitmap -import androidx.core.graphics.drawable.toDrawable import coil.annotation.ExperimentalCoilApi +import coil.dispose import coil.imageLoader import coil.load import coil.request.CachePolicy @@ -48,10 +44,11 @@ import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.toDrawable +import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.DisplayUtils -import com.nextcloud.talk.utils.TextDrawable -import java.util.Locale import kotlin.math.min private const val ROUNDING_PIXEL = 16f @@ -311,37 +308,6 @@ fun ImageView.loadNoteToSelfAvatar() { setImageDrawable(CircularDrawable(layerDrawable)) } -fun ImageView.loadFirstLetterAvatar(name: String): io.reactivex.disposables.Disposable { - val layers = arrayOfNulls(2) - layers[0] = ContextCompat.getDrawable(context, R.drawable.ic_launcher_background) - layers[1] = createTextDrawable(context, name.trimStart().uppercase(Locale.ROOT)) - - val layerDrawable = LayerDrawable(layers) - val data: Any = layerDrawable - - return DisposableWrapper( - load(data) { - transformations(CircleCropTransformation()) - } - ) -} - -fun ImageView.loadChangelogBotAvatar(): io.reactivex.disposables.Disposable = loadSystemAvatar() - -fun ImageView.loadBotsAvatar(): io.reactivex.disposables.Disposable { - val layers = arrayOfNulls(2) - layers[0] = context.getColor(R.color.black).toDrawable() - layers[1] = TextDrawable(context, ">") - val layerDrawable = LayerDrawable(layers) - val data: Any = layerDrawable - - return DisposableWrapper( - load(data) { - transformations(CircleCropTransformation()) - } - ) -} - fun ImageView.loadDefaultGroupCallAvatar(viewThemeUtils: ViewThemeUtils): io.reactivex.disposables.Disposable { val data: Any = viewThemeUtils.talk.themePlaceholderAvatar(this, R.drawable.ic_avatar_group_small) as Any return loadUserAvatar(data) @@ -367,49 +333,24 @@ fun ImageView.loadMailAvatar(viewThemeUtils: ViewThemeUtils): io.reactivex.dispo return loadUserAvatar(data) } -fun ImageView.loadGuestAvatar(user: User, name: String, big: Boolean): io.reactivex.disposables.Disposable = - loadGuestAvatar(user.baseUrl!!, name, big) - -fun ImageView.loadGuestAvatar(baseUrl: String, name: String, big: Boolean): io.reactivex.disposables.Disposable { - val imageRequestUri = ApiUtils.getUrlForGuestAvatar( - baseUrl, - name, - big - ) - return DisposableWrapper( - load(imageRequestUri) { - transformations(CircleCropTransformation()) - listener(onError = { _, result -> - Log.w(TAG, "Can't load guest avatar with URL: $imageRequestUri", result.throwable) - }) +/** + * Client-side avatar for guest and email actors, which have no avatar on the server: a guest who + * gave us their name gets the first character of it drawn on a circle, an unnamed one the generic + * person icon. Nothing is requested from the server for either, matching the web client. + * + * Any avatar request still in flight for the view is cancelled first, so a recycled view cannot be + * overwritten by its predecessor's avatar. + */ +fun ImageView.loadGuestAvatar(displayName: String?, viewThemeUtils: ViewThemeUtils) { + when (val avatar = CharacterAvatarUtils.guestAvatar(displayName, context.getString(R.string.nc_guest))) { + is ActorAvatar.Character -> { + dispose() + setImageDrawable(avatar.toDrawable(context)) } - ) -} -@Suppress("MagicNumber") -private fun createTextDrawable(context: Context, letter: String): Drawable { - val size = 100 - val bitmap = createBitmap(size, size) - val canvas = Canvas(bitmap) - - val paint = Paint().apply { - color = ResourcesCompat.getColor(context.resources, R.color.grey_600, null) - style = Paint.Style.FILL - } - canvas.drawRect(0f, 0f, size.toFloat(), size.toFloat(), paint) - - val textPaint = Paint().apply { - color = Color.WHITE - textSize = size / 2f - isAntiAlias = true - textAlign = Paint.Align.CENTER + ActorAvatar.AppIcon -> loadSystemAvatar() + ActorAvatar.PersonIcon -> loadDefaultAvatar(viewThemeUtils) } - - val xPos = size / 2f - val yPos = (canvas.height / 2 - (textPaint.descent() + textPaint.ascent()) / 2) - canvas.drawText(letter.take(1), xPos, yPos, textPaint) - - return bitmap.toDrawable(context.resources) } private class DisposableWrapper(private val disposable: coil.request.Disposable) : diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 0816301256b..dc8a47c2c96 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -70,6 +70,7 @@ import com.nextcloud.talk.receivers.DismissRecordingAvailableReceiver import com.nextcloud.talk.receivers.MarkAsReadReceiver import com.nextcloud.talk.receivers.ShareRecordingToChatReceiver import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.ConversationUtils diff --git a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVoterViewHolder.kt b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVoterViewHolder.kt index f0174eefe0f..929ece4201b 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVoterViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVoterViewHolder.kt @@ -7,10 +7,7 @@ package com.nextcloud.talk.polls.adapters import android.annotation.SuppressLint -import android.text.TextUtils import android.widget.ImageView -import com.nextcloud.talk.R -import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.PollResultVoterItemBinding import com.nextcloud.talk.extensions.loadFederatedUserAvatar @@ -42,11 +39,7 @@ class PollResultVoterViewHolder( private fun loadAvatar(pollDetail: PollDetails, avatar: ImageView) { when (pollDetail.actorType) { Participant.ActorType.GUESTS -> { - var displayName = NextcloudTalkApplication.sharedApplication?.resources?.getString(R.string.nc_guest) - if (!TextUtils.isEmpty(pollDetail.actorDisplayName)) { - displayName = pollDetail.actorDisplayName!! - } - avatar.loadGuestAvatar(user, displayName!!, false) + avatar.loadGuestAvatar(pollDetail.actorDisplayName, viewThemeUtils) } Participant.ActorType.FEDERATED -> { diff --git a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVotersOverviewViewHolder.kt b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVotersOverviewViewHolder.kt index b23753cfcf1..623e3fd507d 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVotersOverviewViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultVotersOverviewViewHolder.kt @@ -8,12 +8,9 @@ package com.nextcloud.talk.polls.adapters import android.annotation.SuppressLint -import android.text.TextUtils import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView -import com.nextcloud.talk.R -import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.PollResultVotersOverviewItemBinding import com.nextcloud.talk.extensions.loadFederatedUserAvatar @@ -21,12 +18,14 @@ import com.nextcloud.talk.extensions.loadGuestAvatar import com.nextcloud.talk.extensions.loadUserAvatar import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.polls.model.PollDetails +import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.DisplayUtils class PollResultVotersOverviewViewHolder( private val user: User, private val roomToken: String, - override val binding: PollResultVotersOverviewItemBinding + override val binding: PollResultVotersOverviewItemBinding, + private val viewThemeUtils: ViewThemeUtils ) : PollResultViewHolder(binding) { @SuppressLint("SetTextI18n") @@ -72,11 +71,7 @@ class PollResultVotersOverviewViewHolder( private fun loadAvatar(pollDetail: PollDetails, avatar: ImageView) { when (pollDetail.actorType) { Participant.ActorType.GUESTS -> { - var displayName = NextcloudTalkApplication.sharedApplication?.resources?.getString(R.string.nc_guest) - if (!TextUtils.isEmpty(pollDetail.actorDisplayName)) { - displayName = pollDetail.actorDisplayName!! - } - avatar.loadGuestAvatar(user, displayName!!, false) + avatar.loadGuestAvatar(pollDetail.actorDisplayName, viewThemeUtils) } Participant.ActorType.FEDERATED -> { diff --git a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultsAdapter.kt b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultsAdapter.kt index 4af97d1b2e6..4f0cb46f230 100644 --- a/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/polls/adapters/PollResultsAdapter.kt @@ -49,7 +49,7 @@ class PollResultsAdapter( parent, false ) - viewHolder = PollResultVotersOverviewViewHolder(user, roomToken, itemBinding) + viewHolder = PollResultVotersOverviewViewHolder(user, roomToken, itemBinding, viewThemeUtils) } } return viewHolder!! diff --git a/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatar.kt b/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatar.kt new file mode 100644 index 00000000000..15819d32f89 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatar.kt @@ -0,0 +1,97 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.ui + +import android.widget.ImageView +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.viewinterop.AndroidView +import com.nextcloud.talk.R +import com.nextcloud.talk.extensions.loadSystemAvatar +import com.nextcloud.talk.utils.ActorAvatar + +/** + * Circular avatar drawn from a character, the Compose counterpart of [CharacterAvatarDrawable]. + * + * Expects the single character resolved by [com.nextcloud.talk.utils.CharacterAvatarUtils]; the + * character scales with the space the avatar is given, so callers only size the modifier. + */ +@Composable +fun CharacterAvatar( + character: String, + backgroundColor: Color, + textColor: Color, + modifier: Modifier = Modifier +) { + BoxWithConstraints(modifier = modifier, contentAlignment = Alignment.Center) { + // Sized to the shorter side and centered, so a circle stays a circle instead of being + // stretched into a pill when the space the avatar is given is not square + val diameter = minOf(maxWidth, maxHeight) + val fontSize = with(LocalDensity.current) { + (diameter * CharacterAvatarDrawable.TEXT_SIZE_RATIO).toSp() + } + + Box( + modifier = Modifier + .size(diameter) + .clip(CircleShape) + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + Text( + text = character, + color = textColor, + fontSize = fontSize, + maxLines = 1, + softWrap = false, + textAlign = TextAlign.Center + ) + } + } +} + +/** + * The avatar of an actor the server has no avatar for, whichever kind + * [com.nextcloud.talk.utils.CharacterAvatarUtils] resolved it to. + */ +@Composable +fun ActorAvatarImage(avatar: ActorAvatar, modifier: Modifier = Modifier) { + when (avatar) { + is ActorAvatar.Character -> CharacterAvatar( + character = avatar.character, + backgroundColor = colorResource(avatar.backgroundColor), + textColor = colorResource(avatar.textColor), + modifier = modifier + ) + + ActorAvatar.AppIcon -> AndroidView( + factory = { context -> ImageView(context).apply { loadSystemAvatar() } }, + modifier = modifier + ) + + ActorAvatar.PersonIcon -> Image( + painter = painterResource(R.drawable.account_circle_96dp), + contentDescription = stringResource(R.string.user_avatar), + modifier = modifier + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatarDrawable.kt b/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatarDrawable.kt new file mode 100644 index 00000000000..c0ff873f339 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/CharacterAvatarDrawable.kt @@ -0,0 +1,130 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.ui + +import android.content.Context +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.drawable.Drawable +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import com.nextcloud.talk.utils.ActorAvatar +import kotlin.math.min + +/** + * The character avatar as a drawable, for the views and notifications that cannot use the + * [CharacterAvatar] composable. + */ +fun ActorAvatar.Character.toDrawable(context: Context): CharacterAvatarDrawable = + CharacterAvatarDrawable( + character = character, + backgroundColor = ContextCompat.getColor(context, backgroundColor), + textColor = ContextCompat.getColor(context, textColor) + ) + +/** + * Circular avatar drawn from a character, for actors without an avatar on the server - see + * [com.nextcloud.talk.utils.CharacterAvatarUtils] for which actors those are. + * + * The character is scaled to the bounds it is handed rather than to a fixed bitmap, so the same + * avatar stays sharp in a mention chip and in a call tile. + */ +class CharacterAvatarDrawable( + private val character: String, + @ColorInt private val backgroundColor: Int, + @ColorInt private val textColor: Int +) : Drawable() { + + private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = backgroundColor + style = Paint.Style.FILL + } + + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = textColor + textAlign = Paint.Align.CENTER + } + + private val textBounds = Rect() + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + fitTextTo(bounds) + } + + /** + * Sizes the character relative to the circle, then shrinks it if it is still too wide to sit + * inside the circle - a single letter always fits, the two-character bot prompt does not. + */ + private fun fitTextTo(bounds: Rect) { + val diameter = min(bounds.width(), bounds.height()) + if (diameter <= 0 || character.isEmpty()) { + return + } + + textPaint.textSize = diameter * TEXT_SIZE_RATIO + textPaint.getTextBounds(character, 0, character.length, textBounds) + + val maxTextWidth = diameter * MAX_TEXT_WIDTH_RATIO + if (textBounds.width() > maxTextWidth) { + textPaint.textSize *= maxTextWidth / textBounds.width() + } + } + + override fun draw(canvas: Canvas) { + val diameter = min(bounds.width(), bounds.height()) + canvas.drawCircle(bounds.exactCenterX(), bounds.exactCenterY(), diameter / 2f, backgroundPaint) + + if (character.isEmpty()) { + return + } + + val baseline = bounds.exactCenterY() - (textPaint.descent() + textPaint.ascent()) / 2 + canvas.drawText(character, bounds.exactCenterX(), baseline, textPaint) + } + + override fun setAlpha(alpha: Int) { + backgroundPaint.alpha = alpha + textPaint.alpha = alpha + invalidateSelf() + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + backgroundPaint.colorFilter = colorFilter + textPaint.colorFilter = colorFilter + invalidateSelf() + } + + @Deprecated("Deprecated in Drawable", ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat")) + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun getIntrinsicWidth(): Int = INTRINSIC_SIZE + + override fun getIntrinsicHeight(): Int = INTRINSIC_SIZE + + companion object { + /** + * Character height relative to the circle, matching the web client's font-size of half the + * avatar size. + */ + internal const val TEXT_SIZE_RATIO = 0.5f + + /** + * Widest the character may get before it is scaled down, leaving the circle a margin. + */ + internal const val MAX_TEXT_WIDTH_RATIO = 0.6f + + /** + * Fallback size for views that size themselves to the drawable instead of the other way + * round. Large enough to stay sharp on high density screens. + */ + private const val INTRINSIC_SIZE = 128 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt index a1c3bf8fe4b..2f4b6199a20 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt @@ -69,7 +69,9 @@ import com.nextcloud.talk.chat.ui.model.MessageReactionUi import com.nextcloud.talk.chat.ui.model.MessageStatusIcon import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.contacts.loadImage +import com.nextcloud.talk.ui.ActorAvatarImage import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.TextMatchers @@ -259,22 +261,38 @@ fun MessageScaffold( private fun RowScope.MessageLeadingDecoration(uiMessage: ChatMessageUi, isOneToOneConversation: Boolean) { val onAvatarClick = LocalAvatarClickHandler.current if (uiMessage.incoming && isOneToOneConversation && !uiMessage.isGrouped) { - val errorPlaceholderImage: Int = R.drawable.account_circle_96dp - val avatarContext = LocalContext.current - val loadedImage = remember(uiMessage.avatarUrl) { - loadImage(uiMessage.avatarUrl, avatarContext, errorPlaceholderImage) + val avatarModifier = Modifier + .size(48.dp) + .align(Alignment.Top) + .padding(end = 8.dp) + .combinedClickable( + onClick = { onAvatarClick(uiMessage.id) } + ) + val guestLabel = stringResource(R.string.nc_guest) + // Guests, email participants and bots have no avatar on the server, so theirs is drawn here + val actorAvatar = remember(uiMessage.actorType, uiMessage.actorId, uiMessage.actorDisplayName, guestLabel) { + CharacterAvatarUtils.avatarFor( + actorType = uiMessage.actorType, + actorId = uiMessage.actorId, + displayName = uiMessage.actorDisplayName, + guestLabel = guestLabel + ) + } + + if (actorAvatar != null) { + ActorAvatarImage(avatar = actorAvatar, modifier = avatarModifier) + } else { + val errorPlaceholderImage: Int = R.drawable.account_circle_96dp + val avatarContext = LocalContext.current + val loadedImage = remember(uiMessage.avatarUrl) { + loadImage(uiMessage.avatarUrl, avatarContext, errorPlaceholderImage) + } + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.user_avatar), + modifier = avatarModifier + ) } - AsyncImage( - model = loadedImage, - contentDescription = stringResource(R.string.user_avatar), - modifier = Modifier - .size(48.dp) - .align(Alignment.Top) - .padding(end = 8.dp) - .combinedClickable( - onClick = { onAvatarClick(uiMessage.id) } - ) - ) } else if (uiMessage.incoming && isOneToOneConversation) { Spacer(Modifier.width(48.dp)) } else if (uiMessage.incoming) { diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt index 957b494608e..2c782b133f9 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MarkdownText.kt @@ -48,9 +48,13 @@ import androidx.core.content.ContextCompat import coil.imageLoader import coil.request.ImageRequest import coil.transform.CircleCropTransformation +import com.nextcloud.talk.R import com.nextcloud.talk.chat.ui.model.ChatMessageUi import com.nextcloud.talk.events.UserMentionClickEvent import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.ui.toDrawable +import com.nextcloud.talk.utils.ActorAvatar +import com.nextcloud.talk.utils.CharacterAvatarUtils import com.nextcloud.talk.utils.message.MessageUtils import io.noties.markwon.core.spans.LinkSpan import org.greenrobot.eventbus.EventBus @@ -297,7 +301,6 @@ private fun applyMentionChips( val fgColor = if (isSelfMention) selfChipTextColor else chipTextColor val avatarUrl = resolveMentionAvatarUrl( rawId = rawId, - name = name, type = type, mentionId = mentionId, isFederated = isFederated, @@ -316,7 +319,7 @@ private fun applyMentionChips( avatarUrl = avatarUrl ) ) - val fallbackDrawable = ContextCompat.getDrawable(context, fallbackIconRes)?.mutate() ?: continue + val fallbackDrawable = mentionFallbackDrawable(context, type, rawId, name, fallbackIconRes) ?: continue val token = "{$key}" var searchFrom = 0 while (true) { @@ -354,6 +357,24 @@ private fun applyMentionChips( return hasClickableChips } +/** + * What the chip shows until - or instead of - an avatar arrives from the server: guests and email + * participants have none there, so theirs is drawn from their name rather than falling back to the + * generic person icon. + */ +@Suppress("LongParameterList") +private fun mentionFallbackDrawable( + context: Context, + type: String, + rawId: String, + name: String, + fallbackIconRes: Int +): Drawable? { + val character = CharacterAvatarUtils.avatarFor(type, rawId, name, context.getString(R.string.nc_guest)) + as? ActorAvatar.Character + return character?.toDrawable(context) ?: ContextCompat.getDrawable(context, fallbackIconRes)?.mutate() +} + private class MentionClickSpan(private val mentionId: String) : ClickableSpan() { override fun onClick(widget: View) { EventBus.getDefault().post(UserMentionClickEvent(mentionId)) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MentionChip.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MentionChip.kt index 93906c39788..c83c4be8f29 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MentionChip.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MentionChip.kt @@ -52,8 +52,11 @@ import coil.compose.AsyncImage import com.nextcloud.talk.R import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.events.UserMentionClickEvent +import com.nextcloud.talk.ui.ActorAvatarImage import com.nextcloud.talk.ui.theme.LocalViewThemeUtils +import com.nextcloud.talk.utils.ActorAvatar import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CharacterAvatarUtils import org.greenrobot.eventbus.EventBus val mentionParameterTypes = setOf("user", "guest", "call", "user-group", "email", "circle") @@ -103,7 +106,6 @@ fun parseMentionChipModel( val isSelfMention = rawId == activeUserId val avatarUrl = resolveMentionAvatarUrl( rawId = rawId, - name = name, type = type, mentionId = mentionId, isFederated = isFederated, @@ -126,7 +128,6 @@ fun parseMentionChipModel( @Suppress("LongParameterList") fun resolveMentionAvatarUrl( rawId: String, - name: String, type: String, mentionId: String, isFederated: Boolean, @@ -142,11 +143,9 @@ fun resolveMentionAvatarUrl( darkTheme = 0, requestBigSize = false ) - type == "guest" || type == "email" -> ApiUtils.getUrlForGuestAvatar( - baseUrl = baseUrl, - name = name, - requestBigSize = true - ) + // Guests and email participants have no avatar on the server, so nothing is requested for + // them - their chip is drawn from resolveMentionCharacter instead, as on web + type == "guest" || type == "email" -> null type == "call" || type == "user-group" || type == "circle" -> null rawId.isNotEmpty() -> ApiUtils.getUrlForAvatar(baseUrl, rawId, false, false) else -> null @@ -301,12 +300,25 @@ fun MentionChip( @Composable fun MentionChipIcon(mention: MentionChipModel, fallbackIcon: Int) { - if (mention.avatarUrl != null) { - val context = LocalContext.current - val loadedImage = remember(mention.avatarUrl) { loadImage(mention.avatarUrl, context, fallbackIcon) } - AsyncImage(model = loadedImage, contentDescription = null, modifier = Modifier.size(mentionAvatarSize)) - } else { - Icon( + val guestLabel = stringResource(R.string.nc_guest) + // A mentioned guest has no avatar on the server, so their chip is drawn from their name + val character = remember(mention.type, mention.rawId, mention.name, guestLabel) { + CharacterAvatarUtils.avatarFor(mention.type, mention.rawId, mention.name, guestLabel) + as? ActorAvatar.Character + } + when { + character != null -> ActorAvatarImage( + avatar = character, + modifier = Modifier.size(mentionAvatarSize) + ) + + mention.avatarUrl != null -> { + val context = LocalContext.current + val loadedImage = remember(mention.avatarUrl) { loadImage(mention.avatarUrl, context, fallbackIcon) } + AsyncImage(model = loadedImage, contentDescription = null, modifier = Modifier.size(mentionAvatarSize)) + } + + else -> Icon( painter = painterResource(fallbackIcon), contentDescription = null, modifier = Modifier.size(mentionIconSize), diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt index 963351ec813..a214027525e 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt @@ -376,12 +376,6 @@ object ApiUtils { return "$url?cloudId=$cloudId&darkTheme=$darkTheme" } - @JvmStatic - fun getUrlForGuestAvatar(baseUrl: String?, name: String?, requestBigSize: Boolean): String { - val avatarSize = if (requestBigSize) AVATAR_SIZE_BIG else AVATAR_SIZE_SMALL - return baseUrl + "/index.php/avatar/guest/" + Uri.encode(name) + "/" + avatarSize - } - fun getUrlForConversationAvatar(version: Int, baseUrl: String?, token: String?): String = getUrlForRoom(version, baseUrl, token) + "/avatar" diff --git a/app/src/main/java/com/nextcloud/talk/utils/CharacterAvatarUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/CharacterAvatarUtils.kt new file mode 100644 index 00000000000..06147ee0499 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/CharacterAvatarUtils.kt @@ -0,0 +1,147 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import androidx.annotation.ColorRes +import com.nextcloud.talk.R +import java.util.Locale + +/** + * The avatar of an actor the server has no avatar for, drawn by the client instead of being + * requested. Resolved by [CharacterAvatarUtils.avatarFor], rendered by + * [com.nextcloud.talk.ui.ActorAvatarImage] in Compose and by + * [com.nextcloud.talk.ui.CharacterAvatarDrawable] everywhere else. + */ +sealed interface ActorAvatar { + + /** + * A character on a coloured circle: the first character of a guest's name, or the shell prompt + * of a bot. Both colours are theme-aware resources, as the web client's are. + */ + data class Character( + val character: String, + @ColorRes val backgroundColor: Int, + @ColorRes val textColor: Int + ) : ActorAvatar + + /** + * The app's own icon, for the bots that ship their avatar with the app. + */ + object AppIcon : ActorAvatar + + /** + * The generic person icon, for guests who did not tell us their name. + */ + object PersonIcon : ActorAvatar +} + +/** + * Decides what to draw for actors the server has no avatar for. + * + * Guests, email participants and bots have no avatar on the server, so theirs is rendered on the + * client instead of being requested: a guest who told us their name gets the first character of it, + * a bot gets a shell prompt. This mirrors the web client's AvatarWrapper, so both clients show the + * same avatar for the same actor. + * + * A guest without a name is not represented by a character at all - they get the generic person + * icon, because the placeholder name ("Guest") is the same for everyone in the conversation and its + * initial would tell the reader nothing. + */ +object CharacterAvatarUtils { + + /** + * Shell prompt drawn for bots, matching the web client. + */ + const val BOT_CHARACTER = ">_" + + /** + * Actor types without an avatar on the server. Both the plural spelling of the participant and + * chat APIs and the singular one of the message parameters are accepted, so every caller can + * pass the type it was given. + */ + private val GUEST_ACTOR_TYPES = setOf("guests", "guest", "emails", "email") + private val BOT_ACTOR_TYPES = setOf("bots", "bot") + + /** + * Bots that ship their own avatar with the app and therefore never draw a character. + */ + private val CHANGELOG_BOT_IDS = setOf("changelog", "sample") + + /** + * What to draw for the given actor, or null when the actor has an avatar on the server and it + * should be requested as usual. + * + * @param actorType the actor type as the API reports it, e.g. "guests" or "bots" + * @param actorId the actor's id, only used to spot the bots shipping their own avatar + * @param displayName the actor's display name, a guest's character is derived from it + * @param guestLabel the localized placeholder name for unnamed guests, null to accept any name + */ + fun avatarFor(actorType: String?, actorId: String?, displayName: String?, guestLabel: String?): ActorAvatar? = + when (actorType) { + in GUEST_ACTOR_TYPES -> guestAvatar(displayName, guestLabel) + in BOT_ACTOR_TYPES -> botAvatar(actorId) + else -> null + } + + /** + * The avatar of a guest or email participant, for callers whose actor type is already narrowed + * down to those - the name is all that is left to decide. + */ + fun guestAvatar(displayName: String?, guestLabel: String?): ActorAvatar = + guestCharacter(displayName, guestLabel) + ?.let { + ActorAvatar.Character( + character = it, + backgroundColor = R.color.character_avatar_background_guest, + textColor = R.color.character_avatar_text_guest + ) + } + ?: ActorAvatar.PersonIcon + + /** + * The avatar of a bot: the shell prompt, or the app's icon for the bots shipping their own. + */ + fun botAvatar(actorId: String?): ActorAvatar = + if (actorId in CHANGELOG_BOT_IDS) { + ActorAvatar.AppIcon + } else { + ActorAvatar.Character( + character = BOT_CHARACTER, + backgroundColor = R.color.character_avatar_background_bot, + textColor = R.color.character_avatar_text_bot + ) + } + + /** + * Whether the actor told us their name, as opposed to being labelled with the generic + * placeholder every unnamed guest shares. + * + * @param displayName the actor's display name, may be null or blank + * @param guestLabel the localized placeholder name for unnamed guests, null to accept any name + */ + fun hasCustomName(displayName: String?, guestLabel: String?): Boolean { + val name = displayName?.trim() + return !name.isNullOrEmpty() && name != guestLabel?.trim() + } + + /** + * The character to draw for a guest or email actor, or null when there is no name to derive it + * from and the generic person icon should be used instead. + */ + private fun guestCharacter(displayName: String?, guestLabel: String?): String? { + if (!hasCustomName(displayName, guestLabel)) { + return null + } + return firstCharacterOf(displayName!!.trim().uppercase(Locale.getDefault())) + } + + /** + * First character as a whole code point, so names starting with an emoji or any other + * character outside the basic plane do not get cut in half into an unrenderable fragment. + */ + private fun firstCharacterOf(name: String): String = String(Character.toChars(name.codePointAt(0))) +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ChatMessageUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ChatMessageUtils.kt deleted file mode 100644 index 9d271bf7177..00000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/ChatMessageUtils.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2024 Marcel Hibbe - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.utils - -import android.view.View -import android.widget.ImageView -import com.nextcloud.talk.chat.data.model.ChatMessage -import com.nextcloud.talk.extensions.loadBotsAvatar -import com.nextcloud.talk.extensions.loadChangelogBotAvatar -import com.nextcloud.talk.extensions.loadDefaultAvatar -import com.nextcloud.talk.extensions.loadFederatedUserAvatar -import com.nextcloud.talk.extensions.loadFirstLetterAvatar -import com.nextcloud.talk.ui.theme.ViewThemeUtils - -class ChatMessageUtils { - - fun setAvatarOnMessage(view: ImageView, message: ChatMessage, viewThemeUtils: ViewThemeUtils) { - view.visibility = View.VISIBLE - if (message.actorType == "guests" || message.actorType == "emails") { - val actorName = message.actorDisplayName - if (!actorName.isNullOrBlank()) { - view.loadFirstLetterAvatar(actorName) - } else { - view.loadDefaultAvatar(viewThemeUtils) - } - } else if (message.actorType == "bots" && (message.actorId == "changelog" || message.actorId == "sample")) { - view.loadChangelogBotAvatar() - } else if (message.actorType == "bots") { - view.loadBotsAvatar() - } else if (message.actorType == "federated_users") { - view.loadFederatedUserAvatar(message) - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt index dcd778ff836..caf10e48952 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.kt @@ -62,7 +62,6 @@ import com.nextcloud.talk.extensions.loadUserAvatar import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.ApiUtils.getUrlForAvatar import com.nextcloud.talk.utils.ApiUtils.getUrlForFederatedAvatar -import com.nextcloud.talk.utils.ApiUtils.getUrlForGuestAvatar import com.nextcloud.talk.utils.preferences.AppPreferencesImpl import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -196,15 +195,11 @@ object DisplayUtils { chip.setChipIconResource(R.drawable.icon_circular_phone) } chip.setBounds(0, 0, chip.intrinsicWidth, chip.intrinsicHeight) - if (!isGroup) { + // Guests and email participants have no avatar on the server, so their chip keeps the + // person icon set above instead of requesting one, matching the web client + val isGuest = "guests" == type || "guest" == type || "email" == type + if (!isGroup && !isGuest) { var url = getUrlForAvatar(conversationUser.baseUrl, id, false, isDarkModeOn(context)) - if ("guests" == type || "guest" == type || "email" == type) { - url = getUrlForGuestAvatar( - conversationUser.baseUrl, - label.toString(), - true - ) - } if (isFederated) { val darkTheme = if (isDarkModeOn(context)) 1 else 0 url = getUrlForFederatedAvatar( diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 4023b8083eb..6102b2d5c20 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -18,6 +18,7 @@ import android.service.notification.StatusBarNotification import android.text.TextUtils import android.util.Log import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import coil.executeBlocking import coil.imageLoader @@ -28,6 +29,7 @@ import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.RingtoneSettings +import com.nextcloud.talk.ui.toDrawable import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.preferences.AppPreferences import java.io.IOException @@ -342,5 +344,18 @@ object NotificationUtils { return avatarIcon } + /** + * Notification avatar for actors without an avatar on the server, drawn by the client as + * resolved by [CharacterAvatarUtils] instead of being requested. + */ + fun characterAvatarBitmap(context: Context, avatar: ActorAvatar.Character): Bitmap = + avatar.toDrawable(context).toBitmap(CHARACTER_AVATAR_ICON_SIZE, CHARACTER_AVATAR_ICON_SIZE) + + /** + * Pixel size the character avatar is rasterized to for notifications, which cannot scale a + * drawable themselves. + */ + private const val CHARACTER_AVATAR_ICON_SIZE = 128 + private data class Channel(val id: String, val name: String, val description: String, val isImportant: Boolean) } diff --git a/app/src/main/java/com/nextcloud/talk/utils/TextDrawable.kt b/app/src/main/java/com/nextcloud/talk/utils/TextDrawable.kt deleted file mode 100644 index ce44f31b47b..00000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/TextDrawable.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2024 Marcel Hibbe - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package com.nextcloud.talk.utils - -import android.content.Context -import android.graphics.Canvas -import android.graphics.ColorFilter -import android.graphics.Paint -import android.graphics.PixelFormat -import android.graphics.Rect -import android.graphics.drawable.Drawable -import com.nextcloud.talk.R - -class TextDrawable(val context: Context, private var text: String) : Drawable() { - private val paint = Paint() - private val bounds: Rect - - init { - paint.color = context.getColor(R.color.textColorOnPrimaryBackground) - paint.isAntiAlias = true - paint.textSize = TEXT_SIZE - bounds = Rect() - } - - override fun draw(canvas: Canvas) { - if (text.isNotEmpty()) { - paint.getTextBounds( - text, - 0, - text.length, - bounds - ) - val x: Int = (getBounds().width() - bounds.width()) / 2 - val y: Int = ((getBounds().height() + bounds.height()) / 2) + Y_OFFSET - canvas.drawText(text, x.toFloat(), y.toFloat(), paint) - } - } - - override fun setColorFilter(colorFilter: ColorFilter?) { - paint.setColorFilter(colorFilter) - } - - override fun setAlpha(alpha: Int) { - paint.alpha = alpha - } - - @Deprecated("Deprecated in Java", ReplaceWith("PixelFormat.OPAQUE", "android.graphics.PixelFormat")) - override fun getOpacity(): Int = PixelFormat.OPAQUE - - companion object { - private const val Y_OFFSET = 5 - private const val TEXT_SIZE = 50f - } -} diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 4f1d5c84246..4dd49cc3dca 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -45,6 +45,10 @@ #313B75 #8c8c8c + + + #3B3B3B + #3B3B3B #29ffffff diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 69816679830..3c80902ce09 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -56,6 +56,16 @@ #111111 #767676 + + #6B6B6B + #DBDBDB + @color/white + @color/high_emphasis_text + #666666 #FFFFFF diff --git a/app/src/test/java/com/nextcloud/talk/utils/CharacterAvatarUtilsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/CharacterAvatarUtilsTest.kt new file mode 100644 index 00000000000..0e43fe6bec3 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/CharacterAvatarUtilsTest.kt @@ -0,0 +1,115 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import com.nextcloud.talk.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class CharacterAvatarUtilsTest { + + private val guestLabel = "Guest" + + private fun characterOf(actorType: String?, actorId: String?, displayName: String?): String? = + (CharacterAvatarUtils.avatarFor(actorType, actorId, displayName, guestLabel) as? ActorAvatar.Character) + ?.character + + @Test + fun `named guest is drawn from the first character of their name`() { + assertEquals("A", characterOf("guests", "guest-hash", "alice")) + } + + @Test + fun `surrounding whitespace is ignored`() { + assertEquals("B", characterOf("guests", "guest-hash", " bob ")) + } + + @Test + fun `guest without a name gets the person icon`() { + assertSame(ActorAvatar.PersonIcon, CharacterAvatarUtils.guestAvatar(null, guestLabel)) + assertSame(ActorAvatar.PersonIcon, CharacterAvatarUtils.guestAvatar("", guestLabel)) + assertSame(ActorAvatar.PersonIcon, CharacterAvatarUtils.guestAvatar(" ", guestLabel)) + } + + @Test + fun `guest labelled with the placeholder name gets the person icon`() { + assertSame(ActorAvatar.PersonIcon, CharacterAvatarUtils.guestAvatar(guestLabel, guestLabel)) + assertSame(ActorAvatar.PersonIcon, CharacterAvatarUtils.guestAvatar(" Guest ", guestLabel)) + } + + @Test + fun `any name counts as custom when no placeholder name is given`() { + val avatar = CharacterAvatarUtils.guestAvatar("Guest", null) + assertEquals("G", (avatar as ActorAvatar.Character).character) + } + + @Test + fun `first character outside the basic plane stays whole`() { + val name = "😀 party" + assertEquals(name.substring(0, 2), characterOf("guests", "guest-hash", name)) + } + + @Test + fun `both the plural and the singular spelling of an actor type are understood`() { + assertEquals("T", characterOf("guests", "guest-hash", "Test")) + assertEquals("T", characterOf("guest", "guest-hash", "Test")) + assertEquals("T", characterOf("emails", "email-hash", "Test")) + assertEquals("T", characterOf("email", "email-hash", "Test")) + assertEquals(">_", characterOf("bots", "weather-bot", "Weather")) + assertEquals(">_", characterOf("bot", "weather-bot", "Weather")) + } + + @Test + fun `bots are drawn as a shell prompt in the bot colours`() { + val avatar = CharacterAvatarUtils.botAvatar("weather-bot") as ActorAvatar.Character + assertEquals(CharacterAvatarUtils.BOT_CHARACTER, avatar.character) + assertEquals(R.color.character_avatar_background_bot, avatar.backgroundColor) + assertEquals(R.color.character_avatar_text_bot, avatar.textColor) + } + + @Test + fun `guests are drawn in the guest colours`() { + val avatar = CharacterAvatarUtils.guestAvatar("alice", guestLabel) as ActorAvatar.Character + assertEquals(R.color.character_avatar_background_guest, avatar.backgroundColor) + assertEquals(R.color.character_avatar_text_guest, avatar.textColor) + } + + @Test + fun `bots and guests do not share a colour pair`() { + val bot = CharacterAvatarUtils.botAvatar("weather-bot") as ActorAvatar.Character + val guest = CharacterAvatarUtils.guestAvatar("alice", guestLabel) as ActorAvatar.Character + assertNotEquals(bot.backgroundColor, guest.backgroundColor) + assertNotEquals(bot.textColor, guest.textColor) + } + + @Test + fun `bots shipping their own avatar are drawn with the app icon`() { + assertSame(ActorAvatar.AppIcon, CharacterAvatarUtils.botAvatar("changelog")) + assertSame(ActorAvatar.AppIcon, CharacterAvatarUtils.botAvatar("sample")) + assertSame(ActorAvatar.AppIcon, CharacterAvatarUtils.avatarFor("bots", "changelog", "Changelog", guestLabel)) + } + + @Test + fun `actors with an avatar on the server are left to the server`() { + assertNull(CharacterAvatarUtils.avatarFor("users", "alice", "Alice", guestLabel)) + assertNull(CharacterAvatarUtils.avatarFor("federated_users", "alice@cloud", "Alice", guestLabel)) + assertNull(CharacterAvatarUtils.avatarFor(null, null, "Test", guestLabel)) + } + + @Test + fun `custom name detection matches the placeholder name exactly`() { + assertTrue(CharacterAvatarUtils.hasCustomName("Alice", guestLabel)) + assertTrue(CharacterAvatarUtils.hasCustomName("Guest of honour", guestLabel)) + assertFalse(CharacterAvatarUtils.hasCustomName("Guest", guestLabel)) + assertFalse(CharacterAvatarUtils.hasCustomName(null, guestLabel)) + } +}