A1 extract to commons

This commit is contained in:
nrobi144
2026-01-08 10:45:44 +02:00
parent 54c1605de7
commit 211305c572
11 changed files with 146 additions and 38 deletions
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache import android.util.LruCache
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.IChannel
import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
@@ -211,7 +213,7 @@ interface ILocalCache {
) {} ) {}
} }
object LocalCache : ILocalCache { object LocalCache : ILocalCache, ICacheProvider {
val antiSpam = AntiSpamFilter() val antiSpam = AntiSpamFilter()
val users = LargeSoftCache<HexKey, User>() val users = LargeSoftCache<HexKey, User>()
@@ -316,16 +318,35 @@ object LocalCache : ILocalCache {
} }
} }
fun getUserIfExists(key: String): User? { override fun getUserIfExists(key: String): User? {
if (key.isEmpty()) return null if (key.isEmpty()) return null
return users.get(key) return users.get(key)
} }
override fun countUsers(predicate: (String, Any) -> Boolean): Int {
var count = 0
users.forEach { key, user ->
if (predicate(key, user)) count++
}
return count
}
override fun getAnyChannel(note: Any?): IChannel? {
val channelNote = note as? Note ?: return null
val channel = getAnyChannel(channelNote)
// Wrap Channel to implement IChannel interface
return channel?.let {
object : IChannel {
override fun relays(): List<Any>? = it.relays().toList()
}
}
}
fun getAddressableNoteIfExists(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) } fun getAddressableNoteIfExists(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) }
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address) fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address)
fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) } override fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) }
fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId) fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId)
@@ -356,7 +377,7 @@ object LocalCache : ILocalCache {
return null return null
} }
fun checkGetOrCreateNote(key: String): Note? { override fun checkGetOrCreateNote(key: String): Note? {
if (ATag.isATag(key)) { if (ATag.isATag(key)) {
return checkGetOrCreateAddressableNote(key) return checkGetOrCreateAddressableNote(key)
} }
@@ -26,8 +26,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.ThreadLevelCalculator
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadLevelCalculator
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
@@ -21,11 +21,12 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.LevelSignature
import com.vitorpamplona.amethyst.commons.model.ThreadAssembler
import com.vitorpamplona.amethyst.commons.model.ThreadLevelCalculator
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LevelSignature import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.model.ThreadLevelCalculator
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
@@ -40,7 +41,7 @@ class ThreadFeedFilter(
override fun feed(): List<Note> { override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf() val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf()
val followingKeySet = account.kind3FollowList.flow.value.authors val followingKeySet = account.kind3FollowList.flow.value.authors
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: return emptyList() val eventsToWatch = ThreadAssembler(LocalCache).findThreadFor(noteId) ?: return emptyList()
// Filter out drafts made by other accounts on device // Filter out drafts made by other accounts on device
val filteredEvents = val filteredEvents =
@@ -20,8 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies
import com.vitorpamplona.amethyst.commons.model.ThreadAssembler
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress
@@ -53,9 +53,10 @@ fun filterMissingEventsForThread(
val missingAddresses = val missingAddresses =
mapOfSet { mapOfSet {
if (threadInfo.root.event == null && threadInfo.root is AddressableNote) { val rootNote = threadInfo.root
potentialRelaysToFindEvent(threadInfo.root).ifEmpty { defaultRelays }.forEach { relayUrl -> if (rootNote.event == null && rootNote is AddressableNote) {
add(relayUrl, threadInfo.root.address) potentialRelaysToFindEvent(rootNote).ifEmpty { defaultRelays }.forEach { relayUrl ->
add(relayUrl, rootNote.address)
} }
} }
@@ -20,7 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies
import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.commons.model.ThreadAssembler
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState
@@ -45,7 +46,7 @@ class ThreadEventLoaderSubAssembler(
key: ThreadQueryState, key: ThreadQueryState,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter>? { ): List<RelayBasedFilter>? {
val branches = ThreadAssembler().findThreadFor(key.eventId) ?: return null val branches = ThreadAssembler(LocalCache).findThreadFor(key.eventId) ?: return null
val defaultRelays = key.account.followPlusAllMineWithSearch.flow.value val defaultRelays = key.account.followPlusAllMineWithSearch.flow.value
return filterMissingEventsForThread(branches, defaultRelays) return filterMissingEventsForThread(branches, defaultRelays)
} }
@@ -20,7 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies
import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.commons.model.ThreadAssembler
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadQueryState
@@ -42,7 +43,7 @@ class ThreadFilterSubAssembler(
key: ThreadQueryState, key: ThreadQueryState,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter>? { ): List<RelayBasedFilter>? {
val root = ThreadAssembler().findRoot(key.eventId) ?: return null val root = ThreadAssembler(LocalCache).findRoot(key.eventId) ?: return null
return filterEventsInThreadForRoot(root, since) return filterEventsInThreadForRoot(root, since)
} }
@@ -0,0 +1,33 @@
/**
* 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.commons.model
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val levelFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")
actual fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(levelFormatter)
@@ -18,10 +18,11 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
@@ -29,7 +30,9 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
class ThreadAssembler { class ThreadAssembler(
private val cache: ICacheProvider,
) {
private fun searchRoot( private fun searchRoot(
note: Note, note: Note,
testedNotes: MutableSet<Note> = mutableSetOf(), testedNotes: MutableSet<Note> = mutableSetOf(),
@@ -48,9 +51,10 @@ class ThreadAssembler {
?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" } ?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }
?.getOrNull(1) ?.getOrNull(1)
if (markedAsRoot != null) { if (markedAsRoot != null) {
// Check to ssee if there is an error in the tag and the root has replies // Check to see if there is an error in the tag and the root has replies
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) { val rootNote = cache.getNoteIfExists(markedAsRoot) as? Note
return LocalCache.checkGetOrCreateNote(markedAsRoot) if (rootNote?.replyTo?.isEmpty() == true) {
return cache.checkGetOrCreateNote(markedAsRoot) as? Note
} }
} }
@@ -84,7 +88,7 @@ class ThreadAssembler {
) )
fun findRoot(noteId: String): Note? { fun findRoot(noteId: String): Note? {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null val note = cache.checkGetOrCreateNote(noteId) as? Note ?: return null
return if (note.event != null) { return if (note.event != null) {
val thread = OnlyLatestVersionSet() val thread = OnlyLatestVersionSet()
@@ -98,7 +102,7 @@ class ThreadAssembler {
fun findThreadFor(noteId: String): ThreadInfo? { fun findThreadFor(noteId: String): ThreadInfo? {
checkNotInMainThread() checkNotInMainThread()
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return null val note = cache.checkGetOrCreateNote(noteId) as? Note ?: return null
return if (note.event != null) { return if (note.event != null) {
val thread = OnlyLatestVersionSet() val thread = OnlyLatestVersionSet()
@@ -18,15 +18,12 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.commons.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import java.lang.Long.min import kotlin.math.min
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class LevelSignature( data class LevelSignature(
val signature: String, val signature: String,
@@ -34,15 +31,13 @@ data class LevelSignature(
val author: User?, val author: User?,
) )
/**
* Platform-specific date-time formatter for thread signatures.
* Returns formatted timestamp in pattern "uuuu-MM-dd-HH:mm:ss"
*/
expect fun formattedDateTime(timestamp: Long): String
object ThreadLevelCalculator { object ThreadLevelCalculator {
val levelFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")
private fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(levelFormatter)
/** /**
* This method caches signatures during each execution to avoid recalculation in longer threads * This method caches signatures during each execution to avoid recalculation in longer threads
*/ */
@@ -61,6 +61,24 @@ interface ICacheProvider {
* @return Count of users matching the predicate * @return Count of users matching the predicate
*/ */
fun countUsers(predicate: (String, Any) -> Boolean): Int fun countUsers(predicate: (String, Any) -> Boolean): Int
/**
* Gets a Note if it exists in cache.
* Used by ThreadAssembler for finding existing notes.
*
* @param hexKey The note's ID in hex format
* @return The Note if exists in cache, null otherwise
*/
fun getNoteIfExists(hexKey: HexKey): Any?
/**
* Gets an existing Note or creates a new one if it doesn't exist.
* Used by ThreadAssembler for building thread structures.
*
* @param hexKey The note's ID in hex format
* @return The Note (existing or newly created)
*/
fun checkGetOrCreateNote(hexKey: HexKey): Any?
} }
/** /**
@@ -0,0 +1,33 @@
/**
* 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.commons.model
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val levelFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")
actual fun formattedDateTime(timestamp: Long): String =
Instant
.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(levelFormatter)