diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 6537f55e5..c053c1962 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -15,7 +15,9 @@ import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.* import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableSharedFlow @@ -343,7 +345,27 @@ object LocalCache { private fun consume(event: PinListEvent) { consumeBaseReplaceable(event) } private fun consume(event: RelaySetEvent) { consumeBaseReplaceable(event) } private fun consume(event: AudioTrackEvent) { consumeBaseReplaceable(event) } - private fun consume(event: StatusEvent, relay: Relay?) { consumeBaseReplaceable(event) } + private fun consume(event: StatusEvent, relay: Relay?) { + val version = getOrCreateNote(event.id) + val note = getOrCreateAddressableNote(event.address()) + val author = getOrCreateUser(event.pubKey) + + if (version.event == null) { + version.loadEvent(event, author, emptyList()) + version.moveAllReferencesTo(note) + } + + // Already processed this event. + if (note.event?.id() == event.id()) return + + if (event.createdAt > (note.createdAt() ?: 0)) { + note.loadEvent(event, author, emptyList()) + + author.liveSet?.statuses?.invalidateData() + + refreshObservers(note) + } + } fun consume(event: BadgeDefinitionEvent) { consumeBaseReplaceable(event) } @@ -1127,6 +1149,18 @@ object LocalCache { } } + suspend fun findStatusesForUser(user: User): ImmutableList { + checkNotInMainThread() + + return addressables.filter { + val noteEvent = it.value.event + (noteEvent is StatusEvent && noteEvent.pubKey == user.pubkeyHex && !noteEvent.isExpired() && noteEvent.content.isNotBlank()) + }.values + .sortedWith(compareBy({ it.event?.expiration() ?: it.event?.createdAt() }, { it.idHex })) + .reversed() + .toImmutableList() + } + fun cleanObservers() { notes.forEach { it.value.clearLive() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 67d89769a..625e7ec4a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -392,11 +392,16 @@ class UserLiveSet(u: User) { val metadata: UserLiveData = UserLiveData(u) val zaps: UserLiveData = UserLiveData(u) val bookmarks: UserLiveData = UserLiveData(u) + val statuses: UserLiveData = UserLiveData(u) val profilePictureChanges = metadata.map { it.user.profilePicture() }.distinctUntilChanged() + val nip05Changes = metadata.map { + it.user.nip05() + }.distinctUntilChanged() + val userMetadataInfo = metadata.map { it.user.info }.distinctUntilChanged() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 412241c8c..e4201853f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -5,38 +5,48 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.Icon import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState 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.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.map +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.Nip05Verifier +import com.vitorpamplona.amethyst.ui.note.LoadAnyAddressableNote import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon import com.vitorpamplona.amethyst.ui.note.NIP05FailedVerification import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon +import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.nip05 import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds @Composable fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): MutableState { @@ -94,12 +104,7 @@ fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): Mu @Composable fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifier) { - val noteState by baseNote.live().metadata.observeAsState() - val author by remember(noteState) { - derivedStateOf { - noteState?.note?.author - } - } + val author by baseNote.live().authorChanges.observeAsState() author?.let { ObserveDisplayNip05Status(it, columnModifier) @@ -108,35 +113,88 @@ fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifie @Composable fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) { - val nip05 by baseUser.live().metadata.map { - it.user.nip05() - }.observeAsState(baseUser.nip05()) + val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05()) - Crossfade(targetState = nip05, modifier = columnModifier) { - if (it != null) { - DisplayNIP05Line(it, baseUser, columnModifier) - } else { - Text( - text = baseUser.pubkeyDisplayHex(), - color = MaterialTheme.colors.placeholderText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = columnModifier - ) + LoadAnyAddressableNote(baseUser) { statuses -> + Crossfade(targetState = nip05, modifier = columnModifier, label = "ObserveDisplayNip05StatusCrossfade") { + if (it != null) { + VerifyAndDisplayNIP05OrStatusLine(it, statuses, baseUser, columnModifier) + } else { + Text( + text = baseUser.pubkeyDisplayHex(), + color = MaterialTheme.colors.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = columnModifier + ) + } } } } @Composable -private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modifier = Modifier) { +private fun VerifyAndDisplayNIP05OrStatusLine( + nip05: String, + statuses: ImmutableList, + baseUser: User, + columnModifier: Modifier = Modifier +) { + var displayStatusOrNIP05 by remember { + mutableStateOf(0) + } + + val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex) + Column(modifier = columnModifier) { - val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex) - Crossfade(targetState = nip05Verified) { + Crossfade(targetState = displayStatusOrNIP05, label = "NIP05StatusRotationCrossfade") { Row(verticalAlignment = Alignment.CenterVertically) { - DisplayNIP05(nip05, it) + if (statuses.isEmpty()) { + DisplayNIP05(nip05, nip05Verified) + } else if (nip05Verified.value != true || it < 0 || it >= statuses.size) { + DisplayNIP05(nip05, nip05Verified) + } else { + DisplayStatus(statuses[it]) + } } } } + + if (statuses.size > 1) { + LaunchedEffect(Unit) { + while (true) { + delay(10.seconds) + displayStatusOrNIP05 = ((displayStatusOrNIP05 + 1) % (statuses.size + 1)) + } + } + } +} + +@Composable +fun DisplayStatus(addressableNote: AddressableNote) { + val noteState by addressableNote.live().metadata.observeAsState() + + val content = remember(noteState) { addressableNote.event?.content() ?: "" } + val type = remember(noteState) { + (addressableNote.event as? AddressableEvent)?.dTag() ?: "" + } + + when (type) { + "music" -> Icon( + painter = painterResource(id = R.drawable.tunestr), + null, + modifier = Size18Modifier, + tint = MaterialTheme.colors.placeholderText + ) + else -> {} + } + + Text( + text = content, + fontSize = Font14SP, + color = MaterialTheme.colors.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b5b118725..0192190ef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -1052,7 +1052,7 @@ private fun NoteBody( ) } - Spacer(modifier = StdVertSpacer) + Spacer(modifier = Modifier.height(3.dp)) if (!makeItShort) { ReplyRow( @@ -2506,6 +2506,30 @@ fun SecondUserInfoRow( } } +@Composable +fun LoadAnyAddressableNote( + user: User, + content: @Composable (ImmutableList) -> Unit +) { + var statuses: ImmutableList by remember { + mutableStateOf(persistentListOf()) + } + + val userStatus = user.live().statuses.observeAsState() + + LaunchedEffect(key1 = userStatus) { + launch(Dispatchers.IO) { + val myUser = userStatus.value?.user ?: return@launch + val newStatuses = LocalCache.findStatusesForUser(myUser) + if (!equalImmutableLists(statuses, newStatuses)) { + statuses = newStatuses + } + } + } + + content(statuses) +} + @Composable fun DisplayLocation(geohash: String, nav: (String) -> Unit) { val context = LocalContext.current