From 24dc4ba7a55b2697a45b41d85897ca261ed4d860 Mon Sep 17 00:00:00 2001 From: toadlyBroodle Date: Fri, 31 Mar 2023 13:33:42 +0900 Subject: [PATCH] remove hacky fun for getting poll_option from ZapEvent --- .../vitorpamplona/amethyst/model/Account.kt | 1694 ++++++++--------- .../amethyst/service/model/LnZapEvent.kt | 139 +- .../service/model/LnZapRequestEvent.kt | 215 ++- .../amethyst/ui/note/ZapCustomDialog.kt | 387 ++-- 4 files changed, 1205 insertions(+), 1230 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index ce1c4d922..1d7d3b1de 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1,855 +1,839 @@ -package com.vitorpamplona.amethyst.model - -import android.content.res.Resources -import androidx.core.os.ConfigurationCompat -import androidx.lifecycle.LiveData -import com.vitorpamplona.amethyst.service.model.BookmarkListEvent -import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent -import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent -import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent -import com.vitorpamplona.amethyst.service.model.Contact -import com.vitorpamplona.amethyst.service.model.ContactListEvent -import com.vitorpamplona.amethyst.service.model.DeletionEvent -import com.vitorpamplona.amethyst.service.model.IdentityClaim -import com.vitorpamplona.amethyst.service.model.LnZapPaymentRequestEvent -import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent -import com.vitorpamplona.amethyst.service.model.MetadataEvent -import com.vitorpamplona.amethyst.service.model.PrivateDmEvent -import com.vitorpamplona.amethyst.service.model.ReactionEvent -import com.vitorpamplona.amethyst.service.model.ReportEvent -import com.vitorpamplona.amethyst.service.model.RepostEvent -import com.vitorpamplona.amethyst.service.model.TextNoteEvent -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.Relay -import com.vitorpamplona.amethyst.service.relays.RelayPool -import com.vitorpamplona.amethyst.ui.components.BundledUpdate -import com.vitorpamplona.amethyst.ui.note.Nip47URI -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import com.vitorpamplona.amethyst.service.model.* -import com.vitorpamplona.amethyst.service.relays.* -import kotlinx.coroutines.* -import nostr.postr.Persona -import java.util.* -import java.util.Locale - -val DefaultChannels = setOf( - "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr - "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group -) - -fun getLanguagesSpokenByUser(): Set { - val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) - val codedList = mutableSetOf() - for (i in 0 until languageList.size()) { - languageList.get(i)?.let { codedList.add(it.language) } - } - return codedList -} - -@OptIn(DelicateCoroutinesApi::class) -class Account( - val loggedIn: Persona, - var followingChannels: Set = DefaultChannels, - var hiddenUsers: Set = setOf(), - var localRelays: Set = Constants.defaultRelays.toSet(), - var dontTranslateFrom: Set = getLanguagesSpokenByUser(), - var languagePreferences: Map = mapOf(), - var translateTo: String = Locale.getDefault().language, - var zapAmountChoices: List = listOf(500L, 1000L, 5000L), - var zapPaymentRequest: Nip47URI? = null, - var hideDeleteRequestDialog: Boolean = false, - var hideBlockAlertDialog: Boolean = false, - var backupContactList: ContactListEvent? = null -) { - var transientHiddenUsers: Set = setOf() - - // Observers line up here. - val live: AccountLiveData = AccountLiveData(this) - val liveLanguages: AccountLiveData = AccountLiveData(this) - val saveable: AccountLiveData = AccountLiveData(this) - - fun userProfile(): User { - return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) - } - - fun followingChannels(): List { - return followingChannels.map { LocalCache.getOrCreateChannel(it) } - } - - fun hiddenUsers(): List { - return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) } - } - - fun isWriteable(): Boolean { - return loggedIn.privKey != null - } - - fun sendNewRelayList(relays: Map) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val follows = contactList?.follows() ?: emptyList() - val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList() - - if (contactList != null && follows.isNotEmpty()) { - val event = ContactListEvent.create( - follows, - followsTags, - relays, - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } else { - val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!) - - // Keep this local to avoid erasing a good contact list. - // Client.send(event) - LocalCache.consume(event) - } - } - - fun sendNewUserMetadata(toString: String, identities: List) { - if (!isWriteable()) return - - loggedIn.privKey?.let { - val event = MetadataEvent.create(toString, identities, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun reactionTo(note: Note): List { - return note.reactedBy(userProfile(), "+") - } - - fun hasBoosted(note: Note): Boolean { - return boostsTo(note).isNotEmpty() - } - - fun boostsTo(note: Note): List { - return note.boostedBy(userProfile()) - } - - fun hasReacted(note: Note): Boolean { - return note.hasReacted(userProfile(), "+") - } - - fun reactTo(note: Note) { - if (!isWriteable()) return - - if (hasReacted(note)) { - // has already liked this note - return - } - - note.event?.let { - val event = ReactionEvent.createLike(it, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun createZapRequestFor(note: Note, pollOption: Int?, message: String = ""): LnZapRequestEvent? { - if (!isWriteable()) return null - - note.event?.let { event -> - return LnZapRequestEvent.create( - event, - userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } - ?: localRelays.map { it.url }.toSet(), - loggedIn.privKey!!, - pollOption - ) - /*note.event?.let { - return LnZapRequestEvent.create(it, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message) - }*/ - - return null - } - - fun hasWalletConnectSetup(): Boolean { - return zapPaymentRequest != null - } - - fun sendZapPaymentRequestFor(lnInvoice: String) { - if (!isWriteable()) return - - zapPaymentRequest?.let { - val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) - - Client.send(event, it.relayUri) - } - } - - fun createZapRequestFor(user: User): LnZapRequestEvent? { - return createZapRequestFor(user.pubkeyHex) - } - - fun createZapRequestFor(userPubKeyHex: String, message: String = ""): LnZapRequestEvent? { - if (!isWriteable()) return null - - return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message) - } - - fun report(note: Note, type: ReportEvent.ReportType, content: String = "") { - if (!isWriteable()) return - - if (note.hasReacted(userProfile(), "⚠️")) { - // has already liked this note - return - } - - note.event?.let { - val event = ReactionEvent.createWarning(it, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - - note.event?.let { - val event = ReportEvent.create(it, type, loggedIn.privKey!!, content = content) - Client.send(event) - LocalCache.consume(event, null) - } - } - - fun report(user: User, type: ReportEvent.ReportType) { - if (!isWriteable()) return - - if (user.hasReport(userProfile(), type)) { - // has already reported this note - return - } - - val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event, null) - } - - fun delete(note: Note) { - delete(listOf(note)) - } - - fun delete(notes: List) { - if (!isWriteable()) return - - val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex } - - if (myNotes.isNotEmpty()) { - val event = DeletionEvent.create(myNotes, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun boost(note: Note) { - if (!isWriteable()) return - - if (note.hasBoostedInTheLast5Minutes(userProfile())) { - // has already bosted in the past 5mins - return - } - - note.event?.let { - val event = RepostEvent.create(it, loggedIn.privKey!!) - Client.send(event) - LocalCache.consume(event) - } - } - - fun broadcast(note: Note) { - note.event?.let { - Client.send(it) - } - } - - fun follow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val followingUsers = contactList?.follows() ?: emptyList() - val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() - - val event = if (contactList != null) { - ContactListEvent.create( - followingUsers.plus(Contact(user.pubkeyHex, null)), - followingTags, - contactList.relays(), - loggedIn.privKey!! - ) - } else { - val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } - ContactListEvent.create( - listOf(Contact(user.pubkeyHex, null)), - followingTags, - relays, - loggedIn.privKey!! - ) - } - - Client.send(event) - LocalCache.consume(event) - } - - fun follow(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val followingUsers = contactList?.follows() ?: emptyList() - val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() - - val event = if (contactList != null) { - ContactListEvent.create( - followingUsers, - followingTags.plus(tag), - contactList.relays(), - loggedIn.privKey!! - ) - } else { - val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } - ContactListEvent.create( - followingUsers, - followingTags.plus(tag), - relays, - loggedIn.privKey!! - ) - } - - Client.send(event) - LocalCache.consume(event) - } - - fun unfollow(user: User) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val followingUsers = contactList?.follows() ?: emptyList() - val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() - - if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) { - val event = ContactListEvent.create( - followingUsers.filter { it.pubKeyHex != user.pubkeyHex }, - followingTags, - contactList.relays(), - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - } - - fun unfollow(tag: String) { - if (!isWriteable()) return - - val contactList = userProfile().latestContactList - val followingUsers = contactList?.follows() ?: emptyList() - val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() - - if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) { - val event = ContactListEvent.create( - followingUsers, - followingTags.filter { !it.equals(tag, ignoreCase = true) }, - contactList.relays(), - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - } - - fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - val signedEvent = TextNoteEvent.create( - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - extraTags = tags, - privateKey = loggedIn.privKey!! - ) - - Client.send(signedEvent) - LocalCache.consume(signedEvent) - } - - fun sendPoll( - message: String, - replyTo: List?, - mentions: List?, - pollOptions: Map, - valueMaximum: Int?, - valueMinimum: Int?, - consensusThreshold: Int?, - closedAt: Int? - ) { - if (!isWriteable()) return - - val repliesToHex = replyTo?.map { it.idHex } - val mentionsHex = mentions?.map { it.pubkeyHex } - val addresses = replyTo?.mapNotNull { it.address() } - - val signedEvent = PollNoteEvent.create( - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - addresses = addresses, - privateKey = loggedIn.privKey!!, - pollOptions = pollOptions, - valueMaximum = valueMaximum, - valueMinimum = valueMinimum, - consensusThreshold = consensusThreshold, - closedAt = closedAt - ) - println("Sending new PollNoteEvent: %s".format(signedEvent.toJson())) - Client.send(signedEvent) - LocalCache.consume(signedEvent) - } - - fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List?) { - if (!isWriteable()) return - - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = mentions?.map { it.pubkeyHex } - - val signedEvent = ChannelMessageEvent.create( - message = message, - channel = toChannel, - replyTos = repliesToHex, - mentions = mentionsHex, - privateKey = loggedIn.privKey!! - ) - Client.send(signedEvent) - LocalCache.consume(signedEvent, null) - } - - fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null) { - if (!isWriteable()) return - val user = LocalCache.users[toUser] ?: return - - val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } - val mentionsHex = emptyList() - - val signedEvent = PrivateDmEvent.create( - recipientPubKey = user.pubkey(), - publishedRecipientPubKey = user.pubkey(), - msg = message, - replyTos = repliesToHex, - mentions = mentionsHex, - privateKey = loggedIn.privKey!!, - advertiseNip18 = false - ) - Client.send(signedEvent) - LocalCache.consume(signedEvent, null) - } - - fun sendCreateNewChannel(name: String, about: String, picture: String) { - if (!isWriteable()) return - - val metadata = ChannelCreateEvent.ChannelData( - name, - about, - picture - ) - - val event = ChannelCreateEvent.create( - channelInfo = metadata, - privateKey = loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - - joinChannel(event.id) - } - - fun addPrivateBookmark(note: Note) { - if (!isWriteable()) return - - val bookmarks = userProfile().latestBookmarkList - - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents() ?: emptyList(), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), - - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - - fun addPublicBookmark(note: Note) { - if (!isWriteable()) return - - val bookmarks = userProfile().latestBookmarkList - - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), - - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - - fun removePrivateBookmark(note: Note) { - if (!isWriteable()) return - - val bookmarks = userProfile().latestBookmarkList - - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents() ?: emptyList(), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), - - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - - fun removePublicBookmark(note: Note) { - if (!isWriteable()) return - - val bookmarks = userProfile().latestBookmarkList - - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents()?.minus(note.idHex), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), - - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - - loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - } - - fun isInPrivateBookmarks(note: Note): Boolean { - if (!isWriteable()) return false - - if (note is AddressableNote) { - return userProfile().latestBookmarkList?.privateTaggedAddresses(loggedIn.privKey!!) - ?.contains(note.address) == true - } else { - return userProfile().latestBookmarkList?.privateTaggedEvents(loggedIn.privKey!!) - ?.contains(note.idHex) == true - } - } - - fun isInPublicBookmarks(note: Note): Boolean { - if (!isWriteable()) return false - - if (note is AddressableNote) { - return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true - } else { - return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true - } - } - - fun joinChannel(idHex: String) { - followingChannels = followingChannels + idHex - live.invalidateData() - - saveable.invalidateData() - } - - fun leaveChannel(idHex: String) { - followingChannels = followingChannels - idHex - live.invalidateData() - - saveable.invalidateData() - } - - fun hideUser(pubkeyHex: String) { - hiddenUsers = hiddenUsers + pubkeyHex - live.invalidateData() - saveable.invalidateData() - } - - fun showUser(pubkeyHex: String) { - hiddenUsers = hiddenUsers - pubkeyHex - transientHiddenUsers = transientHiddenUsers - pubkeyHex - live.invalidateData() - saveable.invalidateData() - } - - fun changeZapAmounts(newAmounts: List) { - zapAmountChoices = newAmounts - live.invalidateData() - saveable.invalidateData() - } - - fun changeZapPaymentRequest(newServer: Nip47URI?) { - zapPaymentRequest = newServer - live.invalidateData() - saveable.invalidateData() - } - - fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { - if (!isWriteable()) return - - val metadata = ChannelCreateEvent.ChannelData( - name, - about, - picture - ) - - val event = ChannelMetadataEvent.create( - newChannelInfo = metadata, - originalChannelIdHex = channel.idHex, - privateKey = loggedIn.privKey!! - ) - - Client.send(event) - LocalCache.consume(event) - - joinChannel(event.id) - } - - fun decryptContent(note: Note): String? { - val event = note.event - return if (event is PrivateDmEvent && loggedIn.privKey != null) { - var pubkeyToUse = event.pubKey - - val recepientPK = event.recipientPubKey() - - if (note.author == userProfile() && recepientPK != null) { - pubkeyToUse = recepientPK - } - - event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) - } else { - event?.content() - } - } - - fun addDontTranslateFrom(languageCode: String) { - dontTranslateFrom = dontTranslateFrom.plus(languageCode) - liveLanguages.invalidateData() - - saveable.invalidateData() - } - - fun updateTranslateTo(languageCode: String) { - translateTo = languageCode - liveLanguages.invalidateData() - - saveable.invalidateData() - } - - fun prefer(source: String, target: String, preference: String) { - languagePreferences = languagePreferences + Pair("$source,$target", preference) - saveable.invalidateData() - } - - fun preferenceBetween(source: String, target: String): String? { - return languagePreferences.get("$source,$target") - } - - private fun updateContactListTo(newContactList: ContactListEvent?) { - if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return - - // Events might be different objects, we have to compare their ids. - if (backupContactList?.id != newContactList?.id) { - backupContactList = newContactList - saveable.invalidateData() - } - } - - // Takes a User's relay list and adds the types of feeds they are active for. - fun activeRelays(): Array? { - var usersRelayList = userProfile().latestContactList?.relays()?.map { - val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet() - Relay(it.key, it.value.read, it.value.write, localFeedTypes) - } ?: return null - - // Ugly, but forces nostr.band as the only search-supporting relay today. - // TODO: Remove when search becomes more available. - if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) { - usersRelayList = usersRelayList + Relay( - Constants.forcedRelayForSearch.url, - Constants.forcedRelayForSearch.read, - Constants.forcedRelayForSearch.write, - Constants.forcedRelayForSearch.feedTypes - ) - } - - return usersRelayList.toTypedArray() - } - - fun convertLocalRelays(): Array { - return localRelays.map { - Relay(it.url, it.read, it.write, it.feedTypes) - }.toTypedArray() - } - - fun reconnectIfRelaysHaveChanged() { - val newRelaySet = activeRelays() ?: convertLocalRelays() - if (!Client.isSameRelaySetConfig(newRelaySet)) { - Client.disconnect() - Client.connect(newRelaySet) - RelayPool.requestAndWatch() - } - } - - fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers - - fun followingKeySet(): Set { - return userProfile().cachedFollowingKeySet() ?: emptySet() - } - - fun followingTagSet(): Set { - return userProfile().cachedFollowingTagSet() ?: emptySet() - } - - fun isAcceptable(user: User): Boolean { - return !isHidden(user) && // if user hasn't hided this author - user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post - user.countReportAuthorsBy(followingKeySet()) < 5 - } - - fun isAcceptableDirect(note: Note): Boolean { - return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post - note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users - } - - fun isFollowing(user: User): Boolean { - return user.pubkeyHex in followingKeySet() - } - - fun isAcceptable(note: Note): Boolean { - return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author - isAcceptableDirect(note) && - ( - note.event !is RepostEvent || - (note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null) - ) // is not a reaction about a blocked post - } - - fun getRelevantReports(note: Note): Set { - val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet() - - val innerReports = if (note.event is RepostEvent) { - note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList() - } else { - emptyList() - } - - return ( - note.reportsBy(followsPlusMe) + - ( - note.author?.reportsBy(followsPlusMe) ?: emptyList() - ) + innerReports - ).toSet() - } - - fun saveRelayList(value: List) { - localRelays = value.toSet() - sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }) - - saveable.invalidateData() - } - - fun setHideDeleteRequestDialog() { - hideDeleteRequestDialog = true - saveable.invalidateData() - } - - fun setHideBlockAlertDialog() { - hideBlockAlertDialog = true - saveable.invalidateData() - } - - init { - backupContactList?.let { - println("Loading saved contacts ${it.toJson()}") - if (userProfile().latestContactList == null) { - LocalCache.consume(it) - } - } - - // Observes relays to restart connections - userProfile().live().relays.observeForever { - GlobalScope.launch(Dispatchers.IO) { - reconnectIfRelaysHaveChanged() - } - } - - // saves contact list for the next time. - userProfile().live().follows.observeForever { - updateContactListTo(userProfile().latestContactList) - } - - // imports transient blocks due to spam. - LocalCache.antiSpam.liveSpam.observeForever { - GlobalScope.launch(Dispatchers.IO) { - it.cache.spamMessages.snapshot().values.forEach { - if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) { - val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex) - if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) { - transientHiddenUsers = transientHiddenUsers + it.pubkeyHex - } - } - } - } - } - } -} - -class AccountLiveData(private val account: Account) : LiveData(AccountState(account)) { - // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Default) { - if (hasActiveObservers()) { - refresh() - } - } - - fun invalidateData() { - bundler.invalidate() - } - - fun refresh() { - postValue(AccountState(account)) - } -} - -class AccountState(val account: Account) +package com.vitorpamplona.amethyst.model + +import android.content.res.Resources +import androidx.core.os.ConfigurationCompat +import androidx.lifecycle.LiveData +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.Relay +import com.vitorpamplona.amethyst.service.relays.RelayPool +import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.note.Nip47URI +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import nostr.postr.Persona +import java.util.Locale + +val DefaultChannels = setOf( + "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr + "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group +) + +fun getLanguagesSpokenByUser(): Set { + val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) + val codedList = mutableSetOf() + for (i in 0 until languageList.size()) { + languageList.get(i)?.let { codedList.add(it.language) } + } + return codedList +} + +@OptIn(DelicateCoroutinesApi::class) +class Account( + val loggedIn: Persona, + var followingChannels: Set = DefaultChannels, + var hiddenUsers: Set = setOf(), + var localRelays: Set = Constants.defaultRelays.toSet(), + var dontTranslateFrom: Set = getLanguagesSpokenByUser(), + var languagePreferences: Map = mapOf(), + var translateTo: String = Locale.getDefault().language, + var zapAmountChoices: List = listOf(500L, 1000L, 5000L), + var zapPaymentRequest: Nip47URI? = null, + var hideDeleteRequestDialog: Boolean = false, + var hideBlockAlertDialog: Boolean = false, + var backupContactList: ContactListEvent? = null +) { + var transientHiddenUsers: Set = setOf() + + // Observers line up here. + val live: AccountLiveData = AccountLiveData(this) + val liveLanguages: AccountLiveData = AccountLiveData(this) + val saveable: AccountLiveData = AccountLiveData(this) + + fun userProfile(): User { + return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey()) + } + + fun followingChannels(): List { + return followingChannels.map { LocalCache.getOrCreateChannel(it) } + } + + fun hiddenUsers(): List { + return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) } + } + + fun isWriteable(): Boolean { + return loggedIn.privKey != null + } + + fun sendNewRelayList(relays: Map) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val follows = contactList?.follows() ?: emptyList() + val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList() + + if (contactList != null && follows.isNotEmpty()) { + val event = ContactListEvent.create( + follows, + followsTags, + relays, + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } else { + val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!) + + // Keep this local to avoid erasing a good contact list. + // Client.send(event) + LocalCache.consume(event) + } + } + + fun sendNewUserMetadata(toString: String, identities: List) { + if (!isWriteable()) return + + loggedIn.privKey?.let { + val event = MetadataEvent.create(toString, identities, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + + fun reactionTo(note: Note): List { + return note.reactedBy(userProfile(), "+") + } + + fun hasBoosted(note: Note): Boolean { + return boostsTo(note).isNotEmpty() + } + + fun boostsTo(note: Note): List { + return note.boostedBy(userProfile()) + } + + fun hasReacted(note: Note): Boolean { + return note.hasReacted(userProfile(), "+") + } + + fun reactTo(note: Note) { + if (!isWriteable()) return + + if (hasReacted(note)) { + // has already liked this note + return + } + + note.event?.let { + val event = ReactionEvent.createLike(it, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + + fun createZapRequestFor(note: Note, pollOption: Int?, message: String = ""): LnZapRequestEvent? { + if (!isWriteable()) return null + + note.event?.let { event -> + return LnZapRequestEvent.create( + event, + userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } + ?: localRelays.map { it.url }.toSet(), + loggedIn.privKey!!, + pollOption, + message + ) + } + return null + } + + fun hasWalletConnectSetup(): Boolean { + return zapPaymentRequest != null + } + + fun sendZapPaymentRequestFor(lnInvoice: String) { + if (!isWriteable()) return + + zapPaymentRequest?.let { + val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) + + Client.send(event, it.relayUri) + } + } + + fun createZapRequestFor(user: User): LnZapRequestEvent? { + return createZapRequestFor(user) + } + + fun createZapRequestFor(userPubKeyHex: String, message: String = ""): LnZapRequestEvent? { + if (!isWriteable()) return null + + return LnZapRequestEvent.create( + userPubKeyHex, + userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), + loggedIn.privKey!!, + message + ) + } + + fun report(note: Note, type: ReportEvent.ReportType, content: String = "") { + if (!isWriteable()) return + + if (note.hasReacted(userProfile(), "⚠️")) { + // has already liked this note + return + } + + note.event?.let { + val event = ReactionEvent.createWarning(it, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + + note.event?.let { + val event = ReportEvent.create(it, type, loggedIn.privKey!!, content = content) + Client.send(event) + LocalCache.consume(event, null) + } + } + + fun report(user: User, type: ReportEvent.ReportType) { + if (!isWriteable()) return + + if (user.hasReport(userProfile(), type)) { + // has already reported this note + return + } + + val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event, null) + } + + fun delete(note: Note) { + delete(listOf(note)) + } + + fun delete(notes: List) { + if (!isWriteable()) return + + val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex } + + if (myNotes.isNotEmpty()) { + val event = DeletionEvent.create(myNotes, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + + fun boost(note: Note) { + if (!isWriteable()) return + + if (note.hasBoostedInTheLast5Minutes(userProfile())) { + // has already bosted in the past 5mins + return + } + + note.event?.let { + val event = RepostEvent.create(it, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + + fun broadcast(note: Note) { + note.event?.let { + Client.send(it) + } + } + + fun follow(user: User) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val followingUsers = contactList?.follows() ?: emptyList() + val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() + + val event = if (contactList != null) { + ContactListEvent.create( + followingUsers.plus(Contact(user.pubkeyHex, null)), + followingTags, + contactList.relays(), + loggedIn.privKey!! + ) + } else { + val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } + ContactListEvent.create( + listOf(Contact(user.pubkeyHex, null)), + followingTags, + relays, + loggedIn.privKey!! + ) + } + + Client.send(event) + LocalCache.consume(event) + } + + fun follow(tag: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val followingUsers = contactList?.follows() ?: emptyList() + val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() + + val event = if (contactList != null) { + ContactListEvent.create( + followingUsers, + followingTags.plus(tag), + contactList.relays(), + loggedIn.privKey!! + ) + } else { + val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } + ContactListEvent.create( + followingUsers, + followingTags.plus(tag), + relays, + loggedIn.privKey!! + ) + } + + Client.send(event) + LocalCache.consume(event) + } + + fun unfollow(user: User) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val followingUsers = contactList?.follows() ?: emptyList() + val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() + + if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) { + val event = ContactListEvent.create( + followingUsers.filter { it.pubKeyHex != user.pubkeyHex }, + followingTags, + contactList.relays(), + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + } + + fun unfollow(tag: String) { + if (!isWriteable()) return + + val contactList = userProfile().latestContactList + val followingUsers = contactList?.follows() ?: emptyList() + val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList() + + if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) { + val event = ContactListEvent.create( + followingUsers, + followingTags.filter { !it.equals(tag, ignoreCase = true) }, + contactList.relays(), + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + } + + fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + val signedEvent = TextNoteEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + extraTags = tags, + privateKey = loggedIn.privKey!! + ) + + Client.send(signedEvent) + LocalCache.consume(signedEvent) + } + + fun sendPoll( + message: String, + replyTo: List?, + mentions: List?, + pollOptions: Map, + valueMaximum: Int?, + valueMinimum: Int?, + consensusThreshold: Int?, + closedAt: Int? + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + val signedEvent = PollNoteEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + privateKey = loggedIn.privKey!!, + pollOptions = pollOptions, + valueMaximum = valueMaximum, + valueMinimum = valueMinimum, + consensusThreshold = consensusThreshold, + closedAt = closedAt + ) + println("Sending new PollNoteEvent: %s".format(signedEvent.toJson())) + Client.send(signedEvent) + LocalCache.consume(signedEvent) + } + + fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List?) { + if (!isWriteable()) return + + val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val mentionsHex = mentions?.map { it.pubkeyHex } + + val signedEvent = ChannelMessageEvent.create( + message = message, + channel = toChannel, + replyTos = repliesToHex, + mentions = mentionsHex, + privateKey = loggedIn.privKey!! + ) + Client.send(signedEvent) + LocalCache.consume(signedEvent, null) + } + + fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null) { + if (!isWriteable()) return + val user = LocalCache.users[toUser] ?: return + + val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val mentionsHex = emptyList() + + val signedEvent = PrivateDmEvent.create( + recipientPubKey = user.pubkey(), + publishedRecipientPubKey = user.pubkey(), + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + privateKey = loggedIn.privKey!!, + advertiseNip18 = false + ) + Client.send(signedEvent) + LocalCache.consume(signedEvent, null) + } + + fun sendCreateNewChannel(name: String, about: String, picture: String) { + if (!isWriteable()) return + + val metadata = ChannelCreateEvent.ChannelData( + name, + about, + picture + ) + + val event = ChannelCreateEvent.create( + channelInfo = metadata, + privateKey = loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + + joinChannel(event.id) + } + + fun addPrivateBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun addPublicBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun removePrivateBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun removePublicBookmark(note: Note) { + if (!isWriteable()) return + + val bookmarks = userProfile().latestBookmarkList + + val event = BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents()?.minus(note.idHex), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + + fun isInPrivateBookmarks(note: Note): Boolean { + if (!isWriteable()) return false + + if (note is AddressableNote) { + return userProfile().latestBookmarkList?.privateTaggedAddresses(loggedIn.privKey!!) + ?.contains(note.address) == true + } else { + return userProfile().latestBookmarkList?.privateTaggedEvents(loggedIn.privKey!!) + ?.contains(note.idHex) == true + } + } + + fun isInPublicBookmarks(note: Note): Boolean { + if (!isWriteable()) return false + + if (note is AddressableNote) { + return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true + } else { + return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true + } + } + + fun joinChannel(idHex: String) { + followingChannels = followingChannels + idHex + live.invalidateData() + + saveable.invalidateData() + } + + fun leaveChannel(idHex: String) { + followingChannels = followingChannels - idHex + live.invalidateData() + + saveable.invalidateData() + } + + fun hideUser(pubkeyHex: String) { + hiddenUsers = hiddenUsers + pubkeyHex + live.invalidateData() + saveable.invalidateData() + } + + fun showUser(pubkeyHex: String) { + hiddenUsers = hiddenUsers - pubkeyHex + transientHiddenUsers = transientHiddenUsers - pubkeyHex + live.invalidateData() + saveable.invalidateData() + } + + fun changeZapAmounts(newAmounts: List) { + zapAmountChoices = newAmounts + live.invalidateData() + saveable.invalidateData() + } + + fun changeZapPaymentRequest(newServer: Nip47URI?) { + zapPaymentRequest = newServer + live.invalidateData() + saveable.invalidateData() + } + + fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { + if (!isWriteable()) return + + val metadata = ChannelCreateEvent.ChannelData( + name, + about, + picture + ) + + val event = ChannelMetadataEvent.create( + newChannelInfo = metadata, + originalChannelIdHex = channel.idHex, + privateKey = loggedIn.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + + joinChannel(event.id) + } + + fun decryptContent(note: Note): String? { + val event = note.event + return if (event is PrivateDmEvent && loggedIn.privKey != null) { + var pubkeyToUse = event.pubKey + + val recepientPK = event.recipientPubKey() + + if (note.author == userProfile() && recepientPK != null) { + pubkeyToUse = recepientPK + } + + event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) + } else { + event?.content() + } + } + + fun addDontTranslateFrom(languageCode: String) { + dontTranslateFrom = dontTranslateFrom.plus(languageCode) + liveLanguages.invalidateData() + + saveable.invalidateData() + } + + fun updateTranslateTo(languageCode: String) { + translateTo = languageCode + liveLanguages.invalidateData() + + saveable.invalidateData() + } + + fun prefer(source: String, target: String, preference: String) { + languagePreferences = languagePreferences + Pair("$source,$target", preference) + saveable.invalidateData() + } + + fun preferenceBetween(source: String, target: String): String? { + return languagePreferences.get("$source,$target") + } + + private fun updateContactListTo(newContactList: ContactListEvent?) { + if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupContactList?.id != newContactList?.id) { + backupContactList = newContactList + saveable.invalidateData() + } + } + + // Takes a User's relay list and adds the types of feeds they are active for. + fun activeRelays(): Array? { + var usersRelayList = userProfile().latestContactList?.relays()?.map { + val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet() + Relay(it.key, it.value.read, it.value.write, localFeedTypes) + } ?: return null + + // Ugly, but forces nostr.band as the only search-supporting relay today. + // TODO: Remove when search becomes more available. + if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) { + usersRelayList = usersRelayList + Relay( + Constants.forcedRelayForSearch.url, + Constants.forcedRelayForSearch.read, + Constants.forcedRelayForSearch.write, + Constants.forcedRelayForSearch.feedTypes + ) + } + + return usersRelayList.toTypedArray() + } + + fun convertLocalRelays(): Array { + return localRelays.map { + Relay(it.url, it.read, it.write, it.feedTypes) + }.toTypedArray() + } + + fun reconnectIfRelaysHaveChanged() { + val newRelaySet = activeRelays() ?: convertLocalRelays() + if (!Client.isSameRelaySetConfig(newRelaySet)) { + Client.disconnect() + Client.connect(newRelaySet) + RelayPool.requestAndWatch() + } + } + + fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers + + fun followingKeySet(): Set { + return userProfile().cachedFollowingKeySet() ?: emptySet() + } + + fun followingTagSet(): Set { + return userProfile().cachedFollowingTagSet() ?: emptySet() + } + + fun isAcceptable(user: User): Boolean { + return !isHidden(user) && // if user hasn't hided this author + user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post + user.countReportAuthorsBy(followingKeySet()) < 5 + } + + fun isAcceptableDirect(note: Note): Boolean { + return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post + note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users + } + + fun isFollowing(user: User): Boolean { + return user.pubkeyHex in followingKeySet() + } + + fun isAcceptable(note: Note): Boolean { + return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author + isAcceptableDirect(note) && + ( + note.event !is RepostEvent || + (note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null) + ) // is not a reaction about a blocked post + } + + fun getRelevantReports(note: Note): Set { + val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet() + + val innerReports = if (note.event is RepostEvent) { + note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList() + } else { + emptyList() + } + + return ( + note.reportsBy(followsPlusMe) + + ( + note.author?.reportsBy(followsPlusMe) ?: emptyList() + ) + innerReports + ).toSet() + } + + fun saveRelayList(value: List) { + localRelays = value.toSet() + sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }) + + saveable.invalidateData() + } + + fun setHideDeleteRequestDialog() { + hideDeleteRequestDialog = true + saveable.invalidateData() + } + + fun setHideBlockAlertDialog() { + hideBlockAlertDialog = true + saveable.invalidateData() + } + + init { + backupContactList?.let { + println("Loading saved contacts ${it.toJson()}") + if (userProfile().latestContactList == null) { + LocalCache.consume(it) + } + } + + // Observes relays to restart connections + userProfile().live().relays.observeForever { + GlobalScope.launch(Dispatchers.IO) { + reconnectIfRelaysHaveChanged() + } + } + + // saves contact list for the next time. + userProfile().live().follows.observeForever { + updateContactListTo(userProfile().latestContactList) + } + + // imports transient blocks due to spam. + LocalCache.antiSpam.liveSpam.observeForever { + GlobalScope.launch(Dispatchers.IO) { + it.cache.spamMessages.snapshot().values.forEach { + if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) { + val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex) + if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) { + transientHiddenUsers = transientHiddenUsers + it.pubkeyHex + } + } + } + } + } + } +} + +class AccountLiveData(private val account: Account) : LiveData(AccountState(account)) { + // Refreshes observers in batches. + private val bundler = BundledUpdate(300, Dispatchers.Default) { + if (hasActiveObservers()) { + refresh() + } + } + + fun invalidateData() { + bundler.invalidate() + } + + fun refresh() { + postValue(AccountState(account)) + } +} + +class AccountState(val account: Account) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt index d92584ed6..c019bb69d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt @@ -1,73 +1,66 @@ -package com.vitorpamplona.amethyst.service.model - -import android.util.Log -import com.vitorpamplona.amethyst.model.HexKey -import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil -import com.vitorpamplona.amethyst.service.relays.Client -import java.math.BigDecimal - -class LnZapEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) { - - override fun zappedPost() = tags - .filter { it.firstOrNull() == "e" } - .mapNotNull { it.getOrNull(1) } - -/* // TODO add poll_option tag to LnZapEvent - override fun zappedPollOption(): Int? = tags - .filter { it.firstOrNull() == "poll_option" } - .getOrNull(1)?.getOrNull(1)?.toInt() -*/ - // TODO replace this hacky way to get poll option with above function - override fun zappedPollOption(): Int? = description() - ?.substringAfter("poll_option\",\"") - ?.substringBefore("\"") - ?.toInt() - - override fun zappedAuthor() = tags - .filter { it.firstOrNull() == "p" } - .mapNotNull { it.getOrNull(1) } - - override fun amount(): BigDecimal? { - return amount - } - - // Keeps this as a field because it's a heavier function used everywhere. - val amount by lazy { - try { - lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } - } catch (e: Exception) { - Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e) - null - } - } - - override fun containedPost(): Event? = try { - description()?.let { - fromJson(it, Client.lenient) - } - } catch (e: Exception) { - Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e) - null - } - - private fun lnInvoice(): String? = tags - .filter { it.firstOrNull() == "bolt11" } - .mapNotNull { it.getOrNull(1) } - .firstOrNull() - - private fun description(): String? = tags - .filter { it.firstOrNull() == "description" } - .mapNotNull { it.getOrNull(1) } - .firstOrNull() - - companion object { - const val kind = 9735 - } -} +package com.vitorpamplona.amethyst.service.model + +import android.util.Log +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil +import com.vitorpamplona.amethyst.service.relays.Client +import java.math.BigDecimal + +class LnZapEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) { + + override fun zappedPost() = tags + .filter { it.firstOrNull() == "e" } + .mapNotNull { it.getOrNull(1) } + + override fun zappedPollOption(): Int? = containedPost()?.tags + ?.filter { it.firstOrNull() == POLL_OPTION } + ?.getOrNull(0)?.getOrNull(1)?.toInt() + + override fun zappedAuthor() = tags + .filter { it.firstOrNull() == "p" } + .mapNotNull { it.getOrNull(1) } + + override fun amount(): BigDecimal? { + return amount + } + + // Keeps this as a field because it's a heavier function used everywhere. + val amount by lazy { + try { + lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } + } catch (e: Exception) { + Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e) + null + } + } + + override fun containedPost(): Event? = try { + description()?.let { + fromJson(it, Client.lenient) + } + } catch (e: Exception) { + Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e) + null + } + + private fun lnInvoice(): String? = tags + .filter { it.firstOrNull() == "bolt11" } + .mapNotNull { it.getOrNull(1) } + .firstOrNull() + + private fun description(): String? = tags + .filter { it.firstOrNull() == "description" } + .mapNotNull { it.getOrNull(1) } + .firstOrNull() + + companion object { + const val kind = 9735 + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index fae2d053b..5452aa36a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -1,109 +1,106 @@ -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 LnZapRequestEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: List>, - content: String, - sig: HexKey -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - - companion object { - const val kind = 9734 - - fun create( - originalNote: EventInterface, - relays: Set, - privateKey: ByteArray, - pollOption: Int?, - message: String, - createdAt: Long = Date().time / 1000 - ): LnZapRequestEvent { - val content = message - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - var tags = listOf( - listOf("e", originalNote.id()), - listOf("p", originalNote.pubKey()), - listOf("relays") + relays - ) - if (originalNote is LongTextNoteEvent) { - tags = tags + listOf(listOf("a", originalNote.address().toTag())) - } - if (pollOption != null && pollOption >= 0) { - tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString())) - } - - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) - } - - fun create( - userHex: String, - relays: Set, - privateKey: ByteArray, - pollOption: Int?, - message: String, - createdAt: Long = Date().time / 1000 - ): LnZapRequestEvent { - val content = message - val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() - var tags = listOf( - listOf("p", userHex), - listOf("relays") + relays - ) - if (pollOption != null && pollOption >= 0) { - tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString())) - } - val id = generateId(pubKey, createdAt, kind, tags, content) - val sig = Utils.sign(id, privateKey) - return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) - } - } -} - -/* -{ - "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", - "content": "", - "id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d", - "created_at": 1674164539, - "sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d", - "kind": 9734, - "tags": [ - [ - "e", - "3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8" - ], - [ - "p", - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" - ], - [ - "relays", - "wss://relay.damus.io", - "wss://nostr-relay.wlvs.space", - "wss://nostr.fmt.wiz.biz", - "wss://relay.nostr.bg", - "wss://nostr.oxtr.dev", - "wss://nostr.v0l.io", - "wss://brb.io", - "wss://nostr.bitcoiner.social", - "ws://monad.jb55.com:8080", - "wss://relay.snort.social" - ], - [ - "poll_option", "n" - ] - ], - "ots": // TODO -} -*/ +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 LnZapRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + + companion object { + const val kind = 9734 + + fun create( + originalNote: EventInterface, + relays: Set, + privateKey: ByteArray, + pollOption: Int?, + message: String, + createdAt: Long = Date().time / 1000 + ): LnZapRequestEvent { + val content = message + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + var tags = listOf( + listOf("e", originalNote.id()), + listOf("p", originalNote.pubKey()), + listOf("relays") + relays + ) + if (originalNote is LongTextNoteEvent) { + tags = tags + listOf(listOf("a", originalNote.address().toTag())) + } + if (pollOption != null && pollOption >= 0) { + tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString())) + } + + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + + fun create( + userHex: String, + relays: Set, + privateKey: ByteArray, + message: String, + createdAt: Long = Date().time / 1000 + ): LnZapRequestEvent { + val content = message + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = listOf( + listOf("p", userHex), + listOf("relays") + relays + ) + + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey()) + } + } +} + +/* +{ + "pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "content": "", + "id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d", + "created_at": 1674164539, + "sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d", + "kind": 9734, + "tags": [ + [ + "e", + "3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8" + ], + [ + "p", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + ], + [ + "relays", + "wss://relay.damus.io", + "wss://nostr-relay.wlvs.space", + "wss://nostr.fmt.wiz.biz", + "wss://relay.nostr.bg", + "wss://nostr.oxtr.dev", + "wss://nostr.v0l.io", + "wss://brb.io", + "wss://nostr.bitcoiner.social", + "ws://monad.jb55.com:8080", + "wss://relay.snort.social" + ], + [ + "poll_option", "n" + ] + ], + "ots": // TODO +} +*/ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 9cbb35b50..b0ea32a75 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -1,193 +1,194 @@ -package com.vitorpamplona.amethyst.ui.note - -import android.widget.Toast -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -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.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -class ZapOptionstViewModel : ViewModel() { - private var account: Account? = null - - var customAmount by mutableStateOf(TextFieldValue("1000")) - var customMessage by mutableStateOf(TextFieldValue("")) - - fun load(account: Account) { - this.account = account - } - - fun canSend(): Boolean { - return value() != null - } - - fun value(): Long? { - return try { - customAmount.text.trim().toLongOrNull() - } catch (e: Exception) { - null - } - } - - fun cancel() { - } -} - -@Composable -fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: AccountViewModel, baseNote: Note) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val postViewModel: ZapOptionstViewModel = viewModel() - - LaunchedEffect(account) { - postViewModel.load(account) - } - - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - dismissOnClickOutside = false, - usePlatformDefaultWidth = false - ) - ) { - Surface() { - Column(modifier = Modifier.padding(10.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onCancel = { - postViewModel.cancel() - onClose() - }) - - ZapButton( - isActive = postViewModel.canSend() - ) { - scope.launch(Dispatchers.IO) { - accountViewModel.zap( - baseNote, - postViewModel.value()!! * 1000L, - postViewModel.customMessage.text, - context, - onError = { - scope.launch { - Toast - .makeText(context, it, Toast.LENGTH_SHORT).show() - } - }, - onProgress = { - scope.launch(Dispatchers.Main) { - } - } - ) - } - onClose() - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - // stringResource(R.string.new_amount_in_sats - label = { Text(text = stringResource(id = R.string.amount_in_sats)) }, - value = postViewModel.customAmount, - onValueChange = { - postViewModel.customAmount = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number - ), - placeholder = { - Text( - text = "100, 1000, 5000", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - singleLine = true, - modifier = Modifier - .padding(end = 10.dp) - .weight(1f) - ) - } - Spacer(modifier = Modifier.height(5.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - // stringResource(R.string.new_amount_in_sats - label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) }, - value = postViewModel.customMessage, - onValueChange = { - postViewModel.customMessage = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text - ), - placeholder = { - Text( - text = stringResource(id = R.string.custom_zaps_add_a_message_example), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - singleLine = true, - modifier = Modifier - .padding(end = 10.dp) - .weight(1f) - ) - } - } - } - } -} - -@Composable -fun ZapButton(isActive: Boolean, onPost: () -> Unit) { - Button( - onClick = { onPost() }, - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray - ) - ) { - Text(text = "⚡Zap ", color = Color.White) - } -} +package com.vitorpamplona.amethyst.ui.note + +import android.widget.Toast +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ZapOptionstViewModel : ViewModel() { + private var account: Account? = null + + var customAmount by mutableStateOf(TextFieldValue("1000")) + var customMessage by mutableStateOf(TextFieldValue("")) + + fun load(account: Account) { + this.account = account + } + + fun canSend(): Boolean { + return value() != null + } + + fun value(): Long? { + return try { + customAmount.text.trim().toLongOrNull() + } catch (e: Exception) { + null + } + } + + fun cancel() { + } +} + +@Composable +fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: AccountViewModel, baseNote: Note) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val postViewModel: ZapOptionstViewModel = viewModel() + + LaunchedEffect(account) { + postViewModel.load(account) + } + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ) + ) { + Surface() { + Column(modifier = Modifier.padding(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = { + postViewModel.cancel() + onClose() + }) + + ZapButton( + isActive = postViewModel.canSend() + ) { + scope.launch(Dispatchers.IO) { + accountViewModel.zap( + baseNote, + postViewModel.value()!! * 1000L, + null, + postViewModel.customMessage.text, + context, + onError = { + scope.launch { + Toast + .makeText(context, it, Toast.LENGTH_SHORT).show() + } + }, + onProgress = { + scope.launch(Dispatchers.Main) { + } + } + ) + } + onClose() + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + // stringResource(R.string.new_amount_in_sats + label = { Text(text = stringResource(id = R.string.amount_in_sats)) }, + value = postViewModel.customAmount, + onValueChange = { + postViewModel.customAmount = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number + ), + placeholder = { + Text( + text = "100, 1000, 5000", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + singleLine = true, + modifier = Modifier + .padding(end = 10.dp) + .weight(1f) + ) + } + Spacer(modifier = Modifier.height(5.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + // stringResource(R.string.new_amount_in_sats + label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) }, + value = postViewModel.customMessage, + onValueChange = { + postViewModel.customMessage = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text + ), + placeholder = { + Text( + text = stringResource(id = R.string.custom_zaps_add_a_message_example), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + singleLine = true, + modifier = Modifier + .padding(end = 10.dp) + .weight(1f) + ) + } + } + } + } +} + +@Composable +fun ZapButton(isActive: Boolean, onPost: () -> Unit) { + Button( + onClick = { onPost() }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray + ) + ) { + Text(text = "⚡Zap ", color = Color.White) + } +}