kopia lustrzana https://github.com/vitorpamplona/amethyst
first draft
rodzic
7167d57d22
commit
788b7545c5
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
@ -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 -> {
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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()) } }
|
||||
}
|
||||
}
|
|
@ -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?)
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 */
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
Ładowanie…
Reference in New Issue