recommendation-engine
Vitor Pamplona 2023-05-05 18:36:00 -04:00
rodzic 7167d57d22
commit 788b7545c5
20 zmienionych plików z 660 dodań i 33 usunięć

Wyświetl plik

@ -6,10 +6,12 @@ import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
import com.vitorpamplona.amethyst.service.NostrRecommendationResponseDataSource
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
@ -18,6 +20,7 @@ import com.vitorpamplona.amethyst.ui.note.Nip47URI
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.Persona
import nostr.postr.Utils
@ -38,8 +41,12 @@ fun getLanguagesSpokenByUser(): Set<String> {
return codedList
}
val GLOBAL_FOLLOWS = " Global "
val KIND3_FOLLOWS = " All Follows "
val HARDCODED_SCHEME = "hardcoded"
val RECOMMENDATION_SCHEME = "recommendation"
val PEOPLE_LIST_SCHEME = "showlist"
val GLOBAL_FOLLOWS = "$HARDCODED_SCHEME:Global"
val KIND3_FOLLOWS = "$HARDCODED_SCHEME:All Follows"
@OptIn(DelicateCoroutinesApi::class)
class Account(
@ -230,6 +237,55 @@ class Account(
}
}
fun sendRecommendationRequest(listName: String?, onResponse: () -> Unit) {
if (!isWriteable()) return
if (listName == null || !isRecommendation(listName)) return
println("provider1 sendRecommendationRequest $listName")
val (scheme, idHex) = followListNameSplit(listName)
println("provider2 $scheme $idHex")
val provider = userProfile().latestRecommendationSubscriptionList?.taggedUsersWithRelays()?.firstOrNull { it.pubKeyHex == idHex }
if (provider == null) return
println("provider3 ${provider.pubKeyHex} ${provider.relayUri} ${LocalCache.checkGetOrCreateUser(provider.pubKeyHex)?.latestContactList}")
val event = RecommendationRequestEvent.create(
listOf(userProfile().pubkeyHex),
RecommendationFilter(
listOf(
JsonFilter(
kinds = listOf(TextNoteEvent.kind)
)
)
),
provider.pubKeyHex,
provider.relayUri,
loggedIn.privKey!!
)
GlobalScope.launch(Dispatchers.IO) {
val listener = NostrRecommendationResponseDataSource(provider.pubKeyHex, event.pubKey, event.id)
listener.start()
delay(1000)
LocalCache.consume(event) {
println("provider4 response")
// After the response is received.
onResponse()
}
Client.send(event, provider.relayUri, listener.feedTypes) {
println("provider4 onDestroy")
listener.destroy()
}
}
}
fun createZapRequestFor(user: User): LnZapRequestEvent? {
return createZapRequestFor(user)
}
@ -584,6 +640,52 @@ class Account(
joinChannel(event.id)
}
fun addToRecommendations(baseUser: User) {
if (!isWriteable()) return
val recommendations = userProfile().latestRecommendationSubscriptionList
val myPreferredReplyRelays = userProfile().relaysBeingUsed.values.sortedBy { it.counter }.map { it.url }
val providerRelays = baseUser.latestContactList?.relays()?.mapNotNull {
if (it.value.read && it.value.write) {
it.key
} else {
null
}
}
val bestRelay = myPreferredReplyRelays.intersect(providerRelays?.toSet() ?: emptySet()).firstOrNull() ?: providerRelays?.firstOrNull() ?: ""
val event = RecommendationSubscriptionListEvent.addTag(
"p",
baseUser.pubkeyHex,
bestRelay,
true,
recommendations,
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun removeFromRecommendations(baseUser: User) {
if (!isWriteable()) return
val recommendations = userProfile().latestRecommendationSubscriptionList
val event = RecommendationSubscriptionListEvent.removeTag(
baseUser.pubkeyHex,
true,
recommendations,
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun addPrivateBookmark(note: Note) {
if (!isWriteable()) return
@ -763,13 +865,36 @@ class Account(
saveable.invalidateData()
}
fun selectedUsersFollowList(listName: String?): Set<String>? {
if (listName == GLOBAL_FOLLOWS) return null
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingKeySet()
fun followListNameSplit(schemeListName: String): Pair<String, String> {
val parts = schemeListName.split(":")
return if (parts.size == 2) {
Pair(parts[0], parts[1])
} else {
// old version
if (parts.contains("Global")) {
Pair(HARDCODED_SCHEME, GLOBAL_FOLLOWS)
} else if (parts.contains("All Follows")) {
Pair(HARDCODED_SCHEME, KIND3_FOLLOWS)
} else {
Pair(PEOPLE_LIST_SCHEME, schemeListName)
}
}
}
fun isRecommendation(schemeListName: String) = schemeListName.substringBefore(":") == RECOMMENDATION_SCHEME
fun selectedUsersFollowList(schemeListName: String?): Set<String>? {
if (schemeListName == null) return emptySet()
if (schemeListName == GLOBAL_FOLLOWS) return null
if (schemeListName == KIND3_FOLLOWS) return userProfile().cachedFollowingKeySet()
val (scheme, listName) = followListNameSplit(schemeListName)
val privKey = loggedIn.privKey
return if (listName != null) {
println("aaaaaaa $scheme $listName")
return if (scheme == PEOPLE_LIST_SCHEME) {
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag()
val list = LocalCache.addressables[aTag]
if (list != null) {
@ -778,10 +903,20 @@ class Account(
(list.event as? PeopleListEvent)?.privateTaggedUsers(it)
} ?: emptySet()
println("aaaaaaa $scheme $listName ${publicHexList.size}")
(publicHexList + privateHexList).toSet()
} else {
emptySet()
}
} else if (scheme == RECOMMENDATION_SCHEME) {
val recommendedPeople = LocalCache.recommendations[listName]?.response?.recommendedPeople()
// println("provider recommended ${listName} ${LocalCache.recommendations[listName]}")
recommendedPeople?.map {
it.pubkeyHex
}?.toSet() ?: emptySet()
} else {
emptySet()
}

Wyświetl plik

@ -38,6 +38,8 @@ object LocalCache {
val awaitingPaymentRequests =
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
val recommendations = ConcurrentHashMap<HexKey, Recommendation>(10)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
return getOrCreateUser(key)
@ -99,6 +101,15 @@ object LocalCache {
}
}
@Synchronized
fun getOrCreateRecommendationsFrom(pubKey: String): Recommendation {
return recommendations[pubKey] ?: run {
val answer = Recommendation(pubKey)
recommendations.put(pubKey, answer)
answer
}
}
fun checkGetOrCreateAddressableNote(key: String): AddressableNote? {
return try {
val addr = ATag.parse(key, null) // relay doesn't matter for the index.
@ -170,6 +181,8 @@ object LocalCache {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
// refreshes lists on screen.
author.liveSet?.follows?.invalidateData()
}
}
@ -347,6 +360,38 @@ object LocalCache {
}
}
fun consume(event: RecommendationSubscriptionListEvent) {
val user = getOrCreateUser(event.pubKey)
// avoids processing empty contact lists.
if (event.createdAt > (user.latestRecommendationSubscriptionList?.createdAt ?: 0)) {
println("provider: event arrived: ${event.id}")
user.updateRecommendations(event)
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
}
}
fun consume(event: RecommendationRequestEvent) {
// does nothing
}
fun consume(event: RecommendationRequestEvent, onResponse: (RecommendationResponseEvent) -> Unit) {
val user = getOrCreateUser(event.pubKey)
val recomm = getOrCreateRecommendationsFrom(event.pubKey)
recomm.updateRequest(event, user, onResponse)
}
fun consume(event: RecommendationResponseEvent) {
val user = getOrCreateUser(event.pubKey)
val recomm = getOrCreateRecommendationsFrom(event.pubKey)
println("provider recommendation response event ")
recomm.updateResponse(event, user)
}
fun consume(event: PrivateDmEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)

Wyświetl plik

@ -0,0 +1,57 @@
package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.model.RecommendationRequestEvent
import com.vitorpamplona.amethyst.service.model.RecommendationResponseEvent
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.Dispatchers
class Recommendation(val pubKeyHex: HexKey) {
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var response: RecommendationResponseEvent? = null
var author: User? = null
var requests = mapOf<HexKey, ((RecommendationResponseEvent) -> Unit)>()
fun updateRequest(event: RecommendationRequestEvent, author: User, onResponse: (RecommendationResponseEvent) -> Unit) {
this.author = author
requests = requests + Pair(event.id, onResponse)
}
fun updateResponse(event: RecommendationResponseEvent, author: User) {
this.response = event
this.author = author
val requestId = event.requestEvent()
if (requestId != null) {
requests.get(requestId)?.invoke(event)
requests = requests.minus(requestId)
}
live.invalidateData()
}
// Observers line up here.
val live: RecommendationLiveData = RecommendationLiveData(this)
}
class RecommendationLiveData(val recomm: Recommendation) : LiveData<RecommendationState>(RecommendationState(recomm)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
}
}
fun invalidateData() {
bundler.invalidate()
}
private fun refresh() {
postValue(RecommendationState(recomm))
}
}
class RecommendationState(val recomm: Recommendation)

Wyświetl plik

@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.RecommendationSubscriptionListEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
@ -25,6 +26,7 @@ class User(val pubkeyHex: String) {
var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null
var latestRecommendationSubscriptionList: RecommendationSubscriptionListEvent? = null
var notes = setOf<Note>()
private set
@ -80,6 +82,18 @@ class User(val pubkeyHex: String) {
liveSet?.bookmarks?.invalidateData()
}
fun updateRecommendations(event: RecommendationSubscriptionListEvent) {
if (event.id == latestRecommendationSubscriptionList?.id) return
latestRecommendationSubscriptionList = event
liveSet?.follows?.invalidateData()
}
fun isSubscribedToAsRecommendation(user: User): Boolean {
return latestRecommendationSubscriptionList?.isTaggedUser(user.pubkeyHex) ?: false
}
fun updateContactList(event: ContactListEvent) {
if (event.id == latestContactList?.id) return
@ -277,6 +291,10 @@ class User(val pubkeyHex: String) {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun recommendationList(): Set<HexKey> {
return latestRecommendationSubscriptionList?.taggedUsers()?.toSet() ?: emptySet()
}
fun cachedFollowingKeySet(): Set<HexKey> {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
@ -381,6 +399,8 @@ class UserMetadata {
var main_relay: String? = null
var twitter: String? = null
var nip97: Boolean? = null
var updatedMetadataAt: Long = 0
var latestMetadata: MetadataEvent? = null

Wyświetl plik

@ -58,11 +58,11 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountBookmarkListFilter(): TypedFilter {
fun createAccountListsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, RecommendationSubscriptionListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 100
)
@ -109,10 +109,10 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountListsFilter(),
createNotificationFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter()
createAccountAcceptedAwardsFilter()
).ifEmpty { null }
}

Wyświetl plik

@ -92,6 +92,9 @@ abstract class NostrDataSource(val debugName: String) {
is PrivateDmEvent -> LocalCache.consume(event, relay)
is PeopleListEvent -> LocalCache.consume(event)
is ReactionEvent -> LocalCache.consume(event)
is RecommendationRequestEvent -> LocalCache.consume(event)
is RecommendationResponseEvent -> LocalCache.consume(event)
is RecommendationSubscriptionListEvent -> LocalCache.consume(event)
is RecommendRelayEvent -> LocalCache.consume(event)
is ReportEvent -> LocalCache.consume(event, relay)
is RepostEvent -> {

Wyświetl plik

@ -0,0 +1,37 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.RecommendationResponseEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
class NostrRecommendationResponseDataSource(
private var fromServiceHex: String,
private var toUserHex: String,
private var replyingToHex: String
) : NostrDataSource("RecommendationResponseFeed") {
val feedTypes = COMMON_FEED_TYPES
private fun createRecommendationWatcher(): TypedFilter {
// downloads all the reactions to a given event.
return TypedFilter(
types = feedTypes,
filter = JsonFilter(
kinds = listOf(RecommendationResponseEvent.kind),
authors = listOf(fromServiceHex),
tags = mapOf(
"e" to listOf(replyingToHex),
"p" to listOf(toUserHex)
),
limit = 10
)
)
}
val channel = requestNewChannel()
override fun updateChannelFilters() {
channel.typedFilters = listOfNotNull(createRecommendationWatcher()).ifEmpty { null }
}
}

Wyświetl plik

@ -38,6 +38,9 @@ open class Event(
override fun toJson(): String = gson.toJson(this)
fun taggedEventsWithRelays() = tags.filter { it.size > 1 && it[0] == "e" }.map { Contact(it[1], it.getOrNull(2)) }
fun taggedUsersWithRelays() = tags.filter { it.size > 1 && it[0] == "p" }.map { Contact(it[1], it.getOrNull(2)) }
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
@ -243,6 +246,9 @@ open class Event(
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
RecommendationRequestEvent.kind -> RecommendationRequestEvent(id, pubKey, createdAt, tags, content, sig)
RecommendationResponseEvent.kind -> RecommendationResponseEvent(id, pubKey, createdAt, tags, content, sig)
RecommendationSubscriptionListEvent.kind -> RecommendationSubscriptionListEvent(id, pubKey, createdAt, tags, content, sig)
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)

Wyświetl plik

@ -0,0 +1,61 @@
package com.vitorpamplona.amethyst.service.model
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import nostr.postr.Utils
import java.util.Date
class RecommendationRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 20020
fun create(
users: List<String>,
filters: RecommendationFilter,
recommendationServicePubkey: String,
replyRelay: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): RecommendationRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = filters.toJson()
val tags = mutableListOf<List<String>>()
tags.add(listOfNotNull("p", recommendationServicePubkey, replyRelay))
users.forEach {
tags.add(listOfNotNull("p", "087ec396d3ba5e72664ccaf82ffca23ca4f51c90a01477cb2a33304fc0161c7f", replyRelay))
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RecommendationRequestEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
class RecommendationFilter(val filters: List<JsonFilter>) {
fun toJson(): String {
return Event.gson.toJson(toJsonObject())
}
fun toJsonObject(): JsonObject {
val jsonObject = JsonObject()
jsonObject.add("filters", filterToJson(filters))
return jsonObject
}
fun filterToJson(filters: List<JsonFilter>): JsonArray {
return JsonArray().apply { filters.forEach { this.add(it.toJsonObject()) } }
}
}

Wyświetl plik

@ -0,0 +1,71 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class RecommendationResponseEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun requestEvent() = tags.firstOrNull { it.size < 3 && it[0] == "e" }?.getOrNull(1)
fun recommendedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.mapNotNull {
try {
EventScores(it[1], it.getOrNull(2), it.getOrNull(3)?.toFloat())
} catch (e: Exception) {
Log.w("RecommendationResponseEvent", "Unable to parse recommendation score: ${it[1]}, ${it[2]}, ${it[3]}")
null
}
}
fun recommendedPeople() = tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
try {
PubKeyScores(it[1], it.getOrNull(2), it.getOrNull(3)?.toFloat())
} catch (e: Exception) {
Log.w("RecommendationResponseEvent", "Unable to parse recommendation score: ${it[1]}, ${it[2]}, ${it[3]}")
null
}
}
companion object {
const val kind = 20021
fun create(
users: List<String>,
events: List<String>,
filters: RecommendationFilter,
requestingEvent: String,
requestingPubKey: String,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): RecommendationResponseEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = filters.toJson()
val tags = mutableListOf<List<String>>()
tags.add(listOf("e", requestingEvent))
tags.add(listOf("p", requestingPubKey))
users.forEach {
tags.add(listOf("p", it))
}
events.forEach {
tags.add(listOf("e", it))
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RecommendationResponseEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
class EventScores(val idHex: HexKey, val relay: String?, val score: Float?)
class PubKeyScores(val pubkeyHex: HexKey, val relay: String?, val score: Float?)

Wyświetl plik

@ -0,0 +1,96 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class RecommendationSubscriptionListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 10020
fun removeTag(
hex: String,
isPublic: Boolean,
current: RecommendationSubscriptionListEvent?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): RecommendationSubscriptionListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = if (isPublic) {
current?.content ?: ""
} else {
var currentTags = current?.privateTags(privateKey) ?: listOf()
currentTags = currentTags.filter { it.getOrNull(1) != hex }
val msg = gson.toJson(currentTags)
Utils.encrypt(
msg,
privateKey,
pubKey
)
}
val tags = if (isPublic) {
(current?.tags ?: listOf()).filter { it.getOrNull(1) != hex }
} else {
(current?.tags ?: listOf())
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RecommendationSubscriptionListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
fun addTag(
type: String,
hex: String,
relay: String,
isPublic: Boolean,
current: RecommendationSubscriptionListEvent?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): RecommendationSubscriptionListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = if (isPublic) {
current?.content ?: ""
} else {
var currentTags = current?.privateTags(privateKey) ?: listOf()
currentTags = currentTags + listOf(listOf(type, hex, relay))
val msg = gson.toJson(currentTags)
Utils.encrypt(
msg,
privateKey,
pubKey
)
}
val tags = if (isPublic) {
(current?.tags ?: listOf()) + listOf(listOf(type, hex, relay))
} else {
(current?.tags ?: listOf())
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RecommendationSubscriptionListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}

Wyświetl plik

@ -68,12 +68,22 @@ object Client : RelayPool.Listener {
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
if (relay == null) {
RelayPool.send(signedEvent)
GlobalScope.launch(Dispatchers.IO) {
delay(10000) // waits for a reply
onDone?.invoke()
}
} else {
val useConnectedRelayIfPresent = relays.filter { it.url == relay }
if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach {
it.send(signedEvent)
GlobalScope.launch(Dispatchers.IO) {
delay(10000) // waits for a reply
onDone?.invoke()
}
}
} else {
/** temporary connection */

Wyświetl plik

@ -15,7 +15,7 @@ class JsonFilter(
val limit: Int? = null,
val search: String? = null
) {
fun toJson(forRelay: String? = null): String {
fun toJsonObject(forRelay: String? = null): JsonObject {
val jsonObject = JsonObject()
ids?.run {
jsonObject.add("ids", JsonArray().apply { ids.forEach { add(it) } })
@ -56,7 +56,11 @@ class JsonFilter(
search?.run {
jsonObject.addProperty("search", search)
}
return gson.toJson(jsonObject)
return jsonObject
}
fun toJson(forRelay: String? = null): String {
return gson.toJson(toJsonObject(forRelay))
}
companion object {

Wyświetl plik

@ -14,7 +14,7 @@ import okhttp3.WebSocketListener
import java.util.Date
enum class FeedType {
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT, RECOMMENDATION
}
val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
@ -92,6 +92,10 @@ class Relay(
eventDownloadCounterInBytes += text.bytesUsedInMemory()
try {
if (text.contains("20021")) {
Log.w("Relay", "provider Relay onEVENT $url $text")
}
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
val type = msg[0].asString
val channel = msg[1].asString
@ -206,7 +210,10 @@ class Relay(
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
// println("FILTERSSENT $url $request")
if (request.contains("20021")) {
println("provider FILTERSSENT $url $request")
}
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
afterEOSE = false
@ -242,6 +249,7 @@ class Relay(
if (write) {
if (signedEvent !is RelayAuthEvent) {
val event = """["EVENT",${signedEvent.toJson()}]"""
println("provider EVENT SENT $url " + event)
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}

Wyświetl plik

@ -21,6 +21,7 @@ import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
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
@ -31,6 +32,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.model.User
@Composable
fun TextSpinner(
@ -83,7 +85,7 @@ fun TextSpinner(
}
@Composable
fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
fun SpinnerSelectionDialog(options: List<Any>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
@ -105,7 +107,12 @@ fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onD
modifier = Modifier
.fillMaxWidth()
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
if (item is User) {
val userState = item.live().metadata.observeAsState()
Text(text = userState.value?.user?.toBestDisplayName() ?: "", color = MaterialTheme.colors.onSurface)
} else {
Text(text = item.toString(), color = MaterialTheme.colors.onSurface)
}
}
explainers?.getOrNull(index)?.let {
Spacer(modifier = Modifier.height(5.dp))

Wyświetl plik

@ -18,6 +18,8 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val isRecommendationActive = account.isRecommendation(account.defaultHomeFollowList)
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()

Wyświetl plik

@ -48,6 +48,9 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PEOPLE_LIST_SCHEME
import com.vitorpamplona.amethyst.model.RECOMMENDATION_SCHEME
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@ -87,7 +90,11 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
@Composable
fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultStoriesFollowList, true) { listName ->
FollowList(
account.defaultStoriesFollowList,
account,
true
) { listName ->
account.changeDefaultStoriesFollowList(listName)
}
}
@ -96,7 +103,7 @@ fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
@Composable
fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultHomeFollowList, false) { listName ->
FollowList(account.defaultHomeFollowList, account, false) { listName ->
account.changeDefaultHomeFollowList(listName)
}
}
@ -226,59 +233,70 @@ private fun LoggedInUserPictureDrawer(
}
@Composable
fun FollowList(listName: String, withGlobal: Boolean, onChange: (String) -> Unit) {
fun FollowList(selectedList: String, account: Account, withGlobal: Boolean, onChange: (String) -> Unit) {
// Notification
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
val userState by account.userProfile().live().follows.observeAsState()
val user = userState?.user ?: return
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
val defaultOptions = if (withGlobal) listOf<Pair<String, Any>>(kind3Follow, globalFollow) else listOf(kind3Follow)
var followLists by remember { mutableStateOf(defaultOptions) }
val followNames = remember { derivedStateOf { followLists.map { it.second } } }
var availableOptions by remember { mutableStateOf(defaultOptions) }
val followNames = remember { derivedStateOf { availableOptions.map { it.second } } }
LaunchedEffect(key1 = db) {
LaunchedEffect(key1 = db, key2 = selectedList, key3 = user.recommendationList()) {
withContext(Dispatchers.IO) {
followLists = defaultOptions + LocalCache.addressables.mapNotNull {
val followLists = LocalCache.addressables.mapNotNull {
val event = (it.value.event as? PeopleListEvent)
// Has to have an list
if (event != null && (event.tags.size > 1 || event.content.length > 50)) {
Pair(event.dTag(), event.dTag())
if (event != null && user.pubkeyHex == event.pubKey && (event.tags.size > 1 || event.content.length > 50)) {
Pair("$PEOPLE_LIST_SCHEME:${event.dTag()}", event.dTag())
} else {
null
}
}.sortedBy { it.second }
val recommendations = user.recommendationList().mapNotNull { hex ->
LocalCache.checkGetOrCreateUser(hex)?.let { user ->
Pair("$RECOMMENDATION_SCHEME:$hex", user)
}
}
availableOptions = defaultOptions + followLists + recommendations
}
}
SimpleTextSpinner(
placeholder = followLists.firstOrNull { it.first == listName }?.first ?: KIND3_FOLLOWS,
placeholder = availableOptions.firstOrNull { it.first == selectedList }?.second ?: "Select an Option",
options = followNames.value,
onSelect = {
onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
onChange(availableOptions.getOrNull(it)?.first ?: KIND3_FOLLOWS)
}
)
}
@Composable
fun SimpleTextSpinner(
placeholder: String,
options: List<String>,
placeholder: Any,
options: List<Any>,
explainers: List<String>? = null,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
var currentText by remember { mutableStateOf(placeholder) }
var currentSelection by remember { mutableStateOf(placeholder) }
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Text(currentText)
Text((currentSelection as? User)?.toBestDisplayName() ?: currentSelection.toString())
Box(
modifier = Modifier
.matchParentSize()
@ -294,7 +312,7 @@ fun SimpleTextSpinner(
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
currentText = options[it]
currentSelection = options[it]
optionsShowing = false
onSelect(it)
}

Wyświetl plik

@ -56,6 +56,12 @@ fun HomeScreen(
val accountState = account.live.observeAsState()
LaunchedEffect(accountViewModel, accountState.value?.account?.defaultHomeFollowList) {
account.sendRecommendationRequest(account.defaultHomeFollowList) {
NostrHomeDataSource.resetFilters()
homeFeedViewModel.invalidateData()
repliesFeedViewModel.invalidateData()
}
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
NostrHomeDataSource.resetFilters()

Wyświetl plik

@ -387,6 +387,12 @@ private fun ProfileHeader(
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
}
} else if (baseUser.info?.nip97 == true) {
if (accountUser.isSubscribedToAsRecommendation(baseUser)) {
RemoveRecommendations({ coroutineScope.launch(Dispatchers.IO) { account.removeFromRecommendations(baseUser) } })
} else {
AddRecommendations({ coroutineScope.launch(Dispatchers.IO) { account.addToRecommendations(baseUser) } })
}
} else if (accountUser.isFollowingCached(baseUser)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
@ -1036,6 +1042,38 @@ fun FollowButton(onClick: () -> Unit, text: Int = R.string.follow) {
}
}
@Composable
fun AddRecommendations(onClick: () -> Unit, text: Int = R.string.recommendations_subscribe_button) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center)
}
}
@Composable
fun RemoveRecommendations(onClick: () -> Unit, text: Int = R.string.recommendations_unsubscribe_button) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center)
}
}
@Composable
fun ShowUserButton(onClick: () -> Unit) {
Button(

Wyświetl plik

@ -354,4 +354,7 @@
<string name="follow_list_kind3follows">All Follows</string>
<string name="follow_list_global">Global</string>
<string name="recommendations_subscribe_button">Subscribe</string>
<string name="recommendations_unsubscribe_button">Unsubscribe</string>
</resources>