Support for Blocking and Reporting Users/Posts

This commit is contained in:
Vitor Pamplona
2023-01-24 16:59:21 -03:00
parent 45ea408877
commit 687428abc1
38 changed files with 503 additions and 140 deletions
@@ -4,9 +4,9 @@ import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys 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 secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
val preferencesName = "secret_keeper" val preferencesName = "secret_keeper"
@@ -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
}
}
}
}
@@ -36,6 +36,13 @@ object ServiceManager {
NostrNotificationDataSource.account = myAccount NostrNotificationDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount NostrChatroomListDataSource.account = myAccount
NostrGlobalDataSource.account = myAccount
NostrChannelDataSource.account = myAccount
NostrUserProfileDataSource.account = myAccount
NostrUserProfileFollowsDataSource.account = myAccount
NostrUserProfileFollowersDataSource.account = myAccount
NostrAccountDataSource.start() NostrAccountDataSource.start()
NostrGlobalDataSource.start() NostrGlobalDataSource.start()
NostrHomeDataSource.start() NostrHomeDataSource.start()
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.lnurl package com.vitorpamplona.amethyst.lnurl
import androidx.compose.ui.text.toLowerCase
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale import java.util.Locale
import java.util.regex.Pattern import java.util.regex.Pattern
@@ -128,7 +129,7 @@ object LnInvoiceUtil {
} }
private fun multiplier(multiplier: String): BigDecimal { private fun multiplier(multiplier: String): BigDecimal {
return when (multiplier) { return when (multiplier.toLowerCase()) {
"m" -> BigDecimal("0.001") "m" -> BigDecimal("0.001")
"u" -> BigDecimal("0.000001") "u" -> BigDecimal("0.000001")
"n" -> BigDecimal("0.000000001") "n" -> BigDecimal("0.000000001")
@@ -10,8 +10,12 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import java.util.Date 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.Contact
import nostr.postr.Persona import nostr.postr.Persona
import nostr.postr.Utils import nostr.postr.Utils
@@ -27,8 +31,11 @@ val DefaultChannels = setOf(
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
) )
class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet()) { class Account(
var seeReplies: Boolean = true val loggedIn: Persona,
val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet(),
val hiddenUsers: MutableSet<String> = DefaultChannels.toMutableSet()
) {
fun userProfile(): User { fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey) return LocalCache.getOrCreateUser(loggedIn.pubKey)
@@ -38,6 +45,10 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
return followingChannels.map { LocalCache.getOrCreateChannel(it) } return followingChannels.map { LocalCache.getOrCreateChannel(it) }
} }
fun hiddenUsers(): List<User> {
return hiddenUsers.map { LocalCache.getOrCreateUser(it.toByteArray()) }
}
fun isWriteable(): Boolean { fun isWriteable(): Boolean {
return loggedIn.privKey != null return loggedIn.privKey != null
} }
@@ -79,7 +90,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
fun reactTo(note: Note) { fun reactTo(note: Note) {
if (!isWriteable()) return 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 // has already liked this note
return return
} }
@@ -91,6 +102,23 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
} }
} }
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) { fun boost(note: Note) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -186,7 +214,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
LocalCache.consume(signedEvent) LocalCache.consume(signedEvent)
} }
fun sendCreateNewChannel(name: String, about: String, picture: String, accountStateViewModel: AccountStateViewModel) { fun sendCreateNewChannel(name: String, about: String, picture: String) {
if (!isWriteable()) return if (!isWriteable()) return
val metadata = ChannelCreateEvent.ChannelData( val metadata = ChannelCreateEvent.ChannelData(
@@ -201,17 +229,25 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
Client.send(event) Client.send(event)
LocalCache.consume(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) followingChannels.add(idHex)
accountStateViewModel.saveToEncryptedStorage(this)
} }
fun leaveChannel(idHex: String, accountStateViewModel: AccountStateViewModel) { fun leaveChannel(idHex: String) {
followingChannels.remove(idHex) 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) { fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
@@ -280,25 +316,44 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
// Observers line up here. // Observers line up here.
val live: AccountLiveData = AccountLiveData(this) val live: AccountLiveData = AccountLiveData(this)
private fun refreshObservers() { // 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() 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>(AccountState(account)) { class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
fun refresh() { fun refresh() {
postValue(AccountState(account)) postValue(AccountState(account))
} }
override fun onActive() {
super.onActive()
}
override fun onInactive() {
super.onInactive()
}
} }
class AccountState(val account: Account) class AccountState(val account: Account)
@@ -256,6 +256,13 @@ object LocalCache {
it.addReaction(note) it.addReaction(note)
} }
} }
if (event.content == "\u26A0\uFE0F") {
// Counts the replies
repliesTo.forEach {
it.addReport(note)
}
}
} }
fun consume(event: ChannelCreateEvent) { fun consume(event: ChannelCreateEvent) {
@@ -35,6 +35,8 @@ class Note(val idHex: String) {
val reactions = Collections.synchronizedSet(mutableSetOf<Note>()) val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
val boosts = Collections.synchronizedSet(mutableSetOf<Note>()) val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
val reports = Collections.synchronizedSet(mutableSetOf<Note>())
var channel: Channel? = null var channel: Channel? = null
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) { fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
@@ -88,6 +90,11 @@ class Note(val idHex: String) {
invalidateData() invalidateData()
} }
fun addReport(note: Note) {
if (reports.add(note))
invalidateData()
}
fun isReactedBy(user: User): Boolean { fun isReactedBy(user: User): Boolean {
return synchronized(reactions) { return synchronized(reactions) {
reactions.any { it.author == user } reactions.any { it.author == user }
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import nostr.postr.JsonFilter import nostr.postr.JsonFilter
object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") { object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
lateinit var account: Account
var channel: com.vitorpamplona.amethyst.model.Channel? = null var channel: com.vitorpamplona.amethyst.model.Channel? = null
fun loadMessagesBetween(channelId: String) { fun loadMessagesBetween(channelId: String) {
@@ -28,7 +30,7 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
// returns the last Note of each user. // returns the last Note of each user.
override fun feed(): List<Note> { override fun feed(): List<Note> {
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() { override fun updateChannelFilters() {
@@ -33,7 +33,7 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
// returns the last Note of each user. // returns the last Note of each user.
override fun feed(): List<Note> { override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser] val messages = account.userProfile().messages[withUser]?.filter { account.isAcceptable(it) }
return messages?.sortedBy { it.event?.createdAt } ?: emptyList() return messages?.sortedBy { it.event?.createdAt } ?: emptyList()
} }
@@ -64,14 +64,14 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
// returns the last Note of each user. // returns the last Note of each user.
override fun feed(): List<Note> { override fun feed(): List<Note> {
val messages = account.userProfile().messages val messages = account.userProfile().messages
val messagingWith = messages.keys().toList() val messagingWith = messages.keys().toList().filter { account.isAcceptable(it) }
val privateMessages = messagingWith.mapNotNull { val privateMessages = messagingWith.mapNotNull {
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null } messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
} }
val publicChannels = account.followingChannels().map { 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 { val channelsCreatedByMe = LocalCache.channels.values.filter {
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import nostr.postr.JsonFilter import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent import nostr.postr.events.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") { object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = JsonFilter( fun createGlobalFilter() = JsonFilter(
kinds = listOf(TextNoteEvent.kind), kinds = listOf(TextNoteEvent.kind),
limit = 50 limit = 50
@@ -14,6 +16,7 @@ object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
val globalFeedChannel = requestNewChannel() val globalFeedChannel = requestNewChannel()
override fun feed() = LocalCache.notes.values override fun feed() = LocalCache.notes.values
.filter { account.isAcceptable(it) }
.filter { .filter {
it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty() it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()
} }
@@ -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<User>("HiddenAccounts") {
lateinit var account: Account
override fun feed() = account.hiddenUsers()
override fun updateChannelFilters() {}
}
@@ -82,6 +82,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
return LocalCache.notes.values return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet } .filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.filter { account.isAcceptable(it) }
.sortedBy { it.event?.createdAt } .sortedBy { it.event?.createdAt }
.reversed() .reversed()
} }
@@ -18,7 +18,7 @@ object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
override fun feed(): List<Note> { override fun feed(): List<Note> {
val set = account.userProfile().taggedPosts val set = account.userProfile().taggedPosts
val filtered = synchronized(set) { 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() return filtered.sortedBy { it.event?.createdAt }.reversed()
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
@@ -8,6 +9,7 @@ import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent import nostr.postr.events.TextNoteEvent
object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") { object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
lateinit var account: Account
var user: User? = null var user: User? = null
fun loadUserProfile(userId: String) { fun loadUserProfile(userId: String) {
@@ -37,7 +39,7 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
override fun feed(): List<Note> { override fun feed(): List<Note> {
val notes = user?.notes ?: return emptyList() val notes = user?.notes ?: return emptyList()
val sortedNotes = synchronized(notes) { val sortedNotes = synchronized(notes) {
notes.sortedBy { it.event?.createdAt } notes.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }
} }
return sortedNotes.reversed() return sortedNotes.reversed()
} }
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent import nostr.postr.events.ContactListEvent
object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFollowerFeed") { object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFollowerFeed") {
lateinit var account: Account
var user: User? = null var user: User? = null
fun loadUserProfile(userId: String) { fun loadUserProfile(userId: String) {
@@ -24,7 +26,7 @@ object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFo
val followers = user?.followers ?: emptyList() val followers = user?.followers ?: emptyList()
return synchronized(followers) { return synchronized(followers) {
followers.toList() followers.filter { account.isAcceptable(it) }.toList()
} }
} }
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent import nostr.postr.events.ContactListEvent
object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFollowsFeed") { object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFollowsFeed") {
lateinit var account: Account
var user: User? = null var user: User? = null
fun loadUserProfile(userId: String) { fun loadUserProfile(userId: String) {
@@ -24,7 +26,11 @@ object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFoll
val followChannel = requestNewChannel() val followChannel = requestNewChannel()
override fun feed(): List<User> { override fun feed(): List<User> {
return user?.follows?.toList() ?: emptyList() val follows = user?.follows ?: emptyList()
return synchronized(follows) {
follows.filter { account.isAcceptable(it) }.toList()
}
} }
override fun updateChannelFilters() { override fun updateChannelFilters() {
@@ -25,6 +25,10 @@ class ReactionEvent (
companion object { companion object {
const val kind = 7 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 { fun createLike(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
return create("+", originalNote, privateKey, createdAt) return create("+", originalNote, privateKey, createdAt)
} }
@@ -14,21 +14,9 @@ import coil.ImageLoader
import coil.decode.GifDecoder import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder 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.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.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -57,7 +45,7 @@ class MainActivity : ComponentActivity() {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountViewModel: AccountStateViewModel = viewModel { val accountViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(KeyStorage().encryptedPreferences(applicationContext)) AccountStateViewModel(LocalPreferences(applicationContext))
} }
AccountScreen(accountViewModel) AccountScreen(accountViewModel)
@@ -15,6 +15,7 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog 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 import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable @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 postViewModel: NewChannelViewModel = viewModel()
val context = LocalContext.current.applicationContext
postViewModel.load(account, channel, accountStateViewModel) postViewModel.load(account, channel)
Dialog( Dialog(
onDismissRequest = { onClose() }, onDismissRequest = { onClose() },
@@ -55,7 +57,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel:
PostButton( PostButton(
onPost = { onPost = {
postViewModel.create() postViewModel.create(context)
onClose() onClose()
}, },
postViewModel.channelName.value.text.isNotBlank() postViewModel.channelName.value.text.isNotBlank()
@@ -1,25 +1,26 @@
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toHex
class NewChannelViewModel: ViewModel() { class NewChannelViewModel: ViewModel() {
private var account: Account? = null private var account: Account? = null
private var originalChannel: Channel? = null private var originalChannel: Channel? = null
private var accountStateViewModel: AccountStateViewModel? = null
val channelName = mutableStateOf(TextFieldValue()) val channelName = mutableStateOf(TextFieldValue())
val channelPicture = mutableStateOf(TextFieldValue()) val channelPicture = mutableStateOf(TextFieldValue())
val channelDescription = mutableStateOf(TextFieldValue()) val channelDescription = mutableStateOf(TextFieldValue())
fun load(account: Account, channel: Channel?, accountStateViewModel: AccountStateViewModel) { fun load(account: Account, channel: Channel?) {
this.accountStateViewModel = accountStateViewModel
this.account = account this.account = account
if (channel != null) { if (channel != null) {
originalChannel = channel originalChannel = channel
@@ -29,21 +30,24 @@ class NewChannelViewModel: ViewModel() {
} }
} }
fun create() { fun create(context: Context) {
if (originalChannel == null) this.account?.let { account ->
this.account?.sendCreateNewChannel( if (originalChannel == null) {
account.sendCreateNewChannel(
channelName.value.text, channelName.value.text,
channelDescription.value.text, channelDescription.value.text,
channelPicture.value.text, channelPicture.value.text
accountStateViewModel!!
) )
else
this.account?.sendChangeChannel( LocalPreferences(context).saveToEncryptedStorage(account)
} else
account.sendChangeChannel(
channelName.value.text, channelName.value.text,
channelDescription.value.text, channelDescription.value.text,
channelPicture.value.text, channelPicture.value.text,
originalChannel!! originalChannel!!
) )
}
clear() clear()
} }
@@ -23,6 +23,7 @@ import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -67,7 +68,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
val context = LocalContext.current val context = LocalContext.current
// initialize focus reference to be able to request focus programmatically // initialize focus reference to be able to request focus programmatically
val focusRequester = FocusRequester() val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -28,13 +28,13 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable @Composable
fun NewChannelButton(account: Account, accountStateViewModel: AccountStateViewModel) { fun NewChannelButton(account: Account) {
var wantsToPost by remember { var wantsToPost by remember {
mutableStateOf(false) mutableStateOf(false)
} }
if (wantsToPost) if (wantsToPost)
NewChannelView({ wantsToPost = false }, account = account, accountStateViewModel) NewChannelView({ wantsToPost = false }, account = account)
OutlinedButton( OutlinedButton(
onClick = { wantsToPost = true }, onClick = { wantsToPost = true },
@@ -162,6 +162,8 @@ fun ListContent(
modifier: Modifier, modifier: Modifier,
accountViewModel: AccountStateViewModel accountViewModel: AccountStateViewModel
) { ) {
val coroutineScope = rememberCoroutineScope()
Column(modifier = modifier) { Column(modifier = modifier) {
LazyColumn() { LazyColumn() {
item { item {
@@ -178,14 +180,19 @@ fun ListContent(
thickness = 0.25.dp thickness = 0.25.dp
) )
Column(modifier = modifier.padding(horizontal = 25.dp)) { Column(modifier = modifier.padding(horizontal = 25.dp)) {
Row(modifier = Modifier.clickable(onClick = {
navController.navigate(Route.Filters.route)
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})) {
Text( Text(
text = "Settings", text = "Security Filters",
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = W500 fontWeight = W500
) )
Row( }
modifier = Modifier.clickable(onClick = { accountViewModel.logOff() }), Row(modifier = Modifier.clickable(onClick = { accountViewModel.logOff() })) {
) {
Text( Text(
text = "Log out", text = "Log out",
modifier = Modifier.padding(vertical = 15.dp), modifier = Modifier.padding(vertical = 15.dp),
@@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ChannelScreen import com.vitorpamplona.amethyst.ui.screen.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen 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.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.ProfileScreen 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 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 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 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, object Profile : Route("User/{id}", R.drawable.ic_profile,
arguments = listOf(navArgument("id") { type = NavType.StringType } ), arguments = listOf(navArgument("id") { type = NavType.StringType } ),
@@ -65,7 +67,8 @@ val Routes = listOf(
Route.Profile, Route.Profile,
Route.Note, Route.Note,
Route.Room, Route.Room,
Route.Channel Route.Channel,
Route.Filters
) )
@Composable @Composable
@@ -26,7 +26,38 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( 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), modifier = Modifier.padding(30.dp),
color = Color.Gray, color = Color.Gray,
) )
@@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -27,6 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -46,6 +48,9 @@ import nostr.postr.toNpub
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) { 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 noteState by baseNote.live.observeAsState()
val note = noteState?.note val note = noteState?.note
@@ -56,6 +61,11 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
onClick = { }, onClick = { },
onLongClick = { popupExpanded = true }, onLongClick = { popupExpanded = true },
), isInnerNote) ), isInnerNote)
} else if (account?.isAcceptable(note) == false) {
HiddenNote(modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else { } else {
val authorState by note.author!!.live.observeAsState() val authorState by note.author!!.live.observeAsState()
val author = authorState?.user val author = authorState?.user
@@ -195,6 +205,7 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
@Composable @Composable
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) { fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current.applicationContext
DropdownMenu( DropdownMenu(
expanded = popupExpanded, expanded = popupExpanded,
@@ -213,5 +224,12 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) { DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
Text("Broadcast") 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")
}
} }
} }
@@ -14,6 +14,8 @@ import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons 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.BarChart
import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.runtime.Composable 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.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable @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) val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
var popupExpanded by remember { mutableStateOf(false) }
var wantsToReplyTo by remember { var wantsToReplyTo by remember {
mutableStateOf<Note?>(null) mutableStateOf<Note?>(null)
} }
@@ -79,7 +84,7 @@ fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: (
IconButton( IconButton(
modifier = Modifier.then(Modifier.size(24.dp)), 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())) { if (note.isBoostedBy(account.userProfile())) {
Icon( Icon(
@@ -107,7 +112,7 @@ fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: (
IconButton( IconButton(
modifier = Modifier.then(Modifier.size(24.dp)), 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())) { if (note.isReactedBy(account.userProfile())) {
Icon( 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 { fun showCount(size: Int?): String {
@@ -16,5 +16,5 @@ fun ReactionsRowState(baseNote: Note, accountViewModel: AccountViewModel) {
if (account == null || note == null) return if (account == null || note == null) return
ReactionsRow(note, account, accountViewModel::boost, accountViewModel::reactTo) ReactionsRow(note, account, accountViewModel)
} }
@@ -16,23 +16,29 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.FollowButton 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.UnfollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable @Composable
fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) { fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState() val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
val userState by baseUser.live.observeAsState() val userState by baseUser.live.observeAsState()
val user = userState?.user ?: return val user = userState?.user ?: return
val ctx = LocalContext.current.applicationContext
Column(modifier = Column(modifier =
Modifier.clickable( Modifier.clickable(
onClick = { navController.navigate("User/${user.pubkeyHex}") } onClick = { navController.navigate("User/${user.pubkeyHex}") }
@@ -69,10 +75,15 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
} }
Column(modifier = Modifier.padding(start = 10.dp)) { Column(modifier = Modifier.padding(start = 10.dp)) {
if (accountState?.account?.userProfile()?.isFollowing(user) == true) { if (account?.isAcceptable(user) == false) {
UnfollowButton { accountState?.account?.unfollow(user) } ShowUserButton {
account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (account?.userProfile()?.isFollowing(user) == true) {
UnfollowButton { account.unfollow(user) }
} else { } else {
FollowButton { accountState?.account?.follow(user) } FollowButton { account?.follow(user) }
} }
} }
} }
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DefaultChannels import com.vitorpamplona.amethyst.model.DefaultChannels
@@ -30,15 +31,13 @@ import nostr.postr.Persona
import nostr.postr.bechToBytes import nostr.postr.bechToBytes
import nostr.postr.toHex import nostr.postr.toHex
class AccountStateViewModel( class AccountStateViewModel(private val localPreferences: LocalPreferences): ViewModel() {
private val encryptedPreferences: EncryptedSharedPreferences
): ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff) private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow() val accountContent = _accountContent.asStateFlow()
init { init {
// pulls account from storage. // pulls account from storage.
loadFromEncryptedStorage()?.let { localPreferences.loadFromEncryptedStorage()?.let {
login(it) login(it)
} }
} }
@@ -58,14 +57,14 @@ class AccountStateViewModel(
Account(Persona(Hex.decode(key))) Account(Persona(Hex.decode(key)))
} }
saveToEncryptedStorage(account) localPreferences.saveToEncryptedStorage(account)
login(account) login(account)
} }
fun newKey() { fun newKey() {
val account = Account(Persona()) val account = Account(Persona())
saveToEncryptedStorage(account) localPreferences.saveToEncryptedStorage(account)
login(account) login(account)
} }
@@ -83,36 +82,6 @@ class AccountStateViewModel(
fun logOff() { fun logOff() {
_accountContent.update { AccountState.LoggedOff } _accountContent.update { AccountState.LoggedOff }
clearEncryptedStorage() localPreferences.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
}
}
} }
} }
@@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrDataSource import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrHiddenAccountsDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -28,6 +29,10 @@ class NostrUserProfileFollowersUserFeedViewModel(): UserFeedViewModel(
NostrUserProfileFollowersDataSource NostrUserProfileFollowersDataSource
) )
class NostrHiddenAccountsFeedViewModel(): UserFeedViewModel(
NostrHiddenAccountsDataSource
)
open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel() { open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel() {
private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading) private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading)
val feedContent = _feedContent.asStateFlow() val feedContent = _feedContent.asStateFlow()
@@ -1,11 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.model.UserState
class AccountViewModel(private val account: Account): ViewModel() { class AccountViewModel(private val account: Account): ViewModel() {
@@ -15,6 +18,10 @@ class AccountViewModel(private val account: Account): ViewModel() {
account.reactTo(note) account.reactTo(note)
} }
fun report(note: Note) {
account.report(note)
}
fun boost(note: Note) { fun boost(note: Note) {
account.boost(note) account.boost(note)
} }
@@ -26,4 +33,14 @@ class AccountViewModel(private val account: Account): ViewModel() {
fun decrypt(note: Note): String? { fun decrypt(note: Note): String? {
return account.decryptContent(note) 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)
}
} }
@@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -41,6 +42,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
@@ -169,12 +171,12 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
channel?.let { channel?.let {
if (channel.creator == account.userProfile()) { if (channel.creator == account.userProfile()) {
EditButton(account, it, accountStateViewModel) EditButton(account, it)
} else { } else {
if (account.followingChannels.contains(channel.idHex)) { if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account,channel,accountStateViewModel, navController) LeaveButton(account,channel, navController)
} else { } else {
JoinButton(account,channel,accountStateViewModel, navController) JoinButton(account,channel, navController)
} }
} }
} }
@@ -209,13 +211,13 @@ private fun NoteCopyButton(
} }
@Composable @Composable
private fun EditButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel) { private fun EditButton(account: Account, channel: Channel) {
var wantsToPost by remember { var wantsToPost by remember {
mutableStateOf(false) mutableStateOf(false)
} }
if (wantsToPost) if (wantsToPost)
NewChannelView({ wantsToPost = false }, account = account, accountStateViewModel, channel) NewChannelView({ wantsToPost = false }, account = account, channel)
Button( Button(
modifier = Modifier.padding(horizontal = 3.dp), modifier = Modifier.padding(horizontal = 3.dp),
@@ -231,11 +233,14 @@ private fun EditButton(account: Account, channel: Channel, accountStateViewModel
} }
@Composable @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( Button(
modifier = Modifier.padding(horizontal = 3.dp), modifier = Modifier.padding(horizontal = 3.dp),
onClick = { onClick = {
account.joinChannel(channel.idHex, accountStateViewModel) account.joinChannel(channel.idHex)
LocalPreferences(context).saveToEncryptedStorage(account)
navController.navigate(Route.Message.route) navController.navigate(Route.Message.route)
}, },
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
@@ -249,11 +254,14 @@ private fun JoinButton(account: Account, channel: Channel, accountStateViewModel
} }
@Composable @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( Button(
modifier = Modifier.padding(horizontal = 3.dp), modifier = Modifier.padding(horizontal = 3.dp),
onClick = { onClick = {
account.leaveChannel(channel.idHex, accountStateViewModel) account.leaveChannel(channel.idHex)
LocalPreferences(context).saveToEncryptedStorage(account)
navController.navigate(Route.Message.route) navController.navigate(Route.Message.route)
}, },
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
@@ -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)
}
}
}
}
}
}
@@ -85,7 +85,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt
// Does nothing. // Does nothing.
} }
is AccountState.LoggedIn -> { is AccountState.LoggedIn -> {
NewChannelButton(state.account, accountViewModel) NewChannelButton(state.account)
} }
} }
} }
@@ -41,6 +41,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight 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.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note 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 com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nostr.postr.toNpub import nostr.postr.toNpub
import nostr.postr.toNsec
@OptIn(ExperimentalPagerApi::class) @OptIn(ExperimentalPagerApi::class)
@Composable @Composable
@@ -77,6 +80,8 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val accountUserState by account.userProfile().live.observeAsState() val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user val accountUser = accountUserState?.user
val ctx = LocalContext.current.applicationContext
if (userId != null && accountUser != null) { if (userId != null && accountUser != null) {
DisposableEffect(account) { DisposableEffect(account) {
NostrUserProfileDataSource.loadUserProfile(userId) NostrUserProfileDataSource.loadUserProfile(userId)
@@ -145,12 +150,21 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
MessageButton(user, navController) MessageButton(user, navController)
if (accountUser == user && account.isWriteable()) {
NSecCopyButton(account)
}
NPubCopyButton(user) NPubCopyButton(user)
if (accountUser == user) { if (accountUser == user) {
EditButton(account) EditButton(account)
} else { } 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) } UnfollowButton { account.unfollow(user) }
} else { } else {
FollowButton { account.follow(user) } 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 @Composable
private fun NPubCopyButton( private fun NPubCopyButton(
user: User 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)
}
}
@@ -40,6 +40,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -54,6 +55,7 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.note.ChannelName import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose 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.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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 -> itemsIndexed(searchResults.value, key = { _, item -> "u"+item.pubkeyHex }) { index, item ->
UserLine(item) { UserCompose(item, accountViewModel = accountViewModel, navController = navController)
navController.navigate("User/${item.pubkeyHex}")
}
} }
itemsIndexed(searchResultsChannels.value, key = { _, item -> "c"+item.idHex }) { index, item -> itemsIndexed(searchResultsChannels.value, key = { _, item -> "c"+item.idHex }) { index, item ->
@@ -236,7 +236,9 @@ fun UserLine(
Text( Text(
user.info.about?.take(100) ?: "", 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
) )
} }
} }