diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 38be7f0da..950e24d60 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -14,104 +14,109 @@ import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent import nostr.postr.toHex class LocalPreferences(context: Context) { - val encryptedPreferences = EncryptedStorage().preferences(context) - val gson = GsonBuilder().create() + private object PrefKeys { + const val NOSTR_PRIVKEY = "nostr_privkey" + const val NOSTR_PUBKEY = "nostr_pubkey" + const val FOLLOWING_CHANNELS = "following_channels" + const val HIDDEN_USERS = "hidden_users" + const val RELAYS = "relays" + const val DONT_TRANSLATE_FROM = "dontTranslateFrom" + const val LANGUAGE_PREFS = "languagePreferences" + const val TRANSLATE_TO = "translateTo" + const val ZAP_AMOUNTS = "zapAmounts" + const val LATEST_CONTACT_LIST = "latestContactList" + val LAST_READ: (String) -> String = { route -> "last_read_route_${route}" } + } - fun clearEncryptedStorage() { - encryptedPreferences.edit().apply { - remove("nostr_privkey") - remove("nostr_pubkey") - remove("following_channels") - remove("hidden_users") - remove("relays") - remove("dontTranslateFrom") - remove("languagePreferences") - remove("translateTo") - remove("zapAmounts") - remove("latestContactList") - }.apply() - } + private val encryptedPreferences = EncryptedStorage().preferences(context) + private val gson = GsonBuilder().create() - fun saveToEncryptedStorage(account: Account) { - encryptedPreferences.edit().apply { - account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) } - account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) } - account.followingChannels.let { putStringSet("following_channels", it) } - account.hiddenUsers.let { putStringSet("hidden_users", it) } - account.localRelays.let { putString("relays", gson.toJson(it)) } - account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) } - account.languagePreferences.let { putString("languagePreferences", gson.toJson(it)) } - account.translateTo.let { putString("translateTo", it) } - account.zapAmountChoices.let { putString("zapAmounts", gson.toJson(it)) } - account.backupContactList.let { putString("latestContactList", Event.gson.toJson(it)) } - }.apply() - } + fun clearEncryptedStorage() { + encryptedPreferences.edit().apply { + encryptedPreferences.all.keys.forEach { remove(it) } + }.apply() + } - fun loadFromEncryptedStorage(): Account? { - encryptedPreferences.apply { - val privKey = getString("nostr_privkey", null) - val pubKey = getString("nostr_pubkey", null) - val followingChannels = getStringSet("following_channels", null) ?: setOf() - val hiddenUsers = getStringSet("hidden_users", emptySet()) ?: setOf() - val localRelays = gson.fromJson( - getString("relays", "[]"), - object : TypeToken>() {}.type - ) ?: setOf() + fun saveToEncryptedStorage(account: Account) { + encryptedPreferences.edit().apply { + account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) } + account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) } + account.followingChannels.let { putStringSet(PrefKeys.FOLLOWING_CHANNELS, it) } + account.hiddenUsers.let { putStringSet(PrefKeys.HIDDEN_USERS, it) } + account.localRelays.let { putString(PrefKeys.RELAYS, gson.toJson(it)) } + account.dontTranslateFrom.let { putStringSet(PrefKeys.DONT_TRANSLATE_FROM, it) } + account.languagePreferences.let { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(it)) } + account.translateTo.let { putString(PrefKeys.TRANSLATE_TO, it) } + account.zapAmountChoices.let { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(it)) } + account.backupContactList.let { putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(it)) } + }.apply() + } - val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf() - val translateTo = getString("translateTo", null) ?: Locale.getDefault().language + fun loadFromEncryptedStorage(): Account? { + encryptedPreferences.apply { + val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) + val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) + val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf() + val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf() + val localRelays = gson.fromJson( + getString(PrefKeys.RELAYS, "[]"), + object : TypeToken>() {}.type + ) ?: setOf() - val zapAmountChoices = gson.fromJson( - getString("zapAmounts", "[]"), - object : TypeToken>() {}.type - ) ?: listOf(500L, 1000L, 5000L) + val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() + val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language - val latestContactList = try { - getString("latestContactList", null)?.let { - Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent + val zapAmountChoices = gson.fromJson( + getString(PrefKeys.ZAP_AMOUNTS, "[]"), + object : TypeToken>() {}.type + ) ?: listOf(500L, 1000L, 5000L) + + val latestContactList = try { + getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let { + Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent + } + } catch (e: Throwable) { + e.printStackTrace() + null + } + + val languagePreferences = try { + getString(PrefKeys.LANGUAGE_PREFS, null)?.let { + gson.fromJson(it, object : TypeToken>() {}.type) as Map + } ?: mapOf() + } catch (e: Throwable) { + e.printStackTrace() + mapOf() + } + + if (pubKey != null) { + return Account( + Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), + followingChannels, + hiddenUsers, + localRelays, + dontTranslateFrom, + languagePreferences, + translateTo, + zapAmountChoices, + latestContactList + ) + } else { + return null + } } - } catch (e: Throwable) { - e.printStackTrace() - null - } - - val languagePreferences = try { - getString("languagePreferences", null)?.let { - gson.fromJson(it, object : TypeToken>() {}.type) as Map - } ?: mapOf() - } catch (e: Throwable) { - e.printStackTrace() - mapOf() - } - - if (pubKey != null) { - return Account( - Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), - followingChannels, - hiddenUsers, - localRelays, - dontTranslateFrom, - languagePreferences, - translateTo, - zapAmountChoices, - latestContactList - ) - } else { - return null - } } - } - 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) + fun saveLastRead(route: String, timestampInSecs: Long) { + encryptedPreferences.edit().apply { + putLong(PrefKeys.LAST_READ(route), timestampInSecs) + }.apply() + } + + fun loadLastRead(route: String): Long { + encryptedPreferences.run { + return getLong(PrefKeys.LAST_READ(route), 0) + } } - } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 5964847c8..b67fac0ca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -254,7 +254,7 @@ fun ListContent( Spacer(modifier = Modifier.weight(1f)) IconRow( - "Logout", + stringResource(R.string.log_out), R.drawable.ic_logout, MaterialTheme.colors.onBackground, onClick = { accountViewModel.logOff() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt index 926636feb..4a1727088 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt @@ -82,7 +82,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr channel?.let { channel -> var hasNewMessages by remember { mutableStateOf(false) } - LaunchedEffect(key1 = notificationCache) { + LaunchedEffect(key1 = notificationCache, key2 = note) { note.createdAt()?.let { hasNewMessages = it > notificationCache.cache.load("Channel/${channel.idHex}", context) } @@ -125,7 +125,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr userToComposeOn.let { user -> var hasNewMessages by remember { mutableStateOf(false) } - LaunchedEffect(key1 = notificationCache) { + LaunchedEffect(key1 = notificationCache, key2 = note) { noteEvent?.let { hasNewMessages = it.createdAt > notificationCache.cache.load("Room/${userToComposeOn.pubkeyHex}", context) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index ef4a9c386..f056afb5b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -9,21 +9,30 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +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.ui.note.ChatroomCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { +fun ChatroomListFeedView( + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + navController: NavController, + markAsRead: MutableState +) { val feedState by viewModel.feedContent.collectAsStateWithLifecycle() var isRefreshing by remember { mutableStateOf(false) } @@ -45,21 +54,27 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView }, ) { Column() { - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + Crossfade( + targetState = feedState, + animationSpec = tween(durationMillis = 100) + ) { state -> when (state) { is FeedState.Empty -> { FeedEmpty { isRefreshing = true } } + is FeedState.FeedError -> { FeedError(state.errorMessage) { isRefreshing = true } } + is FeedState.Loaded -> { - FeedLoaded(state, accountViewModel, navController) + FeedLoaded(state, accountViewModel, navController, markAsRead) } + FeedState.Loading -> { LoadingFeed() } @@ -73,10 +88,44 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView private fun FeedLoaded( state: FeedState.Loaded, accountViewModel: AccountViewModel, - navController: NavController + navController: NavController, + markAsRead: MutableState, ) { val listState = rememberLazyListState() + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + val notificationCacheState = NotificationCache.live.observeAsState() + val notificationCache = notificationCacheState.value ?: return + val context = LocalContext.current.applicationContext + + LaunchedEffect(key1 = markAsRead.value) { + if (markAsRead.value) { + for (note in state.feed.value) { + note.event?.let { + var route = "" + val channel = note.channel() + + if (channel != null) { + route = "Channel/${channel.idHex}" + } else { + val replyAuthorBase = note.mentions?.first() + var userToComposeOn = note.author!! + if (replyAuthorBase != null) { + if (note.author == account.userProfile()) { + userToComposeOn = replyAuthorBase + } + } + route = "Room/${userToComposeOn.pubkeyHex}" + } + + notificationCache.cache.markAsRead(route, it.createdAt, context) + } + } + markAsRead.value = false + } + } + LazyColumn( contentPadding = PaddingValues( top = 10.dp, @@ -84,7 +133,9 @@ private fun FeedLoaded( ), state = listState ) { - itemsIndexed(state.feed.value, key = { index, item -> if (index == 0) index else item.idHex }) { index, item -> + itemsIndexed( + state.feed.value, + key = { index, item -> if (index == 0) index else item.idHex }) { index, item -> ChatroomCompose( item, accountViewModel = accountViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 2c7391156..e17eaba90 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -116,6 +116,4 @@ class AccountViewModel(private val account: Account): ViewModel() { fun follow(user: User) { account.follow(user) } - - } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt index d8959fd62..253abe625 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -1,19 +1,33 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Tab import androidx.compose.material.TabRow import androidx.compose.material.TabRowDefaults import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource @@ -26,6 +40,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.pagerTabIndicatorOffset import com.google.accompanist.pager.rememberPagerState +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter @@ -33,7 +48,6 @@ import com.vitorpamplona.amethyst.ui.screen.ChatroomListFeedView import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel import kotlinx.coroutines.launch -import com.vitorpamplona.amethyst.R @OptIn(ExperimentalPagerApi::class) @Composable @@ -41,48 +55,78 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon val pagerState = rememberPagerState() val coroutineScope = rememberCoroutineScope() - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - TabRow( - backgroundColor = MaterialTheme.colors.background, - selectedTabIndex = pagerState.currentPage, - indicator = { tabPositions -> - TabRowDefaults.Indicator( - Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), - color = MaterialTheme.colors.primary - ) - }, - ) { - Tab( - selected = pagerState.currentPage == 0, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, - text = { - Text(text = stringResource(R.string.known)) - } - ) + var moreActionsExpanded by remember { mutableStateOf(false) } + val markKnownAsRead = remember { mutableStateOf(false) } + val markNewAsRead = remember { mutableStateOf(false) } - Tab( - selected = pagerState.currentPage == 1, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, - text = { - Text(text = stringResource(R.string.new_requests)) + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + TabRow( + backgroundColor = MaterialTheme.colors.background, + selectedTabIndex = pagerState.currentPage, + indicator = { tabPositions -> + TabRowDefaults.Indicator( + Modifier.pagerTabIndicatorOffset(pagerState, tabPositions), + color = MaterialTheme.colors.primary + ) + }, + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, + text = { + Text(text = stringResource(R.string.known)) + } + ) + + Tab( + selected = pagerState.currentPage == 1, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, + text = { + Text(text = stringResource(R.string.new_requests)) + } + ) + } + HorizontalPager(count = 2, state = pagerState) { + when (pagerState.currentPage) { + 0 -> TabKnown(accountViewModel, navController, markKnownAsRead,) + 1 -> TabNew(accountViewModel, navController, markNewAsRead) } - ) - } - HorizontalPager(count = 2, state = pagerState) { - when (pagerState.currentPage) { - 0 -> TabKnown(accountViewModel, navController) - 1 -> TabNew(accountViewModel, navController) } } } + IconButton( + modifier = Modifier + .padding(0.dp) + .size(30.dp) + .align(Alignment.TopEnd), + onClick = { moreActionsExpanded = true } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null, + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + ) + + ChatroomTabMenu( + moreActionsExpanded, + { moreActionsExpanded = false }, + { markKnownAsRead.value = true }, + { markNewAsRead.value = true }, + ) + } } } @Composable -fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) { +fun TabKnown( + accountViewModel: AccountViewModel, + navController: NavController, + markAsRead: MutableState, +) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -113,13 +157,17 @@ fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) { Column( modifier = Modifier.padding(vertical = 0.dp) ) { - ChatroomListFeedView(feedViewModel, accountViewModel, navController) + ChatroomListFeedView(feedViewModel, accountViewModel, navController, markAsRead) } } } @Composable -fun TabNew(accountViewModel: AccountViewModel, navController: NavController) { +fun TabNew( + accountViewModel: AccountViewModel, + navController: NavController, + markAsRead: MutableState +) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -150,7 +198,37 @@ fun TabNew(accountViewModel: AccountViewModel, navController: NavController) { Column( modifier = Modifier.padding(vertical = 0.dp) ) { - ChatroomListFeedView(feedViewModel, accountViewModel, navController) + ChatroomListFeedView(feedViewModel, accountViewModel, navController, markAsRead) } } -} \ No newline at end of file +} + +@Composable +fun ChatroomTabMenu( + expanded: Boolean, + onDismiss: () -> Unit, + onMarkKnownAsRead: () -> Unit, + onMarkNewAsRead: () -> Unit, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + DropdownMenuItem(onClick = { + onMarkKnownAsRead() + onDismiss() + }) { + Text(stringResource(R.string.mark_all_known_as_read)) + } + DropdownMenuItem(onClick = { + onMarkNewAsRead() + onDismiss() + }) { + Text(stringResource(R.string.mark_all_new_as_read)) + } + DropdownMenuItem(onClick = { + onMarkKnownAsRead() + onMarkNewAsRead() + onDismiss() + }) { + Text(stringResource(R.string.mark_all_as_read)) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 876d4103d..9fc0c1fec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -48,7 +48,7 @@ " Followers" Profile Security Filters - Log out + Logout Show More Lightning Invoice Pay @@ -173,6 +173,9 @@ Report Hateful speech Report Nudity / Porn others + Mark all Known as read + Mark all New as read + Mark all as read ## Key Backup and Safety Tips \n\nYour account is secured by a secret key. The key is long random string starting with **nsec1**. Anyone who has access to your secret key can publish content using your identity.