Compare commits

..
29 Commits
Author SHA1 Message Date
Vitor Pamplona e887232132 v0.89.9 2024-08-07 18:39:02 -04:00
Vitor Pamplona 7bcf4e3750 Removes logs from previous commit 2024-08-07 18:34:38 -04:00
Vitor Pamplona ed0676a5f5 Fixes the order of bookmarks (keeps the order of the event, instead of the created at) 2024-08-07 18:24:35 -04:00
Vitor Pamplona f731c654b0 Improves the async rendering of Base64 content 2024-08-07 18:24:14 -04:00
Vitor Pamplona b026bffe4a Moves discovery and video lists to Outbox when Follows or relay lists are selected 2024-08-07 16:39:24 -04:00
Vitor Pamplona a1aaec0b7d Adds support for selecting authors based on their Outbox relays when searching for notes authored by them 2024-08-07 15:46:27 -04:00
Vitor Pamplona 6f59097ac0 Aligns default note comparator to NIP-01's created at descending and then by id ascending 2024-08-07 15:44:35 -04:00
Vitor Pamplona 263df0c895 Renames PerRelayFilter to SincePerRelay
Creates PerRelay interface to allow other types of filters
2024-08-07 15:44:03 -04:00
Vitor PamplonaandGitHub 1ca5713494 Merge pull request #1008 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-07 12:57:34 -04:00
Crowdin Bot b706c4e871 New Crowdin translations by GitHub Action 2024-08-07 16:04:54 +00:00
Vitor Pamplona e8574c10bb Keep them public to allow testing in these particular functions 2024-08-07 12:02:48 -04:00
Vitor Pamplona 7cb87ea9c5 Refactors to use native contains instead of custom lambdas on Ammolite's Filter 2024-08-07 11:58:00 -04:00
Vitor Pamplona 07e5132943 Refactors Ammolite Filters to be regular ones and creates a PerRelayFilter for the use on Amethyst. 2024-08-07 11:40:01 -04:00
Vitor Pamplona 2b1e3cfc93 Renames the MinimumRelayList to RecommendationProcessor 2024-08-07 11:39:14 -04:00
Vitor Pamplona c17e1f0e1f removes logs from previous commit 2024-08-06 17:14:22 -04:00
Vitor Pamplona e88d1d0d00 Adds haptic feedback to draft deletion swipe 2024-08-06 17:13:06 -04:00
Vitor Pamplona 5fdff97cf8 Moves the ContactList cache lists to AccountViewModel, where it can be disposed more efficiently. 2024-08-06 15:36:04 -04:00
Vitor Pamplona e5328d7975 Improves the accuracy of the Event memory counter. 2024-08-06 15:35:14 -04:00
Vitor Pamplona 71b45b96fb Adds event factory performance test 2024-08-06 11:04:30 -04:00
Vitor PamplonaandGitHub dee28f3aa8 Merge pull request #1006 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-05 17:26:13 -04:00
Crowdin Bot 3a118bb939 New Crowdin translations by GitHub Action 2024-08-05 20:59:07 +00:00
Vitor Pamplona 971c92b27a Adds extension possibility to Quartz's event factory 2024-08-05 16:57:21 -04:00
Vitor Pamplona 4c70065843 Moves DataSource dispatcher from IO to Default 2024-08-05 16:02:35 -04:00
Vitor Pamplona eab69337ff Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-05 16:02:19 -04:00
Vitor Pamplona ab772eb65e Makes stringRes Stable for compose 2024-08-05 15:48:06 -04:00
Vitor PamplonaandGitHub 7a6720a7ab Merge pull request #1005 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-05 15:31:47 -04:00
Crowdin Bot 0f3dc18f7a New Crowdin translations by GitHub Action 2024-08-05 19:16:01 +00:00
Vitor Pamplona 84df86f4b6 Removes mutiny NWC button :( 2024-08-05 15:13:35 -04:00
Vitor Pamplona a0289a0cb4 Moves Relay viewModels to Default thread 2024-08-05 15:08:08 -04:00
95 changed files with 1242 additions and 706 deletions
+2 -2
View File
@@ -16,8 +16,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk libs.versions.android.minSdk.get().toInteger()
targetSdk libs.versions.android.targetSdk.get().toInteger()
versionCode 394
versionName "0.89.8"
versionCode 395
versionName "0.89.9"
buildConfigField "String", "RELEASE_NOTES_ID", "\"1130badb252bdf62aaf93cb65bfa2bb09e7600c7d6764894f2954555b3b73018\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -30,6 +30,7 @@ import coil.decode.SvgDecoder
import coil.size.Precision
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.Base64Fetcher
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@@ -98,6 +99,7 @@ class ServiceManager {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
add(Base64Fetcher.Factory)
} // .logger(DebugLogger())
.okHttpClient { HttpClientManager.getHttpClient() }
.precision(Precision.INEXACT)
@@ -39,10 +39,10 @@ import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.relays.Constants
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
@@ -108,7 +108,6 @@ import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.MinimumRelayListProcessor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -121,7 +120,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
@@ -225,6 +223,7 @@ class Account(
@Immutable
class LiveFollowLists(
val users: Set<String> = emptySet(),
val usersPlusMe: Set<String>,
val hashtags: Set<String> = emptySet(),
val geotags: Set<String> = emptySet(),
val communities: Set<String> = emptySet(),
@@ -423,17 +422,29 @@ class Account(
val liveKind3FollowsFlow: Flow<LiveFollowLists> =
userProfile().flow().follows.stateFlow.transformLatest {
checkNotInMainThread()
// makes sure the output include only valid p tags
val verifiedFollowingUsers = it.user.latestContactList?.verifiedFollowKeySet() ?: emptySet()
emit(
LiveFollowLists(
it.user.cachedFollowingKeySet(),
it.user.cachedFollowingTagSet(),
it.user.cachedFollowingGeohashSet(),
it.user.cachedFollowingCommunitiesSet(),
verifiedFollowingUsers,
verifiedFollowingUsers + keyPair.pubKeyHex,
it.user.latestContactList
?.unverifiedFollowTagSet()
?.map { it.lowercase() }
?.toSet() ?: emptySet(),
it.user.latestContactList
?.unverifiedFollowGeohashSet()
?.toSet() ?: emptySet(),
it.user.latestContactList
?.verifiedFollowAddressSet()
?.toSet() ?: emptySet(),
),
)
}
val liveKind3Follows = liveKind3FollowsFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
val liveKind3Follows = liveKind3FollowsFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
@OptIn(ExperimentalCoroutinesApi::class)
private val liveHomeList: Flow<ListNameNotePair> by lazy {
@@ -465,11 +476,11 @@ class Account(
} else if (peopleListFollows.listName == KIND3_FOLLOWS) {
emit(kind3Follows)
} else if (peopleListFollows.event == null) {
emit(LiveFollowLists())
emit(LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
} else {
val result = waitToDecrypt(peopleListFollows.event)
if (result == null) {
emit(LiveFollowLists())
emit(LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
} else {
emit(result)
}
@@ -481,54 +492,98 @@ class Account(
}
val liveHomeFollowLists: StateFlow<LiveFollowLists?> by lazy {
liveHomeFollowListFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
liveHomeFollowListFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
}
fun relaysFromPeopleListFlows(
currentFollowList: LiveFollowLists,
relayUrlsToIgnore: Set<String>,
): Flow<List<MinimumRelayListProcessor.RelayRecommendation>> =
/**
* filter onion and local host from write relays
* for each user pubkey, a list of valid relays.
*/
private fun assembleAuthorsPerWriteRelay(
userList: Map<HexKey, List<String>>,
hasOnionConnection: Boolean = false,
): Map<String, List<HexKey>> {
checkNotInMainThread()
val authorsPerRelayUrl = mutableMapOf<String, MutableSet<HexKey>>()
val relayUrlsPerAuthor = mutableMapOf<HexKey, MutableSet<String>>()
userList.forEach { userWriteRelayListPair ->
userWriteRelayListPair.value.forEach { relayUrl ->
if (!RelayUrlFormatter.isLocalHost(relayUrl) && (hasOnionConnection || !RelayUrlFormatter.isOnion(relayUrl))) {
RelayUrlFormatter.normalizeOrNull(relayUrl)?.let { normRelayUrl ->
val userSet = authorsPerRelayUrl[normRelayUrl]
if (userSet != null) {
userSet.add(userWriteRelayListPair.key)
} else {
authorsPerRelayUrl[normRelayUrl] = mutableSetOf(userWriteRelayListPair.key)
}
val relaySet = authorsPerRelayUrl[userWriteRelayListPair.key]
if (relaySet != null) {
relaySet.add(normRelayUrl)
} else {
relayUrlsPerAuthor[userWriteRelayListPair.key] = mutableSetOf(normRelayUrl)
}
}
}
}
}
// for each relay, authors that only use this relay go first.
// then keeps order by pubkey asc
val comparator = compareByDescending<HexKey> { relayUrlsPerAuthor[it]?.size ?: 0 }.thenBy { it }
return authorsPerRelayUrl.mapValues {
it.value.sortedWith(comparator)
}
}
fun authorsPerRelay(
pubkeyList: Set<HexKey>,
defaultRelayList: List<String>,
): Flow<Map<String, List<String>>> =
combine(
currentFollowList.users.map {
pubkeyList.map {
getNIP65RelayListFlow(it)
},
) { followsNIP65RelayLists ->
MinimumRelayListProcessor
.reliableRelaySetFor(
followsNIP65RelayLists.mapNotNull {
(it.note.event as? AdvertisedRelayListEvent)
},
relayUrlsToIgnore,
hasOnionConnection = proxy != null,
).sortedByDescending { it.users.size }
assembleAuthorsPerWriteRelay(
followsNIP65RelayLists
.mapNotNull {
val author = (it.note as? AddressableNote)?.address?.pubKeyHex
val event = (it.note.event as? AdvertisedRelayListEvent)
if (event != null) {
event.pubKey to event.writeRelays()
} else {
if (author != null) {
author to defaultRelayList
} else {
Log.e("Account", "This author should NEVER be null. Note: ${it.note.idHex}")
null
}
}
}.toMap(),
hasOnionConnection = proxy != null,
)
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveHomeFollowRelayFlow: Flow<List<MinimumRelayListProcessor.RelayRecommendation>> by lazy {
val liveHomeListAuthorsPerRelayFlow: Flow<Map<String, List<String>>?> by lazy {
combineTransform(liveHomeFollowListFlow, connectToRelaysFlow) { followList, existing ->
if (followList != null) {
emit(
relaysFromPeopleListFlows(
followList,
existing.mapNotNullTo(HashSet()) {
if (it.read && FeedType.FOLLOWS in it.feedTypes) {
it.url
} else {
null
}
},
),
)
emit(authorsPerRelay(followList.usersPlusMe, existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }))
} else {
emit(MutableStateFlow(emptyList()))
emit(MutableStateFlow(null))
}
}.flatMapLatest {
it
}
}
val liveHomeFollowRelays: StateFlow<List<MinimumRelayListProcessor.RelayRecommendation>> by lazy {
liveHomeFollowRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyList())
val liveHomeListAuthorsPerRelay: StateFlow<Map<String, List<String>>?> by lazy {
liveHomeListAuthorsPerRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyMap())
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -540,7 +595,7 @@ class Account(
val liveNotificationFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveNotificationList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -552,7 +607,24 @@ class Account(
val liveStoriesFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveStoriesList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveStoriesListAuthorsPerRelayFlow: Flow<Map<String, List<String>>?> by lazy {
combineTransform(liveStoriesFollowLists, connectToRelaysFlow) { followList, existing ->
if (followList != null) {
emit(authorsPerRelay(followList.usersPlusMe, existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }))
} else {
emit(MutableStateFlow(null))
}
}.flatMapLatest {
it
}
}
val liveStoriesListAuthorsPerRelay: StateFlow<Map<String, List<String>>?> by lazy {
liveStoriesListAuthorsPerRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyMap())
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -564,7 +636,24 @@ class Account(
val liveDiscoveryFollowLists: StateFlow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveDiscoveryList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex)))
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveDiscoveryListAuthorsPerRelayFlow: Flow<Map<String, List<String>>?> by lazy {
combineTransform(liveDiscoveryFollowLists, connectToRelaysFlow) { followList, existing ->
if (followList != null) {
emit(authorsPerRelay(followList.usersPlusMe, existing.filter { it.read }.map { it.url }))
} else {
emit(MutableStateFlow(null))
}
}.flatMapLatest {
it
}
}
val liveDiscoveryListAuthorsPerRelay: StateFlow<Map<String, List<String>>?> by lazy {
liveDiscoveryListAuthorsPerRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyMap())
}
private fun decryptLiveFollows(
@@ -572,10 +661,11 @@ class Account(
onReady: (LiveFollowLists) -> Unit,
) {
listEvent.privateTags(signer) { privateTagList ->
val users = (listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toSet()
onReady(
LiveFollowLists(
users =
(listEvent.bookmarkedPeople() + listEvent.filterUsers(privateTagList)).toSet(),
users = users,
usersPlusMe = users + userProfile().pubkeyHex,
hashtags =
(listEvent.hashtags() + listEvent.filterHashtags(privateTagList)).toSet(),
geotags =
@@ -759,6 +849,10 @@ class Account(
}
}
suspend fun countFollowersOf(pubkey: HexKey): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkey) ?: false }
suspend fun followerCount(): Int = countFollowersOf(keyPair.pubKeyHex)
fun sendNewUserMetadata(
name: String? = null,
picture: String? = null,
@@ -1109,7 +1203,7 @@ class Account(
listOf(
TypedFilter(
setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL),
Filter(
SincePerRelayFilter(
ids = listOf(it.id),
),
),
@@ -2813,9 +2907,7 @@ class Account(
flowHiddenUsers.value.hiddenUsers.contains(userHex) ||
flowHiddenUsers.value.spammers.contains(userHex)
fun followingKeySet(): Set<HexKey> = userProfile().cachedFollowingKeySet()
fun followingTagSet(): Set<HexKey> = userProfile().cachedFollowingTagSet()
fun followingKeySet(): Set<HexKey> = liveKind3Follows.value.users
fun isAcceptable(user: User): Boolean {
if (userProfile().pubkeyHex == user.pubkeyHex) {
@@ -2865,8 +2957,6 @@ class Account(
}
fun getRelevantReports(note: Note): Set<Note> {
val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet()
val innerReports =
if (note.event is RepostEvent || note.event is GenericRepostEvent) {
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
@@ -2875,8 +2965,8 @@ class Account(
}
return (
note.reportsBy(followsPlusMe) +
(note.author?.reportsBy(followsPlusMe) ?: emptyList()) +
note.reportsBy(liveKind3Follows.value.usersPlusMe) +
(note.author?.reportsBy(liveKind3Follows.value.usersPlusMe) ?: emptyList()) +
innerReports
).toSet()
}
@@ -32,9 +32,9 @@ import com.vitorpamplona.amethyst.ui.actions.relays.updated
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
@@ -29,8 +29,8 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Lud06
@@ -336,52 +336,24 @@ class User(
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
fun isFollowingHashtag(tag: String): Boolean = latestContactList?.isTaggedHash(tag) ?: false
fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let {
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.unverifiedFollowTagSet()?.map { it.lowercase() }?.toSet()?.let {
return tag.lowercase() in it
}
?: false
}
fun isFollowingGeohashCached(geoTag: String): Boolean {
return latestContactList?.verifiedFollowGeohashSet?.let {
fun isFollowingGeohash(geoTag: String): Boolean {
return latestContactList?.unverifiedFollowAddressSet()?.toSet()?.let {
return geoTag.lowercase() in it
}
?: false
}
fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it
}
?: false
}
fun isFollowingCached(userHex: String): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return userHex in it
}
?: false
}
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
suspend fun transientFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun cachedFollowingKeySet(): Set<HexKey> = latestContactList?.verifiedFollowKeySet ?: emptySet()
fun cachedFollowingTagSet(): Set<String> = latestContactList?.verifiedFollowTagSet ?: emptySet()
fun cachedFollowingGeohashSet(): Set<HexKey> = latestContactList?.verifiedFollowGeohashSet ?: emptySet()
fun cachedFollowingCommunitiesSet(): Set<HexKey> = latestContactList?.verifiedFollowCommunitySet ?: emptySet()
fun cachedFollowCount(): Int? = latestContactList?.verifiedFollowKeySet?.size
suspend fun cachedFollowerCount(): Int = LocalCache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2024 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.service
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
import java.util.Base64
@Stable
class Base64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val matcher = base64contentPattern.matcher(data.toString())
if (matcher.find()) {
val base64String = matcher.group(2)
val byteArray = Base64.getDecoder().decode(base64String)
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) ?: throw Exception("Unable to load base64 $base64String")
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY,
)
} else {
throw Exception("Unable to load base64 $data")
}
}
object Factory : Fetcher.Factory<Uri> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? {
return if (base64contentPattern.matcher(data.toString()).find()) {
return Base64Fetcher(options, data)
} else {
null
}
}
}
}
object Base64Requester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest =
ImageRequest
.Builder(context)
.data(message)
.fetcherFactory(Base64Fetcher.Factory)
.build()
}
@@ -26,11 +26,11 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Client
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.BadgeAwardEvent
@@ -81,7 +81,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
@@ -92,7 +92,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1,
@@ -103,7 +103,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
StatusEvent.KIND,
@@ -124,7 +124,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
@@ -146,7 +146,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, MuteListEvent.KIND, BadgeProfilesEvent.KIND, EmojiPackSelectionEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100,
@@ -157,7 +157,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(DraftEvent.KIND, ReportEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
@@ -172,7 +172,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400,
),
@@ -189,7 +189,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -222,7 +222,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
GitReplyEvent.KIND,
@@ -244,7 +244,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(GiftWrapEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
@@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
@@ -58,7 +58,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
@@ -71,7 +71,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50,
@@ -86,7 +86,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200,
@@ -96,7 +96,7 @@ object NostrChannelDataSource : AmethystNostrDataSource("ChatroomFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200,
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.PrivateDmEvent
@@ -50,7 +50,7 @@ object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = myPeer.users.toList(),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
@@ -73,7 +73,7 @@ object NostrChatroomDataSource : AmethystNostrDataSource("ChatroomFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to myPeer.users.map { it }),
@@ -24,8 +24,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
@@ -41,7 +41,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since =
@@ -56,7 +56,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(PrivateDmEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
@@ -71,7 +71,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
@@ -91,7 +91,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
// Metadata comes from any relay
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = followingEvents.toList(),
since =
@@ -113,7 +113,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
// Metadata comes from any relay
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to listOf(it)),
limit = 1,
@@ -131,7 +131,7 @@ object NostrChatroomListDataSource : AmethystNostrDataSource("MailBoxFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMessageEvent.KIND),
tags = mapOf("e" to listOf(it)),
since =
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
@@ -38,7 +38,7 @@ object NostrCommunityDataSource : AmethystNostrDataSource("SingleCommunityFeed")
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
authors =
community
.moderators()
@@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
@@ -66,10 +67,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
}
fun createMarketplaceFilter(): List<TypedFilter> {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value
val hashToLoad =
account.liveDiscoveryFollowLists.value
?.hashtags
@@ -81,9 +79,9 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ClassifiedsEvent.KIND),
limit = 300,
@@ -98,7 +96,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
@@ -120,7 +118,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ClassifiedsEvent.KIND),
tags =
mapOf(
@@ -146,7 +144,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(AppDefinitionEvent.KIND),
limit = 300,
tags = mapOf("k" to kTags),
@@ -165,12 +163,14 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
?.users
?.toList()
val followsRelays = account.liveDiscoveryListAuthorsPerRelay.value
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
authors = follows,
SinceAuthorPerRelayFilter(
authors = followsRelays,
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
limit = 300,
since =
@@ -184,7 +184,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
tags = mapOf("p" to it),
kinds = listOf(LiveActivitiesEvent.KIND),
limit = 100,
@@ -200,17 +200,14 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
}
fun createPublicChatFilter(): List<TypedFilter> {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value
val followChats = account.selectedChatsFollowList().toList()
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(ChannelMessageEvent.KIND),
limit = 500,
@@ -225,7 +222,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
ids = followChats,
kinds = listOf(ChannelCreateEvent.KIND, ChannelMessageEvent.KIND),
limit = 300,
@@ -243,15 +240,12 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
}
fun createCommunitiesFilter(): TypedFilter {
val follows =
account.liveDiscoveryFollowLists.value
?.users
?.toList()
val follows = account.liveDiscoveryListAuthorsPerRelay.value
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
limit = 300,
@@ -275,7 +269,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
@@ -305,7 +299,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LiveActivitiesEvent.KIND),
tags =
mapOf(
@@ -335,7 +329,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
@@ -366,7 +360,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND, ChannelMessageEvent.KIND),
tags =
@@ -397,7 +391,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
@@ -427,7 +421,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(CommunityDefinitionEvent.KIND, CommunityPostApprovalEvent.KIND),
tags =
mapOf(
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
@@ -43,7 +43,7 @@ object NostrGeohashDataSource : AmethystNostrDataSource("SingleGeoHashFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
tags =
mapOf(
"g" to
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
@@ -43,7 +43,7 @@ object NostrHashtagDataSource : AmethystNostrDataSource("SingleHashtagFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
tags =
mapOf(
"t" to
@@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
@@ -76,13 +77,13 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
val follows =
account.liveHomeListAuthorsPerRelay.value
return TypedFilter(
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -99,7 +100,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
LiveActivitiesEvent.KIND,
WikiNoteEvent.KIND,
),
authors = followSet,
authors = follows,
limit = 400,
since =
latestEOSEs.users[account.userProfile()]
@@ -111,20 +112,19 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
}
fun createFollowMetadataAndReleaseFilter(): TypedFilter? {
val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.shuffled()?.ifEmpty { null }
val follows = account.liveHomeListAuthorsPerRelay.value
return if (followSet != null) {
return if (follows != null) {
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
kinds =
listOf(
MetadataEvent.KIND,
AdvertisedRelayListEvent.KIND,
),
authors = followSet.take(500),
authors = follows,
since =
latestEOSEs.users[account.userProfile()]
?.followList
@@ -145,7 +145,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -184,7 +184,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -223,7 +223,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.signers.NostrSigner
@@ -39,7 +39,7 @@ class NostrLnZapPaymentResponseDataSource(
return TypedFilter(
types = feedTypes,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = listOf(fromServiceHex),
tags =
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
@@ -95,7 +95,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, aTag.kind),
authors = listOfNotNull(aTag.pubKeyHex),
// just to be sure
@@ -110,7 +110,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
ids = listOfNotNull(hexToWatch),
),
),
@@ -118,7 +118,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(hexToWatch),
// just to be sure
@@ -135,7 +135,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 1000,
@@ -144,7 +144,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -165,7 +165,7 @@ object NostrSearchEventOrUserDataSource : AmethystNostrDataSource("SearchEventFe
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
@@ -25,8 +25,8 @@ import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
@@ -44,7 +44,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelMetadataEvent.KIND),
tags = mapOf("e" to reactionsToWatch),
),
@@ -65,7 +65,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
return TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ChannelCreateEvent.KIND),
ids = interestedEvents.toList(),
),
@@ -88,7 +88,7 @@ object NostrSingleChannelDataSource : AmethystNostrDataSource("SingleChannelFeed
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
@@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
@@ -67,7 +67,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -89,7 +89,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
@@ -117,7 +117,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex),
limit = 5,
@@ -127,7 +127,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex),
@@ -150,7 +150,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -173,7 +173,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
DeletionEvent.KIND,
@@ -200,7 +200,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(TextNoteEvent.KIND),
tags = mapOf("q" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
@@ -232,7 +232,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
ids = interestedEvents.toList(),
),
),
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.EVENT_FINDER_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.EOSETime
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.events.MetadataEvent
@@ -45,7 +45,7 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, AdvertisedRelayListEvent.KIND),
authors = firstTimers,
),
@@ -69,7 +69,7 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND),
authors = groupIds,
since = minEOSEs,
@@ -78,7 +78,7 @@ object NostrSingleUserDataSource : AmethystNostrDataSource("SingleUserFeed") {
TypedFilter(
types = EVENT_FINDER_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ReportEvent.KIND),
tags = mapOf("p" to groupIds),
since = minEOSEs,
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null
@@ -45,7 +45,7 @@ object NostrThreadDataSource : AmethystNostrDataSource("SingleThreadFeed") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
ids = eventsToLoad.toList(),
),
)
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.AppRecommendationEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
@@ -56,7 +56,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -69,7 +69,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(
TextNoteEvent.KIND,
@@ -94,7 +94,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 200,
@@ -107,7 +107,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -120,7 +120,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(ContactListEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
),
@@ -132,7 +132,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BadgeProfilesEvent.KIND),
authors = listOf(it.pubkeyHex),
limit = 1,
@@ -145,7 +145,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(BookmarkListEvent.KIND, PeopleListEvent.KIND, AppRecommendationEvent.KIND),
authors = listOf(it.pubkeyHex),
@@ -159,7 +159,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds =
listOf(ProfileGalleryEntryEvent.KIND),
authors = listOf(it.pubkeyHex),
@@ -173,7 +173,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(BadgeAwardEvent.KIND),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 20,
@@ -24,8 +24,9 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
@@ -64,15 +65,12 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
}
fun createContextualFilter(): TypedFilter {
val follows =
account.liveStoriesFollowLists.value
?.users
?.toList()
val follows = account.liveStoriesListAuthorsPerRelay.value
return TypedFilter(
types = setOf(FeedType.GLOBAL),
types = if (follows == null) setOf(FeedType.GLOBAL) else setOf(FeedType.FOLLOWS),
filter =
Filter(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
limit = 200,
@@ -97,7 +95,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
@@ -128,7 +126,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter =
Filter(
SincePerRelayFilter(
kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND),
tags =
mapOf(
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.ammolite.relays.EOSETime
import com.vitorpamplona.ammolite.relays.filters.EOSETime
class EOSERelayList(
var relayList: Map<String, EOSETime> = emptyMap(),
@@ -49,7 +49,19 @@ fun stringRes(id: Int): String = resourceCache.get(id) ?: stringResource(id).als
@Composable
fun stringRes(
id: Int,
vararg args: Any,
vararg args: String,
): String =
String
.format(
LocalConfiguration.current.locales.get(0),
resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) },
*args,
)
@Composable
fun stringRes(
id: Int,
vararg args: Int?,
): String =
String
.format(
@@ -66,7 +78,22 @@ fun stringRes(
fun stringRes(
ctx: Context,
id: Int,
vararg args: Any?,
vararg args: String?,
): String {
val res = ctx.resources
return String
.format(
res.configuration.locales.get(0),
resourceCache.get(id) ?: res.getString(id).also { resourceCache.put(id, it) },
*args,
)
}
fun stringRes(
ctx: Context,
id: Int,
vararg args: Int?,
): String {
val res = ctx.resources
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
@@ -30,7 +31,7 @@ import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.relays.RelayStats
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.utils.MinimumRelayListProcessor
import com.vitorpamplona.quartz.utils.RelayListRecommendationProcessor
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@Stable
class Kind3RelayListViewModel : ViewModel() {
private lateinit var account: Account
@@ -138,7 +140,7 @@ class Kind3RelayListViewModel : ViewModel() {
private fun refreshProposals() {
_proposedRelays.update {
val proposed =
MinimumRelayListProcessor
RelayListRecommendationProcessor
.reliableRelaySetFor(
account.liveKind3Follows.value.users.mapNotNull {
account.getNIP65RelayList(it)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
@@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@Stable
class Nip65RelayListViewModel : ViewModel() {
private lateinit var account: Account
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.util.Base64
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -29,7 +28,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
@@ -49,8 +47,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -65,10 +61,6 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImagePainter
import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
@@ -83,7 +75,6 @@ import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.Segment
@@ -96,7 +87,6 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -112,7 +102,6 @@ import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.withContext
fun isMarkdown(content: String): Boolean =
content.startsWith("> ") ||
@@ -445,47 +434,7 @@ private fun RenderWordWithPreview(
is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
is Base64Segment -> ImageFromBase64(word.segmentText)
}
}
@Composable
fun ImageFromBase64(base64String: String) {
val context = LocalContext.current
var imageBytes by remember { mutableStateOf<ByteArray?>(null) }
LaunchedEffect(base64String) {
imageBytes =
withContext(Dispatchers.IO) {
var base64String2 = base64String.removePrefix("data:image/jpeg;base64,")
RichTextParser.imageExtensions.forEach {
base64String2 = base64String2.removePrefix("data:image/$it;base64,")
}
runCatching { Base64.decode(base64String2, Base64.DEFAULT) }.getOrNull()
}
}
if (imageBytes == null) {
BlankNote()
} else {
val request =
ImageRequest
.Builder(context)
.data(imageBytes)
.build()
SubcomposeAsyncImage(
model = request,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
) {
when (painter.state) {
is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent()
}
else -> BlankNote()
}
}
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
}
}
@@ -20,15 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@@ -37,22 +33,10 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
import java.util.Base64
@Composable
fun RobohashAsyncImage(
@@ -92,53 +76,34 @@ fun RobohashFallbackAsyncImage(
loadRobohash: Boolean,
) {
if (model != null && loadProfilePicture) {
val isBase64 = model.startsWith("data:image/jpeg;base64,")
if (isBase64) {
val context = LocalContext.current
val base64Painter =
rememberAsyncImagePainter(
model = Base64Requester.imageRequest(context, model),
val painter =
if (loadRobohash) {
rememberVectorPainter(
image = CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight),
)
} else {
forwardingPainter(
painter =
rememberVectorPainter(
image = Icons.Default.Face,
),
colorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter,
)
}
Image(
painter = base64Painter,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
colorFilter = colorFilter,
)
} else {
val painter =
if (loadRobohash) {
rememberVectorPainter(
image = CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight),
)
} else {
forwardingPainter(
painter =
rememberVectorPainter(
image = Icons.Default.Face,
),
colorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter,
)
}
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
)
}
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
)
} else {
if (loadRobohash) {
Image(
@@ -161,48 +126,3 @@ fun RobohashFallbackAsyncImage(
}
}
}
object Base64Requester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest =
ImageRequest
.Builder(context)
.data(message)
.fetcherFactory(Base64Fetcher.Factory)
.build()
}
@Stable
class Base64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val base64String = data.toString().removePrefix("data:image/jpeg;base64,")
val byteArray = Base64.getDecoder().decode(base64String)
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
if (bitmap == null) {
throw Exception("Unable to load base64 $base64String")
}
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}
object Factory : Fetcher.Factory<Uri> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher = Base64Fetcher(options, data)
}
}
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue.EndToStart
@@ -39,10 +40,13 @@ import androidx.compose.material3.SwipeToDismissBoxValue.Settled
import androidx.compose.material3.SwipeToDismissBoxValue.StartToEnd
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
@@ -69,6 +73,7 @@ fun SwipeToDeleteContainer(
}
return@rememberSwipeToDismissBoxState true
},
positionalThreshold = { it * .40f },
)
SwipeToDismissBox(
@@ -86,11 +91,19 @@ fun DismissBackground(dismissState: SwipeToDismissBoxState) {
if (dismissState.currentValue > dismissState.targetValue) {
Color(0xFFFF1744)
} else {
Color.Transparent
MaterialTheme.colorScheme.surfaceVariant
},
label = "DismissBackground",
)
val haptic = LocalHapticFeedback.current
LaunchedEffect(key1 = dismissState.currentValue > dismissState.targetValue) {
// doesn't run for the first time or when the list is updated.
if (dismissState.progress > 0 && dismissState.progress < 1) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
Row(
modifier =
Modifier
@@ -36,15 +36,15 @@ class BookmarkPrivateFeedFilter(
val privateTags = bookmarks?.cachedPrivateTags() ?: return emptyList()
val notes =
bookmarks.filterEvents(privateTags).mapNotNull { LocalCache.checkGetOrCreateNote(it) }
val addresses =
bookmarks.filterAddresses(privateTags).map { LocalCache.getOrCreateAddressableNote(it) }
return notes
.plus(addresses)
.toSet()
.sortedWith(DefaultFeedOrder)
return privateTags
.mapNotNull {
if (it.size > 1 && it[0] == "e") {
LocalCache.checkGetOrCreateNote(it[1])
} else if (it.size > 1 && it[0] == "a") {
LocalCache.checkGetOrCreateAddressableNote(it[1])
} else {
null
}
}.reversed()
}
}
@@ -32,14 +32,16 @@ class BookmarkPublicFeedFilter(
override fun feed(): List<Note> {
val bookmarks = account.userProfile().latestBookmarkList
val notes =
bookmarks?.taggedEvents()?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
val addresses =
bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes
.plus(addresses)
.toSet()
.sortedWith(DefaultFeedOrder)
return bookmarks
?.tags
?.mapNotNull {
if (it.size > 1 && it[0] == "e") {
LocalCache.checkGetOrCreateNote(it[1])
} else if (it.size > 1 && it[0] == "a") {
LocalCache.checkGetOrCreateAddressableNote(it[1])
} else {
null
}
}?.reversed() ?: emptyList()
}
}
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.Event
val DefaultFeedOrder: Comparator<Note> =
compareBy<Note>(
compareByDescending<Note>
{
val noteEvent = it.event
if (noteEvent == null) {
@@ -36,8 +36,4 @@ val DefaultFeedOrder: Comparator<Note> =
null
}
}
},
{
it.idHex
},
).reversed()
}.thenBy { it.idHex }
@@ -763,9 +763,11 @@ class FollowListViewModel(
@OptIn(ExperimentalCoroutinesApi::class)
val liveKind3FollowsFlow: Flow<List<CodeName>> =
account.userProfile().flow().follows.stateFlow.transformLatest {
account.liveKind3FollowsFlow.transformLatest {
checkNotInMainThread()
val communities =
it.user.cachedFollowingCommunitiesSet().mapNotNull {
it.communities.mapNotNull {
LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote ->
CodeName(
"Community/${communityNote.idHex}",
@@ -776,12 +778,12 @@ class FollowListViewModel(
}
val hashtags =
it.user.cachedFollowingTagSet().map {
it.hashtags.map {
CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE)
}
val geotags =
it.user.cachedFollowingGeohashSet().map {
it.geotags.map {
CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE)
}
@@ -1007,7 +1009,7 @@ fun AmethystClickableIcon() {
fun debugState(context: Context) {
Client
.allSubscriptions()
.forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toJson() }}") }
.forEach { Log.d("STATE DUMP", "${it.key} ${it.value.joinToString { it.filter.toDebugJson() }}") }
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
@@ -83,6 +83,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServersListView
@@ -114,7 +115,6 @@ import com.vitorpamplona.ammolite.relays.RelayPoolStatus
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
@@ -151,7 +151,7 @@ fun DrawerContent(
EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, drawerState)
}
FollowingAndFollowerCounts(accountViewModel.account.userProfile(), onClickUser)
FollowingAndFollowerCounts(accountViewModel.account, onClickUser)
HorizontalDivider(
thickness = DividerThickness,
@@ -380,18 +380,12 @@ fun UserStatusDeleteButton(onClick: () -> Unit) {
@Composable
private fun FollowingAndFollowerCounts(
baseAccountUser: User,
baseAccountUser: Account,
onClick: () -> Unit,
) {
var followingCount by remember { mutableStateOf("--") }
val followingCount = baseAccountUser.liveKind3Follows.collectAsStateWithLifecycle()
var followerCount by remember { mutableStateOf("--") }
WatchFollow(baseAccountUser = baseAccountUser) { newFollowing ->
if (followingCount != newFollowing) {
followingCount = newFollowing
}
}
WatchFollower(baseAccountUser = baseAccountUser) { newFollower ->
if (followerCount != newFollower) {
followerCount = newFollower
@@ -402,7 +396,9 @@ private fun FollowingAndFollowerCounts(
modifier = drawerSpacing.clickable(onClick = onClick),
) {
Text(
text = followingCount,
text =
followingCount.value.users.size
.toString(),
fontWeight = FontWeight.Bold,
)
@@ -419,31 +415,19 @@ private fun FollowingAndFollowerCounts(
}
}
@Composable
fun WatchFollow(
baseAccountUser: User,
onReady: (String) -> Unit,
) {
val accountUserFollowsState by baseAccountUser.live().follows.observeAsState()
LaunchedEffect(key1 = accountUserFollowsState) {
launch(Dispatchers.IO) {
onReady(accountUserFollowsState?.user?.cachedFollowCount()?.toString() ?: "--")
}
}
}
@Composable
fun WatchFollower(
baseAccountUser: User,
baseAccountUser: Account,
onReady: (String) -> Unit,
) {
val accountUserFollowersState by baseAccountUser.live().followers.observeAsState()
val accountUserFollowersState by baseAccountUser
.userProfile()
.live()
.followers
.observeAsState()
LaunchedEffect(key1 = accountUserFollowersState) {
launch(Dispatchers.IO) {
onReady(accountUserFollowersState?.user?.cachedFollowerCount()?.toString() ?: "--")
}
onReady(baseAccountUser.followerCount().toString() ?: "--")
}
}
@@ -676,7 +676,7 @@ fun LoadModerators(
val newParticipantUsers =
if (followingKeySet == null) {
val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet()
val allFollows = accountViewModel.account.liveKind3Follows.value.users
val followingParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
@@ -732,7 +732,7 @@ private fun LoadParticipants(
val newParticipantUsers =
if (followingKeySet == null) {
val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet()
val allFollows = accountViewModel.account.liveKind3Follows.value.users
val followingParticipants =
ParticipantListBuilder()
.followsThatParticipateOn(baseNote, allFollows)
@@ -889,7 +889,7 @@ fun RenderChannelThumb(
val newParticipantUsers =
if (followingKeySet == null) {
val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet()
val allFollows = accountViewModel.account.liveKind3Follows.value.users
val followingParticipants =
ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList()
@@ -48,6 +48,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.outlined.ContentPaste
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Button
@@ -426,18 +427,6 @@ fun UpdateZapAmountDialog(
Modifier.weight(1f),
)
IconButton(onClick = {
onClose()
runCatching { uri.openUri("https://app.mutinywallet.com/settings/connections?name=Amethyst") }
}) {
Icon(
painter = painterResource(R.mipmap.mutiny),
null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified,
)
}
IconButton(
onClick = {
onClose()
@@ -458,7 +447,7 @@ fun UpdateZapAmountDialog(
},
) {
Icon(
Icons.Default.ContentPaste,
Icons.Outlined.ContentPaste,
contentDescription = stringRes(id = R.string.paste_from_clipboard),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
@@ -696,7 +685,7 @@ fun authenticate(
stringRes(
context,
R.string.biometric_authentication_failed_explainer_with_error,
errString,
errString.toString(),
),
)
}
@@ -367,11 +367,10 @@ fun WatchUserFollows(
remember {
accountViewModel.userFollows
.map {
it.user.isFollowingCached(userHex) ||
(userHex == accountViewModel.account.userProfile().pubkeyHex)
accountViewModel.isFollowing(userHex) || (userHex == accountViewModel.account.userProfile().pubkeyHex)
}.distinctUntilChanged()
}.observeAsState(
accountViewModel.account.userProfile().isFollowingCached(userHex) ||
accountViewModel.isFollowing(userHex) ||
(userHex == accountViewModel.account.userProfile().pubkeyHex),
)
@@ -29,12 +29,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.text.AnnotatedString
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -44,12 +44,11 @@ fun DisplayFollowingHashtagsInPost(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val userFollowState by accountViewModel.userFollows.observeAsState()
val userFollowState by accountViewModel.account.liveKind3Follows.collectAsStateWithLifecycle()
var firstTag by remember(baseNote) { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = userFollowState) {
val followingTags = userFollowState?.user?.cachedFollowingTagSet() ?: emptySet()
val newFirstTag = baseNote.event?.firstIsTaggedHashes(followingTags)
val newFirstTag = baseNote.event?.firstIsTaggedHashes(userFollowState.hashtags)
if (firstTag != newFirstTag) {
firstTag = newFirstTag
@@ -807,10 +807,10 @@ class AccountViewModel(
fun isFollowing(user: User?): Boolean {
if (user == null) return false
return account.userProfile().isFollowingCached(user)
return account.isFollowing(user)
}
fun isFollowing(user: HexKey): Boolean = account.userProfile().isFollowingCached(user)
fun isFollowing(user: HexKey): Boolean = account.isFollowing(user)
val hideDeleteRequestDialog: Boolean
get() = account.hideDeleteRequestDialog
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -59,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys.DRAFTS
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.maxWidthWithBackground
@Composable
fun DraftListScreen(
@@ -178,24 +178,23 @@ private fun DraftFeedLoaded(
}
}
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
SwipeToDeleteContainer(
modifier =
Modifier
.fillMaxWidth()
.animateContentSize(),
onStartToEnd = { accountViewModel.delete(item) },
onEndToStart = { accountViewModel.delete(item) },
Row(
Modifier
.fillMaxWidth()
.animateItemPlacement(),
) {
Row(
Modifier
.fillMaxWidth()
.animateItemPlacement()
.background(MaterialTheme.colorScheme.background),
SwipeToDeleteContainer(
modifier =
Modifier
.fillMaxWidth()
.animateContentSize(),
onStartToEnd = { accountViewModel.delete(item) },
onEndToStart = { accountViewModel.delete(item) },
) {
NoteCompose(
item,
routeForLastRead = routeForLastRead,
modifier = Modifier.fillMaxWidth(),
modifier = MaterialTheme.colorScheme.maxWidthWithBackground,
isBoostedNote = false,
isHiddenFeed = state.showHidden.value,
quotesLeft = 3,
@@ -188,7 +188,7 @@ fun GeoHashActionOptions(
.observeAsState()
val isFollowingTag by
remember(userState) {
derivedStateOf { userState?.user?.isFollowingGeohashCached(tag) ?: false }
derivedStateOf { userState?.user?.isFollowingGeohash(tag) ?: false }
}
if (isFollowingTag) {
@@ -167,7 +167,7 @@ fun HashtagActionOptions(
.observeAsState()
val isFollowingTag by
remember(userState) {
derivedStateOf { userState?.user?.isFollowingHashtagCached(tag) ?: false }
derivedStateOf { userState?.user?.isFollowingHashtag(tag) ?: false }
}
if (isFollowingTag) {
@@ -189,6 +189,16 @@ val LightInnerPostBorderModifier =
.clip(shape = QuoteBorder)
.border(1.dp, LightSubtleBorder, QuoteBorder)
val DarkMaxWidthWithBackground =
Modifier
.fillMaxWidth()
.background(DarkColorPalette.background)
val LightMaxWidthWithBackground =
Modifier
.fillMaxWidth()
.background(LightColorPalette.background)
val DarkSelectedReactionBoxModifier =
Modifier
.padding(horizontal = 5.dp, vertical = 5.dp)
@@ -395,6 +405,9 @@ val ColorScheme.replyModifier: Modifier
val ColorScheme.innerPostModifier: Modifier
get() = if (isLight) LightInnerPostBorderModifier else DarkInnerPostBorderModifier
val ColorScheme.maxWidthWithBackground: Modifier
get() = if (isLight) LightMaxWidthWithBackground else DarkMaxWidthWithBackground
val ColorScheme.channelNotePictureModifier: Modifier
get() = if (isLight) LightChannelNotePictureModifier else DarkChannelNotePictureModifier
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

@@ -801,4 +801,6 @@
<string name="http_status_508">Detekována smyčka - Server detekuje nekonečnou smyčku při zpracování požadavku</string>
<string name="http_status_511">Vyžadováno ověření sítě - Klient musí být autentizován, aby získal přístup k síti</string>
<string name="add_a_nip96_server">Přidat NIP-96 server</string>
<string name="delete_all">Vymazat vše</string>
<string name="delete_all_drafts_confirmation">Opravdu chcete odstranit všechny koncepty?</string>
</resources>
@@ -806,4 +806,6 @@ anz der Bedingungen ist erforderlich</string>
<string name="http_status_508">Schleife erkannt - Der Server erkennt eine Endlosschleife, während er die Anfrage bearbeitet</string>
<string name="http_status_511">Netzwerk-Authentifizierung erforderlich - Der Client muss authentifiziert werden, um auf das Netzwerk zuzugreifen</string>
<string name="add_a_nip96_server">NIP-96 Server hinzufügen</string>
<string name="delete_all">Alles löschen</string>
<string name="delete_all_drafts_confirmation">Möchten Sie wirklich alle Entwürfe löschen?</string>
</resources>
@@ -9,6 +9,7 @@
<string name="post_was_hidden">Dit bericht is verborgen omdat het je verborgen gebruikers of woorden noemt</string>
<string name="post_was_flagged_as_inappropriate_by">Bericht gemarkeerd als ongepast door</string>
<string name="post_not_found">Bericht niet gevonden</string>
<string name="post_not_found_short">👀</string>
<string name="channel_image">Kanaalafbeelding</string>
<string name="referenced_event_not_found">Verwezen event niet gevonden</string>
<string name="could_not_decrypt_the_message">Note versleuteld met encryptie</string>
@@ -36,6 +37,7 @@
<string name="report_impersonation">Meld imitatie</string>
<string name="report_explicit_content">Rapporteer expliciete inhoud</string>
<string name="report_illegal_behaviour">Rapporteer illegaal gedrag</string>
<string name="report_malware">Rapporteer malware</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Je gebruikt een publieke sleutel en publieke sleutels zijn alleen-lezen. Login met een geheime sleutel om te reageren</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Je gebruikt een publieke sleutel en publieke sleutels zijn alleen-lezen. Login met een geheime sleutel om berichten te boosten</string>
<string name="login_with_a_private_key_to_like_posts">Je gebruikt een publieke sleutel en publieke sleutels zijn alleen-lezen. Login met een geheime sleutel om berichten te liken</string>
@@ -131,6 +133,7 @@
<string name="conversations">Gesprekken</string>
<string name="notes">Notes</string>
<string name="replies">Reacties</string>
<string name="gallery">Galerij</string>
<string name="follows">"Volgend"</string>
<string name="reports">"Rapporten"</string>
<string name="more_options">Meer opties</string>
@@ -254,6 +257,8 @@
<string name="quick_action_delete">Verwijderen</string>
<string name="quick_action_unfollow">Ontvolgen</string>
<string name="quick_action_follow">Volgen</string>
<string name="quick_action_request_deletion_gallery_title">Verwijderen uit galerij</string>
<string name="quick_action_request_deletion_gallery_alert_body">Verwijder deze media uit je galerij, je kunt het later lezen</string>
<string name="quick_action_request_deletion_alert_title">Verzoek om te verwijderen</string>
<string name="quick_action_request_deletion_alert_body">Amethyst zal vragen om uw note te verwijderen van de relays waarmee u momenteel verbonden bent. Er is geen garantie dat uw note permanent wordt verwijderd van deze relays, of van andere relays waar het kan worden opgeslagen.</string>
<string name="quick_action_block_dialog_btn">Blokkeren</string>
@@ -267,6 +272,7 @@
<string name="report_dialog_impersonation">Kwaadwillige imitatie</string>
<string name="report_dialog_nudity">Naaktheid of gevoelige inhoud</string>
<string name="report_dialog_illegal">Illegaal gedrag</string>
<string name="report_dialog_malware">Malware</string>
<string name="report_dialog_blocking_a_user">Als u een gebruiker blokkeert, wordt zijn inhoud in uw app verborgen. Uw notes zijn nog steeds publiekelijk zichtbaar, ook voor mensen die u blokkeert. Geblokkeerde gebruikers worden vermeld in het scherm Beveiligingsfilters.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blokkeer & verberg gebruiker]]></string>
<string name="report_dialog_report_btn">Misbruik melden</string>
@@ -339,6 +345,14 @@
<string name="zap_type_nonzap_explainer">Geen spoor binnen Nostr, alleen in Lightning</string>
<string name="file_server">File server</string>
<string name="zap_forward_lnAddress">LnAddress of @User</string>
<string name="media_servers">Mediaservers</string>
<string name="set_preferred_media_servers">Stel uw gewenste media upload servers in.</string>
<string name="no_media_server_message">Je hebt geen aangepaste mediaservers ingesteld. Je kunt de lijst van Amethyst gebruiken of er hieronder een toevoegen ↓</string>
<string name="built_in_media_servers_title">Ingebouwde mediaservers</string>
<string name="built_in_servers_description">Amethyst\'s standaardlijst. U kunt ze individueel toevoegen of de lijst toevoegen.</string>
<string name="use_default_servers">Gebruik standaard lijst</string>
<string name="add_media_server">Mediaserver toevoegen</string>
<string name="delete_media_server">Mediaserver verwijderen</string>
<string name="upload_server_relays_nip95">Uw relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Bestanden worden geüpload naar en gehost door relays. Ze zijn vrij van een vaste url (afhankelijkheid van derden). Zorg ervoor dat u een NIP-95 relay in uw lijst met relays hebt.</string>
<string name="connect_via_tor_short">Orbot/Tor setup</string>
@@ -507,6 +521,7 @@
<string name="share_or_save">Delen of opslaan</string>
<string name="copy_url_to_clipboard">Kopieer URL naar klembord</string>
<string name="copy_the_note_id_to_the_clipboard">Note naar klembord kopiëren</string>
<string name="add_media_to_gallery">Media toevoegen aan galerij</string>
<string name="created_at">Gemaakt op</string>
<string name="rules">Regels</string>
<string name="login_with_external_signer">Login met Amber</string>
@@ -710,6 +725,8 @@
<string name="private_outbox_section_explainer">Plaats tussen de 1 tot 3 relays om gebeurtenissen op te slaan die niemand anders kan zien, zoals uw Concepten en/of app instellingen. Idealiter zijn deze relays lokaal of vereist authenticatie voordat de inhoud van elke gebruiker wordt gedownload.</string>
<string name="kind_3_section">Algemene relays</string>
<string name="kind_3_section_description">Amethyst gebruikt deze relays om berichten voor jou te downloaden.</string>
<string name="kind_3_recommended_section">Aanbevolen relays</string>
<string name="kind_3_recommended_section_description">Voeg de volgende relays toe aan je algemene relay-lijst om berichten van de vermelde gebruikers te ontvangen.</string>
<string name="search_section">Relays zoeken</string>
<string name="search_section_explainer">Lijst van relays om te gebruiken bij het zoeken naar inhoud of gebruikers. Taggen en zoeken werkt niet als er geen opties beschikbaar zijn. Zorg ervoor dat ze NIP-50 implementeren.</string>
<string name="local_section">Lokale relays</string>
@@ -785,4 +802,7 @@
<string name="http_status_507">Onvoldoende opslagruimte - De server heeft niet genoeg opslagruimte om het verzoek succesvol te verwerken</string>
<string name="http_status_508">Loop gedetecteerd - De server detecteert een oneindige loop tijdens het verwerken van het verzoek</string>
<string name="http_status_511">Netwerk authenticatie vereist - De client moet geauthenticeerd zijn om toegang te krijgen tot het netwerk</string>
<string name="add_a_nip96_server">Voeg een NIP-96 Server toe</string>
<string name="delete_all">Alles verwijderen</string>
<string name="delete_all_drafts_confirmation">Weet u zeker dat u alle concepten wilt verwijderen?</string>
</resources>
@@ -801,4 +801,6 @@
<string name="http_status_508">Loop detectado - O servidor detecta um loop infinito enquanto processa a requisição</string>
<string name="http_status_511">Autenticação de rede necessária - O cliente deve ser autenticado para acessar a rede</string>
<string name="add_a_nip96_server">Adicionar um servidor NIP-96</string>
<string name="delete_all">Apagar tudo</string>
<string name="delete_all_drafts_confirmation">Você tem certeza de que deseja excluir todos os rascunhos?</string>
</resources>
@@ -800,4 +800,6 @@
<string name="http_status_508">Loop upptäckt - Servern upptäcker en oändlig loop medan den bearbetar begäran</string>
<string name="http_status_511">Nätverksautentisering krävs - Klienten måste autentiseras för att få tillgång till nätverket</string>
<string name="add_a_nip96_server">Lägg till en NIP-96 Server</string>
<string name="delete_all">Radera alla</string>
<string name="delete_all_drafts_confirmation">Är du säker på att du vill ta bort alla utkast?</string>
</resources>
@@ -800,4 +800,6 @@
<string name="http_status_508">พบลูป - เซิร์ฟเวอร์ตรวจพบการวนลูปไม่สิ้นสุดขณะประมวลผลคำขอ</string>
<string name="http_status_511">ต้องการการตรวจสอบเครือข่าย - ไคลเอนต์ต้องได้รับการตรวจสอบเพื่อเข้าถึงเครือข่าย</string>
<string name="add_a_nip96_server">เพิ่ม เซิร์ฟเวอร์ NIP-96</string>
<string name="delete_all">ลบทั้งหมด</string>
<string name="delete_all_drafts_confirmation">คุณแน่ใจหรือไม่ว่าต้องการลบแบบร่างทั้งหมด</string>
</resources>
@@ -1,103 +0,0 @@
/**
* Copyright (c) 2024 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.ammolite.relays
import com.vitorpamplona.quartz.events.Event
class Filter(
val ids: List<String>? = null,
val authors: List<String>? = null,
val kinds: List<Int>? = null,
val tags: Map<String, List<String>>? = null,
val since: Map<String, EOSETime>? = null,
val until: Long? = null,
val limit: Int? = null,
val search: String? = null,
) {
fun toJson(forRelay: String? = null): String {
val factory = Event.mapper.nodeFactory
val filter =
factory.objectNode().apply {
ids?.run {
replace(
"ids",
factory.arrayNode(ids.size).apply { ids.forEach { add(it) } },
)
}
authors?.run {
replace(
"authors",
factory.arrayNode(authors.size).apply { authors.forEach { add(it) } },
)
}
kinds?.run {
replace(
"kinds",
factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } },
)
}
tags?.run {
entries.forEach { kv ->
replace(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
)
}
}
since?.run {
if (!isEmpty()) {
if (forRelay != null) {
val relaySince = get(forRelay)
if (relaySince != null) {
put("since", relaySince.time)
}
} else {
val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs ->
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
}
put("since", jsonObjectSince)
}
}
}
until?.run { put("until", until) }
limit?.run { put("limit", limit) }
search?.run { put("search", search) }
}
return Event.mapper.writeValueAsString(filter)
}
fun match(
event: Event,
forRelay: String? = null,
): Boolean {
if (ids?.any { event.id == it } == false) return false
if (kinds?.any { event.kind == it } == false) return false
if (authors?.any { event.pubKey == it } == false) return false
tags?.forEach { tag ->
if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false
}
if (event.createdAt !in (since?.get(forRelay)?.time ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
return false
}
return true
}
}
@@ -192,7 +192,7 @@ abstract class NostrDataSource(
}
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
private val bundler = BundledUpdate(300, Dispatchers.Default)
fun invalidateFilters() {
bundler.invalidate {
@@ -30,14 +30,14 @@ object RelayStats {
fun addBytesReceived(
url: String,
bytesUsedInMemory: Int,
bytesUsedInMemory: Long,
) {
get(url).addBytesReceived(bytesUsedInMemory)
}
fun addBytesSent(
url: String,
bytesUsedInMemory: Int,
bytesUsedInMemory: Long,
) {
get(url).addBytesSent(bytesUsedInMemory)
}
@@ -102,11 +102,11 @@ class RelayStat(
messages.put(debugMessage, debugMessage)
}
fun addBytesReceived(bytesUsedInMemory: Int) {
fun addBytesReceived(bytesUsedInMemory: Long) {
receivedBytes += bytesUsedInMemory
}
fun addBytesSent(bytesUsedInMemory: Int) {
fun addBytesSent(bytesUsedInMemory: Long) {
sentBytes += bytesUsedInMemory
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.ammolite.relays
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import java.util.UUID
data class Subscription(
@@ -42,29 +44,76 @@ data class Subscription(
typedFilters?.forEachIndexed { index, typedFilter ->
val otherFilter = otherFilters?.getOrNull(index) ?: return true
// Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed.
// fast check
if (typedFilter.filter.authors?.size != otherFilter.filter.authors?.size ||
typedFilter.filter.ids?.size != otherFilter.filter.ids?.size ||
typedFilter.filter.tags?.size != otherFilter.filter.tags?.size ||
typedFilter.filter.kinds?.size != otherFilter.filter.kinds?.size ||
typedFilter.filter.limit != otherFilter.filter.limit ||
typedFilter.filter.search?.length != otherFilter.filter.search?.length ||
typedFilter.filter.until != otherFilter.filter.until
) {
return true
if (typedFilter.filter is SincePerRelayFilter && otherFilter.filter is SincePerRelayFilter) {
return isDifferent(typedFilter.filter, otherFilter.filter)
}
// deep check
if (typedFilter.filter.ids != otherFilter.filter.ids ||
typedFilter.filter.authors != otherFilter.filter.authors ||
typedFilter.filter.tags != otherFilter.filter.tags ||
typedFilter.filter.kinds != otherFilter.filter.kinds ||
typedFilter.filter.search != otherFilter.filter.search
) {
return true
if (typedFilter.filter is SinceAuthorPerRelayFilter && otherFilter.filter is SinceAuthorPerRelayFilter) {
return isDifferent(typedFilter.filter, otherFilter.filter)
}
return true
}
return false
}
fun isDifferent(
filter1: SincePerRelayFilter,
filter2: SincePerRelayFilter,
): Boolean {
// Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed.
// fast check
if (filter1.authors?.size != filter2.authors?.size ||
filter1.ids?.size != filter2.ids?.size ||
filter1.tags?.size != filter2.tags?.size ||
filter1.kinds?.size != filter2.kinds?.size ||
filter1.limit != filter2.limit ||
filter1.search?.length != filter2.search?.length ||
filter1.until != filter2.until
) {
return true
}
// deep check
if (filter1.ids != filter2.ids ||
filter1.authors != filter2.authors ||
filter1.tags != filter2.tags ||
filter1.kinds != filter2.kinds ||
filter1.search != filter2.search
) {
return true
}
return false
}
fun isDifferent(
filter1: SinceAuthorPerRelayFilter,
filter2: SinceAuthorPerRelayFilter,
): Boolean {
// Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed.
// fast check
if (filter1.authors?.size != filter2.authors?.size ||
filter1.ids?.size != filter2.ids?.size ||
filter1.tags?.size != filter2.tags?.size ||
filter1.kinds?.size != filter2.kinds?.size ||
filter1.limit != filter2.limit ||
filter1.search?.length != filter2.search?.length ||
filter1.until != filter2.until
) {
return true
}
// deep check
if (filter1.ids != filter2.ids ||
filter1.authors != filter2.authors ||
filter1.tags != filter2.tags ||
filter1.kinds != filter2.kinds ||
filter1.search != filter2.search
) {
return true
}
return false
}
}
@@ -20,7 +20,9 @@
*/
package com.vitorpamplona.ammolite.relays
import com.vitorpamplona.ammolite.relays.filters.IPerRelayFilter
class TypedFilter(
val types: Set<FeedType>,
val filter: Filter,
val filter: IPerRelayFilter,
)
@@ -18,8 +18,11 @@
* 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.ammolite.relays
package com.vitorpamplona.ammolite.relays.filters
/*
* Wrapper class to allow changing in EOSE without modifying the list it is included within
*/
class EOSETime(
var time: Long,
) {
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2024 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.ammolite.relays.filters
import com.vitorpamplona.quartz.events.Event
class Filter(
val ids: List<String>? = null,
val authors: List<String>? = null,
val kinds: List<Int>? = null,
val tags: Map<String, List<String>>? = null,
val since: Long? = null,
val until: Long? = null,
val limit: Int? = null,
val search: String? = null,
) {
fun toJson() = FilterSerializer.toJson(ids, authors, kinds, tags, since, until, limit, search)
fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, since, until)
}
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2024 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.ammolite.relays.filters
import com.vitorpamplona.quartz.events.Event
object FilterMatcher {
fun match(
event: Event,
ids: List<String>? = null,
authors: List<String>? = null,
kinds: List<Int>? = null,
tags: Map<String, List<String>>? = null,
since: Long? = null,
until: Long? = null,
): Boolean {
if (ids?.contains(event.id) == false) return false
if (kinds?.contains(event.kind) == false) return false
if (authors?.contains(event.pubKey) == false) return false
tags?.forEach { tag ->
if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false
}
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
return false
}
return true
}
}
@@ -0,0 +1,94 @@
/**
* Copyright (c) 2024 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.ammolite.relays.filters
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.quartz.events.Event
object FilterSerializer {
fun toJsonObject(
ids: List<String>? = null,
authors: List<String>? = null,
kinds: List<Int>? = null,
tags: Map<String, List<String>>? = null,
since: Long? = null,
until: Long? = null,
limit: Int? = null,
search: String? = null,
): ObjectNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
ids?.run {
replace(
"ids",
factory.arrayNode(ids.size).apply { ids.forEach { add(it) } },
)
}
authors?.run {
replace(
"authors",
factory.arrayNode(authors.size).apply { authors.forEach { add(it) } },
)
}
kinds?.run {
replace(
"kinds",
factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } },
)
}
tags?.run {
entries.forEach { kv ->
replace(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
)
}
}
since?.run { put("since", since) }
until?.run { put("until", until) }
limit?.run { put("limit", limit) }
search?.run { put("search", search) }
}
}
fun toJson(
ids: List<String>? = null,
authors: List<String>? = null,
kinds: List<Int>? = null,
tags: Map<String, List<String>>? = null,
since: Long? = null,
until: Long? = null,
limit: Int? = null,
search: String? = null,
): String =
Event.mapper.writeValueAsString(
toJsonObject(
ids,
authors,
kinds,
tags,
since,
until,
limit,
search,
),
)
}
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2024 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.ammolite.relays.filters
import com.vitorpamplona.quartz.events.Event
interface IPerRelayFilter {
fun toJson(forRelay: String): String
fun match(
event: Event,
forRelay: String,
): Boolean
fun toDebugJson(): String
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2024 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.ammolite.relays.filters
import com.vitorpamplona.quartz.events.Event
/**
* This is a nostr filter with per-relay authors list and since parameters
*/
class SinceAuthorPerRelayFilter(
val ids: List<String>? = null,
val authors: Map<String, List<String>>? = null,
val kinds: List<Int>? = null,
val tags: Map<String, List<String>>? = null,
val since: Map<String, EOSETime>? = null,
val until: Long? = null,
val limit: Int? = null,
val search: String? = null,
) : IPerRelayFilter {
override fun toJson(forRelay: String) = FilterSerializer.toJson(ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until, limit, search)
override fun match(
event: Event,
forRelay: String,
) = FilterMatcher.match(event, ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until)
override fun toDebugJson(): String {
val factory = Event.mapper.nodeFactory
val obj = FilterSerializer.toJsonObject(ids, null, kinds, tags, null, until, limit, search)
authors?.run {
if (isNotEmpty()) {
val jsonObjectPerRelayAuthors = factory.objectNode()
entries.forEach { relayAuthorPairs ->
jsonObjectPerRelayAuthors.put(relayAuthorPairs.key, factory.arrayNode(relayAuthorPairs.value.size).apply { relayAuthorPairs.value.forEach { add(it) } })
}
obj.put("authors", jsonObjectPerRelayAuthors)
}
}
since?.run {
if (isNotEmpty()) {
val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs ->
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
}
obj.put("since", jsonObjectSince)
}
}
return Event.mapper.writeValueAsString(obj)
}
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2024 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.ammolite.relays.filters
import com.vitorpamplona.quartz.events.Event
/**
* This is a nostr filter with per-relay authors list and since parameters
*/
class SincePerRelayFilter(
val ids: List<String>? = null,
val authors: List<String>? = null,
val kinds: List<Int>? = null,
val tags: Map<String, List<String>>? = null,
val since: Map<String, EOSETime>? = null,
val until: Long? = null,
val limit: Int? = null,
val search: String? = null,
) : IPerRelayFilter {
override fun toJson(forRelay: String) = FilterSerializer.toJson(ids, authors, kinds, tags, since?.get(forRelay)?.time, until, limit, search)
override fun match(
event: Event,
forRelay: String,
) = FilterMatcher.match(event, ids, authors, kinds, tags, since?.get(forRelay)?.time, until)
override fun toDebugJson(): String {
val factory = Event.mapper.nodeFactory
val obj = FilterSerializer.toJsonObject(ids, authors, kinds, tags, null, until, limit, search)
since?.run {
if (isNotEmpty()) {
val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs ->
jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
}
obj.put("since", jsonObjectSince)
}
}
return Event.mapper.writeValueAsString(obj)
}
}
@@ -27,6 +27,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.Nip01Serializer
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventFactory
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertTrue
import org.junit.Rule
@@ -246,4 +248,13 @@ class EventBenchmark {
serializer.escapeStringInto(specialEncoders, Nip01Serializer.BufferedDigestWriter(MessageDigest.getInstance("SHA-256")))
}
}
@Test
fun eventFactoryPerformanceTest() {
val now = TimeUtils.now()
val tags = arrayOf(arrayOf(""))
benchmarkRule.measureRepeated {
EventFactory.create("id", "pubkey", now, 1, tags, "content", "sig")
}
}
}
@@ -42,6 +42,47 @@ import java.util.regex.Pattern
import kotlin.coroutines.cancellation.CancellationException
class RichTextParser {
fun createImageContent(
fullUrl: String,
eventTags: ImmutableListOfLists<String>,
description: String?,
callbackUri: String? = null,
): MediaUrlImage {
val frags = Nip54InlineMetadata().parse(fullUrl)
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
return MediaUrlImage(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
)
}
fun createVideoContent(
fullUrl: String,
eventTags: ImmutableListOfLists<String>,
description: String?,
callbackUri: String? = null,
): MediaUrlVideo {
val frags = Nip54InlineMetadata().parse(fullUrl)
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
return MediaUrlVideo(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
)
}
fun parseMediaUrl(
fullUrl: String,
eventTags: ImmutableListOfLists<String>,
@@ -50,50 +91,17 @@ class RichTextParser {
): MediaUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip54InlineMetadata().parse(fullUrl)
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
MediaUrlImage(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
)
createImageContent(fullUrl, eventTags, description, callbackUri)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip54InlineMetadata().parse(fullUrl)
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
MediaUrlVideo(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
)
createVideoContent(fullUrl, eventTags, description, callbackUri)
} else {
null
}
}
private fun parseBase64Images(content: String): LinkedHashSet<String> {
val regex = "data:image/(${imageExtensions.joinToString(separator = "|") { it } });base64,[a-zA-Z0-9+/]+={0,2}"
val pattern = Pattern.compile(regex)
val matcher = pattern.matcher(content)
val base64Images = mutableListOf<String>()
// Find all matches and add them to the list
while (matcher.find()) {
base64Images.add(matcher.group())
}
return base64Images.mapTo(LinkedHashSet(base64Images.size)) { it }
private fun checkBase64(content: String): Boolean {
val matcher = base64contentPattern.matcher(content)
return matcher.find()
}
fun parseValidUrls(content: String): LinkedHashSet<String> {
@@ -129,16 +137,23 @@ class RichTextParser {
val imagesForPager =
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags, content, callbackUri) }.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap = Nip30CustomEmoji.createEmojiMap(tags)
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
val base64Images = segments.map { it.words.filterIsInstance<Base64Segment>() }.flatten()
val imagesForPagerWithBase64 =
imagesForPager +
base64Images
.map { createImageContent(it.segmentText, tags, content, callbackUri) }
.associateBy { it.url }
return RichTextViewerState(
urlSet.toImmutableSet(),
imagesForPager.toImmutableMap(),
imageList.toImmutableList(),
imagesForPagerWithBase64.toImmutableMap(),
imagesForPagerWithBase64.values.toImmutableList(),
emojiMap.toImmutableMap(),
segments,
)
@@ -218,11 +233,8 @@ class RichTextParser {
): Segment {
if (word.isEmpty()) return RegularTextSegment(word)
if (word.startsWith("data:image")) {
val base64Images = parseBase64Images(word)
if (base64Images.isNotEmpty()) {
return Base64Segment(word)
}
if (word.startsWith("data:image/")) {
if (checkBase64(word)) return Base64Segment(word)
}
if (images.contains(word)) return ImageSegment(word)
@@ -338,6 +350,8 @@ class RichTextParser {
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
val base64contentPattern = Pattern.compile("data:image/(${imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})")
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
val hashTagsPattern: Pattern =
Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.crypto
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toHexKey
class KeyPair(
@@ -29,6 +30,7 @@ class KeyPair(
) {
val privKey: ByteArray?
val pubKey: ByteArray
val pubKeyHex: HexKey
init {
if (privKey == null) {
@@ -52,6 +54,8 @@ class KeyPair(
this.pubKey = pubKey
}
}
this.pubKeyHex = this.pubKey.toHexKey().intern()
}
override fun toString(): String = "KeyPair(privateKey=${privKey?.toHexKey()}, publicKey=${pubKey.toHexKey()}"
@@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.encoders
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
data class ATag(
@@ -30,6 +32,13 @@ data class ATag(
val dTag: String,
val relay: String?,
) {
fun countMemory(): Long =
5 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit)
8L + // kind
pubKeyHex.bytesUsedInMemory() +
dTag.bytesUsedInMemory() +
(relay?.bytesUsedInMemory() ?: 0)
fun toTag() = assembleATag(kind, pubKeyHex, dTag)
fun toNAddr(): String =
@@ -27,6 +27,8 @@ import com.fasterxml.jackson.annotation.JsonProperty
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import java.io.ByteArrayInputStream
@Stable
@@ -58,6 +60,28 @@ class AppMetadata {
@Transient
var tags: ImmutableListOfLists<String>? = null
fun countMemory(): Long =
20 * pointerSizeInBytes + // 20 fields, 4 bytes for each reference
(name?.bytesUsedInMemory() ?: 0L) +
(username?.bytesUsedInMemory() ?: 0L) +
(displayName?.bytesUsedInMemory() ?: 0L) +
(picture?.bytesUsedInMemory() ?: 0L) +
(banner?.bytesUsedInMemory() ?: 0L) +
(image?.bytesUsedInMemory() ?: 0L) +
(website?.bytesUsedInMemory() ?: 0L) +
(about?.bytesUsedInMemory() ?: 0L) +
(subscription?.bytesUsedInMemory() ?: 0L) +
(cashuAccepted?.bytesUsedInMemory() ?: 0L) +
(encryptionSupported?.bytesUsedInMemory() ?: 0L) +
(personalized?.bytesUsedInMemory() ?: 0L) + // A Boolean has 8 bytes of header, plus 1 byte of payload, for a total of 9 bytes of information. The JVM then rounds it up to the next multiple of 8. so the one instance of java.lang.Boolean takes up 16 bytes of memory.
(amount?.bytesUsedInMemory() ?: 0L) +
(nip05?.bytesUsedInMemory() ?: 0L) +
(domain?.bytesUsedInMemory() ?: 0L) +
(lud06?.bytesUsedInMemory() ?: 0L) +
(lud16?.bytesUsedInMemory() ?: 0L) +
(twitter?.bytesUsedInMemory() ?: 0L) +
(tags?.lists?.sumOf { it.sumOf { it.bytesUsedInMemory() } } ?: 0L)
fun anyName(): String? = displayName ?: name ?: username
fun anyNameStartsWith(prefix: String): Boolean =
@@ -108,6 +132,8 @@ class AppDefinitionEvent(
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun countMemory(): Long = super.countMemory() + (cachedMetadata?.countMemory() ?: 8L)
@Transient private var cachedMetadata: AppMetadata? = null
fun appMetaData() =
@@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlinx.collections.immutable.ImmutableSet
@Immutable
@@ -37,6 +39,10 @@ class ChannelListEvent(
) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient var publicAndPrivateEventCache: ImmutableSet<HexKey>? = null
override fun countMemory(): Long =
super.countMemory() +
32 + (publicAndPrivateEventCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0L) // rough calculation
override fun dTag() = FIXED_D_TAG
fun publicAndPrivateEvents(
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
@@ -39,6 +40,10 @@ class CommunityListEvent(
) : GeneralListEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient var publicAndPrivateEventCache: ImmutableSet<ATag>? = null
override fun countMemory(): Long =
super.countMemory() +
32 + (publicAndPrivateEventCache?.sumOf { pointerSizeInBytes + it.countMemory() } ?: 0L) // rough calculation
override fun dTag() = FIXED_D_TAG
fun publicAndPrivateEvents(
@@ -46,40 +46,35 @@ class ContactListEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
// This function is only used by the user logged in
// But it is used all the time.
@delegate:Transient
val verifiedFollowKeySet: Set<HexKey> by lazy {
tags.mapNotNullTo(HashSet()) {
try {
if (it.size > 1 && it[0] == "p") {
/**
* Returns a list of p-tags that are verified as hex keys.
*/
fun verifiedFollowKeySet(): Set<HexKey> =
tags.mapNotNullTo(mutableSetOf()) {
if (it.size > 1 && it[0] == "p") {
try {
decodePublicKey(it[1]).toHexKey()
} else {
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse p-tag $it in the contact list of $pubKey with id $id", e)
null
}
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
} else {
null
}
}
}
@delegate:Transient
val verifiedFollowTagSet: Set<String> by lazy {
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
}
@delegate:Transient
val verifiedFollowGeohashSet: Set<String> by lazy {
unverifiedFollowGeohashSet().map { it.lowercase() }.toSet()
}
@delegate:Transient
val verifiedFollowCommunitySet: Set<String> by lazy { unverifiedFollowAddressSet().toSet() }
@delegate:Transient
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy { verifiedFollowKeySet + pubKey }
/**
* Returns a list of a-tags that are verified as correct.
*/
fun verifiedFollowAddressSet(): Set<HexKey> =
tags
.mapNotNullTo(mutableSetOf()) {
if (it.size > 1 && it[0] == "a") {
ATag.parse(it[1], null)?.toTag()
} else {
null
}
}
fun unverifiedFollowKeySet() = tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { it.getOrNull(1) }
@@ -105,7 +100,7 @@ class ContactListEvent(
}
}
fun followsTags() = tags.filter { it.size > 1 && it[0] == "t" }.mapNotNull { it.getOrNull(1) }
fun followsTags() = hashtags()
fun relays(): Map<String, ReadWrite>? =
try {
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class DraftEvent(
@@ -37,6 +38,10 @@ class DraftEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
override fun countMemory(): Long =
super.countMemory() +
32 + (cachedInnerEvent.values.sumOf { pointerSizeInBytes + (it?.countMemory() ?: 0) })
override fun isContentEncoded() = true
fun isDeleted() = content == ""
@@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import java.math.BigDecimal
import java.security.MessageDigest
@@ -60,10 +61,11 @@ open class Event(
override fun isContentEncoded() = false
override fun countMemory(): Long =
12L +
7 * pointerSizeInBytes + // 7 fields, 4 bytes each reference (32bit)
12L + // createdAt + kind
id.bytesUsedInMemory() +
pubKey.bytesUsedInMemory() +
tags.sumOf { it.sumOf { it.bytesUsedInMemory() } } +
tags.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } +
content.bytesUsedInMemory() +
sig.bytesUsedInMemory()
@@ -24,6 +24,11 @@ import com.vitorpamplona.quartz.encoders.toHexKey
class EventFactory {
companion object {
val additionalFactories =
mutableMapOf(
WikiNoteEvent.KIND to ::WikiNoteEvent,
)
fun create(
id: String,
pubKey: String,
@@ -141,7 +146,13 @@ class EventFactory {
VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig)
VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig)
WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig)
else -> Event(id, pubKey, createdAt, kind, tags, content, sig)
else -> {
additionalFactories[kind]?.let {
return it(id, pubKey, createdAt, tags, content, sig)
}
Event(id, pubKey, createdAt, kind, tags, content, sig)
}
}
}
}
@@ -60,7 +60,7 @@ class GalleryListEvent(
createdAt: Long = TimeUtils.now(),
onReady: (GalleryListEvent) -> Unit,
) {
var tags = arrayOf(tagName, url, eventid)
val tags = arrayOf(tagName, url, eventid)
if (relay != null) {
tags + relay
}
@@ -26,6 +26,8 @@ import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
import java.util.HashSet
@@ -42,6 +44,10 @@ abstract class GeneralListEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient private var privateTagsCache: Array<Array<String>>? = null
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0)
override fun isContentEncoded() = true
fun category() = dTag()
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class GiftWrapEvent(
@@ -38,6 +39,10 @@ class GiftWrapEvent(
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
override fun countMemory(): Long =
super.countMemory() +
32 + (cachedInnerEvent.values.sumOf { pointerSizeInBytes + (it?.countMemory() ?: 0) }) // rough calculation
fun copyNoContent(): GiftWrapEvent {
val copy =
GiftWrapEvent(
@@ -24,6 +24,7 @@ import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class LnZapEvent(
@@ -38,6 +39,10 @@ class LnZapEvent(
// This event is also kept in LocalCache (same object)
@Transient val zapRequest: LnZapRequestEvent?
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (zapRequest?.countMemory() ?: 0) // rough calculation
override fun containedPost(): LnZapRequestEvent? =
try {
description()?.ifBlank { null }?.let { fromJson(it) } as? LnZapRequestEvent
@@ -29,6 +29,8 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class LnZapPaymentRequestEvent(
@@ -42,6 +44,10 @@ class LnZapPaymentRequestEvent(
// Once one of an app user decrypts the payment, all users else can see it.
@Transient private var lnInvoice: String? = null
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (lnInvoice?.bytesUsedInMemory() ?: 0) // rough calculation
fun walletServicePubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) walletServicePubKey() ?: pubKey else pubKey
@@ -29,6 +29,8 @@ import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class LnZapPaymentResponseEvent(
@@ -42,6 +44,8 @@ class LnZapPaymentResponseEvent(
// Once one of an app user decrypts the payment, all users else can see it.
@Transient private var response: Response? = null
override fun countMemory(): Long = super.countMemory() + pointerSizeInBytes + (response?.countMemory() ?: 0)
fun requestAuthor() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
fun requestId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
@@ -91,7 +95,9 @@ class LnZapPaymentResponseEvent(
// RESPONSE OBJECTS
abstract class Response(
@JsonProperty("result_type") val resultType: String,
)
) {
abstract fun countMemory(): Long
}
// PayInvoice Call
@@ -100,7 +106,11 @@ class PayInvoiceSuccessResponse(
) : Response("pay_invoice") {
class PayInvoiceResultParams(
val preimage: String,
)
) {
fun countMemory(): Long = pointerSizeInBytes + preimage.bytesUsedInMemory()
}
override fun countMemory(): Long = pointerSizeInBytes + (result?.countMemory() ?: 0)
}
class PayInvoiceErrorResponse(
@@ -109,7 +119,11 @@ class PayInvoiceErrorResponse(
class PayInvoiceErrorParams(
val code: ErrorType?,
val message: String?,
)
) {
fun countMemory(): Long = pointerSizeInBytes + pointerSizeInBytes + (message?.bytesUsedInMemory() ?: 0)
}
override fun countMemory(): Long = pointerSizeInBytes + (error?.countMemory() ?: 0)
enum class ErrorType {
@JsonProperty(value = "RATE_LIMITED")
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import java.nio.charset.Charset
import java.security.SecureRandom
import javax.crypto.BadPaddingException
@@ -47,6 +48,8 @@ class LnZapRequestEvent(
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var privateZapEvent: LnZapPrivateEvent? = null
override fun countMemory(): Long = super.countMemory() + pointerSizeInBytes + (privateZapEvent?.countMemory() ?: 0)
fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
@@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlinx.collections.immutable.ImmutableSet
@Immutable
@@ -39,6 +41,11 @@ class MuteListEvent(
@Transient var publicAndPrivateWordCache: ImmutableSet<String>? = null
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (publicAndPrivateUserCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0) +
pointerSizeInBytes + (publicAndPrivateWordCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0)
override fun dTag() = FIXED_D_TAG
fun publicAndPrivateUsersAndWords(
@@ -26,6 +26,8 @@ import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class NIP90ContentDiscoveryResponseEvent(
@@ -38,6 +40,10 @@ class NIP90ContentDiscoveryResponseEvent(
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient var events: List<HexKey>? = null
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (events?.sumOf { it.bytesUsedInMemory() } ?: 0)
fun innerTags(): List<HexKey> {
if (content.isEmpty()) {
return listOf()
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.ots.VerifyResult
import com.vitorpamplona.quartz.ots.op.OpSHA256
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlinx.coroutines.CancellationException
import java.util.Base64
@@ -48,6 +49,10 @@ class OtsEvent(
@Transient
var verifiedTime: Long? = null
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + Long.SIZE_BYTES // verifiedTime
override fun isContentEncoded() = true
fun digestEvent() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
@@ -24,6 +24,8 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlinx.collections.immutable.ImmutableSet
@Immutable
@@ -39,6 +41,11 @@ class PeopleListEvent(
@Transient var publicAndPrivateWordCache: ImmutableSet<String>? = null
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (publicAndPrivateUserCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0) +
pointerSizeInBytes + (publicAndPrivateWordCache?.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } ?: 0)
fun publicAndPrivateWords(
signer: NostrSigner,
onReady: (ImmutableSet<String>) -> Unit,
@@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.encoders.HexValidator
import com.vitorpamplona.quartz.encoders.Nip54InlineMetadata
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlinx.collections.immutable.persistentSetOf
@Immutable
@@ -41,6 +43,10 @@ class PrivateDmEvent(
ChatroomKeyable {
@Transient private var decryptedContent: Map<HexKey, String> = mapOf()
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (decryptedContent.values.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() })
override fun isContentEncoded() = true
/**
@@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class PrivateOutboxRelayListEvent(
@@ -39,6 +41,10 @@ class PrivateOutboxRelayListEvent(
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var privateTagsCache: Array<Array<String>>? = null
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + (privateTagsCache?.sumOf { pointerSizeInBytes + it.sumOf { pointerSizeInBytes + it.bytesUsedInMemory() } } ?: 0)
override fun dTag() = FIXED_D_TAG
fun relays(): List<String>? =
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
@Immutable
class SealedGossipEvent(
@@ -39,6 +40,10 @@ class SealedGossipEvent(
) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
override fun countMemory(): Long =
super.countMemory() +
pointerSizeInBytes + cachedInnerEvent.values.sumOf { pointerSizeInBytes + (it?.countMemory() ?: 0) }
fun copyNoContent(): SealedGossipEvent {
val copy =
SealedGossipEvent(
@@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
class MinimumRelayListProcessor {
class RelayListRecommendationProcessor {
companion object {
fun transpose(
userList: Map<HexKey, MutableSet<String>>,
@@ -52,7 +52,7 @@ class MinimumRelayListProcessor {
* filter onion and local host from write relays
* for each user pubkey, a list of valid relays.
*/
fun filterValidRelays(
private fun filterValidRelays(
userList: List<AdvertisedRelayListEvent>,
hasOnionConnection: Boolean = false,
): MutableMap<HexKey, MutableSet<String>> {
@@ -76,16 +76,6 @@ class MinimumRelayListProcessor {
return validWriteRelayUrls
}
fun reliableRelaySetFor(
userList: List<AdvertisedRelayListEvent>,
relayUrlsToIgnore: Set<String> = emptySet(),
hasOnionConnection: Boolean = false,
): Set<RelayRecommendation> =
reliableRelaySetFor(
filterValidRelays(userList, hasOnionConnection),
relayUrlsToIgnore,
)
fun reliableRelaySetFor(
usersAndRelays: MutableMap<HexKey, MutableSet<String>>,
relayUrlsToIgnore: Set<String> = emptySet(),
@@ -154,6 +144,16 @@ class MinimumRelayListProcessor {
return returningSet
}
fun reliableRelaySetFor(
userList: List<AdvertisedRelayListEvent>,
relayUrlsToIgnore: Set<String> = emptySet(),
hasOnionConnection: Boolean = false,
): Set<RelayRecommendation> =
reliableRelaySetFor(
filterValidRelays(userList, hasOnionConnection),
relayUrlsToIgnore,
)
}
class RelayRecommendation(
@@ -22,7 +22,11 @@ package com.vitorpamplona.quartz.utils
import kotlin.math.min
fun String.bytesUsedInMemory(): Int = (8 * ((((this.length) * 2) + 45) / 8))
val pointerSizeInBytes = 4
fun String.bytesUsedInMemory(): Long = (8 * (((this.length * 2L) + 45) / 8))
fun Boolean.bytesUsedInMemory(): Long = 8
fun String.containsIgnoreCase(term: String): Boolean {
if (term.isEmpty()) return true // Empty string is contained
@@ -24,7 +24,7 @@ import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import org.junit.Test
class MinimumRelayListProcessorTest {
class RelayListRecommendationProcessorTest {
val userList =
mutableMapOf(
"User1" to mutableSetOf("wss://relay1.com", "wss://relay2.com", "wss://relay3.com"),
@@ -44,13 +44,13 @@ class MinimumRelayListProcessorTest {
"wss://relay5.com" to listOf("User2"),
"wss://relay6.com" to listOf("User2", "User3"),
).toString(),
MinimumRelayListProcessor.transpose(userList).toString(),
RelayListRecommendationProcessor.transpose(userList).toString(),
)
}
@Test
fun testProcessor() {
val recommendations = MinimumRelayListProcessor.reliableRelaySetFor(userList).toList()
val recommendations = RelayListRecommendationProcessor.reliableRelaySetFor(userList).toList()
val rec1 = recommendations[0]