Support for Notification Bubbles and Verified Follows

This commit is contained in:
Vitor Pamplona 2023-01-26 22:09:56 -03:00
parent 93033295be
commit e20669ab3d
21 changed files with 448 additions and 76 deletions

View File

@ -2,8 +2,11 @@ package com.vitorpamplona.amethyst
import android.content.Context
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.ui.navigation.Route
import nostr.postr.Persona
import nostr.postr.toHex
@ -47,4 +50,16 @@ class LocalPreferences(context: Context) {
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences.edit().apply {
putLong("last_read_route_${route}", timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences.run {
return getLong("last_read_route_${route}", 0)
}
}
}

View File

@ -0,0 +1,62 @@
package com.vitorpamplona.amethyst
import android.content.Context
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.model.Note
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
object NotificationCache {
val lastReadByRoute = mutableMapOf<String, Long>()
fun markAsRead(route: String, timestampInSecs: Long, context: Context) {
val lastTime = lastReadByRoute[route]
if (lastTime == null || timestampInSecs > lastTime) {
lastReadByRoute.put(route, timestampInSecs)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
LocalPreferences(context).saveLastRead(route, timestampInSecs)
invalidateData()
}
}
}
fun load(route: String, context: Context): Long {
var lastTime = lastReadByRoute[route]
if (lastTime == null) {
lastTime = LocalPreferences(context).loadLastRead(route)
lastReadByRoute[route] = lastTime
}
return lastTime
}
// Observers line up here.
val live: NotificationLiveData = NotificationLiveData(this)
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
}
class NotificationLiveData(val cache: NotificationCache): LiveData<NotificationState>(NotificationState(cache)) {
fun refresh() {
postValue(NotificationState(cache))
}
}
class NotificationState(val cache: NotificationCache)

View File

@ -10,6 +10,7 @@ import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job

View File

@ -1,18 +1,36 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.BottomNavigation
import androidx.compose.material.BottomNavigationItem
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.note.NewItemsBubble
import kotlinx.coroutines.launch
val bottomNavigationItems = listOf(
Route.Home,
@ -24,6 +42,7 @@ val bottomNavigationItems = listOf(
@Composable
fun AppBottomBar(navController: NavHostController) {
val currentRoute = currentRoute(navController)
val coroutineScope = rememberCoroutineScope()
Column() {
Divider(
@ -36,35 +55,30 @@ fun AppBottomBar(navController: NavHostController) {
) {
bottomNavigationItems.forEach { item ->
BottomNavigationItem(
icon = {
Icon(
painter = painterResource(id = item.icon),
null,
modifier = Modifier.size(if ("Home" == item.route) 24.dp else 20.dp),
tint = if (currentRoute == item.route) MaterialTheme.colors.primary else Color.Unspecified
)
},
icon = { NotifiableIcon(item, currentRoute) },
selected = currentRoute == item.route,
onClick = {
if (currentRoute != item.route) {
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
launchSingleTop = true
restoreState = true
}
} else {
// TODO: Make it scrool to the top
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start) { inclusive = item.route == Route.Home.route }
coroutineScope.launch {
if (currentRoute != item.route) {
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
launchSingleTop = true
restoreState = true
}
} else {
// TODO: Make it scrool to the top
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start) { inclusive = item.route == Route.Home.route }
restoreState = true
}
launchSingleTop = true
restoreState = true
launchSingleTop = true
restoreState = true
}
}
}
}
@ -73,3 +87,52 @@ fun AppBottomBar(navController: NavHostController) {
}
}
}
@Composable
private fun NotifiableIcon(item: Route, currentRoute: String?) {
Box(Modifier.size(if ("Home" == item.route) 25.dp else 23.dp)) {
Icon(
painter = painterResource(id = item.icon),
null,
modifier = Modifier.size(if ("Home" == item.route) 24.dp else 20.dp),
tint = if (currentRoute == item.route) MaterialTheme.colors.primary else Color.Unspecified
)
// Notification
val dbState = LocalCache.live.observeAsState()
val notifState = NotificationCache.live.observeAsState()
val db = dbState.value
val notif = notifState.value
if (db != null && notif != null) {
if (item.hasNewItems(db.cache, notif.cache)) {
Box(
Modifier
.width(10.dp)
.height(10.dp)
.align(Alignment.TopEnd)
) {
Box(
modifier = Modifier
.width(10.dp)
.height(10.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.primary),
contentAlignment = Alignment.TopEnd
) {
Text(
"",
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 12.sp,
modifier = Modifier
.wrapContentHeight()
.align(Alignment.TopEnd)
)
}
}
}
}
}
}

View File

@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
@ -9,13 +10,23 @@ import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.FiltersScreen
import com.vitorpamplona.amethyst.ui.screen.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.SearchScreen
@ -26,14 +37,30 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
sealed class Route(
val route: String,
val icon: Int,
val hasNewItems: @Composable (LocalCache, NotificationCache) -> Boolean = @Composable { _,_ -> false },
val arguments: List<NamedNavArgument> = emptyList(),
val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit
) {
object Home : Route("Home", R.drawable.ic_home, buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } })
object Search : Route("Search", R.drawable.ic_search, buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }})
object Notification : Route("Notification", R.drawable.ic_notifications,buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) }})
object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }})
object Filters : Route("Filters", R.drawable.ic_dm, buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) }})
object Home : Route("Home", R.drawable.ic_home,
hasNewItems = { _, cache -> homeHasNewItems(cache) },
buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } }
)
object Search : Route("Search", R.drawable.ic_search,
buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }}
)
object Notification : Route("Notification", R.drawable.ic_notifications,
hasNewItems = { _, cache -> notificationHasNewItems(cache) },
buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) }}
)
object Message : Route("Message", R.drawable.ic_dm,
hasNewItems = { _, cache -> messagesHasNewItems(cache) },
buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }}
)
object Filters : Route("Filters", R.drawable.ic_dm,
buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) }}
)
object Profile : Route("User/{id}", R.drawable.ic_profile,
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
@ -71,8 +98,58 @@ val Routes = listOf(
Route.Filters
)
//**
//* Functions below only exist because we have not broken the datasource classes into backend and frontend.
//**
@Composable
public fun currentRoute(navController: NavHostController): String? {
val navBackStackEntry by navController.currentBackStackEntryAsState()
return navBackStackEntry?.destination?.route
}
@Composable
private fun homeHasNewItems(cache: NotificationCache): Boolean {
val context = LocalContext.current.applicationContext
val lastTimeFollows = cache.load("HomeFollows", context)
val homeFeed = NostrHomeDataSource.feed().take(100)
val hasNewInFollows = homeFeed.filter {
it.event is RepostEvent || it.replyTo == null || it.replyTo?.size == 0
}.filter {
(it.event?.createdAt ?: 0) > lastTimeFollows
}.isNotEmpty()
return hasNewInFollows
}
@Composable
private fun notificationHasNewItems(cache: NotificationCache): Boolean {
val context = LocalContext.current.applicationContext
val lastTime = cache.load("Notification", context)
return NostrNotificationDataSource.loadTop()
.filter { it.event != null && it.event!!.createdAt > lastTime }
.isNotEmpty()
}
@Composable
private fun messagesHasNewItems(cache: NotificationCache): Boolean {
val context = LocalContext.current.applicationContext
return NostrChatroomListDataSource.feed().take(100).filter {
// only for known sources
val me = NostrChatroomListDataSource.account.userProfile()
it.channel != null || me.messages[it.author]?.firstOrNull { me == it.author } != null
}.filter {
val lastTime = if (it.channel != null) {
cache.load("Channel/${it.channel!!.idHex}", context)
} else {
cache.load("Room/${it.author?.pubkeyHex}", context)
}
if (NostrChatroomListDataSource.account.isAcceptable(it) && it.event != null && it.event!!.createdAt > lastTime) {
println("${it.author?.toBestDisplayName()}")
}
NostrChatroomListDataSource.account.isAcceptable(it) && it.event != null && it.event!!.createdAt > lastTime
}.isNotEmpty()
}

View File

@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@ -17,25 +19,36 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by likeSetCard.note.live.observeAsState()
val note = noteState?.note
val context = LocalContext.current.applicationContext
if (note?.event == null) {
BlankNote(Modifier, isInnerNote)
} else {
Column() {
val isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
Column(
modifier = Modifier.background(
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
)
) {
Row(modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
@ -84,7 +97,7 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, acc
}
}
NoteCompose(note, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
NoteCompose(note, null, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
}
}
}

View File

@ -1,12 +1,19 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Center
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.LocalTextStyle
@ -18,9 +25,12 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -28,6 +38,8 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@ -44,6 +56,11 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user ?: return
val notificationCacheState = NotificationCache.live.observeAsState()
val notificationCache = notificationCacheState.value ?: return
val context = LocalContext.current.applicationContext
if (note?.event == null) {
BlankNote(Modifier)
} else if (note.channel != null) {
@ -53,14 +70,22 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val channelState by note.channel!!.live.observeAsState()
val channel = channelState?.channel
val description = if (note.event is ChannelCreateEvent) {
val noteEvent = note.event
val description = if (noteEvent is ChannelCreateEvent) {
"Channel created"
} else if (note.event is ChannelMetadataEvent) {
} else if (noteEvent is ChannelMetadataEvent) {
"Channel Information changed to "
} else {
note.event?.content
noteEvent?.content
}
channel?.let { channel ->
val hasNewMessages =
if (noteEvent != null)
noteEvent.createdAt > notificationCache.cache.load("Channel/${channel.idHex}", context)
else
false
ChannelName(
channelPicture = channel.profilePicture(),
channelPicturePlaceholder = null,
@ -78,6 +103,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
},
channelLastTime = note.event?.createdAt,
channelLastContent = "${author?.toBestDisplayName()}: " + description,
hasNewMessages = hasNewMessages,
onClick = { navController.navigate("Channel/${channel.idHex}") })
}
@ -98,12 +124,21 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
}
}
val noteEvent = note.event
userToComposeOn?.let { user ->
val hasNewMessages =
if (noteEvent != null)
noteEvent.createdAt > notificationCache.cache.load("Room/${userToComposeOn.pubkeyHex}", context)
else
false
ChannelName(
channelPicture = { UserPicture(user = user, userAccount = accountUser, size = 55.dp) },
channelTitle = { UsernameDisplay(user, it) },
channelLastTime = note.event?.createdAt,
channelLastTime = noteEvent?.createdAt,
channelLastContent = accountViewModel.decrypt(note),
hasNewMessages = hasNewMessages,
onClick = { navController.navigate("Room/${user.pubkeyHex}") })
}
}
@ -117,6 +152,7 @@ fun ChannelName(
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
channelLastContent: String?,
hasNewMessages: Boolean,
onClick: () -> Unit
) {
ChannelName(
@ -134,6 +170,7 @@ fun ChannelName(
channelTitle,
channelLastTime,
channelLastContent,
hasNewMessages,
onClick
)
}
@ -144,6 +181,7 @@ fun ChannelName(
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
channelLastContent: String?,
hasNewMessages: Boolean,
onClick: () -> Unit
) {
Column(modifier = Modifier.clickable(onClick = onClick) ) {
@ -169,21 +207,29 @@ fun ChannelName(
}
if (channelLastContent != null)
Text(
channelLastContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
else
Text(
"Referenced event not found",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (channelLastContent != null)
Text(
channelLastContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
modifier = Modifier.weight(1f)
)
else
Text(
"Referenced event not found",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (hasNewMessages) {
NewItemsBubble()
}
}
}
}
@ -192,4 +238,27 @@ fun ChannelName(
thickness = 0.25.dp
)
}
}
@Composable
fun NewItemsBubble() {
Box(
modifier = Modifier
.padding(start = 3.dp)
.width(10.dp)
.height(10.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.primary),
contentAlignment = Alignment.Center
) {
Text(
"",
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 12.sp,
modifier = Modifier
.wrapContentHeight()
.align(Alignment.Center)
)
}
}

View File

@ -30,12 +30,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@ -50,7 +52,7 @@ val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatroomMessageCompose(baseNote: Note, innerQuote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
@ -62,6 +64,8 @@ fun ChatroomMessageCompose(baseNote: Note, innerQuote: Boolean = false, accountV
var popupExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
if (note?.event == null) {
BlankNote(Modifier)
} else {
@ -82,6 +86,17 @@ fun ChatroomMessageCompose(baseNote: Note, innerQuote: Boolean = false, accountV
shape = ChatBubbleShapeThem
}
// Mark read
val isNew = routeForLastRead?.run {
val isNew = NotificationCache.load(this, context)
val createdAt = note.event?.createdAt
if (createdAt != null)
NotificationCache.markAsRead(this, createdAt, context)
isNew
}
Column() {
Row(
modifier = Modifier.fillMaxWidth(1f).padding(
@ -150,6 +165,7 @@ fun ChatroomMessageCompose(baseNote: Note, innerQuote: Boolean = false, accountV
if (note.event != null)
ChatroomMessageCompose(
note,
null,
innerQuote = true,
accountViewModel = accountViewModel,
navController = navController

View File

@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@ -17,26 +19,37 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by likeSetCard.note.live.observeAsState()
val note = noteState?.note
val context = LocalContext.current.applicationContext
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
Column(modifier = modifier) {
Row( modifier = Modifier
val isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
Column(
modifier = Modifier.background(
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
)
) {
Row(modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
@ -84,7 +97,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
}
}
NoteCompose(note, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
NoteCompose(note, null, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
}
}
}

View File

@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
@ -59,7 +60,14 @@ import nostr.postr.toNpub
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
fun NoteCompose(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
isInnerNote: Boolean = false,
accountViewModel: AccountViewModel,
navController: NavController
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
@ -68,6 +76,8 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
var popupExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
if (note?.event == null) {
BlankNote(modifier.combinedClickable(
onClick = { },
@ -82,6 +92,18 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
val authorState by note.author?.live!!.observeAsState()
val author = authorState?.user ?: return // if it has event, it should have an author
val isNew = routeForLastRead?.run {
val lastTime = NotificationCache.load(this, context)
val createdAt = note.event?.createdAt
if (createdAt != null) {
NotificationCache.markAsRead(this, createdAt, context)
createdAt > lastTime
} else {
false
}
} ?: false
Column(modifier =
modifier.combinedClickable(
onClick = {
@ -95,8 +117,14 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
}
}
},
onLongClick = { popupExpanded = true },
)
onLongClick = { popupExpanded = true }
).run {
if (isNew) {
this.background(MaterialTheme.colors.primary.copy(0.12f))
} else {
this
}
}
) {
Row(
modifier = Modifier

View File

@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String) {
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
@ -68,9 +68,9 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode
) {
itemsIndexed(state.feed, key = { _, item -> item.id() }) { index, item ->
when (item) {
is NoteCard -> NoteCompose(item.note, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
is LikeSetCard -> LikeSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
is BoostSetCard -> BoostSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
is NoteCard -> NoteCompose(item.note, isInnerNote = false, accountViewModel = accountViewModel, navController = navController, routeForLastRead = routeForLastRead)
is LikeSetCard -> LikeSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController, routeForLastRead = routeForLastRead)
is BoostSetCard -> BoostSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController, routeForLastRead = routeForLastRead)
}
}
}

View File

@ -21,7 +21,7 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?) {
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
@ -66,7 +66,7 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
) {
var previousDate: String = ""
itemsIndexed(state.feed, key = { index, item -> if (index == 0) index else item.idHex }) { index, item ->
ChatroomMessageCompose(item, accountViewModel = accountViewModel, navController = navController)
ChatroomMessageCompose(item, routeForLastRead, accountViewModel = accountViewModel, navController = navController)
}
}
}

View File

@ -21,16 +21,20 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?) {
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
@ -73,7 +77,12 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
state = listState
) {
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
NoteCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
NoteCompose(item,
isInnerNote = false,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}

View File

@ -107,7 +107,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
Row() {
NoteCompose(
item,
Modifier.drawReplyLevel(item.replyLevel(), MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
modifier = Modifier.drawReplyLevel(item.replyLevel(), MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
isInnerNote = false,
accountViewModel = accountViewModel,
navController = navController,

View File

@ -94,7 +94,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
.padding(vertical = 0.dp)
.weight(1f, true)
) {
ChatroomFeedView(feedViewModel, accountViewModel, navController)
ChatroomFeedView(feedViewModel, accountViewModel, navController, "Channel/${channelId}")
}
//LAST ROW

View File

@ -69,7 +69,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true)
) {
ChatroomFeedView(feedViewModel, accountViewModel, navController)
ChatroomFeedView(feedViewModel, accountViewModel, navController, "Room/${userId}")
}
//LAST ROW

View File

@ -16,6 +16,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
@ -23,10 +24,13 @@ import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.Routes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.lang.System.currentTimeMillis
import kotlinx.coroutines.launch
@ -75,8 +79,8 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
}
HorizontalPager(count = 2, state = pagerState) {
when (pagerState.currentPage) {
0 -> FeedView(feedViewModel, accountViewModel, navController)
1 -> FeedView(feedViewModelReplies, accountViewModel, navController)
0 -> FeedView(feedViewModel, accountViewModel, navController, Route.Home.route + "Follows")
1 -> FeedView(feedViewModelReplies, accountViewModel, navController, Route.Home.route + "FollowsReplies")
}
}
}

View File

@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@ -26,7 +27,7 @@ fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavCon
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController)
CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController, Route.Notification.route)
}
}
}

View File

@ -248,7 +248,7 @@ fun TabNotes(user: User, accountViewModel: AccountViewModel, navController: NavC
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController)
FeedView(feedViewModel, accountViewModel, navController, null)
}
}
}

View File

@ -74,7 +74,7 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle
modifier = Modifier.padding(vertical = 0.dp)
) {
SearchBar(accountViewModel, navController)
FeedView(feedViewModel, accountViewModel, navController)
FeedView(feedViewModel, accountViewModel, navController, null)
}
}
}
@ -186,6 +186,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = { navController.navigate("Channel/${item.idHex}") })
}

View File

@ -7,6 +7,6 @@ val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val Following = Color(0xFF2CC03D)
val Following = Color(0xFF03DAC5)
val FollowsFollow = Color.Yellow
val NIP05Verified = Color.Blue