fix(nip29): audit fixes for pinned-message jump and back-fill

- Jump effect: key on the note id alone and always clear the request
  after one attempt. Previously keyed on (id, items.list), so a message
  arriving mid-jump cancelled the scroll animation and could leave the
  request stuck (never cleared when the target wasn't loaded), blocking
  repeat taps. Now the animation can't be interrupted and a re-tap always
  re-fires.
- Back-fill pinned bodies: add an id-scoped filter for the group's
  pinnedEventIds on the host relay. A pin can point at a message older
  than the 200-item timeline window, which the h-scoped timeline never
  returns — without this the bar shows a blank preview and can't jump.
- Re-invalidate the relay-group filter when the pin list changes (keyed
  on the pin ids only, so roster/metadata churn doesn't re-subscribe), so
  the new pin ids are actually requested when 39005 arrives.
This commit is contained in:
Claude
2026-07-15 23:52:20 +00:00
parent 9ee6f0dbde
commit a347889c57
3 changed files with 49 additions and 17 deletions
@@ -194,18 +194,18 @@ fun ChatFeedLoaded(
}
}
// External jump request (pinned-message bar). Re-runs when the requested id changes or the loaded
// list shifts (so a pin that arrives after the tap still scrolls once loaded). Clears the request
// as soon as it lands, so a repeat tap on the same pin fires again.
// External jump request (pinned-message bar). Keyed on the id alone, so a message arriving mid-jump
// can't cancel the scroll animation or restart the effect. Always clears the request after one
// attempt — even when the target isn't loaded — so it never sticks and a repeat tap fires again.
val jumpId = jumpToNoteId?.value
LaunchedEffect(jumpId, items.list) {
LaunchedEffect(jumpId) {
if (jumpId != null) {
val index = items.list.indexOfFirst { it.idHex == jumpId }
if (index >= 0) {
listState.animateScrollToItem(index)
highlightedNoteId.value = jumpId
onJumpHandled()
}
onJumpHandled()
}
}
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -58,4 +59,18 @@ fun ChannelFilterAssemblerSubscription(
dataSource.invalidateFilters()
}
}
// Relay groups: when the kind-39005 pin list changes, re-invalidate so the id-based back-fill
// for pinned message bodies (see filterMetadataToRelayGroup) picks up the new ids. Keyed on the
// pin list alone, so unrelated roster/metadata churn doesn't force a re-subscribe.
if (channel is RelayGroupChannel) {
val metadataState by channel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val pinnedIds = (metadataState.channel as? RelayGroupChannel)?.pinnedEventIds ?: channel.pinnedEventIds
LaunchedEffect(pinnedIds) {
dataSource.invalidateFilters()
}
}
}
@@ -49,15 +49,32 @@ private val RELAY_GROUP_METADATA_KINDS =
fun filterMetadataToRelayGroup(
channel: RelayGroupChannel,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
channel.relays().toSet().map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_METADATA_KINDS,
tags = mapOf("d" to listOf(channel.groupId.id)),
since = since?.get(it)?.time,
),
)
}
): List<RelayBasedFilter> {
val relays = channel.relays().toSet()
val directory =
relays.map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_METADATA_KINDS,
tags = mapOf("d" to listOf(channel.groupId.id)),
since = since?.get(it)?.time,
),
)
}
// Back-fill the bodies of pinned messages by id from the host relay. A pin can point at a
// message older than the 200-item timeline window, which the `h`-scoped timeline filter would
// never return — without this the pin bar shows a blank preview and can't jump to it. No `since`:
// pinned events are immutable, so we want them regardless of age.
val pinnedIds = channel.pinnedEventIds
val pins =
if (pinnedIds.isEmpty()) {
emptyList()
} else {
relays.map { RelayBasedFilter(relay = it, filter = Filter(ids = pinnedIds)) }
}
return directory + pins
}