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
@@ -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)
@@ -0,0 +1,222 @@
/**
* 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 androidx.compose.runtime.Stable
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.AddressableEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
class ThreadAssembler(
private val cache: ICacheProvider,
) {
private fun searchRoot(
note: Note,
testedNotes: MutableSet<Note> = mutableSetOf(),
): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
val noteEvent = note.event
if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) return note
testedNotes.add(note)
val markedAsRoot =
noteEvent
?.tags
?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }
?.getOrNull(1)
if (markedAsRoot != null) {
// Check to see if there is an error in the tag and the root has replies
val rootNote = cache.getNoteIfExists(markedAsRoot) as? Note
if (rootNote?.replyTo?.isEmpty() == true) {
return cache.checkGetOrCreateNote(markedAsRoot) as? Note
}
}
val hasNoReplyTo =
note.replyTo?.lastOrNull {
it.replyTo?.isEmpty() == true
}
if (hasNoReplyTo != null) return hasNoReplyTo
// recursive
val roots =
note.replyTo?.mapNotNull {
if (it !in testedNotes) {
searchRoot(it, testedNotes)
} else {
null
}
}
if (roots != null && roots.isNotEmpty()) {
return roots[0]
}
return null
}
@Stable
class ThreadInfo(
val root: Note,
val allNotes: ImmutableSet<Note>,
)
fun findRoot(noteId: String): Note? {
val note = cache.checkGetOrCreateNote(noteId) as? Note ?: return null
return if (note.event != null) {
val thread = OnlyLatestVersionSet()
searchRoot(note, thread) ?: note
} else {
note
}
}
fun findThreadFor(noteId: String): ThreadInfo? {
checkNotInMainThread()
val note = cache.checkGetOrCreateNote(noteId) as? Note ?: return null
return if (note.event != null) {
val thread = OnlyLatestVersionSet()
val threadRoot = searchRoot(note, thread) ?: note
loadUp(note, thread)
loadDown(threadRoot, thread)
// adds the replies of the note in case the search for Root
// did not added them.
note.replies.forEach { loadDown(it, thread) }
ThreadInfo(
root = note,
allNotes = thread.toImmutableSet(),
)
} else {
ThreadInfo(
root = note,
allNotes = setOf(note).toImmutableSet(),
)
}
}
fun loadUp(
note: Note,
thread: MutableSet<Note>,
) {
if (note !in thread) {
thread.add(note)
note.replyTo?.forEach { loadUp(it, thread) }
}
}
fun loadDown(
note: Note,
thread: MutableSet<Note>,
) {
if (note !in thread) {
thread.add(note)
note.replies.forEach { loadDown(it, thread) }
}
}
}
class OnlyLatestVersionSet : MutableSet<Note> {
val map = hashMapOf<Address, Long>()
val set = hashSetOf<Note>()
override fun add(element: Note): Boolean {
val loadedCreatedAt = element.createdAt()
val noteEvent = element.event
return if (element is AddressableNote && loadedCreatedAt != null) {
innerAdd(element.address, element, loadedCreatedAt)
} else if (noteEvent is AddressableEvent && loadedCreatedAt != null) {
innerAdd(noteEvent.address(), element, loadedCreatedAt)
} else {
set.add(element)
}
}
private fun innerAdd(
address: Address,
element: Note,
loadedCreatedAt: Long,
): Boolean {
val existing = map.get(address)
return if (existing == null) {
map.put(address, loadedCreatedAt)
set.add(element)
} else {
if (loadedCreatedAt > existing) {
map.put(address, loadedCreatedAt)
set.add(element)
} else {
false
}
}
}
override fun addAll(elements: Collection<Note>): Boolean = elements.map { add(it) }.any()
override val size: Int
get() = set.size
override fun clear() {
set.clear()
map.clear()
}
override fun isEmpty(): Boolean = set.isEmpty()
override fun containsAll(elements: Collection<Note>): Boolean = set.containsAll(elements)
override fun contains(element: Note): Boolean = set.contains(element)
override fun iterator(): MutableIterator<Note> = set.iterator()
override fun retainAll(elements: Collection<Note>): Boolean = set.retainAll(elements)
override fun removeAll(elements: Collection<Note>): Boolean = elements.map { remove(it) }.any()
override fun remove(element: Note): Boolean {
element.address()?.let {
map.remove(it)
}
(element.event as? AddressableEvent)?.address()?.let {
map.remove(it)
}
return set.remove(element)
}
}
@@ -0,0 +1,138 @@
/**
* 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 com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import kotlin.math.min
data class LevelSignature(
val signature: String,
val createdAt: Long?,
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 {
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(
note: Note,
eventsToConsider: Set<HexKey>,
cachedSignatures: MutableMap<Note, LevelSignature>,
account: User,
accountFollowingSet: Set<String>,
now: Long,
): LevelSignature {
val replyTo = note.replyTo
// estimates the min date by replies if it doesn't exist.
val createdAt =
note.createdAt() ?: min(
note.replies.minOfOrNull { it.createdAt() ?: now } ?: now,
note.reactions.values.minOfOrNull { it.minOfOrNull { it.createdAt() ?: now } ?: now } ?: now,
)
val noteAuthor = note.author
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) + ";",
createdAt = createdAt,
author = noteAuthor,
)
}
val parent =
(
replyTo
.filter {
it.idHex in eventsToConsider
} // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it]
?: replyLevelSignature(
it,
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now,
).apply { cachedSignatures.put(it, this) }
}.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder =
if (noteAuthor != null && parent?.author == noteAuthor) {
// author of the thread first, in **ascending** order
"9" + formattedDateTime((parent.createdAt ?: 0) + (now - createdAt)) + note.idHex.substring(0, 8)
} else if (noteAuthor != null && noteAuthor.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my replies
} else if (noteAuthor != null && noteAuthor.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) // everyone else.
}
val mySignature =
LevelSignature(
signature = "$parentSignature/$threadOrder;",
createdAt = createdAt,
author = note.author,
)
cachedSignatures[note] = mySignature
return mySignature
}
fun replyLevel(
note: Note,
cachedLevels: MutableMap<Note, Int> = mutableMapOf(),
): Int {
val replyTo = note.replyTo
if (
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
cachedLevels[note] = 0
return 0
}
val thisLevel =
replyTo.maxOf {
cachedLevels[it] ?: replyLevel(it, cachedLevels)
} + 1
cachedLevels[note] = thisLevel
return thisLevel
}
}
@@ -61,6 +61,24 @@ interface ICacheProvider {
* @return Count of users matching the predicate
*/
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)