- Refactoring feeds use of the pullrefresh

- Adding more places to check if in the main thread.
This commit is contained in:
Vitor Pamplona
2023-06-05 15:33:16 -04:00
parent b34c1f98d5
commit 5bdcdc6f4a
53 changed files with 1331 additions and 915 deletions
@@ -567,16 +567,15 @@ class Account(
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey(),
publishedRecipientPubKey = user.pubkey(),
recipientPubKey = toUser.pubkey(),
publishedRecipientPubKey = toUser.pubkey(),
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
@@ -1135,7 +1134,9 @@ class Account(
// saves contact list for the next time.
userProfile().live().follows.observeForever {
updateContactListTo(userProfile().latestContactList)
GlobalScope.launch(Dispatchers.IO) {
updateContactListTo(userProfile().latestContactList)
}
}
// imports transient blocks due to spam.
@@ -3,7 +3,9 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.Dispatchers
@@ -16,6 +18,8 @@ class AntiSpamFilter {
@Synchronized
fun isSpam(event: Event, relay: Relay?): Boolean {
checkNotInMainThread()
val idHex = event.id
// if short message, ok
@@ -28,6 +32,7 @@ class AntiSpamFilter {
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
Log.w("Potential SPAM Message for sharing", "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}")
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
// Log down offenders
@@ -32,6 +32,8 @@ object LocalCache {
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
fun checkGetOrCreateUser(key: String): User? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
return getOrCreateUser(key)
}
@@ -40,6 +42,8 @@ object LocalCache {
@Synchronized
fun getOrCreateUser(key: HexKey): User {
// checkNotInMainThread()
return users[key] ?: run {
val answer = User(key)
users.put(key, answer)
@@ -48,6 +52,8 @@ object LocalCache {
}
fun checkGetOrCreateNote(key: String): Note? {
checkNotInMainThread()
if (ATag.isATag(key)) {
return checkGetOrCreateAddressableNote(key)
}
@@ -59,6 +65,8 @@ object LocalCache {
@Synchronized
fun getOrCreateNote(idHex: String): Note {
checkNotInMainThread()
return notes[idHex] ?: run {
val answer = Note(idHex)
notes.put(idHex, answer)
@@ -67,6 +75,8 @@ object LocalCache {
}
fun checkGetOrCreateChannel(key: String): Channel? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
return getOrCreateChannel(key)
}
@@ -85,6 +95,8 @@ object LocalCache {
@Synchronized
fun getOrCreateChannel(key: String): Channel {
checkNotInMainThread()
return channels[key] ?: run {
val answer = Channel(key)
channels.put(key, answer)
@@ -108,6 +120,8 @@ object LocalCache {
@Synchronized
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
checkNotInMainThread()
// we can't use naddr here because naddr might include relay info and
// the preferred relay should not be part of the index.
return addressables[key.toTag()] ?: run {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
@@ -11,6 +12,8 @@ object UrlCachedPreviewer {
private set
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
checkNotInMainThread()
cache[url]?.let {
callback?.onComplete(it)
return
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
@@ -185,6 +186,8 @@ class User(val pubkeyHex: String) {
@Synchronized
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
checkNotInMainThread()
return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>())
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
@@ -194,6 +197,8 @@ class User(val pubkeyHex: String) {
@Synchronized
fun addMessage(user: User, msg: Note) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
@@ -203,6 +208,8 @@ class User(val pubkeyHex: String) {
@Synchronized
fun removeMessage(user: User, msg: Note) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
@@ -1,9 +1,10 @@
package com.vitorpamplona.amethyst.service
import android.os.Looper
import com.vitorpamplona.amethyst.BuildConfig
fun checkNotInMainThread() {
if (isMainThread()) throw OnMainThreadException("It should not be in the MainThread")
if (BuildConfig.DEBUG && isMainThread()) throw OnMainThreadException("It should not be in the MainThread")
}
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -12,9 +11,9 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
var account: Account? = null
var channel: Channel? = null
fun loadMessagesBetween(account: Account, channelId: String) {
fun loadMessagesBetween(account: Account, channel: Channel) {
this.account = account
channel = LocalCache.getOrCreateChannel(channelId)
this.channel = channel
resetFilters()
}
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -12,9 +11,9 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
fun loadMessagesBetween(accountIn: Account, user: User) {
account = accountIn
withUser = LocalCache.users[userId]
withUser = user
resetFilters()
}
@@ -123,6 +123,8 @@ abstract class NostrDataSource(val debugName: String) {
}
fun resetFiltersSuspend() {
checkNotInMainThread()
// saves the channels that are currently active
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
@@ -13,15 +13,22 @@ import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
// adds to database
LocalCache.consume(event, null)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
// adds to database
LocalCache.consume(event, null)
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
}
}
}
@@ -54,6 +61,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
private fun notify(event: LnZapEvent) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -32,6 +33,8 @@ object Client : RelayPool.Listener {
@Synchronized
fun connect(relays: Array<Relay>) {
checkNotInMainThread()
RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList())
@@ -54,6 +57,8 @@ object Client : RelayPool.Listener {
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf()
) {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId)
}
@@ -62,11 +67,15 @@ object Client : RelayPool.Listener {
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf()
) {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected()
}
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
checkNotInMainThread()
if (relay == null) {
RelayPool.send(signedEvent)
} else {
@@ -91,7 +100,7 @@ object Client : RelayPool.Listener {
}
@OptIn(DelicateCoroutinesApi::class)
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
private fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
RelayPool.addRelay(relay)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
@@ -69,6 +70,7 @@ class Relay(
@Synchronized
fun requestAndWatch() {
checkNotInMainThread()
requestAndWatch {
// Sends everything.
Client.allSubscriptions().forEach {
@@ -79,6 +81,7 @@ class Relay(
@Synchronized
fun requestAndWatch(onConnected: (Relay) -> Unit) {
checkNotInMainThread()
if (socket != null) return
try {
@@ -14,6 +14,7 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -26,6 +27,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: Channel? = null) {
@@ -53,10 +56,14 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
onClose()
})
val scope = rememberCoroutineScope()
PostButton(
onPost = {
postViewModel.create()
onClose()
scope.launch(Dispatchers.IO) {
postViewModel.create()
onClose()
}
},
postViewModel.channelName.value.text.isNotBlank()
)
@@ -100,7 +107,9 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
OutlinedTextField(
label = { Text(text = stringResource(R.string.description)) },
modifier = Modifier.fillMaxWidth().height(100.dp),
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
value = postViewModel.channelDescription.value,
onValueChange = { postViewModel.channelDescription.value = it },
placeholder = {
@@ -143,8 +143,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
scope.launch(Dispatchers.IO) {
postViewModel.sendPost()
onClose()
}
},
isActive = postViewModel.canPost()
)
@@ -24,7 +24,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
open class NewPostViewModel : ViewModel() {
open class NewPostViewModel() : ViewModel() {
var account: Account? = null
var originalNote: Note? = null
@@ -132,7 +132,7 @@ open class NewPostViewModel : ViewModel() {
} else if (originalNote?.channelHex() != null) {
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
} else {
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver, wantsToMarkAsSensitive)
}
@@ -226,7 +226,11 @@ open class NewPostViewModel : ViewModel() {
userSuggestionsMainMessage = true
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
viewModelScope.launch(Dispatchers.IO) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
.reversed()
}
} else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList()
@@ -242,7 +246,15 @@ open class NewPostViewModel : ViewModel() {
userSuggestionsMainMessage = false
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
viewModelScope.launch(Dispatchers.IO) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(
compareBy(
{ account?.isFollowing(it) },
{ it.toBestDisplayName() }
)
).reversed()
}
} else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList()
@@ -68,7 +68,9 @@ fun ClickableRoute(
DisplayEvent(nip19, nav)
} else {
Text(
"@${nip19.hex}${nip19.additionalChars} "
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
}
}
@@ -93,19 +95,20 @@ private fun DisplayEvent(
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
if (note.event is ChannelCreateEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Channel/${nip19.hex}",
suffix = addedCharts,
route = remember(noteState) { "Channel/${nip19.hex}" },
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Room/${note.author?.pubkeyHex}",
suffix = addedCharts,
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
nav = nav
)
} else if (channelHex != null) {
@@ -119,16 +122,16 @@ private fun DisplayEvent(
CreateClickableText(
clickablePart = channelDisplayName,
suffix = "${nip19.additionalChars} ",
route = "Channel/${baseChannel.idHex}",
suffix = addedCharts,
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
nav = nav
)
}
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Event/${nip19.hex}",
suffix = addedCharts,
route = remember(noteState) { "Event/${nip19.hex}" },
nav = nav
)
}
@@ -136,7 +139,9 @@ private fun DisplayEvent(
if (noteBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
}
}
@@ -157,21 +162,22 @@ private fun DisplayNote(
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = note.channelHex()
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
if (note.event is ChannelCreateEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Channel/${nip19.hex}",
suffix = addedCharts,
route = remember(noteState) { "Channel/${nip19.hex}" },
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Room/${note.author?.pubkeyHex}",
suffix = addedCharts,
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
nav = nav
)
} else if (channelHex != null) {
@@ -185,16 +191,16 @@ private fun DisplayNote(
CreateClickableText(
clickablePart = channelDisplayName,
suffix = "${nip19.additionalChars} ",
route = "Channel/${baseChannel.idHex}",
suffix = addedCharts,
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
nav = nav
)
}
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Note/${nip19.hex}",
suffix = addedCharts,
route = remember(noteState) { "Note/${nip19.hex}" },
nav = nav
)
}
@@ -202,7 +208,9 @@ private fun DisplayNote(
if (noteBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
}
}
@@ -222,19 +230,24 @@ private fun DisplayAddress(
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val route = remember(noteState) { "Note/${nip19.hex}" }
val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
CreateClickableText(
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Note/${nip19.hex}",
clickablePart = displayName,
suffix = addedCharts,
route = route,
nav = nav
)
}
if (noteBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
}
}
@@ -257,11 +270,14 @@ private fun DisplayUser(
val route = remember(userState) { "User/${it.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
val addedCharts = remember {
"${nip19.additionalChars} "
}
if (userDisplayName != null) {
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
suffix = "${nip19.additionalChars} ",
suffix = addedCharts,
tags = userTags,
route = route,
nav = nav
@@ -271,7 +287,9 @@ private fun DisplayUser(
if (userBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@@ -11,10 +12,16 @@ import androidx.compose.ui.text.AnnotatedString
fun ClickableUrl(urlText: String, url: String) {
val uri = LocalUriHandler.current
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
val doubleCheckedUrl = remember(url) {
if (url.contains("://")) url else "https://$url"
}
val text = remember(urlText) {
AnnotatedString(urlText)
}
ClickableText(
text = AnnotatedString(urlText),
text = text,
onClick = { runCatching { uri.openUri(doubleCheckedUrl) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -6,6 +6,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.model.User
@@ -14,10 +15,19 @@ fun ClickableUserTag(
user: User,
nav: (String) -> Unit
) {
val route = remember {
"User/${user.pubkeyHex}"
}
val innerUserState by user.live().metadata.observeAsState()
val userName = remember(innerUserState) {
AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}")
}
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}"),
onClick = { nav("User/${innerUserState?.user?.pubkeyHex}") },
text = userName,
onClick = { nav(route) },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
@@ -13,14 +13,14 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@Composable
fun MayBeWithdrawal(lnurlWord: String) {
var lnWithdrawal by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = lnurlWord) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord)
}
}
@@ -38,11 +38,19 @@ fun MayBeWithdrawal(lnurlWord: String) {
fun ClickableWithdrawal(withdrawalString: String) {
val context = LocalContext.current
val uri = remember(withdrawalString) {
Uri.parse("lightning:$withdrawalString")
}
val withdraw = remember(withdrawalString) {
AnnotatedString("$withdrawalString ")
}
ClickableText(
text = AnnotatedString("$withdrawalString "),
text = withdraw,
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
val intent = Intent(Intent.ACTION_VIEW, uri)
ContextCompat.startActivity(context, intent, null)
}
},
@@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
const val SHORT_TEXT_LENGTH = 350
@@ -34,8 +35,8 @@ const val SHORT_TEXT_LENGTH = 350
fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>,
modifier: Modifier,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -87,18 +88,26 @@ fun ExpandableRichTextViewer(
)
)
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { showFullText = !showFullText },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_more), color = Color.White)
ShowMoreButton() {
showFullText = !showFullText
}
}
}
}
}
@Composable
private fun ShowMoreButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
.compositeOver(MaterialTheme.colors.background)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_more), color = Color.White)
}
}
@@ -97,8 +97,8 @@ fun isMarkdown(content: String): Boolean {
fun RichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>,
modifier: Modifier,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -126,7 +126,7 @@ data class RichTextViewerState(
@Composable
private fun RenderRegular(
content: String,
tags: List<List<String>>,
tags: ImmutableList<List<String>>,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
@@ -217,7 +217,7 @@ private fun RenderWord(
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: List<List<String>>
tags: ImmutableList<List<String>>
) {
val type = remember(word) {
if (state.imagesForPager[word] != null) {
@@ -267,7 +267,7 @@ private fun RenderWordWithoutPreview(
type: WordType,
word: String,
state: RichTextViewerState,
tags: List<List<String>>,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -300,7 +300,7 @@ private fun RenderWordWithPreview(
type: WordType,
word: String,
state: RichTextViewerState,
tags: List<List<String>>,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -382,7 +382,7 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
}
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: List<List<String>>?, nav: (String) -> Unit) {
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: ImmutableList<List<String>>?, nav: (String) -> Unit) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
@@ -1,23 +1,12 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
var channelId: String? = null
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String?) {
this.account = accountLoggedIn
this.channelId = channelId
}
class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feed(): List<Note> {
val processingChannel = channelId ?: return emptyList()
val channel = LocalCache.getOrCreateChannel(processingChannel)
return channel.notes
.values
.filter { account.isAcceptable(it) }
@@ -26,9 +15,6 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
val processingChannel = channelId ?: return emptySet()
val channel = LocalCache.getOrCreateChannel(processingChannel)
return collection
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
.toSet()
@@ -1,51 +1,29 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
var account: Account? = null
var withUser: String? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
account = accountIn
withUser = userId
}
class ChatroomFeedFilter(val withUser: User, val account: Account) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feed(): List<Note> {
val processingUser = withUser ?: return emptyList()
val myAccount = account
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
if (myAccount == null || myUser == null) return emptyList()
val messages = myAccount
val messages = account
.userProfile()
.privateChatrooms[myUser] ?: return emptyList()
.privateChatrooms[withUser] ?: return emptyList()
return messages.roomMessages
.filter { myAccount.isAcceptable(it) }
.filter { account.isAcceptable(it) }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
val processingUser = withUser ?: return emptySet()
val myAccount = account
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
if (myAccount == null || myUser == null) return emptySet()
val messages = myAccount
val messages = account
.userProfile()
.privateChatrooms[myUser] ?: return emptySet()
.privateChatrooms[withUser] ?: return emptySet()
return collection
.filter { it in messages.roomMessages && account?.isAcceptable(it) == true }
.filter { it in messages.roomMessages && account.isAcceptable(it) == true }
.toSet()
}
@@ -1,12 +1,15 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
abstract class FeedFilter<T> {
@OptIn(ExperimentalTime::class)
fun loadTop(): List<T> {
checkNotInMainThread()
val (feed, elapsed) = measureTimedValue {
feed()
}
@@ -24,6 +27,8 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
@OptIn(ExperimentalTime::class)
open fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
checkNotInMainThread()
val (feed, elapsed) = measureTimedValue {
val newItemsToBeAdded = applyFilter(newItems)
if (newItemsToBeAdded.isNotEmpty()) {
@@ -67,6 +67,7 @@ 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.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
@@ -347,6 +348,8 @@ class FollowListViewModel(val account: Account) : ViewModel() {
}
private suspend fun refreshFollows() {
checkNotInMainThread()
val newFollowLists = LocalCache.addressables.mapNotNull {
val event = (it.value.event as? PeopleListEvent)
// Has to have an list
@@ -23,7 +23,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -42,8 +41,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
@@ -62,11 +63,6 @@ fun ChatroomCompose(
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
val notificationCacheState = NotificationCache.live.observeAsState()
val notificationCache = notificationCacheState.value ?: return
val scope = rememberCoroutineScope()
val channelHex by remember(noteState) {
derivedStateOf {
noteState?.note?.channelHex()
@@ -77,125 +73,168 @@ fun ChatroomCompose(
BlankNote(Modifier)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex!!) { channel ->
val authorState by note.author!!.live().metadata.observeAsState()
val authorName = remember(authorState) {
authorState?.user?.toBestDisplayName()
}
val chanHex = remember { channel.idHex }
val channelState by channel.live.observeAsState()
val channelPicture by remember(channelState) {
derivedStateOf {
channel.profilePicture()
}
}
val channelName by remember(channelState) {
derivedStateOf {
channel.info.name
}
}
val noteEvent = note.event
val description = if (noteEvent is ChannelCreateEvent) {
stringResource(R.string.channel_created)
} else if (noteEvent is ChannelMetadataEvent) {
"${stringResource(R.string.channel_information_changed_to)} "
} else {
noteEvent?.content()
}
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notificationCache, key2 = note) {
scope.launch(Dispatchers.IO) {
note.createdAt()?.let { timestamp ->
hasNewMessages =
timestamp > notificationCache.cache.load("Channel/$chanHex")
}
}
}
ChannelName(
channelIdHex = chanHex,
channelPicture = channelPicture,
channelTitle = { modifier ->
Text(
text = buildAnnotatedString {
withStyle(
SpanStyle(
fontWeight = FontWeight.Bold
)
) {
append(channelName)
}
withStyle(
SpanStyle(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontWeight = FontWeight.Normal
)
) {
append(" ${stringResource(id = R.string.public_chat)}")
}
},
fontWeight = FontWeight.Bold,
modifier = modifier,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
},
channelLastTime = note.createdAt(),
channelLastContent = "$authorName: $description",
hasNewMessages = hasNewMessages,
onClick = { nav("Channel/$chanHex") }
)
ChannelRoomCompose(note, channel, nav)
}
} else {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.verifiedRecipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
val userRoomHex = remember(noteState, accountViewModel) {
(note.event as? PrivateDmEvent)?.talkingWith(accountViewModel.userProfile().pubkeyHex)
} ?: return
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
LoadUser(userRoomHex) { user ->
UserRoomCompose(note, user, accountViewModel, nav)
}
}
}
val noteEvent = note.event
@Composable
private fun ChannelRoomCompose(
note: Note,
channel: Channel,
nav: (String) -> Unit
) {
val authorState by note.author!!.live().metadata.observeAsState()
val authorName = remember(authorState) {
authorState?.user?.toBestDisplayName()
}
userToComposeOn.let { user ->
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
val chanHex = remember { channel.idHex }
LaunchedEffect(key1 = notificationCache, key2 = note) {
scope.launch(Dispatchers.IO) {
noteEvent?.let {
hasNewMessages = it.createdAt() > notificationCache.cache.load(
"Room/${userToComposeOn.pubkeyHex}"
)
}
val channelState by channel.live.observeAsState()
val channelPicture by remember(channelState) {
derivedStateOf {
channel.profilePicture()
}
}
val channelName by remember(channelState) {
derivedStateOf {
channel.info.name
}
}
val noteEvent = note.event
val route = remember(note) {
"Channel/$chanHex"
}
val description = if (noteEvent is ChannelCreateEvent) {
stringResource(R.string.channel_created)
} else if (noteEvent is ChannelMetadataEvent) {
"${stringResource(R.string.channel_information_changed_to)} "
} else {
noteEvent?.content()
}
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = note) {
launch(Dispatchers.IO) {
note.createdAt()?.let { timestamp ->
val lastTime = NotificationCache.load(route)
val newHasNewMessages = timestamp > lastTime
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
}
}
ChannelName(
channelPicture = {
UserPicture(
userToComposeOn,
accountViewModel = accountViewModel,
size = 55.dp
)
},
channelTitle = { UsernameDisplay(userToComposeOn, it) },
channelLastTime = note.createdAt(),
channelLastContent = accountViewModel.decrypt(note),
hasNewMessages = hasNewMessages,
onClick = { nav("Room/${user.pubkeyHex}") }
)
}
}
ChannelName(
channelIdHex = chanHex,
channelPicture = channelPicture,
channelTitle = { modifier ->
Text(
text = buildAnnotatedString {
withStyle(
SpanStyle(
fontWeight = FontWeight.Bold
)
) {
append(channelName)
}
withStyle(
SpanStyle(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontWeight = FontWeight.Normal
)
) {
append(" ${stringResource(id = R.string.public_chat)}")
}
},
fontWeight = FontWeight.Bold,
modifier = modifier,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
},
channelLastTime = remember(note) { note.createdAt() },
channelLastContent = remember(note) { "$authorName: $description" },
hasNewMessages = hasNewMessages,
onClick = { nav(route) }
)
}
@Composable
private fun UserRoomCompose(
note: Note,
user: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = note.event
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
val route = remember(user) {
"Room/${user.pubkeyHex}"
}
LaunchedEffect(key1 = note) {
launch(Dispatchers.IO) {
noteEvent?.let {
val lastTime = NotificationCache.load(route)
val newHasNewMessages = it.createdAt() > lastTime
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
}
}
}
}
ChannelName(
channelPicture = {
UserPicture(
user,
accountViewModel = accountViewModel,
size = 55.dp
)
},
channelTitle = { UsernameDisplay(user, it) },
channelLastTime = remember(note) { note.createdAt() },
channelLastContent = remember(note) { accountViewModel.decrypt(note) },
hasNewMessages = hasNewMessages,
onClick = { nav(route) }
)
}
@Composable
fun LoadUser(baseUserHex: String, content: @Composable (User) -> Unit) {
var user by remember(baseUserHex) {
mutableStateOf<User?>(null)
}
LaunchedEffect(key1 = baseUserHex) {
if (user == null) {
launch(Dispatchers.IO) {
user = LocalCache.checkGetOrCreateUser(baseUserHex)
}
}
}
user?.let {
content(it)
}
}
@Composable
@@ -53,7 +53,6 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -66,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -265,7 +265,6 @@ fun ChatroomMessageCompose(
else -> {
RenderRegularTextNote(
note,
loggedIn,
isAcceptableAndCanPreview.second,
backgroundBubbleColor,
accountViewModel,
@@ -359,13 +358,12 @@ fun ChatTimeAgo(time: Long) {
@Composable
private fun RenderRegularTextNote(
note: Note,
loggedIn: User,
canPreview: Boolean,
backgroundBubbleColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val tags = remember { note.event?.tags() ?: emptyList() }
val tags = remember(note.event) { note.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
val eventContent = remember { accountViewModel.decrypt(note) }
val modifier = remember { Modifier.padding(top = 5.dp) }
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -361,7 +362,7 @@ private fun AuthorPictureAndComment(
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = remember { emptyList() },
tags = remember { persistentListOf() },
modifier = remember { Modifier.fillMaxWidth() },
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
@@ -139,6 +139,7 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Following
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -155,7 +156,7 @@ import kotlin.time.ExperimentalTime
fun NoteCompose(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
modifier: Modifier = remember { Modifier },
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
unPackReply: Boolean = true,
@@ -214,13 +215,12 @@ fun CheckHiddenNoteCompose(
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val isHidden by remember(accountState) {
derivedStateOf {
val isSensitive = note.event?.isSensitive() ?: false
account.isHidden(note.author!!) || (isSensitive && account.showSensitiveContent == false)
accountState?.account?.isHidden(note.author!!) == true || (isSensitive && accountState?.account?.showSensitiveContent == false)
}
}
@@ -347,7 +347,7 @@ private fun WatchForReports(
}
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NormalNote(
baseNote: Note,
@@ -366,8 +366,6 @@ fun NormalNote(
val noteEvent = remember { baseNote.event }
val channelHex = remember { baseNote.channelHex() }
var popupExpanded by remember { mutableStateOf(false) }
if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && channelHex != null) {
ChannelHeader(channelHex = channelHex, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is BadgeDefinitionEvent) {
@@ -378,11 +376,12 @@ fun NormalNote(
FileStorageHeaderDisplay(baseNote)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = routeForLastRead) {
scope.launch(Dispatchers.IO) {
launch(Dispatchers.IO) {
routeForLastRead?.let {
val lastTime = NotificationCache.load(it)
@@ -596,8 +595,8 @@ private fun RenderTextEvent(
hasSensitiveContent = hasSensitiveContent,
accountViewModel = accountViewModel
) {
val modifier = remember(note.event) { Modifier.fillMaxWidth() }
val tags = remember(note.event) { note.event?.tags() ?: emptyList() }
val modifier = remember(note) { Modifier.fillMaxWidth() }
val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
TranslatableRichTextViewer(
content = eventContent,
@@ -646,18 +645,21 @@ private fun RenderPoll(
)
} else {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
accountViewModel = accountViewModel
) {
TranslatableRichTextViewer(
eventContent,
content = eventContent,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags(),
backgroundColor,
accountViewModel,
nav
modifier = remember { Modifier.fillMaxWidth() },
tags = tags,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
PollNote(
@@ -669,7 +671,8 @@ private fun RenderPoll(
)
}
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, nav)
var hashtags = remember { noteEvent.hashtags() }
DisplayUncitedHashtags(hashtags, eventContent, nav)
}
if (!makeItShort) {
@@ -823,10 +826,11 @@ fun RenderAppDefinition(
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
) {
val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = remember { note.event?.tags() ?: emptyList() },
tags = tags,
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
nav = nav
@@ -889,7 +893,8 @@ private fun RenderPrivateMessage(
val hashtags = remember(note.event?.id()) { note.event?.hashtags() ?: emptyList() }
val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
val tags = remember(note.event?.id()) { note.event?.tags() ?: emptyList() }
val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
if (eventContent != null) {
if (makeItShort && isAuthorTheLoggedUser) {
@@ -930,7 +935,7 @@ private fun RenderPrivateMessage(
),
canPreview = !makeItShort,
Modifier.fillMaxWidth(),
emptyList(),
persistentListOf(),
backgroundColor,
accountViewModel,
nav
@@ -1203,7 +1208,7 @@ fun PinListHeader(
) {
val noteEvent = baseNote.event as? PinListEvent ?: return
var pins by remember { mutableStateOf(noteEvent.pins()) }
val pins by remember { mutableStateOf(noteEvent.pins()) }
var expanded by remember {
mutableStateOf(false)
@@ -1242,7 +1247,7 @@ fun PinListHeader(
TranslatableRichTextViewer(
content = pin,
canPreview = true,
tags = remember { emptyList() },
tags = remember { persistentListOf() },
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -1352,7 +1357,7 @@ private fun RenderReport(
content = content,
canPreview = true,
modifier = remember { Modifier },
tags = remember { emptyList() },
tags = remember { persistentListOf() },
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -1690,7 +1695,7 @@ fun DisplayHighlight(
quote,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
emptyList(),
persistentListOf(),
backgroundColor,
accountViewModel,
nav
@@ -2600,6 +2605,8 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
}
}
val scope = rememberCoroutineScope()
if (!state.isFollowingAuthor) {
DropdownMenuItem(onClick = {
accountViewModel.follow(
@@ -2610,13 +2617,32 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
}
Divider()
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: ""))
onDismiss()
}
}
) {
Text(stringResource(R.string.copy_text))
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")); onDismiss() }) {
DropdownMenuItem(
onClick = {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
onDismiss()
}
}
) {
Text(stringResource(R.string.copy_user_pubkey))
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())); onDismiss() }) {
DropdownMenuItem(onClick = {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent()))
onDismiss()
}
}) {
Text(stringResource(R.string.copy_note_id))
}
DropdownMenuItem(onClick = {
@@ -2638,30 +2664,30 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
}
Divider()
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(onClick = { accountViewModel.removePrivateBookmark(note); onDismiss() }) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePrivateBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.remove_from_private_bookmarks))
}
} else {
DropdownMenuItem(onClick = { accountViewModel.addPrivateBookmark(note); onDismiss() }) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPrivateBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.add_to_private_bookmarks))
}
}
if (state.isPublicBookmarkNote) {
DropdownMenuItem(onClick = { accountViewModel.removePublicBookmark(note); onDismiss() }) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePublicBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.remove_from_public_bookmarks))
}
} else {
DropdownMenuItem(onClick = { accountViewModel.addPublicBookmark(note); onDismiss() }) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPublicBookmark(note); onDismiss() } }) {
Text(stringResource(R.string.add_to_public_bookmarks))
}
}
Divider()
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.broadcast(note); onDismiss() } }) {
Text(stringResource(R.string.broadcast))
}
Divider()
if (state.isLoggedUser) {
DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.delete(note); onDismiss() } }) {
Text(stringResource(R.string.request_deletion))
}
} else {
@@ -2671,17 +2697,17 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
}
Divider()
if (state.showSensitiveContent == null || state.showSensitiveContent == true) {
DropdownMenuItem(onClick = { accountViewModel.hideSensitiveContent(); onDismiss() }) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.hideSensitiveContent(); onDismiss() } }) {
Text(stringResource(R.string.content_warning_hide_all_sensitive_content))
}
}
if (state.showSensitiveContent == null || state.showSensitiveContent == false) {
DropdownMenuItem(onClick = { accountViewModel.disableContentWarnings(); onDismiss() }) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.disableContentWarnings(); onDismiss() } }) {
Text(stringResource(R.string.content_warning_show_all_sensitive_content))
}
}
if (state.showSensitiveContent != null) {
DropdownMenuItem(onClick = { accountViewModel.seeContentWarnings(); onDismiss() }) {
DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.seeContentWarnings(); onDismiss() } }) {
Text(stringResource(R.string.content_warning_see_warnings))
}
}
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private fun lightenColor(color: Color, amount: Float): Color {
@@ -154,25 +155,31 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
icon = Icons.Default.ContentCopy,
label = stringResource(R.string.quick_action_copy_text)
) {
clipboardManager.setText(
AnnotatedString(
accountViewModel.decrypt(note) ?: ""
scope.launch(Dispatchers.IO) {
clipboardManager.setText(
AnnotatedString(
accountViewModel.decrypt(note) ?: ""
)
)
)
showToast(R.string.copied_note_text_to_clipboard)
onDismiss()
showToast(R.string.copied_note_text_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id)) {
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}
}
if (!isOwnNote) {
@@ -180,8 +187,10 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
NoteQuickActionItem(Icons.Default.Block, stringResource(R.string.quick_action_block)) {
if (accountViewModel.hideBlockAlertDialog) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
scope.launch(Dispatchers.IO) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
} else {
showBlockAlertDialog = true
}
@@ -198,21 +207,27 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
if (isOwnNote) {
NoteQuickActionItem(Icons.Default.Delete, stringResource(R.string.quick_action_delete)) {
if (accountViewModel.hideDeleteRequestDialog) {
accountViewModel.delete(note)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note)
onDismiss()
}
} else {
showDeleteAlertDialog = true
}
}
} else if (isFollowingUser) {
NoteQuickActionItem(Icons.Default.PersonRemove, stringResource(R.string.quick_action_unfollow)) {
accountViewModel.unfollow(note.author!!)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.unfollow(note.author!!)
onDismiss()
}
}
} else {
NoteQuickActionItem(Icons.Default.PersonAdd, stringResource(R.string.quick_action_follow)) {
accountViewModel.follow(note.author!!)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.follow(note.author!!)
onDismiss()
}
}
}
@@ -221,9 +236,11 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringResource(R.string.broadcast)
) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(icon = Icons.Default.Share, label = stringResource(R.string.quick_action_share)) {
@@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
@@ -109,7 +111,7 @@ private fun OptionNote(
backgroundColor: Color,
nav: (String) -> Unit
) {
val tags = remember { baseNote.event?.tags() ?: emptyList() }
val tags = remember(baseNote) { baseNote.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -163,7 +165,7 @@ private fun RenderOptionAfterVote(
totalRatio: Float,
color: Color,
canPreview: Boolean,
tags: List<List<String>>,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -230,7 +232,7 @@ private fun RenderOptionAfterVote(
private fun RenderOptionBeforeVote(
description: String,
canPreview: Boolean,
tags: List<List<String>>,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.widget.Toast
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -62,6 +63,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
@@ -201,7 +203,9 @@ fun BoostReaction(
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasBoosted(baseNote)) {
accountViewModel.deleteBoostsTo(baseNote)
scope.launch(Dispatchers.IO) {
accountViewModel.deleteBoostsTo(baseNote)
}
} else {
wantsToBoost = true
}
@@ -293,10 +297,12 @@ fun LikeReaction(
modifier = iconButtonModifier,
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasReactedTo(baseNote)) {
accountViewModel.deleteReactionTo(baseNote)
} else {
accountViewModel.reactTo(baseNote)
scope.launch(Dispatchers.IO) {
if (accountViewModel.hasReactedTo(baseNote)) {
accountViewModel.deleteReactionTo(baseNote)
} else {
accountViewModel.reactTo(baseNote)
}
}
} else {
scope.launch {
@@ -386,9 +392,6 @@ fun ZapReaction(
iconSize: Dp = 20.dp,
animationSize: Dp = 14.dp
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
@@ -407,53 +410,18 @@ fun ZapReaction(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = 24.dp),
onClick = {
if (account.zapAmountChoices.isEmpty()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.no_zap_amount_setup_long_press_to_change),
Toast.LENGTH_SHORT
)
.show()
zapClick(
baseNote,
accountViewModel,
scope,
context,
onZappingProgress = { progress: Float ->
zappingProgress = progress
},
onMultipleChoices = {
wantsToZap = true
}
} else if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps),
Toast.LENGTH_SHORT
)
.show()
}
} else if (account.zapAmountChoices.size == 1) {
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
account.zapAmountChoices.first() * 1000,
null,
"",
context,
onError = {
scope.launch {
zappingProgress = 0f
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
},
zapType = account.defaultZapType
)
}
} else if (account.zapAmountChoices.size > 1) {
wantsToZap = true
}
)
},
onLongClick = {
wantsToChangeZapAmount = true
@@ -494,7 +462,7 @@ fun ZapReaction(
}
if (wantsToSetCustomZap) {
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
ZapCustomDialog({ wantsToSetCustomZap = false }, accountViewModel, baseNote)
}
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
@@ -507,7 +475,7 @@ fun ZapReaction(
CircularProgressIndicator(
progress = animatedProgress,
modifier = Modifier.size(animationSize),
modifier = remember { Modifier.size(animationSize) },
strokeWidth = 2.dp
)
} else {
@@ -523,6 +491,63 @@ fun ZapReaction(
ZapAmountText(baseNote, grayTint, accountViewModel)
}
private fun zapClick(
baseNote: Note,
accountViewModel: AccountViewModel,
scope: CoroutineScope,
context: Context,
onZappingProgress: (Float) -> Unit,
onMultipleChoices: () -> Unit
) {
if (accountViewModel.account.zapAmountChoices.isEmpty()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.no_zap_amount_setup_long_press_to_change),
Toast.LENGTH_SHORT
)
.show()
}
} else if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps),
Toast.LENGTH_SHORT
)
.show()
}
} else if (accountViewModel.account.zapAmountChoices.size == 1) {
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
accountViewModel.account.zapAmountChoices.first() * 1000,
null,
"",
context,
onError = {
scope.launch {
onZappingProgress(0f)
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
onZappingProgress(it)
}
},
zapType = accountViewModel.account.defaultZapType
)
}
} else if (accountViewModel.account.zapAmountChoices.size > 1) {
onMultipleChoices()
}
}
@Composable
private fun ZapIcon(
baseNote: Note,
@@ -593,7 +618,7 @@ private fun ZapAmountText(
}
@Composable
public fun ViewCountReaction(
fun ViewCountReaction(
idHex: String,
grayTint: Color,
iconSize: Dp = 20.dp,
@@ -653,12 +678,15 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
offset = IntOffset(0, -50),
onDismissRequest = { onDismiss() }
) {
FlowRow() {
FlowRow {
val scope = rememberCoroutineScope()
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.boost(baseNote)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.boost(baseNote)
onDismiss()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
@@ -56,13 +56,13 @@ class ZapOptionstViewModel : ViewModel() {
}
@Composable
fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: AccountViewModel, baseNote: Note) {
fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, baseNote: Note) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: ZapOptionstViewModel = viewModel()
LaunchedEffect(account) {
postViewModel.load(account)
LaunchedEffect(accountViewModel) {
postViewModel.load(accountViewModel.account)
}
var zappingProgress by remember { mutableStateOf(0f) }
@@ -76,7 +76,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
var selectedZapType by remember { mutableStateOf(account.defaultZapType) }
var selectedZapType by remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType) }
Dialog(
onDismissRequest = { onClose() },
@@ -158,7 +158,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
TextSpinner(
label = stringResource(id = R.string.zap_type),
placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second,
placeholder = zapTypes.filter { it.first == accountViewModel.account.defaultZapType }.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
@@ -150,7 +150,7 @@ fun UserActionOptions(
baseAuthor: User,
accountViewModel: AccountViewModel
) {
val coroutineScope = rememberCoroutineScope()
val scope = rememberCoroutineScope()
val accountState by accountViewModel.accountLiveData.observeAsState()
val isHidden by remember(accountState) {
@@ -168,12 +168,14 @@ fun UserActionOptions(
if (isHidden) {
ShowUserButton {
accountViewModel.show(baseAuthor)
scope.launch(Dispatchers.IO) {
accountViewModel.show(baseAuthor)
}
}
} else if (isFollowing) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { accountViewModel.unfollow(baseAuthor) } }
UnfollowButton { scope.launch(Dispatchers.IO) { accountViewModel.unfollow(baseAuthor) } }
} else {
FollowButton({ coroutineScope.launch(Dispatchers.IO) { accountViewModel.follow(baseAuthor) } })
FollowButton({ scope.launch(Dispatchers.IO) { accountViewModel.follow(baseAuthor) } })
}
}
@@ -26,6 +26,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.note.BadgeCompose
import com.vitorpamplona.amethyst.ui.note.MessageSetCompose
@@ -45,8 +46,15 @@ fun RefresheableCardView(
enablePullRefresh: Boolean = true
) {
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
val pullRefreshState = rememberPullRefreshState(
refreshing,
onRefresh =
{
refreshing = true
viewModel.invalidateData()
refreshing = false
}
)
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
@@ -151,7 +159,9 @@ private fun FeedLoaded(
routeForLastRead: String
) {
val defaultModifier = remember {
Modifier.fillMaxWidth().defaultMinSize(minHeight = 100.dp)
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 100.dp)
}
LazyColumn(
@@ -168,7 +178,7 @@ private fun FeedLoaded(
Row(defaultModifier) {
when (item) {
is NoteCard -> NoteCompose(
item.note,
item,
isBoostedNote = false,
accountViewModel = accountViewModel,
nav = nav,
@@ -208,3 +218,36 @@ private fun FeedLoaded(
}
}
}
@Composable
fun NoteCompose(
baseNote: NoteCard,
routeForLastRead: String? = null,
modifier: Modifier = remember { Modifier },
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
parentBackgroundColor: Color? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val note = remember(baseNote) {
baseNote.note
}
NoteCompose(
baseNote = note,
routeForLastRead = routeForLastRead,
modifier = modifier,
isBoostedNote = isBoostedNote,
isQuotedNote = isQuotedNote,
unPackReply = unPackReply,
makeItShort = makeItShort,
addMarginTop = addMarginTop,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
@@ -2,16 +2,14 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
@@ -19,57 +17,86 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, routeForLastRead: String, onWantsToReply: (Note) -> Unit) {
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val listState = rememberForeverLazyListState(routeForLastRead)
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.invalidateData()
isRefreshing = false
fun RefreshingChatroomFeedView(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String,
onWantsToReply: (Note) -> Unit,
scrollStateKey: String? = null,
enablePullRefresh: Boolean = true
) {
RefresheableView(viewModel, enablePullRefresh) {
SaveableFeedState(viewModel, scrollStateKey) { listState ->
RenderChatroomFeedView(viewModel, accountViewModel, listState, nav, routeForLastRead, onWantsToReply)
}
}
}
Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
isRefreshing = true
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
isRefreshing = true
}
}
is FeedState.Loaded -> {
LaunchedEffect(state.feed.value.firstOrNull()) {
if (listState.firstVisibleItemIndex <= 1) {
listState.animateScrollToItem(0)
}
}
@Composable
fun RenderChatroomFeedView(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String,
onWantsToReply: (Note) -> Unit
) {
val feedState by viewModel.feedContent.collectAsState()
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
reverseLayout = true,
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
ChatroomMessageCompose(item, routeForLastRead, accountViewModel = accountViewModel, nav = nav, onWantsToReply = onWantsToReply)
}
}
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
viewModel.invalidateData()
}
is FeedState.Loading -> {
LoadingFeed()
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
viewModel.invalidateData()
}
}
is FeedState.Loaded -> {
ChatroomFeedLoaded(state, accountViewModel, listState, nav, routeForLastRead, onWantsToReply)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
}
@Composable
fun ChatroomFeedLoaded(
state: FeedState.Loaded,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String,
onWantsToReply: (Note) -> Unit
) {
LaunchedEffect(state.feed.value.firstOrNull()) {
if (listState.firstVisibleItemIndex <= 1) {
listState.animateScrollToItem(0)
}
}
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
reverseLayout = true,
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply
)
}
}
}
@@ -5,6 +5,9 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -16,7 +19,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -25,7 +27,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -90,12 +91,6 @@ private fun FeedLoaded(
) {
val listState = rememberLazyListState()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val notificationCacheState = NotificationCache.live.observeAsState()
val notificationCache = notificationCacheState.value ?: return
LaunchedEffect(key1 = markAsRead.value) {
if (markAsRead.value) {
for (note in state.feed.value) {
@@ -104,21 +99,11 @@ private fun FeedLoaded(
val route = if (channelHex != null) {
"Channel/$channelHex"
} else {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.verifiedRecipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == account.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
"Room/${userToComposeOn.pubkeyHex}"
val roomUser = (note.event as? PrivateDmEvent)?.talkingWith(accountViewModel.account.userProfile().pubkeyHex)
"Room/$roomUser"
}
notificationCache.cache.markAsRead(route, it.createdAt())
NotificationCache.markAsRead(route, it.createdAt())
}
}
markAsRead.value = false
@@ -136,11 +121,13 @@ private fun FeedLoaded(
state.feed.value,
key = { index, item -> if (index == 0) index else item.idHex }
) { _, item ->
ChatroomCompose(
item,
accountViewModel = accountViewModel,
nav = nav
)
Row(Modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp)) {
ChatroomCompose(
item,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
}
@@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
@@ -34,15 +36,27 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun RefresheableFeedView(
viewModel: FeedViewModel,
routeForLastRead: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
scrollStateKey: String? = null,
enablePullRefresh: Boolean = true
) {
RefresheableView(viewModel, enablePullRefresh) {
SaveableFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun RefresheableView(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String?,
scrollStateKey: String? = null,
enablePullRefresh: Boolean = true
viewModel: InvalidatableViewModel,
enablePullRefresh: Boolean = true,
content: @Composable () -> Unit
) {
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
@@ -56,7 +70,7 @@ fun RefresheableView(
Box(modifier) {
Column {
SaveableFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
content()
}
if (enablePullRefresh) {
@@ -72,6 +86,17 @@ private fun SaveableFeedState(
nav: (String) -> Unit,
routeForLastRead: String?,
scrollStateKey: String? = null
) {
SaveableFeedState(viewModel, scrollStateKey) { listState ->
RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
}
}
@Composable
fun SaveableFeedState(
viewModel: FeedViewModel,
scrollStateKey: String? = null,
content: @Composable (LazyListState) -> Unit
) {
val listState = if (scrollStateKey != null) {
rememberForeverLazyListState(scrollStateKey)
@@ -81,7 +106,7 @@ private fun SaveableFeedState(
WatchScrollToTop(viewModel, listState)
RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
content(listState)
}
@Composable
@@ -163,14 +188,16 @@ private fun FeedLoaded(
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
NoteCompose(
item,
routeForLastRead = routeForLastRead,
modifier = baseModifier,
isBoostedNote = false,
accountViewModel = accountViewModel,
nav = nav
)
Row(Modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp)) {
NoteCompose(
item,
routeForLastRead = routeForLastRead,
modifier = baseModifier,
isBoostedNote = false,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
}
@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -41,8 +42,21 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
class NostrChannelFeedViewModel(val channel: Channel, val account: Account) : FeedViewModel(ChannelFeedFilter(channel, account)) {
class Factory(val channel: Channel, val account: Account) : ViewModelProvider.Factory {
override fun <NostrChannelFeedViewModel : ViewModel> create(modelClass: Class<NostrChannelFeedViewModel>): NostrChannelFeedViewModel {
return NostrChannelFeedViewModel(channel, account) as NostrChannelFeedViewModel
}
}
}
class NostrChatroomFeedViewModel(val user: User, val account: Account) : FeedViewModel(ChatroomFeedFilter(user, account)) {
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
override fun <NostrChatRoomFeedViewModel : ViewModel> create(modelClass: Class<NostrChatRoomFeedViewModel>): NostrChatRoomFeedViewModel {
return NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel
}
}
}
class NostrGlobalFeedViewModel(val account: Account) : FeedViewModel(GlobalFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrGlobalFeedViewModel : ViewModel> create(modelClass: Class<NostrGlobalFeedViewModel>): NostrGlobalFeedViewModel {
@@ -115,7 +129,7 @@ class NostrUserAppRecommendationsFeedViewModel(val user: User) : FeedViewModel(U
}
@Stable
abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), InvalidatableViewModel {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow()
@@ -204,7 +218,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val bundler = BundledUpdate(250, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateData(ignoreIfDoing: Boolean = false) {
override fun invalidateData(ignoreIfDoing: Boolean) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
@@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterialApi::class)
@@ -388,10 +389,7 @@ fun NoteMaster(
if (eventContent != null) {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
val tags = remember(note) {
note.event?.tags() ?: emptyList()
}
val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
@@ -2,29 +2,18 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun RefreshingFeedUserFeedView(
viewModel: UserFeedViewModel,
@@ -32,24 +21,8 @@ fun RefreshingFeedUserFeedView(
nav: (String) -> Unit,
enablePullRefresh: Boolean = true
) {
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column() {
UserFeedView(viewModel, accountViewModel, nav)
}
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
RefresheableView(viewModel, enablePullRefresh) {
UserFeedView(viewModel, accountViewModel, nav)
}
}
@@ -47,7 +47,7 @@ class NostrHiddenAccountsFeedViewModel(val account: Account) : UserFeedViewModel
}
}
open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), InvalidatableViewModel {
private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
@@ -91,8 +91,8 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
private val bundler = BundledUpdate(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {
override fun invalidateData(ignoreIfDoing: Boolean) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
@@ -114,3 +114,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
super.onCleared()
}
}
interface InvalidatableViewModel {
fun invalidateData(ignoreIfDoing: Boolean = false)
}
@@ -59,10 +59,6 @@ class AccountViewModel(val account: Account) : ViewModel() {
account.delete(account.boostsTo(note))
}
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType)
}
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
return account.calculateIfNoteWasZappedByAccount(zappedNote)
}
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@@ -73,8 +73,8 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
when (page) {
0 -> RefresheableView(privateFeedViewModel, accountViewModel, nav, null)
1 -> RefresheableView(publicFeedViewModel, accountViewModel, nav, null)
0 -> RefresheableFeedView(privateFeedViewModel, null, accountViewModel, nav)
1 -> RefresheableFeedView(publicFeedViewModel, null, accountViewModel, nav)
}
}
}
@@ -74,13 +74,13 @@ import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun ChannelScreen(
@@ -88,174 +88,256 @@ fun ChannelScreen(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
val context = LocalContext.current
if (channelId == null) return
var channelBase by remember { mutableStateOf<Channel?>(null) }
LaunchedEffect(channelId) {
withContext(Dispatchers.IO) {
val newChannelBase = LocalCache.checkGetOrCreateChannel(channelId)
if (newChannelBase != channelBase) {
channelBase = newChannelBase
}
}
}
channelBase?.let {
PrepareChannelViewModels(
baseChannel = it,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
fun PrepareChannelViewModels(baseChannel: Channel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val feedViewModel: NostrChannelFeedViewModel = viewModel(
key = baseChannel.idHex + "ChannelFeedViewModel",
factory = NostrChannelFeedViewModel.Factory(
baseChannel,
accountViewModel.account
)
)
val channelScreenModel: NewPostViewModel = viewModel()
channelScreenModel.account = accountViewModel.account
channelScreenModel.account = account
ChannelScreen(
channel = baseChannel,
feedViewModel = feedViewModel,
newPostModel = channelScreenModel,
accountViewModel = accountViewModel,
nav = nav
)
}
@Composable
fun ChannelScreen(
channel: Channel,
feedViewModel: NostrChannelFeedViewModel,
newPostModel: NewPostViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val context = LocalContext.current
NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel)
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
NostrChannelDataSource.start()
feedViewModel.invalidateData()
newPostModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Channel Start")
NostrChannelDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Channel Stop")
NostrChannelDataSource.clear()
NostrChannelDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
ChannelHeader(
channel,
accountViewModel,
nav = nav
)
if (account != null && channelId != null) {
val replyTo = remember { mutableStateOf<Note?>(null) }
ChannelFeedFilter.loadMessagesBetween(account, channelId)
NostrChannelDataSource.loadMessagesBetween(account, channelId)
val channelState by NostrChannelDataSource.channel!!.live.observeAsState()
val channel = channelState?.channel ?: return
ChannelFeedFilter.loadMessagesBetween(account, channelId)
val feedViewModel: NostrChannelFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
ChannelFeedFilter.loadMessagesBetween(account, channelId)
NostrChannelDataSource.loadMessagesBetween(account, channelId)
feedViewModel.invalidateData()
channelScreenModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
DisposableEffect(channelId) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Channel Start")
ChannelFeedFilter.loadMessagesBetween(account, channelId)
NostrChannelDataSource.loadMessagesBetween(account, channelId)
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Channel Stop")
NostrChannelDataSource.clear()
NostrChannelDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
ChannelHeader(
channel,
accountViewModel,
nav = nav
)
Column(
modifier = Modifier
Column(
modifier = remember {
Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true)
) {
ChatroomFeedView(feedViewModel, accountViewModel, nav, "Channel/$channelId") {
}
) {
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Channel/${channel.idHex}",
onWantsToReply = {
replyTo.value = it
}
}
)
}
Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = Modifier.height(10.dp))
Row(
Modifier
.padding(horizontal = 10.dp)
.animateContentSize(),
verticalAlignment = Alignment.CenterVertically
) {
val replyingNote = replyTo.value
if (replyingNote != null) {
Column(Modifier.weight(1f)) {
ChatroomMessageCompose(
baseNote = replyingNote,
null,
innerQuote = true,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = {
replyTo.value = it
}
)
}
Column(Modifier.padding(end = 10.dp)) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { replyTo.value = null }
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier
.padding(end = 5.dp)
.size(30.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
}
// LAST ROW
Row(
modifier = Modifier
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = channelScreenModel.message,
onValueChange = {
channelScreenModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
shape = RoundedCornerShape(25.dp),
modifier = Modifier.weight(1f, true),
placeholder = {
Text(
text = stringResource(R.string.reply_here),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
trailingIcon = {
PostButton(
onPost = {
val tagger = NewMessageTagger(channel.idHex, listOfNotNull(replyTo.value?.author), listOfNotNull(replyTo.value), channelScreenModel.message.text)
tagger.run()
account.sendChannelMessage(tagger.message, channel.idHex, tagger.replyTos, tagger.mentions, wantsToMarkAsSensitive = false)
channelScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.invalidateData() // Don't wait a full second before updating
},
isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
modifier = Modifier.padding(end = 10.dp)
)
},
leadingIcon = {
UploadFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
channelScreenModel.upload(it, "", account.defaultFileServer, context)
}
},
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
replyTo.value?.let {
DisplayReplyingToNote(it, accountViewModel, nav) {
replyTo.value = null
}
}
val scope = rememberCoroutineScope()
// LAST ROW
EditFieldRow(newPostModel, accountViewModel) {
scope.launch {
val tagger = NewMessageTagger(
channelHex = channel.idHex,
mentions = listOfNotNull(replyTo.value?.author),
replyTos = listOfNotNull(replyTo.value),
message = newPostModel.message.text
)
tagger.run()
accountViewModel.account.sendChannelMessage(
message = tagger.message,
toChannel = channel.idHex,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
wantsToMarkAsSensitive = false
)
newPostModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.sendToTop()
}
}
}
}
@Composable
fun DisplayReplyingToNote(
replyingNote: Note?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onCancel: () -> Unit
) {
Row(
Modifier
.padding(horizontal = 10.dp)
.animateContentSize(),
verticalAlignment = Alignment.CenterVertically
) {
if (replyingNote != null) {
Column(remember { Modifier.weight(1f) }) {
ChatroomMessageCompose(
baseNote = replyingNote,
null,
innerQuote = true,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = {}
)
}
Column(Modifier.padding(end = 10.dp)) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = onCancel
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier
.padding(end = 5.dp)
.size(30.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
}
}
@Composable
fun EditFieldRow(
channelScreenModel: NewPostViewModel,
accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit
) {
val context = LocalContext.current
Row(
modifier = Modifier
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = channelScreenModel.message,
onValueChange = {
channelScreenModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
shape = RoundedCornerShape(25.dp),
modifier = Modifier.weight(1f, true),
placeholder = {
Text(
text = stringResource(R.string.reply_here),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
trailingIcon = {
PostButton(
onPost = {
onSendNewMessage()
},
isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
modifier = Modifier.padding(end = 10.dp)
)
},
leadingIcon = {
UploadFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
channelScreenModel.upload(it, "", accountViewModel.account.defaultFileServer, context)
}
},
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
}
}
@@ -1,202 +1,182 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrChatRoomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val context = LocalContext.current
val chatRoomScreenModel: NewPostViewModel = viewModel()
chatRoomScreenModel.account = accountViewModel.account
fun ChatroomScreen(
userId: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
if (userId == null) return
var userRoom by remember { mutableStateOf<User?>(null) }
LaunchedEffect(userId) {
withContext(Dispatchers.IO) {
val newUser = LocalCache.checkGetOrCreateUser(userId)
if (newUser != userRoom) {
userRoom = newUser
}
}
}
userRoom?.let {
PrepareChatroomViewModels(
baseUser = it,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
fun PrepareChatroomViewModels(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val feedViewModel: NostrChatroomFeedViewModel = viewModel(
key = baseUser.pubkeyHex + "ChatroomViewModels",
factory = NostrChatroomFeedViewModel.Factory(
baseUser,
accountViewModel.account
)
)
val newPostModel: NewPostViewModel = viewModel()
newPostModel.account = accountViewModel.account
ChatroomScreen(
baseUser = baseUser,
feedViewModel = feedViewModel,
newPostModel = newPostModel,
accountViewModel = accountViewModel,
nav = nav
)
}
@Composable
fun ChatroomScreen(
baseUser: User,
feedViewModel: NostrChatroomFeedViewModel,
newPostModel: NewPostViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val context = LocalContext.current
NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, baseUser)
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(baseUser, accountViewModel) {
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
newPostModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
DisposableEffect(baseUser, accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Private Message Start")
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Private Message Stop")
NostrChatroomDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
ChatroomHeader(baseUser, accountViewModel, nav = nav)
if (userId != null) {
val replyTo = remember { mutableStateOf<Note?>(null) }
ChatroomFeedFilter.loadMessagesBetween(accountViewModel.account, userId)
NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, userId)
val feedViewModel: NostrChatRoomFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(userId) {
feedViewModel.invalidateData()
chatRoomScreenModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
DisposableEffect(userId) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Private Message Start")
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Private Message Stop")
NostrChatroomDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
NostrChatroomDataSource.withUser?.let {
ChatroomHeader(it, accountViewModel, nav = nav)
}
Column(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true)
) {
ChatroomFeedView(feedViewModel, accountViewModel, nav, "Room/$userId") {
Column(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true)
) {
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Room/${baseUser.pubkeyHex}",
onWantsToReply = {
replyTo.value = it
}
)
}
Spacer(modifier = Modifier.height(10.dp))
replyTo.value?.let {
DisplayReplyingToNote(it, accountViewModel, nav) {
replyTo.value = null
}
}
Spacer(modifier = Modifier.height(10.dp))
val scope = rememberCoroutineScope()
Row(Modifier.padding(horizontal = 10.dp).animateContentSize(), verticalAlignment = Alignment.CenterVertically) {
val replyingNote = replyTo.value
if (replyingNote != null) {
Column(Modifier.weight(1f)) {
ChatroomMessageCompose(
baseNote = replyingNote,
null,
innerQuote = true,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = {
replyTo.value = it
}
)
}
Column(Modifier.padding(end = 10.dp)) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { replyTo.value = null }
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(end = 5.dp).size(30.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
}
// LAST ROW
Row(
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = chatRoomScreenModel.message,
onValueChange = { chatRoomScreenModel.updateMessage(it) },
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier.weight(1f, true),
shape = RoundedCornerShape(25.dp),
placeholder = {
Text(
text = stringResource(id = R.string.reply_here),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
trailingIcon = {
PostButton(
onPost = {
accountViewModel.account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value, null, wantsToMarkAsSensitive = false)
chatRoomScreenModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.invalidateData() // Don't wait a full second before updating
},
isActive = chatRoomScreenModel.message.text.isNotBlank() && !chatRoomScreenModel.isUploadingImage,
modifier = Modifier.padding(end = 10.dp)
)
},
leadingIcon = {
UploadFromGallery(
isUploading = chatRoomScreenModel.isUploadingImage,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
chatRoomScreenModel.upload(it, "", accountViewModel.account.defaultFileServer, context)
}
},
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
// LAST ROW
EditFieldRow(newPostModel, accountViewModel) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.sendPrivateMessage(
message = newPostModel.message.text,
toUser = baseUser,
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false
)
newPostModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.sendToTop()
}
}
}
@@ -27,7 +27,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -73,7 +73,7 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String
modifier = Modifier.padding(vertical = 0.dp)
) {
HashtagHeader(tag, accountViewModel)
RefresheableView(feedViewModel, accountViewModel, nav, null)
RefresheableFeedView(feedViewModel, null, accountViewModel, nav)
}
}
}
@@ -34,7 +34,7 @@ import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import kotlinx.collections.immutable.ImmutableList
@@ -121,7 +121,7 @@ private fun HomePages(
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
RefresheableView(
RefresheableFeedView(
viewModel = tabs[page].viewModel,
accountViewModel = accountViewModel,
nav = nav,
@@ -91,12 +91,13 @@ import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowsUserFeedViewM
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileNewThreadsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileZapsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -717,7 +718,7 @@ private fun DrawAdditionalInfo(
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = remember { emptyList() },
tags = remember { persistentListOf() },
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
nav = nav
@@ -767,6 +768,7 @@ private fun DisplayLNAddress(
userHex,
account,
onSuccess = {
zapExpanded = false
// pay directly
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(it, null) { response ->
@@ -1053,7 +1055,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, nav: (String) -> Unit
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false)
}
}
}
@@ -1070,7 +1072,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, nav: (String) -> U
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false)
}
}
}
@@ -1087,7 +1089,7 @@ fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, nav: (Strin
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false)
}
}
}
@@ -1154,7 +1156,7 @@ fun TabReports(baseUser: User, accountViewModel: AccountViewModel, nav: (String)
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false)
}
}
}
@@ -1310,6 +1312,8 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val scope = rememberCoroutineScope()
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
@@ -1322,51 +1326,65 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
Divider()
if (account.isHidden(user)) {
DropdownMenuItem(onClick = {
accountViewModel.show(user)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.show(user)
onDismiss()
}
}) {
Text(stringResource(R.string.unblock_user))
}
} else {
DropdownMenuItem(onClick = {
accountViewModel.hide(user)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.hide(user)
onDismiss()
}
}) {
Text(stringResource(id = R.string.block_hide_user))
}
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.SPAM)
accountViewModel.hide(user)
scope.launch(Dispatchers.IO) {
accountViewModel.report(user, ReportEvent.ReportType.SPAM)
accountViewModel.hide(user)
}
onDismiss()
}) {
Text(stringResource(id = R.string.report_spam_scam))
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.PROFANITY)
accountViewModel.hide(user)
scope.launch(Dispatchers.IO) {
accountViewModel.report(user, ReportEvent.ReportType.PROFANITY)
accountViewModel.hide(user)
}
onDismiss()
}) {
Text(stringResource(R.string.report_hateful_speech))
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION)
accountViewModel.hide(user)
scope.launch(Dispatchers.IO) {
accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION)
accountViewModel.hide(user)
}
onDismiss()
}) {
Text(stringResource(id = R.string.report_impersonation))
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.NUDITY)
accountViewModel.hide(user)
scope.launch(Dispatchers.IO) {
accountViewModel.report(user, ReportEvent.ReportType.NUDITY)
accountViewModel.hide(user)
}
onDismiss()
}) {
Text(stringResource(R.string.report_nudity_porn))
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL)
accountViewModel.hide(user)
scope.launch(Dispatchers.IO) {
accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL)
accountViewModel.hide(user)
}
onDismiss()
}) {
Text(stringResource(id = R.string.report_illegal_behaviour))
@@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -41,6 +42,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) {
@@ -78,6 +81,8 @@ fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss:
)
}
) { pad ->
val scope = rememberCoroutineScope()
Column(
modifier = Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()),
verticalArrangement = Arrangement.SpaceAround
@@ -93,8 +98,10 @@ fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss:
text = stringResource(R.string.report_dialog_block_hide_user_btn),
icon = Icons.Default.Block,
onClick = {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
scope.launch(Dispatchers.IO) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
}
)
SpacerH16()
@@ -124,14 +131,21 @@ fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss:
modifier = Modifier.fillMaxWidth()
)
SpacerH16()
ActionButton(
text = stringResource(R.string.report_dialog_post_report_btn),
icon = Icons.Default.Report,
enabled = selectedReason in 0..reportTypes.lastIndex,
onClick = {
accountViewModel.report(note, reportTypes[selectedReason].first, additionalReason)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.report(
note,
reportTypes[selectedReason].first,
additionalReason
)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
}
)
}
@@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -113,7 +113,7 @@ fun SearchScreen(
modifier = Modifier.padding(vertical = 0.dp)
) {
SearchBar(accountViewModel, nav)
RefresheableView(searchFeedViewModel, accountViewModel, nav, null, ScrollStateKeys.GLOBAL_SCREEN)
RefresheableFeedView(searchFeedViewModel, null, accountViewModel, nav, ScrollStateKeys.GLOBAL_SCREEN)
}
}
}
@@ -253,11 +253,9 @@ private fun RenderVideoOrPictureNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = note.event
var moreActionsExpanded by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize(1f)) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Column(remember { Modifier.fillMaxSize(1f) }) {
Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) {
val noteEvent = remember { note.event }
if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note)
} else if (noteEvent is FileStorageHeaderEvent) {
@@ -266,51 +264,17 @@ private fun RenderVideoOrPictureNote(
}
}
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) {
Column(Modifier.weight(1f)) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) {
Column(Modifier.size(55.dp), verticalArrangement = Arrangement.Center) {
NoteAuthorPicture(note, nav, accountViewModel, 55.dp)
}
Column(
Modifier
.padding(start = 10.dp, end = 10.dp)
.height(60.dp)
.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(note, Modifier.weight(1f))
IconButton(
modifier = Modifier.size(24.dp),
onClick = { moreActionsExpanded = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
NoteDropDownMenu(note, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp)) {
RelayBadges(baseNote = note)
}
}
}
Row(verticalAlignment = Alignment.Bottom, modifier = remember { Modifier.fillMaxSize(1f) }) {
Column(remember { Modifier.weight(1f) }) {
RenderVideoOrPicture(note, nav, accountViewModel)
}
Column(
Modifier
.width(65.dp)
.padding(bottom = 10.dp),
remember {
Modifier
.width(65.dp)
.padding(bottom = 10.dp)
},
verticalArrangement = Arrangement.Center
) {
Row(horizontalArrangement = Arrangement.Center) {
@@ -320,6 +284,73 @@ private fun RenderVideoOrPictureNote(
}
}
@Composable
private fun RenderVideoOrPicture(
note: Note,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Row(remember { Modifier.padding(10.dp) }, verticalAlignment = Alignment.Bottom) {
Column(remember { Modifier.size(55.dp) }, verticalArrangement = Arrangement.Center) {
NoteAuthorPicture(note, nav, accountViewModel, 55.dp)
}
Column(
remember {
Modifier
.padding(start = 10.dp, end = 10.dp)
.height(60.dp)
.weight(1f)
},
verticalArrangement = Arrangement.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
NoteUsernameDisplay(note, remember { Modifier.weight(1f) })
VideoUserOptionAction(note, accountViewModel)
}
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(
remember { note.author!! },
remember { Modifier.weight(1f) }
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 5.dp)
) {
RelayBadges(baseNote = note)
}
}
}
}
@Composable
private fun VideoUserOptionAction(
note: Note,
accountViewModel: AccountViewModel
) {
var moreActionsExpanded by remember { mutableStateOf(false) }
IconButton(
modifier = remember { Modifier.size(24.dp) },
onClick = { moreActionsExpanded = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = remember { Modifier.size(20.dp) },
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
NoteDropDownMenu(
note,
moreActionsExpanded,
{ moreActionsExpanded = false },
accountViewModel
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RelayBadges(baseNote: Note) {
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.Locale
@@ -43,7 +44,7 @@ fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>,
tags: ImmutableList<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -74,7 +75,7 @@ fun TranslatableRichTextViewer(
}
}
Column() {
Column {
ExpandableRichTextViewer(
toBeViewed,
canPreview,
@@ -89,6 +90,8 @@ fun TranslatableRichTextViewer(
val source = translatedTextState.sourceLang
if (source != null && target != null && source != target) {
val scope = rememberCoroutineScope()
Row(
modifier = Modifier
.fillMaxWidth()
@@ -140,8 +143,10 @@ fun TranslatableRichTextViewer(
onDismissRequest = { langSettingsPopupExpanded = false }
) {
DropdownMenuItem(onClick = {
accountViewModel.dontTranslateFrom(source)
langSettingsPopupExpanded = false
scope.launch(Dispatchers.IO) {
accountViewModel.dontTranslateFrom(source)
langSettingsPopupExpanded = false
}
}) {
if (source in accountViewModel.account.dontTranslateFrom) {
Icon(
@@ -159,8 +164,10 @@ fun TranslatableRichTextViewer(
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.prefer(source, target, source)
langSettingsPopupExpanded = false
scope.launch(Dispatchers.IO) {
accountViewModel.prefer(source, target, source)
langSettingsPopupExpanded = false
}
}) {
if (accountViewModel.account.preferenceBetween(source, target) == source) {
Icon(
@@ -177,8 +184,10 @@ fun TranslatableRichTextViewer(
Text(stringResource(R.string.translations_show_in_lang_first, Locale(source).displayName))
}
DropdownMenuItem(onClick = {
accountViewModel.prefer(source, target, target)
langSettingsPopupExpanded = false
scope.launch(Dispatchers.IO) {
accountViewModel.prefer(source, target, target)
langSettingsPopupExpanded = false
}
}) {
if (accountViewModel.account.preferenceBetween(source, target) == target) {
Icon(
@@ -201,8 +210,10 @@ fun TranslatableRichTextViewer(
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { lang ->
DropdownMenuItem(onClick = {
accountViewModel.translateTo(lang)
langSettingsPopupExpanded = false
scope.launch(Dispatchers.IO) {
accountViewModel.translateTo(lang)
langSettingsPopupExpanded = false
}
}) {
if (lang.language in accountViewModel.account.translateTo) {
Icon(