diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/attachment/media/AvatarManager.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/attachment/media/AvatarManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..4975533b17011b67795ca3dff63e8135b792d5fc --- /dev/null +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/attachment/media/AvatarManager.kt @@ -0,0 +1,75 @@ +/* + * Briar Desktop + * Copyright (C) 2021-2022 The Briar Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package org.briarproject.briar.desktop.attachment.media + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.res.loadImageBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.swing.Swing +import kotlinx.coroutines.withContext +import org.briarproject.bramble.api.sync.MessageId +import org.briarproject.briar.api.attachment.AttachmentHeader +import org.briarproject.briar.api.attachment.AttachmentReader +import org.briarproject.briar.api.identity.AuthorInfo +import org.briarproject.briar.desktop.threading.BriarExecutors +import org.briarproject.briar.desktop.ui.LocalAvatarManager +import javax.inject.Inject + +class AvatarManager @Inject constructor( + private val attachmentReader: AttachmentReader, + private val executors: BriarExecutors, +) { + + // access only on Dispatchers.Swing + // TODO we may want to monitor cache size and evict cache entries again + private val cache = HashMap<MessageId, ImageBitmap>() + + suspend fun loadAvatar( + attachmentHeader: AttachmentHeader, + ): ImageBitmap = withContext(Dispatchers.Swing) { + val imageBitmap = cache[attachmentHeader.messageId] + if (imageBitmap != null) return@withContext imageBitmap + executors.runOnDbThreadWithTransaction(true) { txn -> + attachmentReader.getAttachment(txn, attachmentHeader).stream.use { inputStream -> + loadImageBitmap(inputStream) + } + }.also { cache[attachmentHeader.messageId] = it } + } +} + +/** + * Produces a state with an [ImageBitmap] representing the avatar of the given [authorInfo]. + * While loading, the value of the state is null. + * When no state, but null is returned, the given [authorInfo] has no avatar. + */ +@Composable +fun AvatarProducer(authorInfo: AuthorInfo): State<ImageBitmap?>? { + val avatarHeader = authorInfo.avatarHeader + return if (avatarHeader == null) { + null + } else { + val avatarManager = checkNotNull(LocalAvatarManager.current) + produceState<ImageBitmap?>(null, avatarHeader.messageId) { + value = avatarManager.loadAvatar(avatarHeader) + } + } +} diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ProfileCircle.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ProfileCircle.kt index b24a5653c82efa111b5beff9711809dde1f17495..fcbe3a672259a6bee04b945f59d750e840bf03f1 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ProfileCircle.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/contact/ProfileCircle.kt @@ -21,6 +21,7 @@ package org.briarproject.briar.desktop.contact import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.MaterialTheme @@ -32,6 +33,9 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.briarproject.bramble.api.identity.Author +import org.briarproject.briar.api.identity.AuthorInfo +import org.briarproject.briar.desktop.attachment.media.AvatarProducer import org.briarproject.briar.desktop.theme.outline import org.briarproject.briar.desktop.utils.PreviewUtils.preview @@ -62,6 +66,21 @@ fun ProfileCircle(size: Dp, contactItem: ContactItem) { ProfileCircle(size, contactItem.avatar) } +/** + * Display the given [Author]'s avatar, or if it doesn't exist, the [Identicon] + * as a profile image within a circle. + * + * @param size the size of the circle. In order to avoid aliasing effects, pass a multiple + * of 9 here. That helps as the image is based on a 9x9 square grid. + */ +@Composable +fun ProfileCircle(size: Dp, author: Author, authorInfo: AuthorInfo) { + val avatar = AvatarProducer(authorInfo) + avatar?.let { imageBitmapState -> + ProfileCircle(size, imageBitmapState.value) + } ?: ProfileCircle(size, author.id.bytes) +} + /** * Display an [Identicon] as a profile image within a circle based on a user's author id. * @@ -81,12 +100,17 @@ fun ProfileCircle(size: Dp, input: ByteArray) { * @param size the size of the circle. */ @Composable -fun ProfileCircle(size: Dp, avatar: ImageBitmap) { - Image( +fun ProfileCircle(size: Dp, avatar: ImageBitmap?) { + val modifier = Modifier.size(size).clip(CircleShape) + .border(1.dp, MaterialTheme.colors.outline, CircleShape) + // If avatar is null, it should still be loading, so show empty circle + if (avatar == null) Spacer( + modifier = modifier, + ) else Image( bitmap = avatar, contentDescription = null, contentScale = ContentScale.FillBounds, - modifier = Modifier.size(size).clip(CircleShape).border(1.dp, MaterialTheme.colors.outline, CircleShape), + modifier = modifier, ) } diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItemView.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItemView.kt index 4535d1536c414bc491cd0868af4b4de28e1f7ad4..ea46bb752003c4164562471540944f7f1636ab96 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItemView.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/forums/ThreadItemView.kt @@ -128,8 +128,7 @@ fun ThreadItemContentComposable( horizontalArrangement = spacedBy(8.dp), verticalAlignment = CenterVertically, ) { - // TODO load and cache profile images, if available - ProfileCircle(20.dp, item.author.id.bytes) + ProfileCircle(27.dp, item.author, item.authorInfo) Text( modifier = Modifier.weight(1f, fill = false), text = item.authorName, diff --git a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt index 9060f6b89e33b50dedca9f3e57f9512a0e4e5113..9d3ef88e85678f05d6e2f7cca60100cba5d417bc 100644 --- a/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt +++ b/briar-desktop/src/main/kotlin/org/briarproject/briar/desktop/ui/BriarUi.kt @@ -44,6 +44,7 @@ import org.briarproject.bramble.api.event.EventListener import org.briarproject.bramble.api.lifecycle.LifecycleManager import org.briarproject.bramble.api.lifecycle.LifecycleManager.LifecycleState.RUNNING import org.briarproject.bramble.api.lifecycle.event.LifecycleEvent +import org.briarproject.briar.desktop.attachment.media.AvatarManager import org.briarproject.briar.desktop.expiration.ExpirationBanner import org.briarproject.briar.desktop.login.ErrorScreen import org.briarproject.briar.desktop.login.StartupScreen @@ -85,6 +86,7 @@ interface BriarUi { val LocalWindowScope = staticCompositionLocalOf<FrameWindowScope?> { null } val LocalViewModelProvider = staticCompositionLocalOf<ViewModelProvider?> { null } +val LocalAvatarManager = staticCompositionLocalOf<AvatarManager?> { null } val LocalConfiguration = staticCompositionLocalOf<Configuration?> { null } @Immutable @@ -95,6 +97,7 @@ constructor( private val lifecycleManager: LifecycleManager, private val eventBus: EventBus, private val viewModelProvider: ViewModelProvider, + private val avatarManager: AvatarManager, private val configuration: Configuration, private val visualNotificationProvider: VisualNotificationProvider, private val soundNotificationProvider: SoundNotificationProvider, @@ -204,6 +207,7 @@ constructor( LocalWindowScope provides this, LocalWindowFocusState provides focusState, LocalViewModelProvider provides viewModelProvider, + LocalAvatarManager provides avatarManager, LocalConfiguration provides configuration, LocalLocalization provides platformLocalization, ) {