Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.conversationlist.ui

import androidx.annotation.DrawableRes
import com.nextcloud.talk.R
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.conversations.ConversationEnums
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.SpreedFeatures

internal sealed class AvatarContent {
/**
* [versioned] marks an immutable avatar URL: the avatarVersion parameter is the invalidation
* token, so the image may be cached without expiry - a new version changes the URL. Only
* conversation avatars are versioned; a one-to-one room's avatar is the peer's user avatar,
* which is outside the version scheme and must revalidate via the response cache headers.
*/
data class Url(val url: String, val versioned: Boolean) : AvatarContent()
data class Res(@param:DrawableRes val resId: Int) : AvatarContent()
object System : AvatarContent()
object NoteToSelf : AvatarContent()
}

/**
* Resolves what to show as a conversation's avatar.
*
* On servers with the avatar capability (Talk 17+) rooms use the conversation-avatar endpoint.
* For everything but one-to-one rooms the URL carries the avatarVersion as invalidation token,
* so those avatars are immutable content refreshed solely by version changes from the room list
* sync. One-to-one rooms get the peer's user avatar from the same endpoint (which also handles
* federation proxying), but their avatarVersion is a server-side constant that never changes
* when the peer updates their avatar - their URL therefore stays unversioned and relies on the
* default loader's header-driven revalidation. Servers without the capability fall back to the
* unversioned user-avatar endpoint for one-to-one rooms and to themed default icons for group
* and public rooms, whose endpoint does not exist there.
*/
internal fun buildAvatarContent(model: ConversationModel, currentUser: User, isDark: Boolean): AvatarContent {
val hasConversationAvatars = currentUser.hasSpreedFeatureCapability(SpreedFeatures.AVATAR.value)
val avatarVersion = model.avatarVersion.takeIf { it.isNotEmpty() }

return when {
model.objectType == ConversationEnums.ObjectType.SHARE_PASSWORD ->
AvatarContent.Res(R.drawable.ic_circular_lock)

model.objectType == ConversationEnums.ObjectType.FILE ->
AvatarContent.Res(R.drawable.ic_avatar_document)

model.type == ConversationEnums.ConversationType.ROOM_SYSTEM ->
AvatarContent.System

model.type == ConversationEnums.ConversationType.NOTE_TO_SELF ->
AvatarContent.NoteToSelf

hasConversationAvatars && model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL ->
AvatarContent.Url(
ApiUtils.getUrlForConversationAvatarWithVersion(
1,
currentUser.baseUrl,
model.token,
isDark,
null
),
versioned = false
)

hasConversationAvatars && avatarVersion != null ->
AvatarContent.Url(
ApiUtils.getUrlForConversationAvatarWithVersion(
1,
currentUser.baseUrl,
model.token,
isDark,
avatarVersion
),
versioned = true
)

model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL ||
model.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE ->
AvatarContent.Url(
ApiUtils.getUrlForAvatar(currentUser.baseUrl, model.name, false, isDark),
versioned = false
)

model.type == ConversationEnums.ConversationType.ROOM_GROUP_CALL ->
AvatarContent.Res(R.drawable.ic_circular_group)

model.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL ->
AvatarContent.Res(R.drawable.ic_circular_link)

else ->
AvatarContent.Res(R.drawable.account_circle_96dp)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ 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.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
Expand All @@ -61,6 +63,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import coil.compose.AsyncImage
import coil.memory.MemoryCache
import coil.imageLoader
import android.graphics.drawable.BitmapDrawable
import coil.request.ImageRequest
import com.nextcloud.talk.R
import com.nextcloud.talk.chat.data.model.ChatMessage
Expand All @@ -76,6 +81,7 @@ import com.nextcloud.talk.models.json.conversations.ConversationEnums
import com.nextcloud.talk.models.json.participants.Participant
import com.nextcloud.talk.ui.StatusDrawable
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.AvatarImageLoader
import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
import com.nextcloud.talk.utils.DisplayUtils
import com.nextcloud.talk.utils.SpreedFeatures
Expand All @@ -92,44 +98,6 @@ private const val UNREAD_THRESHOLD = 1000
private const val UNREAD_BUBBLE_STROKE_DP = 1.5f
private const val MILLIS_PER_SECOND = 1_000L

private sealed class AvatarContent {
data class Url(val url: String) : AvatarContent()
data class Res(@param:DrawableRes val resId: Int) : AvatarContent()
object System : AvatarContent()
object NoteToSelf : AvatarContent()
}

private fun buildAvatarContent(model: ConversationModel, currentUser: User, isDark: Boolean): AvatarContent {
val avatarVersion = model.avatarVersion.takeIf { it.isNotEmpty() }
return when {
model.objectType == ConversationEnums.ObjectType.SHARE_PASSWORD ->
AvatarContent.Res(R.drawable.ic_circular_lock)

model.objectType == ConversationEnums.ObjectType.FILE ->
AvatarContent.Res(R.drawable.ic_avatar_document)

model.type == ConversationEnums.ConversationType.ROOM_SYSTEM ->
AvatarContent.System

model.type == ConversationEnums.ConversationType.NOTE_TO_SELF ->
AvatarContent.NoteToSelf

model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL ->
AvatarContent.Url(ApiUtils.getUrlForAvatar(currentUser.baseUrl, model.name, false, isDark))

else ->
AvatarContent.Url(
ApiUtils.getUrlForConversationAvatarWithVersion(
1,
currentUser.baseUrl,
model.token,
isDark,
avatarVersion
)
)
}
}

/** Groups the tap callbacks for [ConversationListItem] to keep the parameter count low. */
data class ConversationListItemCallbacks(val onClick: () -> Unit, val onLongClick: () -> Unit)

Expand Down Expand Up @@ -277,20 +245,37 @@ private fun ConversationAvatarImage(model: ConversationModel, currentUser: User,
if (isInPreview) {
Box(modifier = modifier.background(Color.LightGray))
} else {
val imageLoader = remember(avatarContent.versioned) {
if (avatarContent.versioned) AvatarImageLoader.get(context) else context.imageLoader
}
val aliasKey = remember(currentUser.id, model.token, isDark) {
MemoryCache.Key("avatar-${currentUser.id}-${model.token}-" + if (isDark) "dark" else "light")
}
val request = remember(avatarContent.url, credentials) {
ImageRequest.Builder(context)
.data(avatarContent.url)
.diskCacheKey("${avatarContent.url}#v2")
.addHeader("Authorization", credentials)
.crossfade(true)
.listener(
onSuccess = { _, result ->
(result.drawable as? BitmapDrawable)?.bitmap?.let { bitmap ->
imageLoader.memoryCache?.set(aliasKey, MemoryCache.Value(bitmap))
}
}
)
.build()
}
val lastShownAvatar = remember(request) {
imageLoader.memoryCache?.get(aliasKey)?.bitmap?.let { BitmapPainter(it.asImageBitmap()) }
}
AsyncImage(
model = request,
imageLoader = imageLoader,
contentDescription = stringResource(R.string.avatar),
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.account_circle_96dp),
error = painterResource(R.drawable.account_circle_96dp),
placeholder = lastShownAvatar,
error = lastShownAvatar ?: painterResource(R.drawable.account_circle_96dp),
modifier = modifier
)
}
Expand Down
39 changes: 39 additions & 0 deletions app/src/main/java/com/nextcloud/talk/utils/AvatarImageLoader.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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 android.content.Context
import coil.ImageLoader
import coil.imageLoader

/**
* Image loader for versioned conversation-avatar URLs, which are immutable content: the
* avatarVersion URL parameter is the invalidation token, and a new version changes the URL and
* forces the fetch. The server marks avatar responses as cacheable for one day only (private,
* max-age=86400, immutable); ignoring the cache headers removes that expiry for URLs that cannot
* change, so cold starts and offline serve avatars straight from the disk cache without a
* network round-trip.
*
* Must only be used for URLs carrying a version parameter. Unversioned avatar URLs - notably a
* one-to-one room's avatar, which is the peer's user avatar and outside the version scheme -
* need the default loader's header-driven revalidation to ever pick up changes.
*
* Derived from the default loader, so memory and disk caches are shared between both.
*/
object AvatarImageLoader {

@Volatile
private var instance: ImageLoader? = null

fun get(context: Context): ImageLoader =
instance ?: synchronized(this) {
instance ?: context.imageLoader.newBuilder()
.respectCacheHeaders(false)
.build()
.also { instance = it }
}
}
Loading
Loading