diff --git a/app/src/main/java/com/vitorpamplona/amethyst/KeyStorage.kt b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt similarity index 85% rename from app/src/main/java/com/vitorpamplona/amethyst/KeyStorage.kt rename to app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt index 612ce1335..602e05a09 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/KeyStorage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/EncryptedStorage.kt @@ -4,9 +4,9 @@ import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKeys -class KeyStorage { +class EncryptedStorage { - fun encryptedPreferences(context: Context): EncryptedSharedPreferences { + fun preferences(context: Context): EncryptedSharedPreferences { val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC); val preferencesName = "secret_keeper" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt new file mode 100644 index 000000000..07bd34fa0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -0,0 +1,50 @@ +package com.vitorpamplona.amethyst + +import android.content.Context +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.DefaultChannels +import com.vitorpamplona.amethyst.model.toByteArray +import nostr.postr.Persona +import nostr.postr.toHex + +class LocalPreferences(context: Context) { + val encryptedPreferences = EncryptedStorage().preferences(context) + + fun clearEncryptedStorage() { + encryptedPreferences.edit().apply { + remove("nostr_privkey") + remove("nostr_pubkey") + remove("following_channels") + remove("hidden_users") + }.apply() + } + + fun saveToEncryptedStorage(account: Account) { + encryptedPreferences.edit().apply { + account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) } + account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) } + account.followingChannels.let { putStringSet("following_channels", it) } + account.hiddenUsers.let { putStringSet("hidden_users", it) } + }.apply() + } + + fun loadFromEncryptedStorage(): Account? { + encryptedPreferences.apply { + val privKey = getString("nostr_privkey", null) + val pubKey = getString("nostr_pubkey", null) + val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet() + val hiddenUsers = getStringSet("hidden_users", emptySet())?.toMutableSet() ?: mutableSetOf() + + if (pubKey != null) { + return Account( + Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), + followingChannels, + hiddenUsers + ) + } else { + return null + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 881e8557b..f638f542d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -36,6 +36,13 @@ object ServiceManager { NostrNotificationDataSource.account = myAccount NostrChatroomListDataSource.account = myAccount + NostrGlobalDataSource.account = myAccount + NostrChannelDataSource.account = myAccount + + NostrUserProfileDataSource.account = myAccount + NostrUserProfileFollowsDataSource.account = myAccount + NostrUserProfileFollowersDataSource.account = myAccount + NostrAccountDataSource.start() NostrGlobalDataSource.start() NostrHomeDataSource.start() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/lnurl/LnInvoiceUtil.kt b/app/src/main/java/com/vitorpamplona/amethyst/lnurl/LnInvoiceUtil.kt index 6e7582408..dd4db5c05 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/lnurl/LnInvoiceUtil.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/lnurl/LnInvoiceUtil.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.lnurl +import androidx.compose.ui.text.toLowerCase import java.math.BigDecimal import java.util.Locale import java.util.regex.Pattern @@ -128,7 +129,7 @@ object LnInvoiceUtil { } private fun multiplier(multiplier: String): BigDecimal { - return when (multiplier) { + return when (multiplier.toLowerCase()) { "m" -> BigDecimal("0.001") "u" -> BigDecimal("0.000001") "n" -> BigDecimal("0.000000001") 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 19bb061cb..bafe7eed6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -10,8 +10,12 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.RelayPool -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import java.util.Date +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import nostr.postr.Contact import nostr.postr.Persona import nostr.postr.Utils @@ -27,8 +31,11 @@ val DefaultChannels = setOf( "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group ) -class Account(val loggedIn: Persona, val followingChannels: MutableSet = DefaultChannels.toMutableSet()) { - var seeReplies: Boolean = true +class Account( + val loggedIn: Persona, + val followingChannels: MutableSet = DefaultChannels.toMutableSet(), + val hiddenUsers: MutableSet = DefaultChannels.toMutableSet() +) { fun userProfile(): User { return LocalCache.getOrCreateUser(loggedIn.pubKey) @@ -38,6 +45,10 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet = return followingChannels.map { LocalCache.getOrCreateChannel(it) } } + fun hiddenUsers(): List { + return hiddenUsers.map { LocalCache.getOrCreateUser(it.toByteArray()) } + } + fun isWriteable(): Boolean { return loggedIn.privKey != null } @@ -79,7 +90,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet = fun reactTo(note: Note) { if (!isWriteable()) return - if (note.reactions.firstOrNull { it.author == userProfile() } != null) { + if (note.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "+️" } != null) { // has already liked this note return } @@ -91,6 +102,23 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet = } } + fun report(note: Note) { + if (!isWriteable()) return + + if ( + note.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "⚠️"} != null + ) { + // has already liked this note + return + } + + note.event?.let { + val event = ReactionEvent.createWarning(it, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } + } + fun boost(note: Note) { if (!isWriteable()) return @@ -186,7 +214,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet = LocalCache.consume(signedEvent) } - fun sendCreateNewChannel(name: String, about: String, picture: String, accountStateViewModel: AccountStateViewModel) { + fun sendCreateNewChannel(name: String, about: String, picture: String) { if (!isWriteable()) return val metadata = ChannelCreateEvent.ChannelData( @@ -201,17 +229,25 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet = Client.send(event) LocalCache.consume(event) - joinChannel(event.id.toHex(), accountStateViewModel) + joinChannel(event.id.toHex()) } - fun joinChannel(idHex: String, accountStateViewModel: AccountStateViewModel) { + fun joinChannel(idHex: String) { followingChannels.add(idHex) - accountStateViewModel.saveToEncryptedStorage(this) } - fun leaveChannel(idHex: String, accountStateViewModel: AccountStateViewModel) { + fun leaveChannel(idHex: String) { followingChannels.remove(idHex) - accountStateViewModel.saveToEncryptedStorage(this) + } + + fun hideUser(pubkeyHex: String) { + hiddenUsers.add(pubkeyHex) + invalidateData() + } + + fun showUser(pubkeyHex: String) { + hiddenUsers.remove(pubkeyHex) + invalidateData() } fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { @@ -280,25 +316,44 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet = // Observers line up here. val live: AccountLiveData = AccountLiveData(this) - private fun refreshObservers() { - live.refresh() + // Refreshes observers in batches. + var handlerWaiting = false + @Synchronized + fun invalidateData() { + if (handlerWaiting) return + + handlerWaiting = true + val scope = CoroutineScope(Job() + Dispatchers.Default) + scope.launch { + delay(100) + live.refresh() + handlerWaiting = false + } } + fun isAcceptable(user: User): Boolean { + return user !in hiddenUsers() // if user hasn't hided this author + } + + fun isAcceptableDirect(note: Note): Boolean { + return note.reports.firstOrNull { it.author == userProfile() } == null // if user has not reported this post + && note.reports.filter { it.author in userProfile().follows }.size < 5 // if it has 5 reports by reliable users + } + + fun isAcceptable(note: Note): Boolean { + return note.author?.let { isAcceptable(it) } ?: true // if user hasn't hided this author + && isAcceptableDirect(note) + && (note.event !is ReactionEvent + || (note.event is ReactionEvent && note.replyTo?.firstOrNull { isAcceptableDirect(note) } == null) + ) // is not a reaction about a blocked post + } } class AccountLiveData(private val account: Account): LiveData(AccountState(account)) { fun refresh() { postValue(AccountState(account)) } - - override fun onActive() { - super.onActive() - } - - override fun onInactive() { - super.onInactive() - } } class AccountState(val account: Account) \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index a3400c6e4..2e8a215e7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -256,6 +256,13 @@ object LocalCache { it.addReaction(note) } } + + if (event.content == "\u26A0\uFE0F") { + // Counts the replies + repliesTo.forEach { + it.addReport(note) + } + } } fun consume(event: ChannelCreateEvent) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 3e7516453..adb9ecdce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -35,6 +35,8 @@ class Note(val idHex: String) { val reactions = Collections.synchronizedSet(mutableSetOf()) val boosts = Collections.synchronizedSet(mutableSetOf()) + val reports = Collections.synchronizedSet(mutableSetOf()) + var channel: Channel? = null fun loadEvent(event: Event, author: User, mentions: List, replyTo: MutableList) { @@ -88,6 +90,11 @@ class Note(val idHex: String) { invalidateData() } + fun addReport(note: Note) { + if (reports.add(note)) + invalidateData() + } + fun isReactedBy(user: User): Boolean { return synchronized(reactions) { reactions.any { it.author == user } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt index b56208209..6f5a5533f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import nostr.postr.JsonFilter object NostrChannelDataSource: NostrDataSource("ChatroomFeed") { + lateinit var account: Account var channel: com.vitorpamplona.amethyst.model.Channel? = null fun loadMessagesBetween(channelId: String) { @@ -28,7 +30,7 @@ object NostrChannelDataSource: NostrDataSource("ChatroomFeed") { // returns the last Note of each user. override fun feed(): List { - return channel?.notes?.values?.sortedBy { it.event?.createdAt } ?: emptyList() + return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt } ?: emptyList() } override fun updateChannelFilters() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatRoomDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatRoomDataSource.kt index 2f260bb36..c39e21770 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatRoomDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatRoomDataSource.kt @@ -33,7 +33,7 @@ object NostrChatRoomDataSource: NostrDataSource("ChatroomFeed") { // returns the last Note of each user. override fun feed(): List { - val messages = account.userProfile().messages[withUser] + val messages = account.userProfile().messages[withUser]?.filter { account.isAcceptable(it) } return messages?.sortedBy { it.event?.createdAt } ?: emptyList() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index 347f228b0..3e2dd8a14 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -64,14 +64,14 @@ object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") { // returns the last Note of each user. override fun feed(): List { val messages = account.userProfile().messages - val messagingWith = messages.keys().toList() + val messagingWith = messages.keys().toList().filter { account.isAcceptable(it) } val privateMessages = messagingWith.mapNotNull { messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null } } val publicChannels = account.followingChannels().map { - it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null } + it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null } } val channelsCreatedByMe = LocalCache.channels.values.filter { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt index de69847a4..cc0536e1c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import nostr.postr.JsonFilter import nostr.postr.events.TextNoteEvent object NostrGlobalDataSource: NostrDataSource("GlobalFeed") { + lateinit var account: Account fun createGlobalFilter() = JsonFilter( kinds = listOf(TextNoteEvent.kind), limit = 50 @@ -14,6 +16,7 @@ object NostrGlobalDataSource: NostrDataSource("GlobalFeed") { val globalFeedChannel = requestNewChannel() override fun feed() = LocalCache.notes.values + .filter { account.isAcceptable(it) } .filter { it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHiddenAccountsDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHiddenAccountsDataSource.kt new file mode 100644 index 000000000..28079d7e6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHiddenAccountsDataSource.kt @@ -0,0 +1,16 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import nostr.postr.JsonFilter +import nostr.postr.events.TextNoteEvent + +object NostrHiddenAccountsDataSource: NostrDataSource("HiddenAccounts") { + lateinit var account: Account + + override fun feed() = account.hiddenUsers() + + override fun updateChannelFilters() {} +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 222b4085c..2fe41c278 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -82,6 +82,7 @@ object NostrHomeDataSource: NostrDataSource("HomeFeed") { return LocalCache.notes.values .filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet } + .filter { account.isAcceptable(it) } .sortedBy { it.event?.createdAt } .reversed() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt index 4d8067a70..27f5fcd41 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt @@ -18,7 +18,7 @@ object NostrNotificationDataSource: NostrDataSource("NotificationFeed") { override fun feed(): List { val set = account.userProfile().taggedPosts val filtered = synchronized(set) { - set.filter { it.event != null } + set.filter { it.event != null }.filter { account.isAcceptable(it) } } return filtered.sortedBy { it.event?.createdAt }.reversed() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index b62d7edc4..94f37508b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -8,6 +9,7 @@ import nostr.postr.events.MetadataEvent import nostr.postr.events.TextNoteEvent object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") { + lateinit var account: Account var user: User? = null fun loadUserProfile(userId: String) { @@ -37,7 +39,7 @@ object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") { override fun feed(): List { val notes = user?.notes ?: return emptyList() val sortedNotes = synchronized(notes) { - notes.sortedBy { it.event?.createdAt } + notes.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt } } return sortedNotes.reversed() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowersDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowersDataSource.kt index 7291d7b68..efc00a104 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowersDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowersDataSource.kt @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import nostr.postr.JsonFilter import nostr.postr.events.ContactListEvent object NostrUserProfileFollowersDataSource: NostrDataSource("UserProfileFollowerFeed") { + lateinit var account: Account var user: User? = null fun loadUserProfile(userId: String) { @@ -24,7 +26,7 @@ object NostrUserProfileFollowersDataSource: NostrDataSource("UserProfileFo val followers = user?.followers ?: emptyList() return synchronized(followers) { - followers.toList() + followers.filter { account.isAcceptable(it) }.toList() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowsDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowsDataSource.kt index 8c32972b8..b9debe29c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowsDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileFollowsDataSource.kt @@ -1,11 +1,13 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import nostr.postr.JsonFilter import nostr.postr.events.ContactListEvent object NostrUserProfileFollowsDataSource: NostrDataSource("UserProfileFollowsFeed") { + lateinit var account: Account var user: User? = null fun loadUserProfile(userId: String) { @@ -24,7 +26,11 @@ object NostrUserProfileFollowsDataSource: NostrDataSource("UserProfileFoll val followChannel = requestNewChannel() override fun feed(): List { - return user?.follows?.toList() ?: emptyList() + val follows = user?.follows ?: emptyList() + + return synchronized(follows) { + follows.filter { account.isAcceptable(it) }.toList() + } } override fun updateChannelFilters() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt index 556e1e5f9..a5ac96524 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt @@ -25,6 +25,10 @@ class ReactionEvent ( companion object { const val kind = 7 + fun createWarning(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent { + return create("\u26A0\uFE0F", originalNote, privateKey, createdAt) + } + fun createLike(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent { return create("+", originalNote, privateKey, createdAt) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 21f9bb081..6c113cfc3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -14,21 +14,9 @@ import coil.ImageLoader import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder -import com.vitorpamplona.amethyst.KeyStorage +import com.vitorpamplona.amethyst.EncryptedStorage +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.NostrAccountDataSource -import com.vitorpamplona.amethyst.service.NostrChannelDataSource -import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource -import com.vitorpamplona.amethyst.service.NostrGlobalDataSource -import com.vitorpamplona.amethyst.service.NostrHomeDataSource -import com.vitorpamplona.amethyst.service.NostrNotificationDataSource -import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource -import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource -import com.vitorpamplona.amethyst.service.NostrThreadDataSource -import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource -import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource -import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel @@ -57,7 +45,7 @@ class MainActivity : ComponentActivity() { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { val accountViewModel: AccountStateViewModel = viewModel { - AccountStateViewModel(KeyStorage().encryptedPreferences(applicationContext)) + AccountStateViewModel(LocalPreferences(applicationContext)) } AccountScreen(accountViewModel) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt index edd2ddfe6..bf0b7c892 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt @@ -15,6 +15,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -26,10 +27,11 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel: AccountStateViewModel, channel: Channel? = null) { +fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) { val postViewModel: NewChannelViewModel = viewModel() + val context = LocalContext.current.applicationContext - postViewModel.load(account, channel, accountStateViewModel) + postViewModel.load(account, channel) Dialog( onDismissRequest = { onClose() }, @@ -55,7 +57,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel: PostButton( onPost = { - postViewModel.create() + postViewModel.create(context) onClose() }, postViewModel.channelName.value.text.isNotBlank() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt index 6b472474c..51e4eb6f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt @@ -1,25 +1,26 @@ package com.vitorpamplona.amethyst.ui.actions +import android.content.Context import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import nostr.postr.toHex class NewChannelViewModel: ViewModel() { private var account: Account? = null private var originalChannel: Channel? = null - private var accountStateViewModel: AccountStateViewModel? = null val channelName = mutableStateOf(TextFieldValue()) val channelPicture = mutableStateOf(TextFieldValue()) val channelDescription = mutableStateOf(TextFieldValue()) - fun load(account: Account, channel: Channel?, accountStateViewModel: AccountStateViewModel) { - this.accountStateViewModel = accountStateViewModel + fun load(account: Account, channel: Channel?) { this.account = account if (channel != null) { originalChannel = channel @@ -29,21 +30,24 @@ class NewChannelViewModel: ViewModel() { } } - fun create() { - if (originalChannel == null) - this.account?.sendCreateNewChannel( - channelName.value.text, - channelDescription.value.text, - channelPicture.value.text, - accountStateViewModel!! - ) - else - this.account?.sendChangeChannel( - channelName.value.text, - channelDescription.value.text, - channelPicture.value.text, - originalChannel!! - ) + fun create(context: Context) { + this.account?.let { account -> + if (originalChannel == null) { + account.sendCreateNewChannel( + channelName.value.text, + channelDescription.value.text, + channelPicture.value.text + ) + + LocalPreferences(context).saveToEncryptedStorage(account) + } else + account.sendChangeChannel( + channelName.value.text, + channelDescription.value.text, + channelPicture.value.text, + originalChannel!! + ) + } clear() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index f590ef121..0bf86d4f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -23,6 +23,7 @@ import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -67,7 +68,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account val context = LocalContext.current // initialize focus reference to be able to request focus programmatically - val focusRequester = FocusRequester() + val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current LaunchedEffect(Unit) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt index f22df4563..a40b7e4b9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewChannelButton.kt @@ -28,13 +28,13 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun NewChannelButton(account: Account, accountStateViewModel: AccountStateViewModel) { +fun NewChannelButton(account: Account) { var wantsToPost by remember { mutableStateOf(false) } if (wantsToPost) - NewChannelView({ wantsToPost = false }, account = account, accountStateViewModel) + NewChannelView({ wantsToPost = false }, account = account) OutlinedButton( onClick = { wantsToPost = true }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 552633394..53d2655de 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -162,6 +162,8 @@ fun ListContent( modifier: Modifier, accountViewModel: AccountStateViewModel ) { + val coroutineScope = rememberCoroutineScope() + Column(modifier = modifier) { LazyColumn() { item { @@ -178,14 +180,19 @@ fun ListContent( thickness = 0.25.dp ) Column(modifier = modifier.padding(horizontal = 25.dp)) { - Text( - text = "Settings", - fontSize = 18.sp, - fontWeight = W500 - ) - Row( - modifier = Modifier.clickable(onClick = { accountViewModel.logOff() }), - ) { + Row(modifier = Modifier.clickable(onClick = { + navController.navigate(Route.Filters.route) + coroutineScope.launch { + scaffoldState.drawerState.close() + } + })) { + Text( + text = "Security Filters", + fontSize = 18.sp, + fontWeight = W500 + ) + } + Row(modifier = Modifier.clickable(onClick = { accountViewModel.logOff() })) { Text( text = "Log out", modifier = Modifier.padding(vertical = 15.dp), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index f4e6d9ebd..31112c070 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.ChannelScreen import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen +import com.vitorpamplona.amethyst.ui.screen.FiltersScreen import com.vitorpamplona.amethyst.ui.screen.HomeScreen import com.vitorpamplona.amethyst.ui.screen.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.ProfileScreen @@ -32,6 +33,7 @@ sealed class Route( object Search : Route("Search", R.drawable.ic_search, buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }}) object Notification : Route("Notification", R.drawable.ic_notifications,buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) }}) object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }}) + object Filters : Route("Filters", R.drawable.ic_dm, buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) }}) object Profile : Route("User/{id}", R.drawable.ic_profile, arguments = listOf(navArgument("id") { type = NavType.StringType } ), @@ -65,7 +67,8 @@ val Routes = listOf( Route.Profile, Route.Note, Route.Room, - Route.Channel + Route.Channel, + Route.Filters ) @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index f5bb3678f..3c20f1f46 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -26,7 +26,38 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) { verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Referenced event not found", + text = "Referenced post not found", + modifier = Modifier.padding(30.dp), + color = Color.Gray, + ) + } + + Divider( + modifier = Modifier.padding(vertical = 10.dp), + thickness = 0.25.dp + ) + } + } + } +} + + +@Composable +fun HiddenNote(modifier: Modifier = Modifier, isQuote: Boolean = false) { + Column(modifier = modifier) { + Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) { + Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) { + Row( + modifier = Modifier.padding( + start = 20.dp, + end = 20.dp, + bottom = 25.dp, + top = 15.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Post was flagged as inappropriate", modifier = Modifier.padding(30.dp), color = Color.Gray, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index ab8fb2e25..9dcac9126 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Divider import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -27,6 +28,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -46,6 +48,9 @@ import nostr.postr.toNpub @OptIn(ExperimentalFoundationApi::class) @Composable fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account + val noteState by baseNote.live.observeAsState() val note = noteState?.note @@ -56,6 +61,11 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool onClick = { }, onLongClick = { popupExpanded = true }, ), isInnerNote) + } else if (account?.isAcceptable(note) == false) { + HiddenNote(modifier.combinedClickable( + onClick = { }, + onLongClick = { popupExpanded = true }, + ), isInnerNote) } else { val authorState by note.author!!.live.observeAsState() val author = authorState?.user @@ -195,6 +205,7 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool @Composable fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) { val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current.applicationContext DropdownMenu( expanded = popupExpanded, @@ -213,5 +224,12 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) { Text("Broadcast") } + Divider() + DropdownMenuItem(onClick = { accountViewModel.report(note); onDismiss() }) { + Text("Report Post") + } + DropdownMenuItem(onClick = { note.author?.let { accountViewModel.hide(it, context) }; onDismiss() }) { + Text("Hide User") + } } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 3a02a74bb..7a66383ad 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -14,6 +14,8 @@ import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.Visibility import androidx.compose.runtime.Composable @@ -39,11 +41,14 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.NewPostView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: (Note) -> Unit) { +fun ReactionsRow(note: Note, account: Account, accountViewModel: AccountViewModel) { val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + var popupExpanded by remember { mutableStateOf(false) } + var wantsToReplyTo by remember { mutableStateOf(null) } @@ -79,7 +84,7 @@ fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: ( IconButton( modifier = Modifier.then(Modifier.size(24.dp)), - onClick = { if (account.isWriteable()) boost(note) } + onClick = { if (account.isWriteable()) accountViewModel.boost(note) } ) { if (note.isBoostedBy(account.userProfile())) { Icon( @@ -107,7 +112,7 @@ fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: ( IconButton( modifier = Modifier.then(Modifier.size(24.dp)), - onClick = { if (account.isWriteable()) reactTo(note) } + onClick = { if (account.isWriteable()) accountViewModel.reactTo(note) } ) { if (note.isReactedBy(account.userProfile())) { Icon( @@ -161,8 +166,20 @@ fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: ( } - + IconButton( + modifier = Modifier.then(Modifier.size(24.dp)), + onClick = { popupExpanded = true } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = Modifier.size(15.dp), + tint = grayTint, + ) + } } + + NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) } fun showCount(size: Int?): String { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRowState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRowState.kt index 1bcec3b38..c93f4b07b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRowState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRowState.kt @@ -16,5 +16,5 @@ fun ReactionsRowState(baseNote: Note, accountViewModel: AccountViewModel) { if (account == null || note == null) return - ReactionsRow(note, account, accountViewModel::boost, accountViewModel::reactTo) + ReactionsRow(note, account, accountViewModel) } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 37070edd6..6bbbceebf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -16,23 +16,29 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.navigation.NavController import coil.compose.AsyncImage import coil.compose.rememberAsyncImagePainter +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.FollowButton +import com.vitorpamplona.amethyst.ui.screen.ShowUserButton import com.vitorpamplona.amethyst.ui.screen.UnfollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account val userState by baseUser.live.observeAsState() val user = userState?.user ?: return + val ctx = LocalContext.current.applicationContext + Column(modifier = Modifier.clickable( onClick = { navController.navigate("User/${user.pubkeyHex}") } @@ -69,10 +75,15 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle } Column(modifier = Modifier.padding(start = 10.dp)) { - if (accountState?.account?.userProfile()?.isFollowing(user) == true) { - UnfollowButton { accountState?.account?.unfollow(user) } + if (account?.isAcceptable(user) == false) { + ShowUserButton { + account.showUser(user.pubkeyHex) + LocalPreferences(ctx).saveToEncryptedStorage(account) + } + } else if (account?.userProfile()?.isFollowing(user) == true) { + UnfollowButton { account.unfollow(user) } } else { - FollowButton { accountState?.account?.follow(user) } + FollowButton { account?.follow(user) } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index e7a8d112e..16adaf0dd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.security.crypto.EncryptedSharedPreferences +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.DefaultChannels @@ -30,15 +31,13 @@ import nostr.postr.Persona import nostr.postr.bechToBytes import nostr.postr.toHex -class AccountStateViewModel( - private val encryptedPreferences: EncryptedSharedPreferences -): ViewModel() { +class AccountStateViewModel(private val localPreferences: LocalPreferences): ViewModel() { private val _accountContent = MutableStateFlow(AccountState.LoggedOff) val accountContent = _accountContent.asStateFlow() init { // pulls account from storage. - loadFromEncryptedStorage()?.let { + localPreferences.loadFromEncryptedStorage()?.let { login(it) } } @@ -58,14 +57,14 @@ class AccountStateViewModel( Account(Persona(Hex.decode(key))) } - saveToEncryptedStorage(account) + localPreferences.saveToEncryptedStorage(account) login(account) } fun newKey() { val account = Account(Persona()) - saveToEncryptedStorage(account) + localPreferences.saveToEncryptedStorage(account) login(account) } @@ -83,36 +82,6 @@ class AccountStateViewModel( fun logOff() { _accountContent.update { AccountState.LoggedOff } - clearEncryptedStorage() - } - - fun clearEncryptedStorage() { - encryptedPreferences.edit().apply { - remove("nostr_privkey") - remove("nostr_pubkey") - remove("following_channels") - }.apply() - } - - fun saveToEncryptedStorage(account: Account) { - encryptedPreferences.edit().apply { - account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) } - account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) } - account.followingChannels.let { putStringSet("following_channels", account.followingChannels) } - }.apply() - } - - fun loadFromEncryptedStorage(): Account? { - encryptedPreferences.apply { - val privKey = getString("nostr_privkey", null) - val pubKey = getString("nostr_pubkey", null) - val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet() - - if (pubKey != null) { - return Account(Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), followingChannels) - } else { - return null - } - } + localPreferences.clearEncryptedStorage() } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index 73f30cbe4..bb7016d2b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrDataSource +import com.vitorpamplona.amethyst.service.NostrHiddenAccountsDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource import kotlinx.coroutines.CoroutineScope @@ -28,6 +29,10 @@ class NostrUserProfileFollowersUserFeedViewModel(): UserFeedViewModel( NostrUserProfileFollowersDataSource ) +class NostrHiddenAccountsFeedViewModel(): UserFeedViewModel( + NostrHiddenAccountsDataSource +) + open class UserFeedViewModel(val dataSource: NostrDataSource): ViewModel() { private val _feedContent = MutableStateFlow(UserFeedState.Loading) val feedContent = _feedContent.asStateFlow() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index e083a03c7..02655dc79 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1,11 +1,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountState import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState class AccountViewModel(private val account: Account): ViewModel() { @@ -15,6 +18,10 @@ class AccountViewModel(private val account: Account): ViewModel() { account.reactTo(note) } + fun report(note: Note) { + account.report(note) + } + fun boost(note: Note) { account.boost(note) } @@ -26,4 +33,14 @@ class AccountViewModel(private val account: Account): ViewModel() { fun decrypt(note: Note): String? { return account.decryptContent(note) } + + fun hide(user: User, ctx: Context) { + account.hideUser(user.pubkeyHex) + LocalPreferences(ctx).saveToEncryptedStorage(account) + } + + fun show(user: User, ctx: Context) { + account.showUser(user.pubkeyHex) + LocalPreferences(ctx).saveToEncryptedStorage(account) + } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index c3cee606b..a9f908995 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization @@ -41,6 +42,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import coil.compose.AsyncImage import coil.compose.rememberAsyncImagePainter +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note @@ -169,12 +171,12 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel: channel?.let { if (channel.creator == account.userProfile()) { - EditButton(account, it, accountStateViewModel) + EditButton(account, it) } else { if (account.followingChannels.contains(channel.idHex)) { - LeaveButton(account,channel,accountStateViewModel, navController) + LeaveButton(account,channel, navController) } else { - JoinButton(account,channel,accountStateViewModel, navController) + JoinButton(account,channel, navController) } } } @@ -209,13 +211,13 @@ private fun NoteCopyButton( } @Composable -private fun EditButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel) { +private fun EditButton(account: Account, channel: Channel) { var wantsToPost by remember { mutableStateOf(false) } if (wantsToPost) - NewChannelView({ wantsToPost = false }, account = account, accountStateViewModel, channel) + NewChannelView({ wantsToPost = false }, account = account, channel) Button( modifier = Modifier.padding(horizontal = 3.dp), @@ -231,11 +233,14 @@ private fun EditButton(account: Account, channel: Channel, accountStateViewModel } @Composable -private fun JoinButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel, navController: NavController) { +private fun JoinButton(account: Account, channel: Channel, navController: NavController) { + val context = LocalContext.current.applicationContext + Button( modifier = Modifier.padding(horizontal = 3.dp), onClick = { - account.joinChannel(channel.idHex, accountStateViewModel) + account.joinChannel(channel.idHex) + LocalPreferences(context).saveToEncryptedStorage(account) navController.navigate(Route.Message.route) }, shape = RoundedCornerShape(20.dp), @@ -249,11 +254,14 @@ private fun JoinButton(account: Account, channel: Channel, accountStateViewModel } @Composable -private fun LeaveButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel, navController: NavController) { +private fun LeaveButton(account: Account, channel: Channel, navController: NavController) { + val context = LocalContext.current.applicationContext + Button( modifier = Modifier.padding(horizontal = 3.dp), onClick = { - account.leaveChannel(channel.idHex, accountStateViewModel) + account.leaveChannel(channel.idHex) + LocalPreferences(context).saveToEncryptedStorage(account) navController.navigate(Route.Message.route) }, shape = RoundedCornerShape(20.dp), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/FiltersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/FiltersScreen.kt new file mode 100644 index 000000000..2b69b1288 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/FiltersScreen.kt @@ -0,0 +1,70 @@ +package com.vitorpamplona.amethyst.ui.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.TabRowDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.pagerTabIndicatorOffset +import com.google.accompanist.pager.rememberPagerState +import com.vitorpamplona.amethyst.service.NostrHiddenAccountsDataSource +import com.vitorpamplona.amethyst.service.NostrThreadDataSource +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.launch + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun FiltersScreen(accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account + + if (account != null) { + NostrHiddenAccountsDataSource.account = account + + val feedViewModel: NostrHiddenAccountsFeedViewModel = viewModel() + + Column(Modifier.fillMaxHeight()) { + Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) { + val pagerState = rememberPagerState() + val coroutineScope = rememberCoroutineScope() + + TabRow( + selectedTabIndex = pagerState.currentPage, + indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), + color = MaterialTheme.colors.primary + ) + }, + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + text = { + Text(text = "Blocked Users") + } + ) + } + HorizontalPager(count = 1, state = pagerState) { + when (pagerState.currentPage) { + 0 -> UserFeedView(feedViewModel, accountViewModel, navController) + } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index d3a4ab634..4a0dd486e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -85,7 +85,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt // Does nothing. } is AccountState.LoggedIn -> { - NewChannelButton(state.account, accountViewModel) + NewChannelButton(state.account) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 2eec43405..3d34f7239 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -54,6 +55,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.pagerTabIndicatorOffset import com.google.accompanist.pager.rememberPagerState +import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note @@ -67,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.launch import nostr.postr.toNpub +import nostr.postr.toNsec @OptIn(ExperimentalPagerApi::class) @Composable @@ -77,6 +80,8 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro val accountUserState by account.userProfile().live.observeAsState() val accountUser = accountUserState?.user + val ctx = LocalContext.current.applicationContext + if (userId != null && accountUser != null) { DisposableEffect(account) { NostrUserProfileDataSource.loadUserProfile(userId) @@ -145,12 +150,21 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro MessageButton(user, navController) + if (accountUser == user && account.isWriteable()) { + NSecCopyButton(account) + } + NPubCopyButton(user) if (accountUser == user) { EditButton(account) } else { - if (accountUser.isFollowing(user)) { + if (account?.isAcceptable(user) == false) { + ShowUserButton { + account.showUser(user.pubkeyHex) + LocalPreferences(ctx).saveToEncryptedStorage(account) + } + } else if (accountUser.isFollowing(user)) { UnfollowButton { account.unfollow(user) } } else { FollowButton { account.follow(user) } @@ -267,6 +281,25 @@ fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController: } } +@Composable +private fun NSecCopyButton( + account: Account +) { + val clipboardManager = LocalClipboardManager.current + + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { account.loggedIn.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) } }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ), + ) { + Text(text = "nsec", color = Color.White) + } +} + @Composable private fun NPubCopyButton( user: User @@ -358,4 +391,18 @@ fun FollowButton(onClick: () -> Unit) { } } +@Composable +fun ShowUserButton(onClick: () -> Unit) { + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = onClick, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = "Unblock", color = Color.White) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 62a3715ae..0ace05c03 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController @@ -54,6 +55,7 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.ui.note.ChannelName import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -167,9 +169,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont ) ) { itemsIndexed(searchResults.value, key = { _, item -> "u"+item.pubkeyHex }) { index, item -> - UserLine(item) { - navController.navigate("User/${item.pubkeyHex}") - } + UserCompose(item, accountViewModel = accountViewModel, navController = navController) } itemsIndexed(searchResultsChannels.value, key = { _, item -> "c"+item.idHex }) { index, item -> @@ -236,7 +236,9 @@ fun UserLine( Text( user.info.about?.take(100) ?: "", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } }