From c07202944debb87ab7d43abac6c469ab87c9e0ee Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Nov 2025 18:52:40 +0100 Subject: [PATCH 1/6] Adjusted subscription cleanup to avoid mutating the watcher map while iterating it, preventing the ConcurrentModificationException when accounts switch --- .../follows/AccountFollowsLoaderSubAssembler.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 36fc9474c..87c91c143 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -186,11 +186,11 @@ class AccountFollowsLoaderSubAssembler( } // removes accounts that are not being subscribed anymore. - accountUpdatesJobMap.forEach { - if (it.key !in uniqueSubscribedAccounts.keys) { - endWatcher(it.key) - } - } + // Cancel watchers for accounts no longer observed using a snapshot to avoid CME + accountUpdatesJobMap.keys + .toList() + .filter { it !in uniqueSubscribedAccounts.keys } + .forEach { endWatcher(it) } } private val accountUpdatesJobMap = mutableMapOf() From a82d6565fa58ea7ad82d5964718b2c5af851a3cf Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Nov 2025 18:55:58 +0100 Subject: [PATCH 2/6] Hardened EOSEAccountFast against concurrent access so callers no longer iterate over live mutable maps --- .../amethyst/service/relays/EOSE.kt | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index 07ebfdd11..dfa5b2db4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -125,36 +125,46 @@ class EOSEAccountFast( cacheSize: Int = 20, ) { private val users: LruCache = LruCache(cacheSize) + private val lock = Any() fun addOrUpdate( user: T, relayUrl: NormalizedRelayUrl, time: Long, ) { - val relayList = users[user] - if (relayList == null) { - val newList = EOSERelayList() - users.put(user, newList) + synchronized(lock) { + val relayList = users[user] + if (relayList == null) { + val newList = EOSERelayList() + users.put(user, newList) - newList.addOrUpdate(relayUrl, time) - } else { - relayList.addOrUpdate(relayUrl, time) + newList.addOrUpdate(relayUrl, time) + } else { + relayList.addOrUpdate(relayUrl, time) + } } } fun removeEveryoneBut(list: Set) { - users.snapshot().forEach { - if (it.key !in list) { - users.remove(it.key) + synchronized(lock) { + users.snapshot().forEach { + if (it.key !in list) { + users.remove(it.key) + } } } } fun removeDataFor(user: T) { - users.remove(user) + synchronized(lock) { + users.remove(user) + } } - fun since(key: T) = users[key]?.relayList + fun since(key: T): SincePerRelayMap? = + synchronized(lock) { + users[key]?.relayList?.toMutableMap() + } fun newEose( user: T, From 545cd2ff6df93a57ba6e6ea7a4600243657ab9ad Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Nov 2025 19:02:53 +0100 Subject: [PATCH 3/6] synchronize all cache mutations and supply sinceRelaySet so callers get snapshot copies rework groupByRelayPresence to build relay snapshots via sinceRelaySet, filtering with immutable lists to prevent ConcurrentModificationException when relays update mid-iteration --- .../user/watchers/UserReportsSubAssembler.kt | 27 +++++++++++++------ .../amethyst/service/relays/EOSE.kt | 5 ++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt index 651077a71..157187c06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -90,19 +90,30 @@ class UserReportsSubAssembler( users: Iterable, eoseCache: EOSEAccountFast, inRelays: Set, - ): Collection> = - users - .groupBy { - eoseCache - .since(it) - ?.keys - ?.intersect(inRelays) - ?.hashCode() + ): Collection> { + if (users.none()) return emptyList() + + val relaySnapshot = inRelays.toSet() + + return users + .groupBy { user -> + val relaysForUser = eoseCache.sinceRelaySet(user) + if (relaysForUser.isNullOrEmpty() || relaySnapshot.isEmpty()) { + null + } else { + val intersection = relaysForUser.filter { it in relaySnapshot }.sorted() + if (intersection.isEmpty()) { + null + } else { + intersection.hashCode() + } + } }.values .map { // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again it.sortedBy { it.pubkeyHex } } + } fun findMinimumEOSEsForUsers( users: List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index dfa5b2db4..9229450da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -166,6 +166,11 @@ class EOSEAccountFast( users[key]?.relayList?.toMutableMap() } + fun sinceRelaySet(key: T): Set? = + synchronized(lock) { + users[key]?.relayList?.keys?.toSet() + } + fun newEose( user: T, relayUrl: NormalizedRelayUrl, From 409b3b43f71e5ac52751e3f3361d0fa8ce5c5e5d Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Nov 2025 19:06:18 +0100 Subject: [PATCH 4/6] optimise imports --- .../amethyst/model/nip51Lists/peopleList/FollowListsState.kt | 1 - .../main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt | 1 - .../amethyst/ui/screen/loggedIn/lists/display/PeopleListView.kt | 1 - .../amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt | 1 - 4 files changed, 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt index 3e3f76bd3..788272c28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt @@ -46,7 +46,6 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update -import kotlin.collections.map /** * Maintains several stateflows for each step in processing PeopleLists diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 1f053cb2f..3fc5d2d3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing -import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListView.kt index d8069b5a6..edb1092f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListView.kt @@ -43,7 +43,6 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserComposeNoAction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.PeopleListItem import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.HalfHalfHorzModifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt index f97b712e0..dd6b32788 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Lock From 34aca458ed010e4ada10ded7238669733c93bbb5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 6 Nov 2025 13:49:15 -0500 Subject: [PATCH 5/6] migrates to njump.to --- .../com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index a8f43af94..3ca426f39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -114,7 +114,7 @@ val externalLinkForUser = { user: User -> } val njumpLink = { nip19BechAddress: String -> - "https://njump.me/$nip19BechAddress" + "https://njump.to/$nip19BechAddress" } val externalLinkForNote = { note: Note -> From 6727bd0430d236b511881924377f76ee7fd52cdd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 6 Nov 2025 14:15:59 -0500 Subject: [PATCH 6/6] Improves padding of the ListItem interfaces --- .../amethyst/ui/layouts/ChatHeaderLayout.kt | 112 ++- .../ui/layouts/listItem/SlimListItemLayout.kt | 703 ++++++++++++++++++ .../amethyst/ui/note/UserCompose.kt | 35 +- .../amethyst/ui/note/ZapNoteCompose.kt | 139 +++- .../chats/rooms/ChatroomHeaderCompose.kt | 38 +- .../loggedIn/profile/zaps/LnZapFeedView.kt | 9 +- .../vitorpamplona/amethyst/ui/theme/Theme.kt | 24 +- 7 files changed, 919 insertions(+), 141 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index 204e28356..68281470f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewItemsBubble @@ -47,57 +48,86 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.utils.TimeUtils @Composable @Preview fun ChannelNamePreview() { - Column { - ChatHeaderLayout( - channelPicture = { - Image( - painter = painterRes(R.drawable.github, 1), - contentDescription = stringRes(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - ) - }, - firstRow = { - Text("This is my author", Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - }, - secondRow = { - Text("This is a message from this person", Modifier.weight(1f)) - NewItemsBubble() - }, - onClick = {}, - ) - - HorizontalDivider(thickness = DividerThickness) - - ListItem( - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + ThemeComparisonColumn { + Column { + ChatHeaderLayout( + channelPicture = { + Image( + painter = painterRes(R.drawable.github, 1), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + ) + }, + firstRow = { Text("This is my author", Modifier.weight(1f)) TimeAgo(TimeUtils.now()) - } - }, - supportingContent = { - Row { + }, + secondRow = { Text("This is a message from this person", Modifier.weight(1f)) + Spacer(modifier = Height4dpModifier) NewItemsBubble() - } - }, - leadingContent = { - Image( - painter = painterRes(R.drawable.github, 2), - contentDescription = stringRes(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Size55Modifier, - ) - }, - ) + }, + onClick = {}, + ) - HorizontalDivider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) + + ListItem( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row { + Text("This is a message from this person", Modifier.weight(1f)) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + + SlimListItem( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is a message from this person", Modifier.weight(1f)) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt new file mode 100644 index 000000000..89dc89d79 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt @@ -0,0 +1,703 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.layouts.listItem + +import android.view.Surface +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.MultiContentMeasurePolicy +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout +import com.vitorpamplona.amethyst.ui.layouts.listItem.ListTokens.ListItemContainerElevation +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewItemsBubble +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.math.max + +/** + * This is a copy of Material3's ListItemLayout.kt file, with the only change being the padding change from 16.dp to 10.dp + */ + +@Composable +@Preview +fun ChannelNamePreview() { + ThemeComparisonColumn { + Column { + ChatHeaderLayout( + channelPicture = { + Image( + painter = painterRes(R.drawable.github, 1), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + ) + }, + firstRow = { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + }, + secondRow = { + Text("This is a message from this person", Modifier.weight(1f)) + NewItemsBubble() + }, + onClick = {}, + ) + + HorizontalDivider(thickness = DividerThickness) + + SlimListItem( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is a message from this person", Modifier.weight(1f)) + Spacer(modifier = Height4dpModifier) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + + ListItem( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is a message from this person", Modifier.weight(1f)) + Spacer(modifier = Height4dpModifier) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + } + } +} + +@Composable +fun SlimListItem( + headlineContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + overlineContent: @Composable (() -> Unit)? = null, + supportingContent: @Composable (() -> Unit)? = null, + leadingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), + tonalElevation: Dp = ListItemContainerElevation, + shadowElevation: Dp = ListItemContainerElevation, +) { + val decoratedHeadlineContent: @Composable () -> Unit = { + ProvideTextStyleFromToken( + colors.headlineColor, + MaterialTheme.typography.bodyLarge, + headlineContent, + ) + } + val decoratedSupportingContent: @Composable (() -> Unit)? = + supportingContent?.let { + { + Box(SupportingContentTopPadding) { + ProvideTextStyleFromToken( + colors.supportingTextColor, + MaterialTheme.typography.bodyMedium, + it, + ) + } + } + } + val decoratedOverlineContent: @Composable (() -> Unit)? = + overlineContent?.let { + { + ProvideTextStyleFromToken( + colors.overlineColor, + MaterialTheme.typography.labelSmall, + it, + ) + } + } + val decoratedLeadingContent: @Composable (() -> Unit)? = + leadingContent?.let { + { + Box(LeadingContentEndPadding) { + CompositionLocalProvider( + LocalContentColor provides colors.leadingIconColor, + content = it, + ) + } + } + } + val decoratedTrailingContent: @Composable (() -> Unit)? = + trailingContent?.let { + { + Box(TrailingContentStartPadding) { + ProvideTextStyleFromToken( + colors.trailingIconColor, + MaterialTheme.typography.labelSmall, + content = it, + ) + } + } + } + + Surface( + modifier = Modifier.semantics(mergeDescendants = true) {}.then(modifier), + shape = ListItemDefaults.shape, + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, + ) { + SlimListItemLayout( + headline = decoratedHeadlineContent, + overline = decoratedOverlineContent, + supporting = decoratedSupportingContent, + leading = decoratedLeadingContent, + trailing = decoratedTrailingContent, + ) + } +} + +@Composable +private fun SlimListItemLayout( + leading: @Composable (() -> Unit)?, + trailing: @Composable (() -> Unit)?, + headline: @Composable () -> Unit, + overline: @Composable (() -> Unit)?, + supporting: @Composable (() -> Unit)?, +) { + val measurePolicy = remember { ListItemMeasurePolicy() } + Layout( + contents = + listOf(headline, overline ?: {}, supporting ?: {}, leading ?: {}, trailing ?: {}), + measurePolicy = measurePolicy, + ) +} + +private class ListItemMeasurePolicy : MultiContentMeasurePolicy { + override fun MeasureScope.measure( + measurables: List>, + constraints: Constraints, + ): MeasureResult { + val ( + headlineMeasurable, + overlineMeasurable, + supportingMeasurable, + leadingMeasurable, + trailingMeasurable, + ) = + measurables + var currentTotalWidth = 0 + + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val startPadding = ListItemStartPadding + val endPadding = ListItemEndPadding + val horizontalPadding = (startPadding + endPadding).roundToPx() + + // ListItem layout has a cycle in its dependencies which we use + // intrinsic measurements to break: + // 1. Intrinsic leading/trailing width + // 2. Intrinsic supporting height + // 3. Intrinsic vertical padding + // 4. Actual leading/trailing measurement + // 5. Actual supporting measurement + // 6. Actual vertical padding + val intrinsicLeadingWidth = + leadingMeasurable.firstOrNull()?.minIntrinsicWidth(constraints.maxHeight) ?: 0 + val intrinsicTrailingWidth = + trailingMeasurable.firstOrNull()?.minIntrinsicWidth(constraints.maxHeight) ?: 0 + val intrinsicSupportingWidthConstraint = + looseConstraints.maxWidth.subtractConstraintSafely( + intrinsicLeadingWidth + intrinsicTrailingWidth + horizontalPadding, + ) + val intrinsicSupportingHeight = + supportingMeasurable + .firstOrNull() + ?.minIntrinsicHeight(intrinsicSupportingWidthConstraint) ?: 0 + val intrinsicIsSupportingMultiline = + isSupportingMultilineHeuristic(intrinsicSupportingHeight) + val intrinsicListItemType = + ListItemType( + hasOverline = overlineMeasurable.firstOrNull() != null, + hasSupporting = supportingMeasurable.firstOrNull() != null, + isSupportingMultiline = intrinsicIsSupportingMultiline, + ) + val intrinsicVerticalPadding = (verticalPadding(intrinsicListItemType) * 2).roundToPx() + + val paddedLooseConstraints = + looseConstraints.offset( + horizontal = -horizontalPadding, + vertical = -intrinsicVerticalPadding, + ) + + val leadingPlaceable = leadingMeasurable.firstOrNull()?.measure(paddedLooseConstraints) + currentTotalWidth += leadingPlaceable.widthOrZero + + val trailingPlaceable = + trailingMeasurable + .firstOrNull() + ?.measure(paddedLooseConstraints.offset(horizontal = -currentTotalWidth)) + currentTotalWidth += trailingPlaceable.widthOrZero + + var currentTotalHeight = 0 + + val headlinePlaceable = + headlineMeasurable + .firstOrNull() + ?.measure(paddedLooseConstraints.offset(horizontal = -currentTotalWidth)) + currentTotalHeight += headlinePlaceable.heightOrZero + + val supportingPlaceable = + supportingMeasurable + .firstOrNull() + ?.measure( + paddedLooseConstraints.offset( + horizontal = -currentTotalWidth, + vertical = -currentTotalHeight, + ), + ) + currentTotalHeight += supportingPlaceable.heightOrZero + val isSupportingMultiline = + supportingPlaceable != null && + (supportingPlaceable[FirstBaseline] != supportingPlaceable[LastBaseline]) + + val overlinePlaceable = + overlineMeasurable + .firstOrNull() + ?.measure( + paddedLooseConstraints.offset( + horizontal = -currentTotalWidth, + vertical = -currentTotalHeight, + ), + ) + + val listItemType = + ListItemType( + hasOverline = overlinePlaceable != null, + hasSupporting = supportingPlaceable != null, + isSupportingMultiline = isSupportingMultiline, + ) + val topPadding = verticalPadding(listItemType) + val verticalPadding = topPadding * 2 + + val width = + calculateWidth( + leadingWidth = leadingPlaceable.widthOrZero, + trailingWidth = trailingPlaceable.widthOrZero, + headlineWidth = headlinePlaceable.widthOrZero, + overlineWidth = overlinePlaceable.widthOrZero, + supportingWidth = supportingPlaceable.widthOrZero, + horizontalPadding = horizontalPadding, + constraints = constraints, + ) + val height = + calculateHeight( + leadingHeight = leadingPlaceable.heightOrZero, + trailingHeight = trailingPlaceable.heightOrZero, + headlineHeight = headlinePlaceable.heightOrZero, + overlineHeight = overlinePlaceable.heightOrZero, + supportingHeight = supportingPlaceable.heightOrZero, + listItemType = listItemType, + verticalPadding = verticalPadding.roundToPx(), + constraints = constraints, + ) + + return place( + width = width, + height = height, + leadingPlaceable = leadingPlaceable, + trailingPlaceable = trailingPlaceable, + headlinePlaceable = headlinePlaceable, + overlinePlaceable = overlinePlaceable, + supportingPlaceable = supportingPlaceable, + isThreeLine = listItemType == ListItemType.ThreeLine, + startPadding = startPadding.roundToPx(), + endPadding = endPadding.roundToPx(), + topPadding = topPadding.roundToPx(), + ) + } + + override fun IntrinsicMeasureScope.maxIntrinsicHeight( + measurables: List>, + width: Int, + ): Int = calculateIntrinsicHeight(measurables, width, IntrinsicMeasurable::maxIntrinsicHeight) + + override fun IntrinsicMeasureScope.maxIntrinsicWidth( + measurables: List>, + height: Int, + ): Int = calculateIntrinsicWidth(measurables, height, IntrinsicMeasurable::maxIntrinsicWidth) + + override fun IntrinsicMeasureScope.minIntrinsicHeight( + measurables: List>, + width: Int, + ): Int = calculateIntrinsicHeight(measurables, width, IntrinsicMeasurable::minIntrinsicHeight) + + override fun IntrinsicMeasureScope.minIntrinsicWidth( + measurables: List>, + height: Int, + ): Int = calculateIntrinsicWidth(measurables, height, IntrinsicMeasurable::minIntrinsicWidth) + + private fun IntrinsicMeasureScope.calculateIntrinsicWidth( + measurables: List>, + height: Int, + intrinsicMeasure: IntrinsicMeasurable.(height: Int) -> Int, + ): Int { + val ( + headlineMeasurable, + overlineMeasurable, + supportingMeasurable, + leadingMeasurable, + trailingMeasurable, + ) = + measurables + return calculateWidth( + leadingWidth = leadingMeasurable.firstOrNull()?.intrinsicMeasure(height) ?: 0, + trailingWidth = trailingMeasurable.firstOrNull()?.intrinsicMeasure(height) ?: 0, + headlineWidth = headlineMeasurable.firstOrNull()?.intrinsicMeasure(height) ?: 0, + overlineWidth = overlineMeasurable.firstOrNull()?.intrinsicMeasure(height) ?: 0, + supportingWidth = supportingMeasurable.firstOrNull()?.intrinsicMeasure(height) ?: 0, + horizontalPadding = (ListItemStartPadding + ListItemEndPadding).roundToPx(), + constraints = Constraints(), + ) + } + + private fun IntrinsicMeasureScope.calculateIntrinsicHeight( + measurables: List>, + width: Int, + intrinsicMeasure: IntrinsicMeasurable.(width: Int) -> Int, + ): Int { + val ( + headlineMeasurable, + overlineMeasurable, + supportingMeasurable, + leadingMeasurable, + trailingMeasurable, + ) = + measurables + + var remainingWidth = + width.subtractConstraintSafely((ListItemStartPadding + ListItemEndPadding).roundToPx()) + val leadingHeight = + leadingMeasurable.firstOrNull()?.let { + val height = it.intrinsicMeasure(remainingWidth) + remainingWidth = + remainingWidth.subtractConstraintSafely( + it.maxIntrinsicWidth(Constraints.Infinity), + ) + height + } ?: 0 + val trailingHeight = + trailingMeasurable.firstOrNull()?.let { + val height = it.intrinsicMeasure(remainingWidth) + remainingWidth = + remainingWidth.subtractConstraintSafely( + it.maxIntrinsicWidth(Constraints.Infinity), + ) + height + } ?: 0 + val overlineHeight = overlineMeasurable.firstOrNull()?.intrinsicMeasure(remainingWidth) ?: 0 + val headlineHeight = headlineMeasurable.firstOrNull()?.intrinsicMeasure(remainingWidth) ?: 0 + val supportingHeight = + supportingMeasurable.firstOrNull()?.intrinsicMeasure(remainingWidth) ?: 0 + val isSupportingMultiline = isSupportingMultilineHeuristic(supportingHeight) + val listItemType = + ListItemType( + hasOverline = overlineHeight > 0, + hasSupporting = supportingHeight > 0, + isSupportingMultiline = isSupportingMultiline, + ) + + return calculateHeight( + leadingHeight = leadingHeight, + trailingHeight = trailingHeight, + headlineHeight = headlineHeight, + overlineHeight = overlineHeight, + supportingHeight = supportingHeight, + listItemType = listItemType, + verticalPadding = (verticalPadding(listItemType) * 2).roundToPx(), + constraints = Constraints(), + ) + } +} + +private fun IntrinsicMeasureScope.calculateWidth( + leadingWidth: Int, + trailingWidth: Int, + headlineWidth: Int, + overlineWidth: Int, + supportingWidth: Int, + horizontalPadding: Int, + constraints: Constraints, +): Int { + if (constraints.hasBoundedWidth) { + return constraints.maxWidth + } + // Fallback behavior if width constraints are infinite + val mainContentWidth = maxOf(headlineWidth, overlineWidth, supportingWidth) + return horizontalPadding + leadingWidth + mainContentWidth + trailingWidth +} + +private fun IntrinsicMeasureScope.calculateHeight( + leadingHeight: Int, + trailingHeight: Int, + headlineHeight: Int, + overlineHeight: Int, + supportingHeight: Int, + listItemType: ListItemType, + verticalPadding: Int, + constraints: Constraints, +): Int { + val defaultMinHeight = + when (listItemType) { + ListItemType.OneLine -> ListTokens.ListItemOneLineContainerHeight + ListItemType.TwoLine -> ListTokens.ListItemTwoLineContainerHeight + else -> ListTokens.ListItemThreeLineContainerHeight + } + val minHeight = max(constraints.minHeight, defaultMinHeight.roundToPx()) + + val mainContentHeight = headlineHeight + overlineHeight + supportingHeight + + return max(minHeight, verticalPadding + maxOf(leadingHeight, mainContentHeight, trailingHeight)) + .coerceAtMost(constraints.maxHeight) +} + +private fun MeasureScope.place( + width: Int, + height: Int, + leadingPlaceable: Placeable?, + trailingPlaceable: Placeable?, + headlinePlaceable: Placeable?, + overlinePlaceable: Placeable?, + supportingPlaceable: Placeable?, + isThreeLine: Boolean, + startPadding: Int, + endPadding: Int, + topPadding: Int, +): MeasureResult = + layout(width, height) { + leadingPlaceable?.let { + it.placeRelative( + x = startPadding, + y = if (isThreeLine) topPadding else CenterVertically.align(it.height, height), + ) + } + + val mainContentX = startPadding + leadingPlaceable.widthOrZero + val mainContentY = + if (isThreeLine) { + topPadding + } else { + val totalHeight = + headlinePlaceable.heightOrZero + + overlinePlaceable.heightOrZero + + supportingPlaceable.heightOrZero + CenterVertically.align(totalHeight, height) + } + var currentY = mainContentY + + overlinePlaceable?.placeRelative(mainContentX, currentY) + currentY += overlinePlaceable.heightOrZero + + headlinePlaceable?.placeRelative(mainContentX, currentY) + currentY += headlinePlaceable.heightOrZero + + supportingPlaceable?.placeRelative(mainContentX, currentY) + + trailingPlaceable?.let { + it.placeRelative( + x = width - endPadding - it.width, + y = if (isThreeLine) topPadding else CenterVertically.align(it.height, height), + ) + } + } + +internal val Placeable?.widthOrZero: Int + get() = this?.width ?: 0 + +internal val Placeable?.heightOrZero: Int + get() = this?.height ?: 0 + +/** + * Subtracts one value from another, where both values represent constraints used in layout. + * + * Notably: + * - if [this] is [Constraints.Infinity], the result stays [Constraints.Infinity] + * - the result is coerced to be non-negative + */ +internal fun Int.subtractConstraintSafely(other: Int): Int { + if (this == Constraints.Infinity) { + return this + } + return (this - other).coerceAtLeast(0) +} + +// In the actual layout phase, we can query supporting baselines, +// but for an intrinsic measurement pass, we have to estimate. +private fun Density.isSupportingMultilineHeuristic(estimatedSupportingHeight: Int): Boolean = estimatedSupportingHeight > 30.sp.roundToPx() + +private fun verticalPadding(listItemType: ListItemType): Dp = + when (listItemType) { + ListItemType.ThreeLine -> ListItemThreeLineVerticalPadding + else -> ListItemVerticalPadding + } + +@JvmInline +private value class ListItemType private constructor( + private val lines: Int, +) : Comparable { + override operator fun compareTo(other: ListItemType) = lines.compareTo(other.lines) + + companion object { + /** One line list item */ + val OneLine = ListItemType(1) + + /** Two line list item */ + val TwoLine = ListItemType(2) + + /** Three line list item */ + val ThreeLine = ListItemType(3) + + internal operator fun invoke( + hasOverline: Boolean, + hasSupporting: Boolean, + isSupportingMultiline: Boolean, + ): ListItemType = + when { + (hasOverline && hasSupporting) || isSupportingMultiline -> ThreeLine + hasOverline || hasSupporting -> TwoLine + else -> OneLine + } + } +} + +@Composable +internal fun ProvideTextStyleFromToken( + contentColor: Color, + textStyle: TextStyle, + content: @Composable () -> Unit, +) { + val mergedStyle = LocalTextStyle.current.merge(textStyle) + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalTextStyle provides mergedStyle, + content = content, + ) +} + +object ListTokens { + val ListItemContainerElevation = 0.0.dp + val ListItemOneLineContainerHeight = 56.0.dp + val ListItemThreeLineContainerHeight = 88.0.dp + val ListItemTwoLineContainerHeight = 72.0.dp +} + +// Container related defaults +// TODO: Make sure these values stay up to date until replaced with tokens. +@VisibleForTesting internal val ListItemVerticalPadding = 8.dp + +@VisibleForTesting internal val ListItemThreeLineVerticalPadding = 12.dp + +@VisibleForTesting internal val ListItemStartPadding = 10.dp + +@VisibleForTesting internal val ListItemEndPadding = 10.dp + +// Icon related defaults. +// TODO: Make sure these values stay up to date until replaced with tokens. +@VisibleForTesting internal val LeadingContentEndPadding = Modifier.padding(end = 10.dp) + +// Icon related defaults. +// TODO: Make sure these values stay up to date until replaced with tokens. +@VisibleForTesting internal val SupportingContentTopPadding = Modifier.padding(top = 2.dp) + +// Trailing related defaults. +// TODO: Make sure these values stay up to date until replaced with tokens. +@VisibleForTesting internal val TrailingContentStartPadding = Modifier.padding(start = 10.dp) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 1f053cb2f..92e56cfe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing +import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -45,33 +46,31 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ShowUserButton import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.StdStartPadding @Composable fun UserCompose( baseUser: User, - modifier: Modifier = StdPadding, + modifier: Modifier = Modifier, accountViewModel: AccountViewModel, nav: INav, ) { - Row( + SlimListItem( modifier = modifier.clickable { nav.nav(routeFor(baseUser)) }, - verticalAlignment = Alignment.CenterVertically, - ) { - UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) - - Column(modifier = remember { StdStartPadding.weight(1f) }) { - Row(verticalAlignment = Alignment.CenterVertically) { - UsernameDisplay(baseUser, accountViewModel = accountViewModel) - } - + leadingContent = { + UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) + }, + headlineContent = { + UsernameDisplay(baseUser, accountViewModel = accountViewModel) + }, + supportingContent = { AboutDisplay(baseUser, accountViewModel) - } - - Row(modifier = StdStartPadding) { - UserActionOptions(baseUser, accountViewModel, nav) - } - } + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + UserActionOptions(baseUser, accountViewModel, nav) + } + }, + ) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index 1258ca406..44388b352 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,19 +37,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe +import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapReqResponse import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.StdStartPadding +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.Dispatchers @@ -63,11 +68,11 @@ fun ZapNoteCompose( ) { val baseNoteRequest by observeNote(baseReqResponse.zapRequest, accountViewModel) - var baseAuthor by remember { mutableStateOf(null) } + var baseAuthor by remember { mutableStateOf(baseReqResponse.zapRequest.author) } LaunchedEffect(baseNoteRequest) { - baseNoteRequest?.note?.let { - accountViewModel.decryptAmountMessage(it, baseReqResponse.zapEvent) { baseAuthor = it?.user } + accountViewModel.decryptAmountMessage(baseNoteRequest.note, baseReqResponse.zapEvent) { + baseAuthor = it?.user } } @@ -81,49 +86,95 @@ fun ZapNoteCompose( ), verticalArrangement = Arrangement.Center, ) { - baseAuthor?.let { RenderZapNote(it, baseReqResponse.zapEvent, nav, accountViewModel) } + baseAuthor?.let { RenderZapNoteSlim(it, baseReqResponse.zapEvent, accountViewModel, nav) } } } } +@Preview +@Composable +fun RenderZapNotePreview() { + val accountViewModel = mockAccountViewModel() + + val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + val note1: Note = LocalCache.getOrCreateNote("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b") + + ThemeComparisonColumn { + RenderZapNote( + user1, + note1, + accountViewModel, + EmptyNav, + ) + } +} + +@Preview +@Composable +fun RenderZapNoteSlimPreview() { + val accountViewModel = mockAccountViewModel() + + val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + val note1: Note = LocalCache.getOrCreateNote("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b") + + ThemeComparisonColumn { + RenderZapNoteSlim( + user1, + note1, + accountViewModel, + EmptyNav, + ) + } +} + @Composable private fun RenderZapNote( baseAuthor: User, zapNote: Note, - nav: INav, accountViewModel: AccountViewModel, + nav: INav, ) { - Row( - modifier = - remember { - Modifier.padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - ) - }, - verticalAlignment = Alignment.CenterVertically, - ) { - UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav) - - Column( - modifier = remember { StdStartPadding.weight(1f) }, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseAuthor, accountViewModel = accountViewModel) } - Row(verticalAlignment = Alignment.CenterVertically) { AboutDisplay(baseAuthor, accountViewModel) } - } - - Column( - modifier = StdStartPadding, - verticalArrangement = Arrangement.Center, - ) { + ListItem( + leadingContent = { + UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav) + }, + headlineContent = { + UsernameDisplay(baseAuthor, accountViewModel = accountViewModel) + }, + supportingContent = { ZapAmount(zapNote, accountViewModel) - } + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + UserActionOptions(baseAuthor, accountViewModel, nav) + } + }, + ) +} - Row(modifier = StdStartPadding) { - UserActionOptions(baseAuthor, accountViewModel, nav) - } - } +@Composable +private fun RenderZapNoteSlim( + baseAuthor: User, + zapNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + SlimListItem( + leadingContent = { + UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav) + }, + headlineContent = { + UsernameDisplay(baseAuthor, accountViewModel = accountViewModel) + }, + supportingContent = { + ZapAmount(zapNote, accountViewModel) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + UserActionOptions(baseAuthor, accountViewModel, nav) + } + }, + ) } @Composable @@ -154,6 +205,20 @@ private fun ZapAmount( } } +@Composable +fun AboutDisplayNoFormat( + baseAuthor: User, + accountViewModel: AccountViewModel, +) { + val aboutMe by observeUserAboutMe(baseAuthor, accountViewModel) + + Text( + aboutMe, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + @Composable fun AboutDisplay( baseAuthor: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 4dca81854..c323c467c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -20,13 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -36,20 +32,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -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.LocalContext import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -76,8 +66,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.Room import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier +import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.newItemBubbleModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -316,6 +308,7 @@ private fun UserRoomCompose( val lastReadTime by accountViewModel.account.loadLastReadFlow("Room/${room.hashCode()}").collectAsStateWithLifecycle() if ((lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { + Spacer(modifier = Height4dpModifier) NewItemsBubble() } }, @@ -412,6 +405,7 @@ fun ChannelName( } if (hasNewMessages) { + Spacer(modifier = Height4dpModifier) NewItemsBubble() } }, @@ -434,23 +428,5 @@ private fun TimeAgo(channelLastTime: Long?) { @Composable fun NewItemsBubble() { - Box( - modifier = - Modifier - .padding(start = 3.dp) - .width(10.dp) - .height(10.dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colorScheme.primary), - contentAlignment = Alignment.Center, - ) { - Text( - "", - color = Color.White, - textAlign = TextAlign.Center, - fontSize = 12.sp, - maxLines = 1, - modifier = Modifier.wrapContentHeight().align(Alignment.Center), - ) - } + Box(MaterialTheme.colorScheme.newItemBubbleModifier) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt index acd98d7cb..b82dc987e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt @@ -21,15 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -82,11 +79,7 @@ private fun LnZapFeedLoaded( ) { itemsIndexed(items, key = { _, item -> item.zapEvent.idHex }) { _, item -> ZapNoteCompose(item, accountViewModel = accountViewModel, nav = nav) - - HorizontalDivider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness, - ) + HorizontalDivider(thickness = DividerThickness) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 0aa4a5f27..217d898ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -27,10 +27,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme @@ -253,18 +251,28 @@ val DarkRelayIconModifier = val darkLargeProfilePictureModifier = Modifier - .width(120.dp) - .height(120.dp) + .size(120.dp) .clip(shape = CircleShape) .border(3.dp, DarkColorPalette.onBackground, CircleShape) val lightLargeProfilePictureModifier = Modifier - .width(120.dp) - .height(120.dp) + .size(120.dp) .clip(shape = CircleShape) .border(3.dp, LightColorPalette.onBackground, CircleShape) +val darkNewItemBubbleModifier = + Modifier + .size(10.dp) + .clip(shape = CircleShape) + .background(DarkColorPalette.primary) + +val lightNewItemBubbleModifier = + Modifier + .size(10.dp) + .clip(shape = CircleShape) + .background(LightColorPalette.primary) + val RichTextDefaults = RichTextStyle().resolveDefaults() val MarkDownStyleOnDark = @@ -462,6 +470,10 @@ val ColorScheme.selectedReactionBoxModifier: Modifier val ColorScheme.largeProfilePictureModifier: Modifier get() = if (isLight) lightLargeProfilePictureModifier else darkLargeProfilePictureModifier +@Suppress("ModifierFactoryExtensionFunction") +val ColorScheme.newItemBubbleModifier: Modifier + get() = if (isLight) lightNewItemBubbleModifier else darkNewItemBubbleModifier + val chartLightColors = VicoTheme( candlestickCartesianLayerColors =