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

View File

@ -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"

View File

@ -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
}
}
}
}

View File

@ -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()

View File

@ -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")

View File

@ -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<String> = DefaultChannels.toMutableSet()) {
var seeReplies: Boolean = true
class Account(
val loggedIn: Persona,
val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet(),
val hiddenUsers: MutableSet<String> = DefaultChannels.toMutableSet()
) {
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey)
@ -38,6 +45,10 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
}
fun hiddenUsers(): List<User> {
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<String> =
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<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) {
if (!isWriteable()) return
@ -186,7 +214,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
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<String> =
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<String> =
// 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>(AccountState(account)) {
fun refresh() {
postValue(AccountState(account))
}
override fun onActive() {
super.onActive()
}
override fun onInactive() {
super.onInactive()
}
}
class AccountState(val account: Account)

View File

@ -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) {

View File

@ -35,6 +35,8 @@ class Note(val idHex: String) {
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
val reports = Collections.synchronizedSet(mutableSetOf<Note>())
var channel: Channel? = null
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
@ -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 }

View File

@ -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<Note>("ChatroomFeed") {
lateinit var account: Account
var channel: com.vitorpamplona.amethyst.model.Channel? = null
fun loadMessagesBetween(channelId: String) {
@ -28,7 +30,7 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
// returns the last Note of each user.
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() {

View File

@ -33,7 +33,7 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
// returns the last Note of each user.
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()
}

View File

@ -64,14 +64,14 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
// returns the last Note of each user.
override fun feed(): List<Note> {
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 {

View File

@ -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<Note>("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
limit = 50
@ -14,6 +16,7 @@ object NostrGlobalDataSource: NostrDataSource<Note>("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()
}

View File

@ -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() {}
}

View File

@ -82,6 +82,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("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()
}

View File

@ -18,7 +18,7 @@ object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
override fun feed(): List<Note> {
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()

View File

@ -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<Note>("UserProfileFeed") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
@ -37,7 +39,7 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
override fun feed(): List<Note> {
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()
}

View File

@ -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<User>("UserProfileFollowerFeed") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
@ -24,7 +26,7 @@ object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFo
val followers = user?.followers ?: emptyList()
return synchronized(followers) {
followers.toList()
followers.filter { account.isAcceptable(it) }.toList()
}
}

View File

@ -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<User>("UserProfileFollowsFeed") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
@ -24,7 +26,11 @@ object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFoll
val followChannel = requestNewChannel()
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() {

View File

@ -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)
}

View File

@ -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)

View File

@ -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()

View File

@ -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()
}

View File

@ -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) {

View File

@ -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 },

View File

@ -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),

View File

@ -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

View File

@ -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,
)

View File

@ -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")
}
}
}

View File

@ -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<Note?>(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 {

View File

@ -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)
}

View File

@ -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) }
}
}
}

View File

@ -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>(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()
}
}

View File

@ -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<User>): ViewModel() {
private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading)
val feedContent = _feedContent.asStateFlow()

View File

@ -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)
}
}

View File

@ -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),

View File

@ -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)
}
}
}
}
}
}

View File

@ -85,7 +85,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt
// Does nothing.
}
is AccountState.LoggedIn -> {
NewChannelButton(state.account, accountViewModel)
NewChannelButton(state.account)
}
}
}

View File

@ -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)
}
}

View File

@ -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
)
}
}