Restructuring ViewModels

This commit is contained in:
Vitor Pamplona
2023-05-31 16:30:11 -04:00
parent 3744f90e87
commit d942c12627
49 changed files with 1399 additions and 1078 deletions
@@ -39,14 +39,14 @@ object NotificationCache {
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(NotificationState(cache))
}
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
if (hasActiveObservers()) {
postValue(NotificationState(cache))
}
}
}
}
@@ -1166,14 +1166,14 @@ class Account(
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Default) {
if (hasActiveObservers()) {
refresh()
}
}
private val bundler = BundledUpdate(300, Dispatchers.Default)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
if (hasActiveObservers()) {
refresh()
}
}
}
fun refresh() {
@@ -55,14 +55,14 @@ class AntiSpamFilter {
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(AntiSpamState(cache))
}
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
if (hasActiveObservers()) {
postValue(AntiSpamState(cache))
}
}
}
}
@@ -74,14 +74,14 @@ class Channel(val idHex: String) {
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(ChannelState(channel))
}
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
if (hasActiveObservers()) {
postValue(ChannelState(channel))
}
}
}
override fun onActive() {
@@ -414,14 +414,14 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
// if (hasObservers()) {
postValue(NoteState(note))
// }
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
// if (hasObservers()) {
postValue(NoteState(note))
// }
}
}
override fun onActive() {
@@ -400,14 +400,14 @@ class UserMetadata {
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(UserState(user))
}
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
if (hasActiveObservers()) {
postValue(UserState(user))
}
}
}
override fun onActive() {
@@ -103,16 +103,16 @@ abstract class NostrDataSource(val debugName: String) {
}
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() {
bundler.invalidate()
bundler.invalidate() {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
}
fun resetFilters() {
@@ -49,18 +49,18 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.lang.Math.round
@Composable
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, relayToAdd: String = "") {
val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsState()
LaunchedEffect(Unit) {
postViewModel.load(account)
postViewModel.load(accountViewModel.account)
}
Dialog(
@@ -16,13 +16,12 @@ import java.util.concurrent.atomic.AtomicBoolean
*/
class BundledUpdate(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default,
val onUpdate: () -> Unit
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
private var onlyOneInBlock = AtomicBoolean()
private var invalidatesAgain = false
fun invalidate(ignoreIfDoing: Boolean = false) {
fun invalidate(ignoreIfDoing: Boolean = false, onUpdate: suspend () -> Unit) {
if (onlyOneInBlock.getAndSet(true)) {
if (!ignoreIfDoing) {
invalidatesAgain = true
@@ -157,145 +157,159 @@ private fun RenderRegular(
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = if (isArabic(paragraph)) {
paragraph.trim().split(' ')
.reversed()
} else {
paragraph.trim().split(' ')
}
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val img = state.imagesForPager[word]
if (img != null) {
ZoomableContentView(img, state.imageList)
} else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, nav)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
val s = remember(paragraph) {
if (isArabic(paragraph)) {
paragraph.trim().split(' ').reversed()
} else {
if (state.urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, nav)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
paragraph.trim().split(' ')
}
}
s.forEach { word: String ->
RenderWord(word, state, canPreview, backgroundColor, accountViewModel, nav, tags)
}
}
}
}
@Composable
private fun RenderWord(
word: String,
state: RichTextViewerState,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: List<List<String>>?
) {
if (canPreview) {
// Explicit URL
val img = state.imagesForPager[word]
if (img != null) {
ZoomableContentView(img, state.imageList)
} else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, nav)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
if (state.urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, nav)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
@@ -124,7 +125,9 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) }
val exoPlayer = remember(videoUri) {
var exoPlayer by remember { mutableStateOf<ExoPlayer?>(null) }
LaunchedEffect(key1 = videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
description?.let {
@@ -135,7 +138,7 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
val media = mediaBuilder.build()
ExoPlayer.Builder(context).build().apply {
exoPlayer = ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = if (mutedInstance.value) 0f else 1f
@@ -152,53 +155,15 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
}
}
DisposableEffect(
BoxWithConstraints() {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible && !exoPlayer.isPlaying) {
exoPlayer.play()
} else if (!visible && exoPlayer.isPlaying) {
exoPlayer.pause()
}
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
thumb?.let { defaultArtwork = thumb }
hideController()
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
innerOnDialog(it)
}
}
}
}
)
exoPlayer?.let {
RenderVideoPlayer(it, context, thumb, onDialog, mutedInstance)
}
MuteButton(mutedInstance) {
mutedInstance.value = !mutedInstance.value
DefaultMutedSetting.value = mutedInstance.value
exoPlayer.volume = if (mutedInstance.value) 0f else 1f
}
}
) {
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.pause()
exoPlayer?.pause()
}
else -> {}
}
@@ -207,12 +172,64 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
lifecycle.addObserver(observer)
onDispose {
exoPlayer.release()
exoPlayer?.release()
lifecycle.removeObserver(observer)
}
}
}
@Composable
private fun RenderVideoPlayer(
exoPlayer: ExoPlayer,
context: Context,
thumb: Drawable?,
onDialog: ((Boolean) -> Unit)?,
mutedInstance: MutableState<Boolean>
) {
BoxWithConstraints() {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible && !exoPlayer.isPlaying) {
exoPlayer.play()
} else if (!visible && exoPlayer.isPlaying) {
exoPlayer.pause()
}
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
thumb?.let { defaultArtwork = thumb }
hideController()
resizeMode =
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
innerOnDialog(it)
}
}
}
}
)
MuteButton(mutedInstance) {
mutedInstance.value = !mutedInstance.value
DefaultMutedSetting.value = mutedInstance.value
exoPlayer.volume = if (mutedInstance.value) 0f else 1f
}
}
}
fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed {
val view = LocalView.current
var isVisible: Boolean? by remember { mutableStateOf(null) }
@@ -10,8 +10,7 @@ import com.vitorpamplona.amethyst.ui.actions.updated
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
object ChatroomListKnownFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feed(): List<Note> {
@@ -3,13 +3,12 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
object ChatroomListNewFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
class ChatroomListNewFeedFilter(val account: Account) : FeedFilter<Note>() {
// returns the last Note of each user.
override fun feed(): List<Note> {
val me = account.userProfile()
val followingKeySet = ChatroomListKnownFeedFilter.account.followingKeySet()
val followingKeySet = account.followingKeySet()
val privateChatrooms = account.userProfile().privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
@@ -5,8 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
class GlobalFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
@@ -6,9 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
@@ -10,9 +10,7 @@ import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
@@ -7,9 +7,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
@@ -1,21 +1,18 @@
package com.vitorpamplona.amethyst.ui.dal
import com.google.errorprone.annotations.Immutable
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadAssembler
object ThreadFeedFilter : FeedFilter<Note>() {
var noteId: String? = null
@Immutable
class ThreadFeedFilter(val noteId: String) : FeedFilter<Note>() {
override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, String> = mutableMapOf()
val eventsToWatch = noteId?.let { ThreadAssembler().findThreadFor(it) } ?: emptySet()
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: emptySet()
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature(cachedSignatures) }
return eventsToWatch.sortedWith(order)
}
fun loadThread(noteId: String?) {
this.noteId = noteId
}
}
@@ -6,9 +6,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
object VideoFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
@@ -151,21 +151,14 @@ private fun RowScope.HasNewItemsIcon(
scope.launch {
if (!selected) {
navController.navigate(route.base) {
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
popUpTo(Route.Home.route)
launchSingleTop = true
restoreState = true
}
} else {
val newRoute = route.route.replace("{scrollToTop}", "true")
navController.navigate(newRoute) {
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start) { inclusive = route.route == Route.Home.route }
restoreState = true
}
popUpTo(Route.Home.route)
launchSingleTop = true
restoreState = true
}
@@ -209,27 +202,30 @@ private fun RowScope.BottomIcon(
@Composable
private fun NotifiableIcon(icon: Int, size: Dp, iconSize: Dp, selected: Boolean, hasNewItems: Boolean) {
Box(Modifier.size(size)) {
Box(remember { Modifier.size(size) }) {
Icon(
painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier.size(iconSize),
modifier = remember { Modifier.size(iconSize) },
tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified
)
if (hasNewItems) {
Box(
Modifier
.width(10.dp)
.height(10.dp)
.align(Alignment.TopEnd)
) {
Box(
modifier = Modifier
remember {
Modifier
.width(10.dp)
.height(10.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.primary),
.align(Alignment.TopEnd)
}
) {
Box(
modifier = remember {
Modifier
.width(10.dp)
.height(10.dp)
.clip(shape = CircleShape)
}.background(MaterialTheme.colors.primary),
contentAlignment = Alignment.TopEnd
) {
Text(
@@ -237,9 +233,11 @@ private fun NotifiableIcon(icon: Int, size: Dp, iconSize: Dp, selected: Boolean,
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 12.sp,
modifier = Modifier
.wrapContentHeight()
.align(Alignment.TopEnd)
modifier = remember {
Modifier
.wrapContentHeight()
.align(Alignment.TopEnd)
}
)
}
}
@@ -1,25 +1,18 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
@@ -39,50 +32,27 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AppNavigation(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
searchFeedViewModel: NostrGlobalFeedViewModel,
videoFeedViewModel: NostrVideoFeedViewModel,
notifFeedViewModel: NotificationViewModel,
userReactionsStatsModel: UserReactionsViewModel,
navController: NavHostController,
accountViewModel: AccountViewModel,
nextPage: String? = null
) {
val homePagerState = rememberPagerState()
var actionableNextPage by remember { mutableStateOf<String?>(nextPage) }
// Avoids creating ViewModels for performance reasons (up to 1 second delays)
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val accountHex = remember(accountState) { accountState?.account?.userProfile()?.pubkeyHex }
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
val homeFeedViewModel: NostrHomeFeedViewModel = viewModel()
val repliesFeedViewModel: NostrHomeRepliesFeedViewModel = viewModel()
GlobalFeedFilter.account = account
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel()
VideoFeedFilter.account = account
val videoFeedViewModel: NostrVideoFeedViewModel = viewModel()
NotificationFeedFilter.account = account
val notifFeedViewModel: NotificationViewModel = viewModel()
val userReactionsStatsModel: UserReactionsViewModel = viewModel()
val scope = rememberCoroutineScope()
LaunchedEffect(accountHex) {
scope.launch(Dispatchers.IO) {
userReactionsStatsModel.load(account.userProfile())
userReactionsStatsModel.initializeSuspend()
}
}
val nav = remember {
{ route: String ->
if (getRouteWithArguments(navController) != route) {
@@ -96,23 +66,20 @@ fun AppNavigation(
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
VideoScreen(
videoFeedView = videoFeedViewModel,
accountViewModel = accountViewModel,
nav = nav,
scrollToTop = scrollToTop
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) {
LaunchedEffect(key1 = Unit) {
LaunchedEffect(key1 = it) {
if (scrollToTop) {
scope.launch {
delay(1000)
videoFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop")
}
}
}
VideoScreen(
videoFeedView = videoFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
@@ -120,23 +87,20 @@ fun AppNavigation(
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
SearchScreen(
searchFeedViewModel = searchFeedViewModel,
accountViewModel = accountViewModel,
nav = nav,
scrollToTop = scrollToTop
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) {
LaunchedEffect(key1 = Unit) {
LaunchedEffect(key1 = it) {
if (scrollToTop) {
scope.launch {
delay(1000)
searchFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop")
}
}
}
SearchScreen(
searchFeedViewModel = searchFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
@@ -145,25 +109,24 @@ fun AppNavigation(
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
val nip47 = it.arguments?.getString("nip47")
LaunchedEffect(key1 = it) {
if (scrollToTop) {
scope.launch {
homeFeedViewModel.sendToTop()
repliesFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop")
}
}
}
HomeScreen(
homeFeedViewModel = homeFeedViewModel,
repliesFeedViewModel = repliesFeedViewModel,
accountViewModel = accountViewModel,
nav = nav,
pagerState = homePagerState,
scrollToTop = scrollToTop,
nip47 = nip47
)
// Avoids running scroll to top when back button is pressed
if (scrollToTop) {
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("scrollToTop")
}
}
}
if (nip47 != null) {
LaunchedEffect(key1 = Unit) {
scope.launch {
@@ -179,28 +142,37 @@ fun AppNavigation(
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
NotificationScreen(
notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
accountViewModel = accountViewModel,
nav = nav,
scrollToTop = scrollToTop
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) {
LaunchedEffect(key1 = Unit) {
LaunchedEffect(key1 = it) {
if (scrollToTop) {
scope.launch {
delay(1000)
notifFeedViewModel.clear()
notifFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop")
}
}
}
NotificationScreen(
notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
composable(Route.Message.route, content = { ChatroomListScreen(accountViewModel, nav) })
composable(
Route.Message.route,
content = {
ChatroomListScreen(
knownFeedViewModel,
newFeedViewModel,
accountViewModel,
nav
)
}
)
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) })
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) })
@@ -26,6 +26,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -42,6 +44,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
@@ -72,13 +75,18 @@ import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
@@ -93,27 +101,51 @@ fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController
@Composable
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(followLists, account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
account.changeDefaultStoriesFollowList(listName)
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
val accountState by accountViewModel.accountLiveData.observeAsState()
accountState?.account?.let { account ->
FollowList(
followLists,
account.defaultStoriesFollowList,
account.userProfile(),
true
) { listName ->
account.changeDefaultStoriesFollowList(listName)
}
}
}
}
@Composable
fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(followLists, account.defaultHomeFollowList, account.userProfile(), false) { listName ->
account.changeDefaultHomeFollowList(listName)
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
val accountState by accountViewModel.accountLiveData.observeAsState()
accountState?.account?.let { account ->
FollowList(
followLists,
account.defaultHomeFollowList,
account.userProfile(),
false
) { listName ->
account.changeDefaultHomeFollowList(listName)
}
}
}
}
@Composable
fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(followLists, account.defaultNotificationFollowList, account.userProfile(), true) { listName ->
account.changeDefaultNotificationFollowList(listName)
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
val accountState by accountViewModel.accountLiveData.observeAsState()
accountState?.account?.let { account ->
FollowList(
followLists,
account.defaultNotificationFollowList,
account.userProfile(),
true
) { listName ->
account.changeDefaultNotificationFollowList(listName)
}
}
}
}
@@ -127,10 +159,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
@OptIn(coil.annotation.ExperimentalCoilApi::class)
@Composable
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (Account) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (AccountViewModel) -> Unit) {
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState()
@@ -142,7 +171,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
}
if (wantsToEditRelays) {
NewRelayListView({ wantsToEditRelays = false }, account)
NewRelayListView({ wantsToEditRelays = false }, accountViewModel)
}
Column() {
@@ -163,7 +192,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
content(account)
content(accountViewModel)
}
Column(
@@ -193,7 +222,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
}
},
navigationIcon = {
LoggedInUserPictureDrawer(account) {
LoggedInUserPictureDrawer(accountViewModel) {
coroutineScope.launch {
scaffoldState.drawerState.open()
}
@@ -219,10 +248,10 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
@Composable
private fun LoggedInUserPictureDrawer(
account: Account,
accountViewModel: AccountViewModel,
onClick: () -> Unit
) {
val accountUserState by account.userProfile().live().metadata.observeAsState()
val accountUserState by accountViewModel.account.userProfile().live().metadata.observeAsState()
val accountUser = accountUserState?.user ?: return
IconButton(
@@ -230,8 +259,8 @@ private fun LoggedInUserPictureDrawer(
modifier = Modifier
) {
RobohashAsyncImageProxy(
robot = accountUser.pubkeyHex,
model = ResizeImage(accountUser.profilePicture(), 34.dp),
robot = remember { accountUser.pubkeyHex },
model = remember(accountUser) { ResizeImage(accountUser.profilePicture(), 34.dp) },
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(34.dp)
@@ -248,33 +277,31 @@ fun FollowList(followListsModel: FollowListViewModel, listName: String, loggedIn
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
val followLists = remember(followListsModel.followLists) {
(defaultOptions + followListsModel.followLists)
val followLists by followListsModel.followLists.collectAsState()
val allLists = remember(followLists) {
(defaultOptions + followLists)
}
val followNames = remember(followLists) {
derivedStateOf {
followLists.map { it.second }
allLists.map { it.second }
}
}
SimpleTextSpinner(
placeholder = followLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
placeholder = allLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
options = followNames.value,
onSelect = {
onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
onChange(allLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
}
)
}
class FollowListViewModel : ViewModel() {
var followLists by mutableStateOf<List<Pair<String, String>>>(emptyList())
var account: Account? = null
fun load(account: Account?) {
this.account = account
refresh()
}
@Stable
class FollowListViewModel(val account: Account) : ViewModel() {
private var _followLists = MutableStateFlow<ImmutableList<Pair<String, String>>>(emptyList<Pair<String, String>>().toPersistentList())
val followLists = _followLists.asStateFlow()
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
@@ -284,28 +311,25 @@ class FollowListViewModel : ViewModel() {
}
private suspend fun refreshFollows() {
val myAccount = account ?: return
val newFollowLists = LocalCache.addressables.mapNotNull {
val event = (it.value.event as? PeopleListEvent)
// Has to have an list
if (event != null && event.pubKey == myAccount.userProfile().pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
if (event != null && event.pubKey == account.userProfile().pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
Pair(event.dTag(), event.dTag())
} else {
null
}
}.sortedBy { it.second }
}.sortedBy { it.second }.toImmutableList()
withContext(Dispatchers.Main) {
if (followLists != newFollowLists) {
followLists = newFollowLists
}
if (!equalImmutableLists(_followLists.value, newFollowLists)) {
_followLists.emit(newFollowLists)
}
}
var collectorJob: Job? = null
init {
refresh()
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
newNotes.forEach {
@@ -321,6 +345,12 @@ class FollowListViewModel : ViewModel() {
collectorJob?.cancel()
super.onCleared()
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <FollowListViewModel : ViewModel> create(modelClass: Class<FollowListViewModel>): FollowListViewModel {
return FollowListViewModel(account) as FollowListViewModel
}
}
}
@Composable
@@ -156,9 +156,8 @@ object HomeLatestItem : LatestItem() {
newNotes: Set<Note>
): Boolean {
val lastTime = cache.load("HomeFollows")
HomeNewThreadFeedFilter.account = account
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter)
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account))
return (newestItem?.createdAt() ?: 0) > lastTime
}
@@ -171,9 +170,8 @@ object NotificationLatestItem : LatestItem() {
newNotes: Set<Note>
): Boolean {
val lastTime = cache.load("Notification")
NotificationFeedFilter.account = account
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter)
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter(account))
return (newestItem?.createdAt() ?: 0) > lastTime
}
@@ -185,9 +183,7 @@ object MessagesLatestItem : LatestItem() {
cache: NotificationCache,
newNotes: Set<Note>
): Boolean {
ChatroomListKnownFeedFilter.account = account
val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter)
val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter(account))
val roomUserHex = (newestItem?.event as? PrivateDmEvent)?.talkingWith(account.userProfile().pubkeyHex)
@@ -107,7 +107,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
routeForLastRead = routeForLastRead,
isBoostedNote = true,
addMarginTop = false,
parentBackgroundColor = backgroundColor,
parentBackgroundColor = null,
accountViewModel = accountViewModel,
nav = nav
)
@@ -79,9 +79,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
LaunchedEffect(key1 = multiSetCard.createdAt()) {
scope.launch(Dispatchers.IO) {
val newIsNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
val newIsNew = multiSetCard.maxCreatedAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt)
NotificationCache.markAsRead(routeForLastRead, multiSetCard.maxCreatedAt)
if (newIsNew != isNew) {
isNew = newIsNew
@@ -476,7 +476,7 @@ fun ZapReaction(
}
if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account)
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, accountViewModel = accountViewModel)
}
if (wantsToSetCustomZap) {
@@ -65,6 +65,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -74,15 +75,14 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope as rememberCoroutineScope
class UpdateZapAmountViewModel : ViewModel() {
private var account: Account? = null
class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
var nextAmount by mutableStateOf(TextFieldValue(""))
var amountSet by mutableStateOf(listOf<Long>())
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
@@ -90,8 +90,7 @@ class UpdateZapAmountViewModel : ViewModel() {
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE)
fun load(account: Account) {
this.account = account
fun load() {
this.amountSet = account.zapAmountChoices
this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("")
@@ -185,14 +184,25 @@ class UpdateZapAmountViewModel : ViewModel() {
TextFieldValue(contact.secret ?: "")
}
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <UpdateZapAmountViewModel : ViewModel> create(modelClass: Class<UpdateZapAmountViewModel>): UpdateZapAmountViewModel {
return UpdateZapAmountViewModel(account) as UpdateZapAmountViewModel
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: String? = null) {
fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, accountViewModel: AccountViewModel) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: UpdateZapAmountViewModel = viewModel()
val postViewModel: UpdateZapAmountViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex,
factory = UpdateZapAmountViewModel.Factory(accountViewModel.account)
)
val uri = LocalUriHandler.current
val zapTypes = listOf(
@@ -205,8 +215,8 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
LaunchedEffect(account) {
postViewModel.load(account)
LaunchedEffect(accountViewModel) {
postViewModel.load()
if (nip47uri != null) {
try {
postViewModel.updateNIP47(nip47uri)
@@ -341,7 +351,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
) {
TextSpinner(
label = stringResource(id = R.string.zap_type_explainer),
placeholder = zapTypes.filter { it.first == account.defaultZapType }
placeholder = zapTypes.filter { it.first == accountViewModel.defaultZapType() }
.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
@@ -14,10 +14,9 @@ import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -27,6 +26,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.patrykandpatrick.vico.core.chart.composed.ComposedChartEntryModel
import com.patrykandpatrick.vico.core.entry.ChartEntryModel
@@ -34,6 +34,7 @@ import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer
import com.patrykandpatrick.vico.core.entry.composed.plus
import com.patrykandpatrick.vico.core.entry.entryOf
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -47,6 +48,8 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.time.Instant
@@ -80,47 +83,50 @@ fun UserReactionsRow(
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserReplyReaction(model.replies[model.today()])
val replies by model.replies.collectAsState()
UserReplyReaction(replies[model.today()])
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserBoostReaction(model.boosts[model.today()])
val boosts by model.boosts.collectAsState()
UserBoostReaction(boosts[model.today()])
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserLikeReaction(model.reactions[model.today()])
val reactions by model.reactions.collectAsState()
UserLikeReaction(reactions[model.today()])
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserZapReaction(model.zaps[model.today()])
val zaps by model.zaps.collectAsState()
UserZapReaction(zaps[model.today()])
}
}
}
@Stable
class UserReactionsViewModel : ViewModel() {
var user: User? = null
class UserReactionsViewModel(val account: Account) : ViewModel() {
val user: User = account.userProfile()
var reactions by mutableStateOf<Map<String, Int>>(emptyMap())
var boosts by mutableStateOf<Map<String, Int>>(emptyMap())
var zaps by mutableStateOf<Map<String, BigDecimal>>(emptyMap())
var replies by mutableStateOf<Map<String, Int>>(emptyMap())
private var _reactions = MutableStateFlow<Map<String, Int>>(emptyMap())
private var _boosts = MutableStateFlow<Map<String, Int>>(emptyMap())
private var _zaps = MutableStateFlow<Map<String, BigDecimal>>(emptyMap())
private var _replies = MutableStateFlow<Map<String, Int>>(emptyMap())
var chartModel by mutableStateOf<ComposedChartEntryModel<ChartEntryModel>?>(null)
var axisLabels by mutableStateOf<List<String>>(emptyList())
private var _chartModel = MutableStateFlow<ComposedChartEntryModel<ChartEntryModel>?>(null)
private var _axisLabels = MutableStateFlow<List<String>>(emptyList())
val reactions = _reactions.asStateFlow()
val boosts = _boosts.asStateFlow()
val zaps = _zaps.asStateFlow()
val replies = _replies.asStateFlow()
val chartModel = _chartModel.asStateFlow()
val axisLabels = _axisLabels.asStateFlow()
private var takenIntoAccount = setOf<HexKey>()
private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
fun load(baseUser: User) {
user = baseUser
reactions = emptyMap()
boosts = emptyMap()
zaps = emptyMap()
replies = emptyMap()
takenIntoAccount = emptySet()
}
fun formatDate(createAt: Long): String {
return sdf.format(
Instant.ofEpochSecond(createAt)
@@ -131,8 +137,8 @@ class UserReactionsViewModel : ViewModel() {
fun today() = sdf.format(LocalDateTime.now())
fun initializeSuspend() {
val currentUser = user?.pubkeyHex ?: return
private suspend fun initializeSuspend() {
val currentUser = user.pubkeyHex
val reactions = mutableMapOf<String, Int>()
val boosts = mutableMapOf<String, Int>()
@@ -172,21 +178,21 @@ class UserReactionsViewModel : ViewModel() {
}
this.takenIntoAccount = takenIntoAccount
this.reactions = reactions
this.replies = replies
this.zaps = zaps
this.boosts = boosts
this._reactions.emit(reactions)
this._replies.emit(replies)
this._zaps.emit(zaps)
this._boosts.emit(boosts)
refreshChartModel()
}
fun addToStatsSuspend(newNotes: Set<Note>) {
val currentUser = user?.pubkeyHex ?: return
suspend fun addToStatsSuspend(newNotes: Set<Note>) {
val currentUser = user.pubkeyHex
val reactions = this.reactions.toMutableMap()
val boosts = this.boosts.toMutableMap()
val zaps = this.zaps.toMutableMap()
val replies = this.replies.toMutableMap()
val reactions = this._reactions.value.toMutableMap()
val boosts = this._boosts.value.toMutableMap()
val zaps = this._zaps.value.toMutableMap()
val replies = this._replies.value.toMutableMap()
val takenIntoAccount = this.takenIntoAccount.toMutableSet()
var hasNewElements = false
@@ -227,16 +233,16 @@ class UserReactionsViewModel : ViewModel() {
if (hasNewElements) {
this.takenIntoAccount = takenIntoAccount
this.reactions = reactions
this.replies = replies
this.zaps = zaps
this.boosts = boosts
this._reactions.emit(reactions)
this._replies.emit(replies)
this._zaps.emit(zaps)
this._boosts.emit(boosts)
refreshChartModel()
}
}
private fun refreshChartModel() {
private suspend fun refreshChartModel() {
val day = 24 * 60 * 60L
val now = LocalDateTime.now()
val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE")
@@ -244,36 +250,51 @@ class UserReactionsViewModel : ViewModel() {
val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) }
val listOfCountCurves = listOf(
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, replies[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, boosts[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, reactions[dateStr]?.toFloat() ?: 0f) }
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _replies.value[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _boosts.value[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _reactions.value[dateStr]?.toFloat() ?: 0f) }
)
val listOfValueCurves = listOf(
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, zaps[dateStr]?.toFloat() ?: 0f) }
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _zaps.value[dateStr]?.toFloat() ?: 0f) }
)
val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel()
val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel()
this.axisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) }
this.chartModel = chartEntryModelProducer1.plus(chartEntryModelProducer2)
this._axisLabels.emit(listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) })
this._chartModel.emit(chartEntryModelProducer1.plus(chartEntryModelProducer2))
}
var collectorJob: Job? = null
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
addToStatsSuspend(newNotes)
_reactions.value = emptyMap()
_boosts.value = emptyMap()
_zaps.value = emptyMap()
_replies.value = emptyMap()
takenIntoAccount = emptySet()
viewModelScope.launch(Dispatchers.IO) {
initializeSuspend()
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
addToStatsSuspend(newNotes)
}
}
}
}
override fun onCleared() {
collectorJob?.cancel()
super.onCleared()
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <UserReactionsViewModel : ViewModel> create(modelClass: Class<UserReactionsViewModel>): UserReactionsViewModel {
return UserReactionsViewModel(account) as UserReactionsViewModel
}
}
}
@Composable
@@ -61,16 +61,22 @@ class ZapUserSetCard(val user: User, val zapEvents: ImmutableMap<Note, Note>) :
@Immutable
class MultiSetCard(val note: Note, val boostEvents: ImmutableList<Note>, val likeEvents: ImmutableList<Note>, val zapEvents: ImmutableMap<Note, Note>) : Card() {
val createdAt = maxOf(
val maxCreatedAt = maxOf(
zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0,
likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0,
boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0
)
val minCreatedAt = minOf(
zapEvents.minOfOrNull { it.value.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
likeEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE
)
override fun createdAt(): Long {
return createdAt
return maxCreatedAt
}
override fun id() = note.idHex + "X" + createdAt
override fun id() = note.idHex + "X" + maxCreatedAt + "X" + minCreatedAt
}
@Immutable
@@ -39,72 +39,42 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun CardFeedView(
fun RefresheableCardView(
viewModel: CardFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.fillMaxSize().pullRefresh(pullRefreshState)) {
Column(modifier = Modifier.fillMaxSize()) {
Crossfade(
modifier = Modifier.fillMaxSize(),
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
when (state) {
is CardFeedState.Empty -> {
FeedEmpty {
refreshing = true
}
}
is CardFeedState.FeedError -> {
FeedError(state.errorMessage) {
refreshing = true
}
}
is CardFeedState.Loaded -> {
if (refreshing) {
refreshing = false
}
FeedLoaded(
state = state,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead,
scrollStateKey = scrollStateKey,
scrollToTop = scrollToTop
)
}
CardFeedState.Loading -> {
LoadingFeed()
}
}
}
Box(modifier) {
Column {
SaveableCardFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}
}
@Composable
private fun FeedLoaded(
state: CardFeedState.Loaded,
private fun SaveableCardFeedState(
viewModel: CardFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String,
scrollStateKey: String?,
scrollToTop: Boolean = false
scrollStateKey: String? = null
) {
val listState = if (scrollStateKey != null) {
rememberForeverLazyListState(scrollStateKey)
@@ -112,24 +82,76 @@ private fun FeedLoaded(
rememberLazyListState()
}
if (scrollToTop) {
LaunchedEffect(Unit) {
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
listState.scrollToItem(index = 0)
}
}
}
WatchScrollToTop(viewModel, listState)
LazyNotificationList(listState, state, routeForLastRead, accountViewModel, nav)
RenderCardFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
}
@Composable
private fun LazyNotificationList(
listState: LazyListState,
state: CardFeedState.Loaded,
routeForLastRead: String,
private fun WatchScrollToTop(
viewModel: CardFeedViewModel,
listState: LazyListState
) {
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
}
}
@Composable
fun RenderCardFeed(
viewModel: CardFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String
) {
val feedState by viewModel.feedContent.collectAsState()
Crossfade(
modifier = Modifier.fillMaxSize(),
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is CardFeedState.Empty -> {
FeedEmpty {
viewModel.invalidateData()
}
}
is CardFeedState.FeedError -> {
FeedError(state.errorMessage) {
viewModel.invalidateData()
}
}
is CardFeedState.Loaded -> {
FeedLoaded(
state = state,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
}
CardFeedState.Loading -> {
LoadingFeed()
}
}
}
}
@Composable
private fun FeedLoaded(
state: CardFeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String
) {
LazyColumn(
modifier = remember { Modifier.fillMaxSize() },
@@ -4,6 +4,7 @@ import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -35,12 +36,35 @@ import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@Stable
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter)
class NotificationViewModel(val account: Account) : CardFeedViewModel(NotificationFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NotificationViewModel : ViewModel> create(modelClass: Class<NotificationViewModel>): NotificationViewModel {
return NotificationViewModel(account) as NotificationViewModel
}
}
}
@Stable
open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
// Simple counter that changes when it needs to invalidate everything
private val _scrollToTop = MutableStateFlow<Int>(0)
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
suspend fun sendToTop() {
if (scrolltoTopPending) return
scrolltoTopPending = true
_scrollToTop.emit(_scrollToTop.value + 1)
}
suspend fun sentToTop() {
scrolltoTopPending = false
}
private var lastAccount: Account? = null
private var lastNotes: Set<Note>? = null
@@ -150,7 +174,10 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList()
val zapsInCard = zapsPerEvent[baseNote] ?: emptyMap()
val singleList = (boostsInCard + zapsInCard.values + reactionsInCard).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val singleList = (boostsInCard + zapsInCard.values + reactionsInCard)
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
singleList.chunked(50).map { chunk ->
MultiSetCard(
baseNote,
@@ -234,19 +261,32 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
private val bundler = BundledUpdate(1000, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
refreshSuspended()
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
private val bundler = BundledUpdate(1000, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(1000, Dispatchers.IO)
@OptIn(ExperimentalTime::class)
fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing)
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
refreshSuspended()
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
}
@OptIn(ExperimentalTime::class)
fun invalidateDataAndSendToTop(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
refreshSuspended()
sendToTop()
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
}
@OptIn(ExperimentalTime::class)
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
@@ -35,17 +36,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FeedView(
fun RefresheableView(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String?,
scrollStateKey: String? = null,
scrollToTop: Boolean = false,
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
@@ -58,42 +56,7 @@ fun FeedView(
Box(modifier) {
Column {
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
refresh()
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
refresh()
}
}
is FeedState.Loaded -> {
if (refreshing) {
refreshing = false
}
FeedLoaded(
state,
routeForLastRead,
accountViewModel,
nav,
scrollStateKey,
scrollToTop
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
SaveableFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
}
if (enablePullRefresh) {
@@ -103,13 +66,12 @@ fun FeedView(
}
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
routeForLastRead: String?,
private fun SaveableFeedState(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
scrollStateKey: String?,
scrollToTop: Boolean = false
routeForLastRead: String?,
scrollStateKey: String? = null
) {
val listState = if (scrollStateKey != null) {
rememberForeverLazyListState(scrollStateKey)
@@ -117,14 +79,78 @@ private fun FeedLoaded(
rememberLazyListState()
}
if (scrollToTop) {
LaunchedEffect(Unit) {
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
listState.scrollToItem(index = 0)
WatchScrollToTop(viewModel, listState)
RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
}
@Composable
private fun RenderFeed(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String?
) {
val feedState by viewModel.feedContent.collectAsState()
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
viewModel.invalidateData()
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
viewModel.invalidateData()
}
}
is FeedState.Loaded -> {
FeedLoaded(
state,
listState,
routeForLastRead,
accountViewModel,
nav
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
}
@Composable
private fun WatchScrollToTop(
viewModel: FeedViewModel,
listState: LazyListState
) {
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
}
}
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
listState: LazyListState,
routeForLastRead: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val baseModifier = remember {
Modifier
}
@@ -1,8 +1,11 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -37,26 +40,90 @@ import kotlinx.coroutines.launch
class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
class NostrVideoFeedViewModel : FeedViewModel(VideoFeedFilter)
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
class NostrGlobalFeedViewModel(val account: Account) : FeedViewModel(GlobalFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrGlobalFeedViewModel : ViewModel> create(modelClass: Class<NostrGlobalFeedViewModel>): NostrGlobalFeedViewModel {
return NostrGlobalFeedViewModel(account) as NostrGlobalFeedViewModel
}
}
}
class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrVideoFeedViewModel : ViewModel> create(modelClass: Class<NostrVideoFeedViewModel>): NostrVideoFeedViewModel {
return NostrVideoFeedViewModel(account) as NostrVideoFeedViewModel
}
}
}
class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) {
class Factory(val noteId: String) : ViewModelProvider.Factory {
override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel {
return NostrThreadFeedViewModel(noteId) as NostrThreadFeedViewModel
}
}
}
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
class NostrUserProfileBookmarksFeedViewModel : FeedViewModel(UserProfileBookmarksFeedFilter)
class NostrChatroomListKnownFeedViewModel : FeedViewModel(ChatroomListKnownFeedFilter)
class NostrChatroomListNewFeedViewModel : FeedViewModel(ChatroomListNewFeedFilter)
class NostrHomeFeedViewModel : FeedViewModel(HomeNewThreadFeedFilter)
class NostrHomeRepliesFeedViewModel : FeedViewModel(HomeConversationsFeedFilter)
class NostrChatroomListKnownFeedViewModel(val account: Account) : FeedViewModel(ChatroomListKnownFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrChatroomListKnownFeedViewModel : ViewModel> create(modelClass: Class<NostrChatroomListKnownFeedViewModel>): NostrChatroomListKnownFeedViewModel {
return NostrChatroomListKnownFeedViewModel(account) as NostrChatroomListKnownFeedViewModel
}
}
}
class NostrChatroomListNewFeedViewModel(val account: Account) : FeedViewModel(ChatroomListNewFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrChatroomListNewFeedViewModel : ViewModel> create(modelClass: Class<NostrChatroomListNewFeedViewModel>): NostrChatroomListNewFeedViewModel {
return NostrChatroomListNewFeedViewModel(account) as NostrChatroomListNewFeedViewModel
}
}
}
@Stable
class NostrHomeFeedViewModel(val account: Account) : FeedViewModel(HomeNewThreadFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrHomeFeedViewModel : ViewModel> create(modelClass: Class<NostrHomeFeedViewModel>): NostrHomeFeedViewModel {
return NostrHomeFeedViewModel(account) as NostrHomeFeedViewModel
}
}
}
@Stable
class NostrHomeRepliesFeedViewModel(val account: Account) : FeedViewModel(HomeConversationsFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrHomeRepliesFeedViewModel : ViewModel> create(modelClass: Class<NostrHomeRepliesFeedViewModel>): NostrHomeRepliesFeedViewModel {
return NostrHomeRepliesFeedViewModel(account) as NostrHomeRepliesFeedViewModel
}
}
}
class NostrBookmarkPublicFeedViewModel : FeedViewModel(BookmarkPublicFeedFilter)
class NostrBookmarkPrivateFeedViewModel : FeedViewModel(BookmarkPrivateFeedFilter)
@Stable
abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow()
// Simple counter that changes when it needs to invalidate everything
private val _scrollToTop = MutableStateFlow<Int>(0)
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
suspend fun sendToTop() {
if (scrolltoTopPending) return
scrolltoTopPending = true
_scrollToTop.emit(_scrollToTop.value + 1)
}
suspend fun sentToTop() {
scrolltoTopPending = false
}
fun newListFromDataSource(): ImmutableList<Note> {
return localFilter.loadTop().toImmutableList()
}
@@ -119,15 +186,24 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing)
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
fun invalidateDataAndSendToTop(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
}
}
fun invalidateInsertData(newItems: Set<Note>) {
@@ -136,7 +212,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
var collectorJob: Job? = null
private var collectorJob: Job? = null
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
@@ -58,14 +58,14 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
var collectorJob: Job? = null
@@ -13,7 +13,6 @@ 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.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -83,14 +82,14 @@ class RelayFeedViewModel : ViewModel() {
currentUser = null
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
}
@@ -101,9 +100,6 @@ fun RelayFeedView(
accountViewModel: AccountViewModel,
enablePullRefresh: Boolean = true
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val feedState by viewModel.feedContent.collectAsState()
var wantsToAddRelay by remember {
@@ -111,7 +107,7 @@ fun RelayFeedView(
}
if (wantsToAddRelay.isNotEmpty()) {
NewRelayListView({ wantsToAddRelay = "" }, account, wantsToAddRelay)
NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay)
}
var refreshing by remember { mutableStateOf(false) }
@@ -21,6 +21,10 @@ object ScrollStateKeys {
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
}
object PagerStateKeys {
const val HOME_SCREEN = "Home"
}
@Composable
fun rememberForeverLazyListState(
key: String,
@@ -62,7 +66,7 @@ fun rememberForeverPagerState(
savedOffset
)
}
DisposableEffect(Unit) {
DisposableEffect(scrollState) {
onDispose {
val lastIndex = scrollState.currentPage
val lastOffset = scrollState.currentPageOffsetFraction
@@ -62,14 +62,14 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
var collectorJob: Job? = null
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
@@ -26,8 +26,8 @@ import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.util.Locale
@Immutable
class AccountViewModel(private val account: Account) : ViewModel() {
@Stable
class AccountViewModel(val account: Account) : ViewModel() {
val accountLiveData: LiveData<AccountState> = account.live.map { it }
val accountLanguagesLiveData: LiveData<AccountState> = account.liveLanguages.map { it }
@@ -249,6 +249,10 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.updateShowSensitiveContent(null)
}
fun defaultZapType(): LnZapEvent.ZapType {
return account.defaultZapType
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
return AccountViewModel(account) as AccountViewModel
@@ -22,9 +22,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
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 -> FeedView(privateFeedViewModel, accountViewModel, nav, null)
1 -> FeedView(publicFeedViewModel, accountViewModel, nav, null)
0 -> RefresheableView(privateFeedViewModel, accountViewModel, nav, null)
1 -> RefresheableView(publicFeedViewModel, accountViewModel, nav, null)
}
}
}
@@ -22,11 +22,11 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
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
@@ -41,8 +41,6 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.ChatroomListFeedView
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
@@ -51,7 +49,12 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatroomListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun ChatroomListScreen(
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
@@ -59,30 +62,15 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
val markKnownAsRead = remember { mutableStateOf(false) }
val markNewAsRead = remember { mutableStateOf(false) }
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
ChatroomListKnownFeedFilter.account = account
val knownFeedViewModel: NostrChatroomListKnownFeedViewModel = viewModel()
ChatroomListNewFeedFilter.account = account
val newFeedViewModel: NostrChatroomListNewFeedViewModel = viewModel()
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.account = account
NostrChatroomListDataSource.start()
knownFeedViewModel.invalidateData()
newFeedViewModel.invalidateData()
}
WatchAccountForListScreen(knownFeedViewModel, newFeedViewModel, accountViewModel)
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.account = account
NostrChatroomListDataSource.start()
knownFeedViewModel.invalidateData()
newFeedViewModel.invalidateData()
knownFeedViewModel.invalidateData(true)
newFeedViewModel.invalidateData(true)
}
}
@@ -92,7 +80,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
}
}
val tabs by remember {
val tabs by remember(knownFeedViewModel, markKnownAsRead) {
derivedStateOf {
listOf(
ChatroomListTabItem(R.string.known, knownFeedViewModel, markKnownAsRead),
@@ -159,6 +147,16 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
}
}
@Composable
fun WatchAccountForListScreen(knownFeedViewModel: NostrChatroomListKnownFeedViewModel, newFeedViewModel: NostrChatroomListNewFeedViewModel, accountViewModel: AccountViewModel) {
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.start()
knownFeedViewModel.invalidateData(true)
newFeedViewModel.invalidateData(true)
}
}
@Immutable
class ChatroomListTabItem(val resource: Int, val viewModel: FeedViewModel, val markAsRead: MutableState<Boolean>)
@Composable
@@ -25,8 +25,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -75,7 +75,7 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String
modifier = Modifier.padding(vertical = 0.dp)
) {
HashtagHeader(tag, account)
FeedView(feedViewModel, accountViewModel, nav, null)
RefresheableView(feedViewModel, accountViewModel, nav, null)
}
}
}
@@ -12,6 +12,7 @@ import androidx.compose.material.TabRow
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -27,15 +28,17 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
import com.vitorpamplona.amethyst.ui.screen.FeedView
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.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -46,34 +49,22 @@ fun HomeScreen(
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
pagerState: PagerState,
scrollToTop: Boolean = false,
nip47: String? = null
) {
val coroutineScope = rememberCoroutineScope()
var wantsToAddNip47 by remember { mutableStateOf(nip47) }
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN)
LaunchedEffect(accountViewModel, account.defaultHomeFollowList) {
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.invalidateData(true)
repliesFeedViewModel.invalidateData(true)
}
WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, accountViewModel)
if (wantsToAddNip47 != null) {
UpdateZapAmountDialog({ wantsToAddNip47 = null }, account = account, wantsToAddNip47)
UpdateZapAmountDialog({ wantsToAddNip47 = null }, wantsToAddNip47, accountViewModel)
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
NostrHomeDataSource.invalidateFilters()
}
}
@@ -84,23 +75,12 @@ fun HomeScreen(
}
}
if (scrollToTop) {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit) {
scope.launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.invalidateData(true)
repliesFeedViewModel.invalidateData(true)
}
}
}
val tabs by remember(homeFeedViewModel, repliesFeedViewModel) {
mutableStateOf(
listOf(
TabItem(R.string.new_threads, homeFeedViewModel, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS),
TabItem(R.string.conversations, repliesFeedViewModel, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES)
)
).toImmutableList()
)
}
@@ -108,35 +88,67 @@ fun HomeScreen(
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage
) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = pagerState.currentPage == index,
text = {
Text(text = stringResource(tab.resource))
},
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
}
)
}
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
FeedView(
viewModel = tabs[page].viewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = tabs[page].routeForLastRead,
scrollStateKey = tabs[page].scrollStateKey,
scrollToTop = scrollToTop
)
}
HomePages(pagerState, tabs, accountViewModel, nav)
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun HomePages(
pagerState: PagerState,
tabs: ImmutableList<TabItem>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
TabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage
) {
val coroutineScope = rememberCoroutineScope()
tabs.forEachIndexed { index, tab ->
Tab(
selected = pagerState.currentPage == index,
text = {
Text(text = stringResource(tab.resource))
},
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
}
)
}
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
RefresheableView(
viewModel = tabs[page].viewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = tabs[page].routeForLastRead,
scrollStateKey = tabs[page].scrollStateKey
)
}
}
@Composable
fun WatchAccountForHomeScreen(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
accountViewModel: AccountViewModel
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val scope = rememberCoroutineScope()
LaunchedEffect(accountViewModel, account.defaultHomeFollowList) {
scope.launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.invalidateDataAndSendToTop(true)
repliesFeedViewModel.invalidateDataAndSendToTop(true)
}
}
}
@Immutable
class TabItem(val resource: Int, val viewModel: FeedViewModel, val routeForLastRead: String, val scrollStateKey: String)
@@ -20,7 +20,6 @@ import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
@@ -37,8 +36,16 @@ import com.vitorpamplona.amethyst.ui.navigation.AppTopBar
import com.vitorpamplona.amethyst.ui.navigation.DrawerContent
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.currentRoute
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@@ -61,11 +68,51 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
}
}
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account }
val followLists: FollowListViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "FollowListViewModel",
factory = FollowListViewModel.Factory(accountViewModel.account)
)
val followLists: FollowListViewModel = viewModel()
followLists.load(account)
// Avoids creating ViewModels for performance reasons (up to 1 second delays)
val homeFeedViewModel: NostrHomeFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrHomeFeedViewModel",
factory = NostrHomeFeedViewModel.Factory(accountViewModel.account)
)
val repliesFeedViewModel: NostrHomeRepliesFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrHomeRepliesFeedViewModel",
factory = NostrHomeRepliesFeedViewModel.Factory(accountViewModel.account)
)
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrGlobalFeedViewModel",
factory = NostrGlobalFeedViewModel.Factory(accountViewModel.account)
)
val videoFeedViewModel: NostrVideoFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrVideoFeedViewModel",
factory = NostrVideoFeedViewModel.Factory(accountViewModel.account)
)
val notifFeedViewModel: NotificationViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NotificationViewModel",
factory = NotificationViewModel.Factory(accountViewModel.account)
)
val userReactionsStatsModel: UserReactionsViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "UserReactionsViewModel",
factory = UserReactionsViewModel.Factory(accountViewModel.account)
)
val knownFeedViewModel: NostrChatroomListKnownFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrChatroomListKnownFeedViewModel",
factory = NostrChatroomListKnownFeedViewModel.Factory(accountViewModel.account)
)
val newFeedViewModel: NostrChatroomListNewFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrChatroomListNewFeedViewModel",
factory = NostrChatroomListNewFeedViewModel.Factory(accountViewModel.account)
)
ModalBottomSheetLayout(
sheetState = sheetState,
@@ -95,7 +142,19 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
scaffoldState = scaffoldState
) {
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
AppNavigation(navController, accountViewModel, startingPage)
AppNavigation(
homeFeedViewModel,
repliesFeedViewModel,
knownFeedViewModel,
newFeedViewModel,
searchFeedViewModel,
videoFeedViewModel,
notifFeedViewModel,
userReactionsStatsModel,
navController,
accountViewModel,
startingPage
)
}
}
}
@@ -9,11 +9,11 @@ import androidx.compose.material.Divider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.Modifier
import androidx.compose.ui.graphics.Brush
@@ -23,7 +23,6 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.patrykandpatrick.vico.compose.axis.horizontal.bottomAxis
import com.patrykandpatrick.vico.compose.axis.vertical.endAxis
import com.patrykandpatrick.vico.compose.axis.vertical.startAxis
@@ -38,9 +37,7 @@ import com.patrykandpatrick.vico.core.chart.composed.plus
import com.patrykandpatrick.vico.core.chart.line.LineChart
import com.patrykandpatrick.vico.core.chart.values.ChartValues
import com.patrykandpatrick.vico.core.component.shape.shader.DynamicShaders
import com.patrykandpatrick.vico.core.entry.composed.plus
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.OneGiga
import com.vitorpamplona.amethyst.ui.note.OneKilo
@@ -48,13 +45,11 @@ import com.vitorpamplona.amethyst.ui.note.OneMega
import com.vitorpamplona.amethyst.ui.note.UserReactionsRow
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.note.showCount
import com.vitorpamplona.amethyst.ui.screen.CardFeedView
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableCardView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.roundToInt
@@ -64,24 +59,15 @@ fun NotificationScreen(
notifFeedViewModel: NotificationViewModel,
userReactionsStatsModel: UserReactionsViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
scrollToTop: Boolean = false
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
LaunchedEffect(accountViewModel, account.defaultNotificationFollowList) {
NostrAccountDataSource.invalidateFilters()
NotificationFeedFilter.account = account
notifFeedViewModel.invalidateData(true)
}
WatchAccountForNotifications(notifFeedViewModel, accountViewModel)
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrAccountDataSource.invalidateFilters()
NotificationFeedFilter.account = account
}
}
@@ -91,16 +77,6 @@ fun NotificationScreen(
}
}
if (scrollToTop) {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit) {
scope.launch(Dispatchers.IO) {
notifFeedViewModel.clear()
notifFeedViewModel.invalidateData(true)
}
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -108,18 +84,35 @@ fun NotificationScreen(
SummaryBar(
model = userReactionsStatsModel
)
CardFeedView(
RefresheableCardView(
viewModel = notifFeedViewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = Route.Notification.base,
scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN,
scrollToTop = scrollToTop
scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN
)
}
}
}
@Composable
fun WatchAccountForNotifications(
notifFeedViewModel: NotificationViewModel,
accountViewModel: AccountViewModel
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
LaunchedEffect(accountViewModel, account.defaultNotificationFollowList) {
NostrAccountDataSource.invalidateFilters()
if (notifFeedViewModel.scrollToTop.value > 0) {
notifFeedViewModel.clear()
}
notifFeedViewModel.invalidateDataAndSendToTop(true)
}
}
@Composable
fun SummaryBar(model: UserReactionsViewModel) {
var showChart by remember {
@@ -167,7 +160,9 @@ fun SummaryBar(model: UserReactionsViewModel) {
)
if (showChart) {
model.chartModel?.let {
val axisModel by model.axisLabels.collectAsState()
val chartModel by model.chartModel.collectAsState()
chartModel?.let {
Row(
modifier = Modifier
.padding(vertical = 10.dp, horizontal = 20.dp)
@@ -186,7 +181,7 @@ fun SummaryBar(model: UserReactionsViewModel) {
valueFormatter = AmountAxisValueFormatter()
),
bottomAxis = bottomAxis(
valueFormatter = LabelValueFormatter(model.axisLabels)
valueFormatter = LabelValueFormatter(axisModel)
)
)
}
@@ -82,7 +82,6 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.ShowQRDialog
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.LnZapFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel
@@ -91,6 +90,7 @@ 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.RelayFeedView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
@@ -115,7 +115,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, nav: (Str
userBase?.let {
ProfileScreen(
user = it,
baseUser = it,
accountViewModel = accountViewModel,
nav = nav
)
@@ -124,19 +124,16 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, nav: (Str
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ProfileScreen(user: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
fun ProfileScreen(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
UserProfileNewThreadFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
UserProfileConversationsFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
UserProfileFollowersFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
UserProfileFollowsFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
UserProfileZapsFeedFilter.loadUserProfile(baseUser)
UserProfileReportsFeedFilter.loadUserProfile(baseUser)
UserProfileBookmarksFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
UserProfileNewThreadFeedFilter.loadUserProfile(account, user)
UserProfileConversationsFeedFilter.loadUserProfile(account, user)
UserProfileFollowersFeedFilter.loadUserProfile(account, user)
UserProfileFollowsFeedFilter.loadUserProfile(account, user)
UserProfileZapsFeedFilter.loadUserProfile(user)
UserProfileReportsFeedFilter.loadUserProfile(user)
UserProfileBookmarksFeedFilter.loadUserProfile(account, user)
NostrUserProfileDataSource.loadUserProfile(user)
NostrUserProfileDataSource.loadUserProfile(baseUser)
val lifeCycleOwner = LocalLifecycleOwner.current
@@ -148,7 +145,7 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, nav: (String)
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Profidle Start")
NostrUserProfileDataSource.loadUserProfile(user)
NostrUserProfileDataSource.loadUserProfile(baseUser)
NostrUserProfileDataSource.start()
}
if (event == Lifecycle.Event.ON_PAUSE) {
@@ -167,8 +164,6 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, nav: (String)
}
}
val baseUser = NostrUserProfileDataSource.user ?: return
var columnSize by remember { mutableStateOf(IntSize.Zero) }
var tabsSize by remember { mutableStateOf(IntSize.Zero) }
@@ -209,7 +204,7 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, nav: (String)
.fillMaxHeight()
) {
Column(modifier = Modifier.padding()) {
ProfileHeader(baseUser, nav, account, accountViewModel)
ProfileHeader(baseUser, nav, accountViewModel)
ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
@@ -342,7 +337,6 @@ private fun FollowTabHeader(baseUser: User) {
private fun ProfileHeader(
baseUser: User,
nav: (String) -> Unit,
account: Account,
accountViewModel: AccountViewModel
) {
var popupExpanded by remember { mutableStateOf(false) }
@@ -394,7 +388,7 @@ private fun ProfileHeader(
) {
UserPicture(
baseUser = baseUser,
baseUserAccount = account.userProfile(),
baseUserAccount = accountViewModel.userProfile(),
size = 100.dp,
modifier = Modifier.border(
3.dp,
@@ -427,11 +421,11 @@ private fun ProfileHeader(
// No need for this button anymore
// NPubCopyButton(baseUser)
ProfileActions(baseUser, account, coroutineScope)
ProfileActions(baseUser, accountViewModel, coroutineScope)
}
}
DrawAdditionalInfo(baseUser, account, accountViewModel, nav)
DrawAdditionalInfo(baseUser, accountViewModel, nav)
Divider(modifier = Modifier.padding(top = 6.dp))
}
@@ -446,11 +440,14 @@ private fun ProfileHeader(
@Composable
private fun ProfileActions(
baseUser: User,
account: Account,
accountViewModel: AccountViewModel,
coroutineScope: CoroutineScope
) {
val accountUserState by account.userProfile().live().follows.observeAsState()
val accountLocalUserState by account.live.observeAsState()
val accountLocalUserState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountLocalUserState) { accountLocalUserState?.account } ?: return
val accountUserState by accountViewModel.account.userProfile().live().follows.observeAsState()
val accountUser = remember(accountUserState) { accountUserState?.user } ?: return
val isHidden by remember(accountUserState, accountLocalUserState) {
@@ -497,7 +494,7 @@ private fun ProfileActions(
}
@Composable
private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
private fun DrawAdditionalInfo(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val userState by baseUser.live().metadata.observeAsState()
val user = remember(userState) { userState?.user } ?: return
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
@@ -607,7 +604,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() }
val pubkeyHex = remember { baseUser.pubkeyHex }
DisplayLNAddress(lud16, pubkeyHex, account, scope, context)
DisplayLNAddress(lud16, pubkeyHex, accountViewModel.account, scope, context)
val identities = user.info?.latestMetadata?.identityClaims()
if (!identities.isNullOrEmpty()) {
@@ -733,25 +730,54 @@ private fun DisplayBadges(
nav: (String) -> Unit
) {
val userBadgeState by baseUser.live().badges.observeAsState()
val userBadge = remember(userBadgeState) { userBadgeState?.user } ?: return
userBadge.acceptedBadges?.let { note ->
(note.event as? BadgeProfilesEvent)?.let { event ->
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
event.badgeAwardEvents().forEach { badgeAwardEvent ->
val baseNote = LocalCache.notes[badgeAwardEvent]
if (baseNote != null) {
val badgeAwardState by baseNote.live().metadata.observeAsState()
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
if (baseBadgeDefinition != null) {
BadgeThumb(baseBadgeDefinition, nav, 35.dp)
}
}
}
val badgeList by remember(userBadgeState) {
derivedStateOf {
val list = (userBadgeState?.user?.acceptedBadges?.event as? BadgeProfilesEvent)?.badgeAwardEvents()
if (list.isNullOrEmpty()) {
null
} else {
list
}
}
}
badgeList?.let { list ->
FlowRow(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp)
) {
list.forEach { badgeAwardEvent ->
LoadAndRenderBadge(badgeAwardEvent, nav)
}
}
}
}
@Composable
private fun LoadAndRenderBadge(badgeAwardEventHex: String, nav: (String) -> Unit) {
var baseNote by remember {
mutableStateOf<Note?>(null)
}
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = badgeAwardEventHex) {
scope.launch(Dispatchers.IO) {
baseNote = LocalCache.getOrCreateNote(badgeAwardEventHex)
}
}
baseNote?.let {
val badgeAwardState by it.live().metadata.observeAsState()
val baseBadgeDefinition by remember(badgeAwardState) {
derivedStateOf {
badgeAwardState?.note?.replyTo?.firstOrNull()
}
}
baseBadgeDefinition?.let {
BadgeThumb(it, nav, 35.dp)
}
}
}
@Composable
@@ -762,7 +788,7 @@ fun BadgeThumb(
pictureModifier: Modifier = Modifier
) {
BadgeThumb(note, size, pictureModifier) {
nav("Note/${it.idHex}")
nav("Note/$it")
}
}
@@ -771,19 +797,19 @@ fun BadgeThumb(
baseNote: Note,
size: Dp,
pictureModifier: Modifier = Modifier,
onClick: ((Note) -> Unit)? = null
onClick: ((String) -> Unit)? = null
) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val event = (note.event as? BadgeDefinitionEvent)
val image = event?.thumb() ?: event?.image()
Box(
Modifier
.width(size)
.height(size)
remember {
Modifier
.width(size)
.height(size)
}
) {
val noteState by baseNote.live().metadata.observeAsState()
val event = remember(noteState) { noteState?.note?.event as? BadgeDefinitionEvent } ?: return
val image = remember(noteState) { event.thumb() ?: event.image() }
if (image == null) {
RobohashAsyncImage(
robot = "authornotfound",
@@ -796,7 +822,7 @@ fun BadgeThumb(
)
} else {
RobohashFallbackAsyncImage(
robot = note.idHex,
robot = event.id,
robotSize = size,
model = image,
contentDescription = stringResource(id = R.string.profile_image),
@@ -807,7 +833,7 @@ fun BadgeThumb(
.background(MaterialTheme.colors.background)
.run {
if (onClick != null) {
this.clickable(onClick = { onClick(note) })
this.clickable(onClick = { onClick(event.id) })
} else {
this
}
@@ -871,7 +897,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, nav: (String) -> Unit
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
}
}
}
@@ -888,7 +914,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, nav: (String) -> U
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
}
}
}
@@ -905,7 +931,7 @@ fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, nav: (Strin
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
}
}
}
@@ -981,7 +1007,7 @@ fun TabReports(baseUser: User, accountViewModel: AccountViewModel, nav: (String)
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
}
}
}
@@ -55,15 +55,14 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -81,25 +80,16 @@ import kotlinx.coroutines.channels.Channel as CoroutineChannel
fun SearchScreen(
searchFeedViewModel: NostrGlobalFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
scrollToTop: Boolean = false
nav: (String) -> Unit
) {
val lifeCycleOwner = LocalLifecycleOwner.current
val account = accountViewModel.accountLiveData.value?.account ?: return
GlobalFeedFilter.account = account
LaunchedEffect(accountViewModel) {
GlobalFeedFilter.account = account
NostrGlobalDataSource.resetFilters()
searchFeedViewModel.invalidateData()
}
WatchAccountForSearchScreen(searchFeedViewModel, accountViewModel)
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Global Start")
GlobalFeedFilter.account = account
NostrGlobalDataSource.start()
NostrSearchEventOrUserDataSource.start()
searchFeedViewModel.invalidateData()
@@ -123,11 +113,19 @@ fun SearchScreen(
modifier = Modifier.padding(vertical = 0.dp)
) {
SearchBar(accountViewModel, nav)
FeedView(searchFeedViewModel, accountViewModel, nav, null, ScrollStateKeys.GLOBAL_SCREEN, scrollToTop)
RefresheableView(searchFeedViewModel, accountViewModel, nav, null, ScrollStateKeys.GLOBAL_SCREEN)
}
}
}
@Composable
fun WatchAccountForSearchScreen(searchFeedViewModel: NostrGlobalFeedViewModel, accountViewModel: AccountViewModel) {
LaunchedEffect(accountViewModel) {
NostrGlobalDataSource.resetFilters()
searchFeedViewModel.invalidateData(true)
}
}
class SearchBarViewModel : ViewModel() {
var account: Account? = null
@@ -168,14 +166,14 @@ class SearchBarViewModel : ViewModel() {
searchResultsNotes.value = emptyList()
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
runSearch()
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
runSearch()
}
}
fun isSearching() = searchValue.isNotBlank()
@@ -7,7 +7,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
@@ -15,55 +14,51 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrThreadFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ThreadFeedView
@Composable
fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val account by accountViewModel.accountLiveData.observeAsState()
if (noteId == null) return
val lifeCycleOwner = LocalLifecycleOwner.current
if (account != null && noteId != null) {
ThreadFeedFilter.loadThread(noteId)
val feedViewModel: NostrThreadFeedViewModel = viewModel()
val feedViewModel: NostrThreadFeedViewModel = viewModel(
key = noteId + "NostrThreadFeedViewModel",
factory = NostrThreadFeedViewModel.Factory(noteId)
)
LaunchedEffect(noteId) {
NostrThreadDataSource.loadThread(noteId)
ThreadFeedFilter.loadThread(noteId)
feedViewModel.invalidateData()
}
LaunchedEffect(noteId) {
NostrThreadDataSource.loadThread(noteId)
feedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Thread Start")
NostrThreadDataSource.loadThread(noteId)
NostrThreadDataSource.start()
ThreadFeedFilter.loadThread(noteId)
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Thread Stop")
ThreadFeedFilter.loadThread(null)
NostrThreadDataSource.loadThread(null)
NostrThreadDataSource.stop()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Thread Start")
NostrThreadDataSource.loadThread(noteId)
NostrThreadDataSource.start()
feedViewModel.invalidateData()
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
if (event == Lifecycle.Event.ON_PAUSE) {
println("Thread Stop")
NostrThreadDataSource.loadThread(null)
NostrThreadDataSource.stop()
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
ThreadFeedView(noteId, feedViewModel, accountViewModel, nav)
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
ThreadFeedView(noteId, feedViewModel, accountViewModel, nav)
}
}
}
@@ -68,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
@@ -82,12 +81,12 @@ import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -95,30 +94,16 @@ import kotlinx.coroutines.launch
fun VideoScreen(
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
scrollToTop: Boolean = false
nav: (String) -> Unit
) {
val lifeCycleOwner = LocalLifecycleOwner.current
val account = accountViewModel.accountLiveData.value?.account ?: return
val accountState = account.live.observeAsState()
NostrVideoDataSource.account = account
VideoFeedFilter.account = account
LaunchedEffect(accountViewModel, accountState.value?.account?.defaultStoriesFollowList) {
VideoFeedFilter.account = account
NostrVideoDataSource.account = account
NostrVideoDataSource.resetFilters()
videoFeedView.invalidateData()
}
WatchAccountForVideoScreen(videoFeedView = videoFeedView, accountViewModel = accountViewModel)
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Video Start")
VideoFeedFilter.account = account
NostrVideoDataSource.account = account
NostrVideoDataSource.start()
}
if (event == Lifecycle.Event.ON_PAUSE) {
@@ -133,32 +118,69 @@ fun VideoScreen(
}
}
if (scrollToTop) {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit) {
scope.launch(Dispatchers.IO) {
NostrVideoDataSource.resetFilters()
videoFeedView.invalidateData()
}
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(videoFeedView, accountViewModel, nav, ScrollStateKeys.VIDEO_SCREEN, scrollToTop)
SaveableFeedState(videoFeedView, accountViewModel, nav, ScrollStateKeys.VIDEO_SCREEN)
}
}
}
@Composable
fun FeedView(
fun WatchAccountForVideoScreen(videoFeedView: NostrVideoFeedViewModel, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
LaunchedEffect(accountViewModel, account.defaultStoriesFollowList) {
NostrVideoDataSource.resetFilters()
videoFeedView.invalidateDataAndSendToTop(true)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SaveableFeedState(
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
routeForLastRead: String?,
scrollStateKey: String? = null
) {
val pagerState = if (scrollStateKey != null) {
rememberForeverPagerState(scrollStateKey)
} else {
remember { PagerState() }
}
WatchScrollToTop(videoFeedView, pagerState)
RenderPage(videoFeedView, accountViewModel, pagerState, nav)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
public fun WatchScrollToTop(
viewModel: FeedViewModel,
pagerState: PagerState
) {
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
pagerState.scrollToPage(page = 0)
viewModel.sentToTop()
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RenderPage(
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
pagerState: PagerState,
nav: (String) -> Unit
) {
val feedState by videoFeedView.feedContent.collectAsState()
@@ -180,10 +202,9 @@ fun FeedView(
is FeedState.Loaded -> {
SlidingCarousel(
state.feed,
pagerState,
accountViewModel,
nav,
scrollStateKey,
scrollToTop
nav
)
}
@@ -200,23 +221,10 @@ fun FeedView(
@Composable
fun SlidingCarousel(
feed: MutableState<ImmutableList<Note>>,
pagerState: PagerState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
nav: (String) -> Unit
) {
val pagerState = if (scrollStateKey != null) {
rememberForeverPagerState(scrollStateKey)
} else {
remember { PagerState() }
}
if (scrollToTop) {
LaunchedEffect(Unit) {
pagerState.scrollToPage(page = 0)
}
}
VerticalPager(
pageCount = feed.value.size,
state = pagerState,
@@ -55,26 +55,16 @@ fun TranslatableRichTextViewer(
var showOriginal by remember { mutableStateOf(false) }
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
WatchLanguageChanges(content, accountViewModel) { result, newShowOriginal ->
if (translatedTextState.result != result.result ||
translatedTextState.sourceLang != result.sourceLang ||
translatedTextState.targetLang != result.targetLang
) {
translatedTextState = result
}
val scope = rememberCoroutineScope()
LaunchedEffect(accountState) {
scope.launch(Dispatchers.IO) {
LanguageTranslatorService.autoTranslate(
content,
account.dontTranslateFrom,
account.translateTo
).addOnCompleteListener { task ->
if (task.isSuccessful && content != task.result.result) {
if (task.result.sourceLang != null && task.result.targetLang != null) {
val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!)
showOriginal = preference == task.result.sourceLang
}
translatedTextState = task.result
}
}
if (showOriginal != newShowOriginal) {
showOriginal = newShowOriginal
}
}
@@ -98,137 +88,135 @@ fun TranslatableRichTextViewer(
val target = translatedTextState.targetLang
val source = translatedTextState.sourceLang
if (source != null && target != null) {
if (source != target) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 5.dp)
if (source != null && target != null && source != target) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 5.dp)
) {
val clickableTextStyle =
SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
val annotatedTranslationString = buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append(stringResource(R.string.translations_auto))
}
append("-${stringResource(R.string.translations_translated_from)} ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
append(" ${stringResource(R.string.translations_to)} ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
append(Locale(target).displayName)
}
}
ClickableText(
text = annotatedTranslationString,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
overflow = TextOverflow.Visible,
maxLines = 3
) { spanOffset ->
annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
.firstOrNull()
?.also { span ->
if (span.tag == "showOriginal") {
showOriginal = span.item.toBoolean()
} else {
langSettingsPopupExpanded = !langSettingsPopupExpanded
}
}
}
DropdownMenu(
expanded = langSettingsPopupExpanded,
onDismissRequest = { langSettingsPopupExpanded = false }
) {
val clickableTextStyle =
SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
val annotatedTranslationString = buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append(stringResource(R.string.translations_auto))
DropdownMenuItem(onClick = {
accountViewModel.dontTranslateFrom(source)
langSettingsPopupExpanded = false
}) {
if (source in accountViewModel.account.dontTranslateFrom) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
append("-${stringResource(R.string.translations_translated_from)} ")
Spacer(modifier = Modifier.size(10.dp))
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
append(" ${stringResource(R.string.translations_to)} ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
append(Locale(target).displayName)
}
Text(stringResource(R.string.translations_never_translate_from_lang, Locale(source).displayName))
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.prefer(source, target, source)
langSettingsPopupExpanded = false
}) {
if (accountViewModel.account.preferenceBetween(source, target) == source) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
ClickableText(
text = annotatedTranslationString,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
overflow = TextOverflow.Visible,
maxLines = 3
) { spanOffset ->
annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
.firstOrNull()
?.also { span ->
if (span.tag == "showOriginal") {
showOriginal = span.item.toBoolean()
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.translations_show_in_lang_first, Locale(source).displayName))
}
DropdownMenuItem(onClick = {
accountViewModel.prefer(source, target, target)
langSettingsPopupExpanded = false
}) {
if (accountViewModel.account.preferenceBetween(source, target) == target) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.translations_show_in_lang_first, Locale(target).displayName))
}
Divider()
val languageList =
ConfigurationCompat.getLocales(Resources.getSystem().configuration)
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { lang ->
DropdownMenuItem(onClick = {
accountViewModel.translateTo(lang)
langSettingsPopupExpanded = false
}) {
if (lang.language in accountViewModel.account.translateTo) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
langSettingsPopupExpanded = !langSettingsPopupExpanded
Spacer(modifier = Modifier.size(24.dp))
}
}
}
DropdownMenu(
expanded = langSettingsPopupExpanded,
onDismissRequest = { langSettingsPopupExpanded = false }
) {
DropdownMenuItem(onClick = {
accountViewModel.dontTranslateFrom(source)
langSettingsPopupExpanded = false
}) {
if (source in account.dontTranslateFrom) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.translations_never_translate_from_lang, Locale(source).displayName))
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.prefer(source, target, source)
langSettingsPopupExpanded = false
}) {
if (account.preferenceBetween(source, target) == source) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.translations_show_in_lang_first, Locale(source).displayName))
}
DropdownMenuItem(onClick = {
accountViewModel.prefer(source, target, target)
langSettingsPopupExpanded = false
}) {
if (account.preferenceBetween(source, target) == target) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.translations_show_in_lang_first, Locale(target).displayName))
}
Divider()
val languageList =
ConfigurationCompat.getLocales(Resources.getSystem().configuration)
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { lang ->
DropdownMenuItem(onClick = {
accountViewModel.translateTo(lang)
langSettingsPopupExpanded = false
}) {
if (lang.language in account.translateTo) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.translations_always_translate_to_lang, lang.displayName))
}
Text(stringResource(R.string.translations_always_translate_to_lang, lang.displayName))
}
}
}
@@ -237,3 +225,28 @@ fun TranslatableRichTextViewer(
}
}
}
@Composable
fun WatchLanguageChanges(content: String, accountViewModel: AccountViewModel, onTranslated: (ResultOrError, Boolean) -> Unit) {
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val scope = rememberCoroutineScope()
LaunchedEffect(accountState) {
scope.launch(Dispatchers.IO) {
LanguageTranslatorService.autoTranslate(
content,
account.dontTranslateFrom,
account.translateTo
).addOnCompleteListener { task ->
if (task.isSuccessful && content != task.result.result) {
if (task.result.sourceLang != null && task.result.targetLang != null) {
val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!)
onTranslated(task.result, preference == task.result.sourceLang)
}
}
}
}
}
}