A1 extract to commons
This commit is contained in:
+33
@@ -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)
|
||||
+222
@@ -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)
|
||||
}
|
||||
}
|
||||
+138
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+18
@@ -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?
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+33
@@ -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)
|
||||
Reference in New Issue
Block a user