From 04c13003170f452839e2eee73cd364ea33805e0f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 8 May 2023 11:04:37 -0400 Subject: [PATCH 01/15] Adding List Choice to Notifications --- .../com/vitorpamplona/amethyst/LocalPreferences.kt | 4 ++++ .../com/vitorpamplona/amethyst/model/Account.kt | 7 +++++++ .../amethyst/ui/dal/NotificationFeedFilter.kt | 6 ++++++ .../amethyst/ui/navigation/AppTopBar.kt | 14 ++++++++++++-- .../amethyst/ui/screen/CardFeedViewModel.kt | 2 +- .../amethyst/ui/screen/loggedIn/HomeScreen.kt | 6 +++--- .../ui/screen/loggedIn/NotificationScreen.kt | 7 +++++-- 7 files changed, 38 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index a3a16dda8..eee89bf05 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -50,6 +50,7 @@ private object PrefKeys { const val DEFAULT_FILE_SERVER = "defaultFileServer" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" + const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val LATEST_CONTACT_LIST = "latestContactList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" @@ -203,6 +204,7 @@ object LocalPreferences { putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer)) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList) + putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList) putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest)) putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) @@ -225,6 +227,7 @@ object LocalPreferences { val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS + val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS val zapAmountChoices = gson.fromJson( getString(PrefKeys.ZAP_AMOUNTS, "[]"), @@ -288,6 +291,7 @@ object LocalPreferences { defaultFileServer, defaultHomeFollowList, defaultStoriesFollowList, + defaultNotificationFollowList, zapPaymentRequestServer, hideDeleteRequestDialog, hideBlockAlertDialog, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 726d8db7c..2796ed79a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -55,6 +55,7 @@ class Account( var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR, var defaultHomeFollowList: String = KIND3_FOLLOWS, var defaultStoriesFollowList: String = GLOBAL_FOLLOWS, + var defaultNotificationFollowList: String = GLOBAL_FOLLOWS, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, @@ -751,6 +752,12 @@ class Account( saveable.invalidateData() } + fun changeDefaultNotificationFollowList(name: String) { + defaultNotificationFollowList = name + live.invalidateData() + saveable.invalidateData() + } + fun changeZapAmounts(newAmounts: List) { zapAmountChoices = newAmounts live.invalidateData() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index ba746ebba..ea26abdd3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -18,6 +19,10 @@ object NotificationFeedFilter : AdditiveFeedFilter() { } private fun innerApplyFilter(collection: Collection): Set { + val isGlobal = account.defaultNotificationFollowList == GLOBAL_FOLLOWS + + val followingKeySet = account.selectedUsersFollowList(account.defaultNotificationFollowList) ?: emptySet() + val loggedInUser = account.userProfile() val loggedInUserHex = loggedInUser.pubkeyHex @@ -28,6 +33,7 @@ object NotificationFeedFilter : AdditiveFeedFilter() { it.event !is BadgeDefinitionEvent && it.event !is BadgeProfilesEvent && it.author !== loggedInUser && + (isGlobal || it.author?.pubkeyHex in followingKeySet) && it.event?.isTaggedUser(loggedInUserHex) ?: false && (it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && tagsAnEventByUser(it, loggedInUser) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 3cc324086..b18aefcda 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -81,6 +81,7 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac // Route.Profile.route -> TopBarWithBackButton(navController) Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel) Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel) + Route.Notification.base -> NotificationTopBar(scaffoldState, accountViewModel) else -> MainTopBar(scaffoldState, accountViewModel) } } @@ -103,6 +104,15 @@ fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) } } +@Composable +fun NotificationTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { + GenericTopBar(scaffoldState, accountViewModel) { account -> + FollowList(account.defaultNotificationFollowList, account.userProfile(), true) { listName -> + account.changeDefaultNotificationFollowList(listName) + } + } +} + @Composable fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { GenericTopBar(scaffoldState, accountViewModel) { @@ -273,13 +283,13 @@ fun SimpleTextSpinner( ) { val interactionSource = remember { MutableInteractionSource() } var optionsShowing by remember { mutableStateOf(false) } - var currentText by remember { mutableStateOf(placeholder) } + var currentText by remember(placeholder) { mutableStateOf(placeholder) } Box( modifier = modifier, contentAlignment = Alignment.Center ) { - Text(currentText) + Text(placeholder) Box( modifier = Modifier .matchParentSize() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index defff2d01..70c152010 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -38,7 +38,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { private var lastAccount: Account? = null private var lastNotes: List? = null - private fun refresh() { + fun refresh() { val scope = CoroutineScope(Job() + Dispatchers.Default) scope.launch { refreshSuspended() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index 5d7c6265c..658963ab4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -50,12 +50,12 @@ fun HomeScreen( nip47: String? = null ) { val coroutineScope = rememberCoroutineScope() - val account = accountViewModel.accountLiveData.value?.account ?: return var wantsToAddNip47 by remember { mutableStateOf(nip47) } - val accountState = account.live.observeAsState() + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return - LaunchedEffect(accountViewModel, accountState.value?.account?.defaultHomeFollowList) { + LaunchedEffect(accountViewModel, account.defaultHomeFollowList) { HomeNewThreadFeedFilter.account = account HomeConversationsFeedFilter.account = account NostrHomeDataSource.resetFilters() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index 96149588d..9046a1569 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavController +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.screen.CardFeedView @@ -34,9 +35,11 @@ fun NotificationScreen( notifFeedViewModel.clear() } - LaunchedEffect(accountViewModel) { + LaunchedEffect(account.userProfile().pubkeyHex, account.defaultNotificationFollowList) { + NostrAccountDataSource.resetFilters() NotificationFeedFilter.account = account - notifFeedViewModel.invalidateData() + notifFeedViewModel.clear() + notifFeedViewModel.refresh() } val lifeCycleOwner = LocalLifecycleOwner.current From 4dcf38c492ca1c9ef541dccfe044552224c737cd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 8 May 2023 14:46:23 -0400 Subject: [PATCH 02/15] Fixing tab sliders from jumping from one state to the other --- .../ui/screen/loggedIn/BookmarkListScreen.kt | 4 +- .../ui/screen/loggedIn/ChatroomListScreen.kt | 168 +++++++----------- .../ui/screen/loggedIn/HiddenUsersScreen.kt | 4 +- .../amethyst/ui/screen/loggedIn/HomeScreen.kt | 53 +++--- .../ui/screen/loggedIn/ProfileScreen.kt | 4 +- 5 files changed, 102 insertions(+), 131 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt index ab92f670d..b9e79bef6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt @@ -72,8 +72,8 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon } ) } - HorizontalPager(pageCount = 2, state = pagerState) { - when (pagerState.currentPage) { + HorizontalPager(pageCount = 2, state = pagerState) { page -> + when (page) { 0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null) 1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null) } 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 b86669ebc..8c7c26e02 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 @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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 @@ -44,6 +45,7 @@ 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 import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel import kotlinx.coroutines.launch @@ -58,6 +60,48 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon 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() + } + + 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() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + + val tabs by remember { + derivedStateOf { + listOf( + ChatroomListTabItem(R.string.known, knownFeedViewModel, markKnownAsRead), + ChatroomListTabItem(R.string.new_requests, newFeedViewModel, markNewAsRead) + ) + } + } + Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxHeight()) { Column( @@ -68,21 +112,17 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon backgroundColor = MaterialTheme.colors.background, selectedTabIndex = pagerState.currentPage ) { - 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)) - } - ) + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + text = { + Text(text = stringResource(tab.resource)) + }, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(index) } + } + ) + } } IconButton( @@ -107,102 +147,20 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon } } - HorizontalPager(pageCount = 2, state = pagerState) { - when (pagerState.currentPage) { - 0 -> TabKnown(accountViewModel, navController, markKnownAsRead) - 1 -> TabNew(accountViewModel, navController, markNewAsRead) - } + HorizontalPager(pageCount = 2, state = pagerState) { page -> + ChatroomListFeedView( + viewModel = tabs[page].viewModel, + accountViewModel = accountViewModel, + navController = navController, + markAsRead = tabs[page].markAsRead + ) } } } } } -@Composable -fun TabKnown( - accountViewModel: AccountViewModel, - navController: NavController, - markAsRead: MutableState -) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return - - ChatroomListKnownFeedFilter.account = account - val feedViewModel: NostrChatroomListKnownFeedViewModel = viewModel() - - LaunchedEffect(accountViewModel) { - NostrChatroomListDataSource.account = account - NostrChatroomListDataSource.start() - feedViewModel.invalidateData() - } - - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(accountViewModel) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrChatroomListDataSource.account = account - NostrChatroomListDataSource.start() - feedViewModel.invalidateData() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } - - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - ChatroomListFeedView(feedViewModel, accountViewModel, navController, markAsRead) - } - } -} - -@Composable -fun TabNew( - accountViewModel: AccountViewModel, - navController: NavController, - markAsRead: MutableState -) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return - - ChatroomListNewFeedFilter.account = account - val feedViewModel: NostrChatroomListNewFeedViewModel = viewModel() - - LaunchedEffect(accountViewModel) { - NostrChatroomListDataSource.account = account - NostrChatroomListDataSource.start() - feedViewModel.invalidateData() - } - - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(accountViewModel) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - NostrChatroomListDataSource.account = account - NostrChatroomListDataSource.start() - feedViewModel.invalidateData() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } - - Column(Modifier.fillMaxHeight()) { - Column( - modifier = Modifier.padding(vertical = 0.dp) - ) { - ChatroomListFeedView(feedViewModel, accountViewModel, navController, markAsRead) - } - } -} +class ChatroomListTabItem(val resource: Int, val viewModel: FeedViewModel, val markAsRead: MutableState) @Composable fun ChatroomTabMenu( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 40dfa3099..243e57289 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -53,8 +53,8 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont } ) } - HorizontalPager(pageCount = 1, state = pagerState) { - when (pagerState.currentPage) { + HorizontalPager(pageCount = 1, state = pagerState) { page -> + when (page) { 0 -> UserFeedView(feedViewModel, accountViewModel, navController) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index 658963ab4..713cafd32 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -33,6 +33,7 @@ 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.ScrollStateKeys @@ -85,6 +86,15 @@ fun HomeScreen( } } + 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) + ) + ) + } + Column(Modifier.fillMaxHeight()) { Column( modifier = Modifier.padding(vertical = 0.dp) @@ -93,28 +103,31 @@ fun HomeScreen( backgroundColor = MaterialTheme.colors.background, selectedTabIndex = pagerState.currentPage ) { - Tab( - selected = pagerState.currentPage == 0, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, - text = { - Text(text = stringResource(R.string.new_threads)) - } - ) - - Tab( - selected = pagerState.currentPage == 1, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, - text = { - Text(text = stringResource(R.string.conversations)) - } - ) - } - HorizontalPager(pageCount = 2, state = pagerState) { - when (pagerState.currentPage) { - 0 -> FeedView(homeFeedViewModel, accountViewModel, navController, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS, scrollToTop) - 1 -> FeedView(repliesFeedViewModel, accountViewModel, navController, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES, scrollToTop) + 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, + navController = navController, + routeForLastRead = tabs[page].routeForLastRead, + scrollStateKey = tabs[page].scrollStateKey, + scrollToTop = scrollToTop + ) + } } } } + +class TabItem(val resource: Int, val viewModel: FeedViewModel, val routeForLastRead: String, val scrollStateKey: String) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index fffd2a126..cd6379cca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -288,8 +288,8 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController: modifier = with(LocalDensity.current) { Modifier.height((columnSize.height - tabsSize.height).toDp()) } - ) { - when (pagerState.currentPage) { + ) { page -> + when (page) { 0 -> TabNotesNewThreads(accountViewModel, navController) 1 -> TabNotesConversations(accountViewModel, navController) 2 -> TabFollows(baseUser, accountViewModel, navController) From 81290f2b26b1487f55302a612ab8f0eccd200227 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 8 May 2023 14:58:39 -0400 Subject: [PATCH 03/15] Moving follows to the known list. --- .../amethyst/ui/dal/ChatroomListKnownFeedFilter.kt | 3 ++- .../amethyst/ui/dal/ChatroomListNewFeedFilter.kt | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index 396933e94..812872134 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -9,10 +9,11 @@ object ChatroomListKnownFeedFilter : FeedFilter() { // returns the last Note of each user. override fun feed(): List { val me = account.userProfile() + val followingKeySet = account.followingKeySet() val privateChatrooms = me.privateChatrooms val messagingWith = privateChatrooms.keys.filter { - me.hasSentMessagesTo(it) && account.isAcceptable(it) + (it.pubkeyHex in followingKeySet || me.hasSentMessagesTo(it)) && !account.isHidden(it) } val privateMessages = messagingWith.mapNotNull { it -> diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt index d53b689d3..4ca7334e5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt @@ -8,11 +8,12 @@ object ChatroomListNewFeedFilter : FeedFilter() { // returns the last Note of each user. override fun feed(): List { - val me = ChatroomListKnownFeedFilter.account.userProfile() + val me = account.userProfile() + val followingKeySet = ChatroomListKnownFeedFilter.account.followingKeySet() val privateChatrooms = account.userProfile().privateChatrooms val messagingWith = privateChatrooms.keys.filter { - !me.hasSentMessagesTo(it) && account.isAcceptable(it) + it.pubkeyHex !in followingKeySet && !me.hasSentMessagesTo(it) && account.isAcceptable(it) } val privateMessages = messagingWith.mapNotNull { it -> From 3e6e7d4863ae0f96bbb837f7fe3ad8c24747d5cb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 8 May 2023 15:34:59 -0400 Subject: [PATCH 04/15] - Checks if the file still exists before discarding a duplicate event. - Adding a per list EOSE --- .../amethyst/model/LocalCache.kt | 26 +++++++++++-------- .../service/NostrAccountDataSource.kt | 6 ++--- .../service/NostrChatroomListDataSource.kt | 13 +++++----- .../amethyst/service/NostrHomeDataSource.kt | 6 ++--- .../amethyst/service/relays/EOSE.kt | 23 +++++++++++++--- 5 files changed, 47 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 20ca49cd8..6469b5b26 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -736,20 +736,24 @@ object LocalCache { note.addRelay(relay) } + val file = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95") + + if (!file.exists()) { + try { + val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95") + cachePath.mkdirs() + val stream = FileOutputStream(File(cachePath, event.id)) + stream.write(event.decode()) + stream.close() + Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}") + } catch (e: IOException) { + Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e) + } + } + // Already processed this event. if (note.event != null) return - try { - val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95") - cachePath.mkdirs() - val stream = FileOutputStream(File(cachePath, event.id)) - stream.write(event.decode()) - stream.close() - Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}") - } catch (e: IOException) { - Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e) - } - // this is an invalid event. But we don't need to keep the data in memory. val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 45bbb4f1b..aba906e2a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -75,7 +75,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { filter = JsonFilter( kinds = listOf(ReportEvent.kind), authors = listOf(account.userProfile().pubkeyHex), - since = latestEOSEs.users[account.userProfile()]?.relayList + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList ) ) } @@ -96,12 +96,12 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { ), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), limit = 400, - since = latestEOSEs.users[account.userProfile()]?.relayList + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList ) ) val accountChannel = requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time) + latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time) } override fun updateChannelFilters() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index f89e79b77..bfa173826 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -15,13 +15,14 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { lateinit var account: Account val latestEOSEs = EOSEAccount() + val chatRoomList = "ChatroomList" fun createMessagesToMeFilter() = TypedFilter( types = setOf(FeedType.PRIVATE_DMS), filter = JsonFilter( kinds = listOf(PrivateDmEvent.kind), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - since = latestEOSEs.users[account.userProfile()]?.relayList + since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList ) ) @@ -30,7 +31,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { filter = JsonFilter( kinds = listOf(PrivateDmEvent.kind), authors = listOf(account.userProfile().pubkeyHex), - since = latestEOSEs.users[account.userProfile()]?.relayList + since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList ) ) @@ -39,7 +40,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { filter = JsonFilter( kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind), authors = listOf(account.userProfile().pubkeyHex), - since = latestEOSEs.users[account.userProfile()]?.relayList + since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList ) ) @@ -48,7 +49,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { filter = JsonFilter( kinds = listOf(ChannelCreateEvent.kind), ids = account.followingChannels.toList(), - since = latestEOSEs.users[account.userProfile()]?.relayList + since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList ) ) @@ -72,7 +73,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { filter = JsonFilter( kinds = listOf(ChannelMessageEvent.kind), tags = mapOf("e" to listOf(it)), - since = latestEOSEs.users[account.userProfile()]?.relayList, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList, limit = 25 // Remember to consider spam that is being removed from the UI ) ) @@ -80,7 +81,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { } val chatroomListChannel = requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time) + latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time) } override fun updateChannelFilters() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index a4b5e8232..d83151205 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -59,7 +59,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), authors = followSet, limit = 400, - since = latestEOSEs.users[account.userProfile()]?.relayList + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList ) ) } @@ -79,13 +79,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { }.flatten() ), limit = 100, - since = latestEOSEs.users[account.userProfile()]?.relayList + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList ) ) } val followAccountChannel = requestNewChannel { time, relayUrl -> - latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time) + latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time) } override fun updateChannelFilters() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index ba2193a97..19ed34f6d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -19,13 +19,28 @@ class EOSERelayList(var relayList: Map = emptyMap()) { } } -class EOSEAccount(var users: Map = emptyMap()) { - fun addOrUpdate(user: User, relayUrl: String, time: Long) { - val relayList = users[user] +class EOSEFollowList(var followList: Map = emptyMap()) { + fun addOrUpdate(listCode: String, relayUrl: String, time: Long) { + val relayList = followList[listCode] if (relayList == null) { - users = users + mapOf(user to EOSERelayList(mapOf(relayUrl to EOSETime(time)))) + val newList = EOSERelayList() + newList.addOrUpdate(relayUrl, time) + followList = followList + mapOf(listCode to newList) } else { relayList.addOrUpdate(relayUrl, time) } } } + +class EOSEAccount(var users: Map = emptyMap()) { + fun addOrUpdate(user: User, listCode: String, relayUrl: String, time: Long) { + val followList = users[user] + if (followList == null) { + val newList = EOSEFollowList() + newList.addOrUpdate(listCode, relayUrl, time) + users = users + mapOf(user to newList) + } else { + followList.addOrUpdate(listCode, relayUrl, time) + } + } +} From 53ec9d777f7e4c1d98d8e9f17af7db9177637c85 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 8 May 2023 16:10:16 -0400 Subject: [PATCH 05/15] - Moving postValue to IO thread - launching navigate in a thread --- .../amethyst/NotificationCache.kt | 8 ++---- .../amethyst/model/AntiSpamFilter.kt | 8 ++---- .../vitorpamplona/amethyst/model/Channel.kt | 8 ++---- .../amethyst/model/LocalCache.kt | 2 +- .../com/vitorpamplona/amethyst/model/Note.kt | 8 ++---- .../com/vitorpamplona/amethyst/model/User.kt | 8 ++---- .../amethyst/ui/note/BadgeCompose.kt | 10 ++++--- .../amethyst/ui/note/BoostSetCompose.kt | 27 ++++++++++++++----- .../amethyst/ui/note/LikeSetCompose.kt | 26 +++++++++++++----- .../amethyst/ui/note/MessageSetCompose.kt | 7 ++++- .../amethyst/ui/note/MultiSetCompose.kt | 4 ++- .../amethyst/ui/note/NoteCompose.kt | 4 ++- .../amethyst/ui/note/ZapSetCompose.kt | 22 ++++++++++----- .../amethyst/ui/screen/ChatroomFeedView.kt | 2 +- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 4 +-- 15 files changed, 87 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt index 0dc17365f..2bb193fba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/NotificationCache.kt @@ -38,19 +38,15 @@ object NotificationCache { class NotificationLiveData(val cache: NotificationCache) : LiveData(NotificationState(cache)) { // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Main) { + private val bundler = BundledUpdate(300, Dispatchers.IO) { if (hasActiveObservers()) { - refresh() + postValue(NotificationState(cache)) } } fun invalidateData() { bundler.invalidate() } - - fun refresh() { - postValue(NotificationState(cache)) - } } class NotificationState(val cache: NotificationCache) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 34b26bba3..520a51e2a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -55,19 +55,15 @@ class AntiSpamFilter { class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData(AntiSpamState(cache)) { // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Main) { + private val bundler = BundledUpdate(300, Dispatchers.IO) { if (hasActiveObservers()) { - refresh() + postValue(AntiSpamState(cache)) } } fun invalidateData() { bundler.invalidate() } - - private fun refresh() { - postValue(AntiSpamState(cache)) - } } class AntiSpamState(val cache: AntiSpamFilter) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index c5cda7629..7716d04cc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -74,9 +74,9 @@ class Channel(val idHex: String) { class ChannelLiveData(val channel: Channel) : LiveData(ChannelState(channel)) { // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Main) { + private val bundler = BundledUpdate(300, Dispatchers.IO) { if (hasActiveObservers()) { - refresh() + postValue(ChannelState(channel)) } } @@ -84,10 +84,6 @@ class ChannelLiveData(val channel: Channel) : LiveData(ChannelStat bundler.invalidate() } - private fun refresh() { - postValue(ChannelState(channel)) - } - override fun onActive() { super.onActive() NostrSingleChannelDataSource.add(channel.idHex) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 6469b5b26..dc7177f98 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -916,7 +916,7 @@ object LocalCache { class LocalCacheLiveData : LiveData>(setOf()) { // Refreshes observers in batches. - private val bundler = BundledInsert(300, Dispatchers.Main) + private val bundler = BundledInsert(300, Dispatchers.IO) fun invalidateData(newNote: Note) { bundler.invalidateList(newNote) { bundledNewNotes -> diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index aa3ce4ad5..c391efeb6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -410,9 +410,9 @@ class NoteLiveSet(u: Note) { class NoteLiveData(val note: Note) : LiveData(NoteState(note)) { // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Main) { + private val bundler = BundledUpdate(300, Dispatchers.IO) { if (hasActiveObservers()) { - refresh() + postValue(NoteState(note)) } } @@ -420,10 +420,6 @@ class NoteLiveData(val note: Note) : LiveData(NoteState(note)) { bundler.invalidate() } - private fun refresh() { - postValue(NoteState(note)) - } - override fun onActive() { super.onActive() if (note is AddressableNote) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 392cc17f8..7d5877952 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -396,9 +396,9 @@ class UserMetadata { class UserLiveData(val user: User) : LiveData(UserState(user)) { // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Main) { + private val bundler = BundledUpdate(300, Dispatchers.IO) { if (hasActiveObservers()) { - refresh() + postValue(UserState(user)) } } @@ -406,10 +406,6 @@ class UserLiveData(val user: User) : LiveData(UserState(user)) { bundler.invalidate() } - private fun refresh() { - postValue(UserState(user)) - } - override fun onActive() { super.onActive() NostrSingleUserDataSource.add(user) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 3f71245ce..066777618 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -75,10 +75,12 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL .background(backgroundColor) .combinedClickable( onClick = { - routeFor( - note, - accountViewModel.userProfile() - )?.let { navController.navigate(it) } + scope.launch { + routeFor( + note, + accountViewModel.userProfile() + )?.let { navController.navigate(it) } + } }, onLongClick = { popupExpanded = true } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt index 107e02fe1..10b61d0d6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt @@ -17,6 +17,7 @@ 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 @@ -31,6 +32,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.BoostSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class) @@ -45,6 +47,8 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro val noteEvent = note?.event var popupExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + if (note == null) { BlankNote(Modifier, isInnerNote) } else { @@ -65,12 +69,19 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro } Column( - modifier = Modifier.background(backgroundColor).combinedClickable( - onClick = { - routeFor(note, account.userProfile())?.let { navController.navigate(it) } - }, - onLongClick = { popupExpanded = true } - ) + modifier = Modifier + .background(backgroundColor) + .combinedClickable( + onClick = { + scope.launch { + routeFor( + note, + account.userProfile() + )?.let { navController.navigate(it) } + } + }, + onLongClick = { popupExpanded = true } + ) ) { Row( modifier = Modifier @@ -90,7 +101,9 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro Icon( painter = painterResource(R.drawable.ic_retweeted), null, - modifier = Modifier.size(16.dp).align(Alignment.TopEnd), + modifier = Modifier + .size(16.dp) + .align(Alignment.TopEnd), tint = Color.Unspecified ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt index 9436512a8..eb22f8631 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt @@ -17,6 +17,7 @@ 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 @@ -31,6 +32,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.LikeSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class) @@ -44,6 +46,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route val noteEvent = note?.event var popupExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() if (note == null) { BlankNote(Modifier, isInnerNote) @@ -65,12 +68,19 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route } Column( - modifier = Modifier.background(backgroundColor).combinedClickable( - onClick = { - routeFor(note, account.userProfile())?.let { navController.navigate(it) } - }, - onLongClick = { popupExpanded = true } - ) + modifier = Modifier + .background(backgroundColor) + .combinedClickable( + onClick = { + scope.launch { + routeFor( + note, + account.userProfile() + )?.let { navController.navigate(it) } + } + }, + onLongClick = { popupExpanded = true } + ) ) { Row( modifier = Modifier @@ -90,7 +100,9 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route Icon( painter = painterResource(R.drawable.ic_liked), null, - modifier = Modifier.size(16.dp).align(Alignment.TopEnd), + modifier = Modifier + .size(16.dp) + .align(Alignment.TopEnd), tint = Color.Unspecified ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index 094f6f033..4e6fa71fe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -69,7 +69,12 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal Column( modifier = Modifier.background(backgroundColor).combinedClickable( onClick = { - routeFor(note, accountViewModel.userProfile())?.let { navController.navigate(it) } + scope.launch { + routeFor( + note, + accountViewModel.userProfile() + )?.let { navController.navigate(it) } + } }, onLongClick = { popupExpanded = true } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 7b454639a..1aebd5efa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -89,7 +89,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun .background(backgroundColor) .combinedClickable( onClick = { - routeFor(note, account.userProfile())?.let { navController.navigate(it) } + scope.launch { + routeFor(note, account.userProfile())?.let { navController.navigate(it) } + } }, onLongClick = { popupExpanded = true } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index d552c92a3..5c6bdc959 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -274,7 +274,9 @@ fun NoteComposeInner( modifier = modifier .combinedClickable( onClick = { - routeFor(note, loggedIn)?.let { navController.navigate(it) } + scope.launch { + routeFor(note, loggedIn)?.let { navController.navigate(it) } + } }, onLongClick = { popupExpanded = true } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt index d0c8d7b78..b99f3007c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt @@ -19,6 +19,7 @@ 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 @@ -33,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.screen.ZapSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class) @@ -46,6 +48,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor val noteEvent = note?.event var popupExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() if (note == null) { BlankNote(Modifier, isInnerNote) @@ -67,12 +70,19 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor } Column( - modifier = Modifier.background(backgroundColor).combinedClickable( - onClick = { - routeFor(note, account.userProfile())?.let { navController.navigate(it) } - }, - onLongClick = { popupExpanded = true } - ) + modifier = Modifier + .background(backgroundColor) + .combinedClickable( + onClick = { + scope.launch { + routeFor( + note, + account.userProfile() + )?.let { navController.navigate(it) } + } + }, + onLongClick = { popupExpanded = true } + ) ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index 169aefd3e..1f67a61f3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -67,7 +67,7 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode } } } - FeedState.Loading -> { + is FeedState.Loading -> { LoadingFeed() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 937332cdc..94c960493 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -41,7 +41,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterialApi::class) @Composable fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) { - val coroutineScope = rememberCoroutineScope() + val scope = rememberCoroutineScope() val navController = rememberNavController() val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) val sheetState = rememberModalBottomSheetState( @@ -69,7 +69,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun drawerContent = { DrawerContent(navController, scaffoldState, sheetState, accountViewModel) BackHandler(enabled = scaffoldState.drawerState.isOpen) { - coroutineScope.launch { scaffoldState.drawerState.close() } + scope.launch { scaffoldState.drawerState.close() } } }, floatingActionButton = { From 432597568608bc2a274d42ba5371420f8aeaa8a5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 8 May 2023 16:20:40 -0400 Subject: [PATCH 06/15] v0.44.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 1086fcee1..a4d21bc8b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 150 - versionName "0.43.2" + versionCode 151 + versionName "0.44.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From bb8bb16370aeda7b7fcef7beb332f7084c050a02 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 9 May 2023 11:10:39 -0400 Subject: [PATCH 07/15] Better regex for nostr:n.. citations in text. --- .../vitorpamplona/amethyst/CitationTests.kt | 89 +++++++++++++++++++ .../amethyst/service/nip19/Nip19.kt | 2 +- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/vitorpamplona/amethyst/CitationTests.kt diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/CitationTests.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/CitationTests.kt new file mode 100644 index 000000000..450f300a3 --- /dev/null +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/CitationTests.kt @@ -0,0 +1,89 @@ +package com.vitorpamplona.amethyst + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import junit.framework.TestCase.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CitationTests { + val json = """ + { + "content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", + "created_at": 1683596206, + "id": "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758", + "kind": 1, + "pubkey": "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", + "sig": "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce", + "tags": [ + [ + "e", + "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3", + "", + "root" + ], + [ + "e", + "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc", + "", + "reply" + ], + [ + "p", + "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" + ], + [ + "p", + "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" + ], + [ + "p", + "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + ], + [ + "p", + "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2" + ], + [ + "p", + "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac" + ], + [ + "p", + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + ], + [ + "p", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + ], + [ + "p", + "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0" + ], + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ], + "seenOn": [ + "wss://nostr.wine/" + ] +} +""" + + @Test + fun parseEvent() { + val event = Event.fromJson(json, true) as TextNoteEvent + + val expectedCitations = setOf( + "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168", + "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", + "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ) + + assertEquals(expectedCitations, event.citedUsers()) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt index 4a42c1adf..460f1488e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt @@ -13,7 +13,7 @@ object Nip19 { USER, NOTE, EVENT, RELAY, ADDRESS } - val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE) + val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE) data class Return( val type: Type, From 3287196243dde958e20a06032b246ed687af6614 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 9 May 2023 11:10:53 -0400 Subject: [PATCH 08/15] updating gradle plugin --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 503c6fd89..2c099a9ed 100644 --- a/build.gradle +++ b/build.gradle @@ -10,8 +10,8 @@ buildscript { } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.0.0' apply false - id 'com.android.library' version '8.0.0' apply false + id 'com.android.application' version '8.0.1' apply false + id 'com.android.library' version '8.0.1' apply false id 'org.jetbrains.kotlin.android' version '1.8.10' apply false id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false } From 470281fa8782aee4fbe97bd5e397fa320d7f2a5c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 9 May 2023 12:58:56 -0400 Subject: [PATCH 09/15] Filters lists out of the user's profile --- .../amethyst/service/NostrUserProfileDataSource.kt | 2 +- .../amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 44222d800..a0e216f6e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -82,7 +82,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(BookmarkListEvent.kind), + kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind), authors = listOf(it.pubkeyHex), limit = 1 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index 327b40917..13d197421 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -4,6 +4,8 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.model.BookmarkListEvent +import com.vitorpamplona.amethyst.service.model.PeopleListEvent object UserProfileNewThreadFeedFilter : FeedFilter() { var account: Account? = null @@ -15,7 +17,8 @@ object UserProfileNewThreadFeedFilter : FeedFilter() { } override fun feed(): List { - val longFormNotes = LocalCache.addressables.values.filter { it.author == user } + val longFormNotes = LocalCache.addressables.values + .filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent) } return user?.notes ?.plus(longFormNotes) From 633be54dd404adb68ef5c7adc56b268d46ff9b6a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 9 May 2023 13:20:59 -0400 Subject: [PATCH 10/15] Updates dependencies --- app/build.gradle | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a4d21bc8b..e88d2de29 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -86,7 +86,7 @@ android { dependencies { implementation 'androidx.core:core-ktx:1.10.0' - implementation 'androidx.activity:activity-compose:1.7.0' + implementation 'androidx.activity:activity-compose:1.7.1' implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" diff --git a/build.gradle b/build.gradle index 2c099a9ed..d06865d09 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { fragment_version = "1.5.7" lifecycle_version = '2.6.1' - compose_ui_version = '1.4.2' + compose_ui_version = '1.4.3' nav_version = "2.5.3" room_version = "2.4.3" accompanist_version = '0.30.0' From b8bc370bc1adf60699eb61591d91dfdc9f2ef6aa Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 10 May 2023 11:16:47 -0400 Subject: [PATCH 11/15] Support for Push Notifications in the PlayStore build --- app/build.gradle | 5 + app/google-services.json | 68 +++++++ .../notifications/PushNotificationUtils.kt | 8 + app/src/main/AndroidManifest.xml | 1 + .../amethyst/LocalPreferences.kt | 8 +- .../vitorpamplona/amethyst/model/Account.kt | 18 +- .../amethyst/model/LocalCache.kt | 60 +++++- .../amethyst/service/NostrDataSource.kt | 69 +------ .../amethyst/service/model/PrivateDmEvent.kt | 2 +- .../amethyst/service/nip19/Nip19.kt | 6 +- .../EventNotificationConsumer.kt | 97 +++++++++ .../notifications/NotificationUtils.kt | 185 ++++++++++++++++++ .../service/notifications/RegisterAccounts.kt | 73 +++++++ .../vitorpamplona/amethyst/ui/MainActivity.kt | 38 +++- app/src/main/res/values/strings.xml | 14 +- app/src/play/AndroidManifest.xml | 29 +++ .../PushNotificationReceiverService.kt | 22 +++ .../notifications/PushNotificationUtils.kt | 31 +++ build.gradle | 3 + 19 files changed, 645 insertions(+), 92 deletions(-) create mode 100644 app/google-services.json create mode 100644 app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt create mode 100644 app/src/play/AndroidManifest.xml create mode 100644 app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt create mode 100644 app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt diff --git a/app/build.gradle b/app/build.gradle index e88d2de29..bec364b6f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'org.jlleitschuh.gradle.ktlint' version "11.3.1" + id 'com.google.gms.google-services' } android { @@ -172,6 +173,10 @@ dependencies { // Google services model the translate text playImplementation 'com.google.mlkit:translate:17.0.1' + // PushNotifications + playImplementation platform('com.google.firebase:firebase-bom:32.0.0') + playImplementation 'com.google.firebase:firebase-messaging-ktx' + // Automatic memory leak detection debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 000000000..4a9fa85f5 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,68 @@ +{ + "project_info": { + "project_number": "768341258853", + "project_id": "amethyst-3057a", + "storage_bucket": "amethyst-3057a.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:768341258853:android:5d07c35a37b24ff36b8c8c", + "android_client_info": { + "package_name": "com.vitorpamplona.amethyst" + } + }, + "oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:768341258853:android:e0200680f552484d6b8c8c", + "android_client_info": { + "package_name": "com.vitorpamplona.amethyst.debug" + } + }, + "oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt new file mode 100644 index 000000000..af98585d0 --- /dev/null +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -0,0 +1,8 @@ +package com.vitorpamplona.amethyst.service.notifications + +import com.vitorpamplona.amethyst.AccountInfo + +class PushNotificationUtils { + fun init(accounts: List) { + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2fc406f2c..4c7929cf2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -70,6 +70,7 @@ android:name="com.journeyapps.barcodescanner.CaptureActivity" android:screenOrientation="fullSensor" tools:replace="screenOrientation" /> + \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index eee89bf05..001c4ddde 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -213,7 +213,13 @@ object LocalPreferences { } fun loadFromEncryptedStorage(): Account? { - encryptedPreferences(currentAccount()).apply { + val acc = loadFromEncryptedStorage(currentAccount()) + acc?.registerObservers() + return acc + } + + fun loadFromEncryptedStorage(npub: String?): Account? { + encryptedPreferences(npub).apply { val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2796ed79a..9fa2c65f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1037,14 +1037,7 @@ class Account( saveable.invalidateData() } - init { - backupContactList?.let { - println("Loading saved contacts ${it.toJson()}") - if (userProfile().latestContactList == null) { - LocalCache.consume(it) - } - } - + fun registerObservers() { // Observes relays to restart connections userProfile().live().relays.observeForever { GlobalScope.launch(Dispatchers.IO) { @@ -1071,6 +1064,15 @@ class Account( } } } + + init { + backupContactList?.let { + println("Loading saved contacts ${it.toJson()}") + if (userProfile().latestContactList == null) { + LocalCache.consume(it) + } + } + } } class AccountLiveData(private val account: Account) : LiveData(AccountState(account)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index dc7177f98..38f50ab20 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -347,7 +347,7 @@ object LocalCache { } } - fun consume(event: PrivateDmEvent, relay: Relay?) { + fun consume(event: PrivateDmEvent, relay: Relay?): Note { val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -357,7 +357,7 @@ object LocalCache { } // Already processed this event. - if (note.event != null) return + if (note.event != null) return note val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) } @@ -374,6 +374,8 @@ object LocalCache { } refreshObservers(note) + + return note } fun consume(event: DeletionEvent) { @@ -911,6 +913,60 @@ object LocalCache { private fun refreshObservers(newNote: Note) { live.invalidateData(newNote) } + + fun consume(event: Event, relay: Relay?) { + if (!event.hasValidSignature()) return + + try { + when (event) { + is BadgeAwardEvent -> consume(event) + is BadgeDefinitionEvent -> consume(event) + is BadgeProfilesEvent -> consume(event) + is BookmarkListEvent -> consume(event) + is ChannelCreateEvent -> consume(event) + is ChannelHideMessageEvent -> consume(event) + is ChannelMessageEvent -> consume(event, relay) + is ChannelMetadataEvent -> consume(event) + is ChannelMuteUserEvent -> consume(event) + is ContactListEvent -> consume(event) + is DeletionEvent -> consume(event) + + is FileHeaderEvent -> consume(event, relay) + is FileStorageEvent -> consume(event, relay) + is FileStorageHeaderEvent -> consume(event, relay) + is HighlightEvent -> consume(event, relay) + is LnZapEvent -> { + event.zapRequest?.let { + consume(it, relay) + } + consume(event) + } + is LnZapRequestEvent -> consume(event) + is LnZapPaymentRequestEvent -> consume(event) + is LnZapPaymentResponseEvent -> consume(event) + is LongTextNoteEvent -> consume(event, relay) + is MetadataEvent -> consume(event) + is PrivateDmEvent -> consume(event, relay) + is PeopleListEvent -> consume(event) + is ReactionEvent -> consume(event) + is RecommendRelayEvent -> consume(event) + is ReportEvent -> consume(event, relay) + is RepostEvent -> { + event.containedPost()?.let { + consume(it, relay) + } + consume(event) + } + is TextNoteEvent -> consume(event, relay) + is PollNoteEvent -> consume(event, relay) + else -> { + Log.w("Event Not Supported", event.toJson()) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } } class LocalCacheLiveData : LiveData>(setOf()) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 5de8ef2bf..99db838b5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -3,28 +3,7 @@ package com.vitorpamplona.amethyst.service import android.util.Log import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.model.* -import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent -import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent -import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent -import com.vitorpamplona.amethyst.service.model.BookmarkListEvent -import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent -import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent -import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent -import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent -import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent -import com.vitorpamplona.amethyst.service.model.ContactListEvent -import com.vitorpamplona.amethyst.service.model.DeletionEvent import com.vitorpamplona.amethyst.service.model.Event -import com.vitorpamplona.amethyst.service.model.LnZapEvent -import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent -import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.service.model.MetadataEvent -import com.vitorpamplona.amethyst.service.model.PrivateDmEvent -import com.vitorpamplona.amethyst.service.model.ReactionEvent -import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent -import com.vitorpamplona.amethyst.service.model.ReportEvent -import com.vitorpamplona.amethyst.service.model.RepostEvent -import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Subscription @@ -52,8 +31,6 @@ abstract class NostrDataSource(val debugName: String) { private val clientListener = object : Client.Listener() { override fun onEvent(event: Event, subscriptionId: String, relay: Relay) { if (subscriptionId in subscriptions.keys) { - if (!event.hasValidSignature()) return - val key = "$debugName $subscriptionId ${event.kind}" val keyValue = eventCounter.get(key) if (keyValue != null) { @@ -62,51 +39,7 @@ abstract class NostrDataSource(val debugName: String) { eventCounter = eventCounter + Pair(key, Counter(1)) } - try { - when (event) { - is BadgeAwardEvent -> LocalCache.consume(event) - is BadgeDefinitionEvent -> LocalCache.consume(event) - is BadgeProfilesEvent -> LocalCache.consume(event) - is BookmarkListEvent -> LocalCache.consume(event) - is ChannelCreateEvent -> LocalCache.consume(event) - is ChannelHideMessageEvent -> LocalCache.consume(event) - is ChannelMessageEvent -> LocalCache.consume(event, relay) - is ChannelMetadataEvent -> LocalCache.consume(event) - is ChannelMuteUserEvent -> LocalCache.consume(event) - is ContactListEvent -> LocalCache.consume(event) - is DeletionEvent -> LocalCache.consume(event) - - is FileHeaderEvent -> LocalCache.consume(event, relay) - is FileStorageEvent -> LocalCache.consume(event, relay) - is FileStorageHeaderEvent -> LocalCache.consume(event, relay) - is HighlightEvent -> LocalCache.consume(event, relay) - is LnZapEvent -> { - event.zapRequest?.let { onEvent(it, subscriptionId, relay) } - LocalCache.consume(event) - } - is LnZapRequestEvent -> LocalCache.consume(event) - is LnZapPaymentRequestEvent -> LocalCache.consume(event) - is LnZapPaymentResponseEvent -> LocalCache.consume(event) - is LongTextNoteEvent -> LocalCache.consume(event, relay) - is MetadataEvent -> LocalCache.consume(event) - is PrivateDmEvent -> LocalCache.consume(event, relay) - is PeopleListEvent -> LocalCache.consume(event) - is ReactionEvent -> LocalCache.consume(event) - is RecommendRelayEvent -> LocalCache.consume(event) - is ReportEvent -> LocalCache.consume(event, relay) - is RepostEvent -> { - event.containedPost()?.let { onEvent(it, subscriptionId, relay) } - LocalCache.consume(event) - } - is TextNoteEvent -> LocalCache.consume(event, relay) - is PollNoteEvent -> LocalCache.consume(event, relay) - else -> { - Log.w("Event Not Supported", event.toJson()) - } - } - } catch (e: Exception) { - e.printStackTrace() - } + LocalCache.consume(event, relay) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt index c10837f3a..20e4bc696 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt @@ -21,7 +21,7 @@ class PrivateDmEvent( * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used * for initial messages. */ - fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" } + private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" } fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt index 460f1488e..6bfc27e69 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt @@ -20,7 +20,7 @@ object Nip19 { val hex: String, val relay: String? = null, val author: String? = null, - val kind: Long? = null, + val kind: Int? = null, val additionalChars: String = "" ) @@ -109,7 +109,7 @@ object Nip19 { val kind = tlv.get(Tlv.Type.KIND.id) ?.get(0) - ?.let { Tlv.toInt32(it) }?.toLong() + ?.let { Tlv.toInt32(it) } return Return(Type.EVENT, hex, relay, author, kind) } @@ -140,7 +140,7 @@ object Nip19 { val kind = tlv.get(Tlv.Type.KIND.id) ?.get(0) - ?.let { Tlv.toInt32(it) }?.toLong() + ?.let { Tlv.toInt32(it) } return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt new file mode 100644 index 000000000..e971ed0a6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -0,0 +1,97 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.app.NotificationManager +import android.content.Context +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification +import com.vitorpamplona.amethyst.ui.note.showAmount + +class EventNotificationConsumer(private val applicationContext: Context) { + fun consume(event: Event) { + // adds to database + LocalCache.consume(event, null) + + when (event) { + is PrivateDmEvent -> notify(event) + is LnZapEvent -> notify(event) + } + } + + private fun notify(event: PrivateDmEvent) { + val note = LocalCache.notes[event.id] ?: return + + LocalPreferences.allSavedAccounts().forEach { + val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + + if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { + val followingKeySet = acc.followingKeySet() + + val messagingWith = acc.userProfile().privateChatrooms.keys.filter { + ( + it.pubkeyHex in followingKeySet || acc.userProfile() + .hasSentMessagesTo(it) + ) && !acc.isHidden(it) + }.toSet() + + if (note.author in messagingWith) { + val content = acc.decryptContent(note) ?: "" + val user = note.author?.toBestDisplayName() ?: "" + val userPicture = note.author?.profilePicture() + val noteUri = note.toNEvent() + notificationManager().sendDMNotification(content, user, userPicture, noteUri, applicationContext) + } + } + } + } + + private fun notify(event: LnZapEvent) { + val noteZapEvent = LocalCache.notes[event.id] ?: return + val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } + val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } + + LocalPreferences.allSavedAccounts().forEach { + val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + + if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { + val amount = showAmount(event.amount) + val senderInfo = (noteZapRequest?.event as? LnZapRequestEvent)?.let { + val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest) + if (decryptedContent != null) { + val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) + Pair(author, decryptedContent.content) + } else if (!noteZapRequest.event?.content().isNullOrBlank()) { + Pair(noteZapRequest.author, noteZapRequest.event?.content()) + } else { + Pair(noteZapRequest.author, null) + } + } + + val zappedContent = noteZapped?.event?.content()?.split("\n")?.get(0)?.take(50) + val user = senderInfo?.first?.toBestDisplayName() ?: "" + var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount) + senderInfo?.second?.let { + title += " ($it)" + } + var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user) + zappedContent?.let { + content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent) + } + val userPicture = senderInfo?.first?.profilePicture() + val noteUri = "nostr:Notifications" + notificationManager().sendZapNotification(content, title, userPicture, noteUri, applicationContext) + } + } + } + + private fun notificationManager(): NotificationManager { + return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt new file mode 100644 index 000000000..d8d5d5d0b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -0,0 +1,185 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import androidx.core.app.NotificationCompat +import coil.ImageLoader +import coil.executeBlocking +import coil.request.ImageRequest +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.MainActivity + +object NotificationUtils { + + // Notification ID. + private var notificationId = 0 + + private var dmChannel: NotificationChannel? = null + private var zapChannel: NotificationChannel? = null + + private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel { + if (dmChannel != null) return dmChannel!! + + dmChannel = NotificationChannel( + applicationContext.getString(R.string.app_notification_dms_channel_id), + applicationContext.getString(R.string.app_notification_dms_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = applicationContext.getString(R.string.app_notification_dms_channel_description) + } + + // Register the channel with the system + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(dmChannel!!) + + return dmChannel!! + } + + private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel { + if (zapChannel != null) return zapChannel!! + + zapChannel = NotificationChannel( + applicationContext.getString(R.string.app_notification_zaps_channel_id), + applicationContext.getString(R.string.app_notification_zaps_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = applicationContext.getString(R.string.app_notification_zaps_channel_description) + } + + // Register the channel with the system + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(zapChannel!!) + + return zapChannel!! + } + + fun NotificationManager.sendZapNotification( + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + applicationContext: Context + ) { + val zapChannel = getOrCreateZapChannel(applicationContext) + val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id) + + sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext) + } + + fun NotificationManager.sendDMNotification( + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + applicationContext: Context + ) { + val dmChannel = getOrCreateDMChannel(applicationContext) + val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id) + + sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext) + } + + fun NotificationManager.sendNotification( + messageBody: String, + messageTitle: String, + pictureUrl: String?, + uri: String, + channelId: String, + applicationContext: Context + ) { + if (pictureUrl != null) { + val request = ImageRequest.Builder(applicationContext) + .data(pictureUrl) + .build() + + val imageLoader = ImageLoader(applicationContext) + val imageResult = imageLoader.executeBlocking(request) + sendNotification( + messageBody = messageBody, + messageTitle = messageTitle, + picture = imageResult.drawable as? BitmapDrawable, + uri = uri, + channelId, + applicationContext = applicationContext + ) + } else { + sendNotification( + messageBody = messageBody, + messageTitle = messageTitle, + picture = null, + uri = uri, + channelId, + applicationContext = applicationContext + ) + } + } + + private fun NotificationManager.sendNotification( + messageBody: String, + messageTitle: String, + picture: BitmapDrawable?, + uri: String, + channelId: String, + applicationContext: Context + ) { + val contentIntent = Intent(applicationContext, MainActivity::class.java).apply { + data = Uri.parse(uri) + } + + val contentPendingIntent = PendingIntent.getActivity( + applicationContext, + notificationId, + contentIntent, + PendingIntent.FLAG_MUTABLE + ) + + // Build the notification + val builderPublic = NotificationCompat.Builder( + applicationContext, + channelId + ) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(applicationContext.getString(R.string.app_notification_private_message)) + .setLargeIcon(picture?.bitmap) + .setGroup(messageTitle) + .setContentIntent(contentPendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + + // Build the notification + val builder = NotificationCompat.Builder( + applicationContext, + channelId + ) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(messageTitle) + .setContentText(messageBody) + .setLargeIcon(picture?.bitmap) + .setGroup(messageTitle) + .setContentIntent(contentPendingIntent) + .setPublicVersion(builderPublic.build()) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + + notify(notificationId, builder.build()) + + notificationId++ + } + + /** + * Cancels all notifications. + */ + fun NotificationManager.cancelNotifications() { + cancelAll() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt new file mode 100644 index 000000000..d3cb9d0c5 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -0,0 +1,73 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.util.Log +import com.vitorpamplona.amethyst.AccountInfo +import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.service.model.RelayAuthEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +class RegisterAccounts( + private val accounts: List +) { + + // creates proof that it controls all accounts + private fun signEventsToProveControlOfAccounts( + accounts: List, + notificationToken: String + ): List { + return accounts.mapNotNull { + val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) + if (acc != null) { + val relayToUse = acc.activeRelays()?.firstOrNull { it.read } + if (relayToUse != null) { + acc.createAuthEvent(relayToUse, notificationToken) + } else { + null + } + } else { + null + } + } + } + + private fun postRegistrationEvent(events: List) { + try { + val jsonObject = """{ + "events": [ ${events.joinToString(", ") { it.toJson() }} ] + } + """ + + val mediaType = "application/json; charset=utf-8".toMediaType() + val body = jsonObject.toRequestBody(mediaType) + + val request = Request.Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url("https://push.amethyst.social/register") + .post(body) + .build() + + val client = OkHttpClient.Builder().build() + + client.newCall(request).execute() + } catch (e: java.lang.Exception) { + Log.e("FirebaseMsgService", "Unable to register with push server", e) + } + } + + fun go(notificationToken: String) { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + postRegistrationEvent( + signEventsToProveControlOfAccounts(accounts, notificationToken) + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 808caf46f..f09b0735c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -20,7 +20,12 @@ import coil.decode.SvgDecoder import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.VideoCache +import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.nip19.Nip19 +import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.components.muted import com.vitorpamplona.amethyst.ui.navigation.Route @@ -39,15 +44,30 @@ class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val nip19 = Nip19.uriToRoute(intent?.data?.toString()) - val startingPage = when (nip19?.type) { - Nip19.Type.USER -> "User/${nip19.hex}" - Nip19.Type.NOTE -> "Note/${nip19.hex}" - Nip19.Type.EVENT -> "Event/${nip19.hex}" - Nip19.Type.ADDRESS -> "Note/${nip19.hex}" - else -> null + val uri = intent?.data?.toString() + + val startingPage = if (uri.equals("nostr:Notifications", true)) { + Route.Notification.route.replace("{scrollToTop}", "true") + } else { + val nip19 = Nip19.uriToRoute(uri) + when (nip19?.type) { + Nip19.Type.USER -> "User/${nip19.hex}" + Nip19.Type.NOTE -> "Note/${nip19.hex}" + Nip19.Type.EVENT -> { + if (nip19.kind == PrivateDmEvent.kind) { + "Room/${nip19.author}" + } else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) { + "Channel/${nip19.hex}" + } else { + "Event/${nip19.hex}" + } + } + + Nip19.Type.ADDRESS -> "Note/${nip19.hex}" + else -> null + } } ?: try { - intent?.data?.toString()?.let { + uri?.let { Nip47.parse(it) val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) Route.Home.base + "?nip47=" + encodedUri @@ -100,6 +120,8 @@ class MainActivity : FragmentActivity() { GlobalScope.launch(Dispatchers.IO) { ServiceManager.start() } + + PushNotificationUtils().init(LocalPreferences.allSavedAccounts()) } override fun onPause() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 703bb8e12..cab4a7e44 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -357,9 +357,21 @@ Files are hosted by your relays. New NIP: check if they support - Follow List All Follows Global + DefaultChannelID + New notification arrived + + PrivateMessagesID + Private Messages + Notifies you when a private message arrives + + ZapsID + Zaps Received + Notifies you when somebody zaps you + %1$s sats + from %1$s + for %1$s diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml new file mode 100644 index 000000000..f85be4db8 --- /dev/null +++ b/app/src/play/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt new file mode 100644 index 000000000..a933abb52 --- /dev/null +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -0,0 +1,22 @@ +package com.vitorpamplona.amethyst.service.notifications + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.service.model.Event + +class PushNotificationReceiverService : FirebaseMessagingService() { + + // this is called when a message is received + override fun onMessageReceived(remoteMessage: RemoteMessage) { + remoteMessage.data.let { + val eventStr = remoteMessage.data["event"] ?: return + val event = Event.fromJson(eventStr, true) + EventNotificationConsumer(applicationContext).consume(event) + } + } + + override fun onNewToken(token: String) { + RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token) + } +} diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt new file mode 100644 index 000000000..9fc979cb9 --- /dev/null +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationUtils.kt @@ -0,0 +1,31 @@ +package com.vitorpamplona.amethyst.service.notifications + +import android.util.Log +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging +import com.vitorpamplona.amethyst.AccountInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class PushNotificationUtils { + fun init(accounts: List) { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + // get user notification token provided by firebase + FirebaseMessaging.getInstance().token.addOnCompleteListener( + OnCompleteListener { task -> + if (!task.isSuccessful) { + Log.w("FirebaseMsgService", "Fetching FCM registration token failed", task.exception) + return@OnCompleteListener + } + // Get new FCM registration token + val notificationToken = task.result + + RegisterAccounts(accounts).go(notificationToken) + } + ) + } + } +} diff --git a/build.gradle b/build.gradle index d06865d09..09111b578 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,9 @@ buildscript { accompanist_version = '0.30.0' coil_version = '2.3.0' } + dependencies { + classpath 'com.google.gms:google-services:4.3.15' + } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '8.0.1' apply false From c87b246a4e8a8179d95b741575379bf6cdb05876 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 10 May 2023 11:19:32 -0400 Subject: [PATCH 12/15] Fixing tests --- .../com/vitorpamplona/amethyst/NIP19ParserTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt index cbd9950b8..2f618ddf9 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt @@ -61,7 +61,7 @@ class NIP19ParserTest { assertEquals("31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", result?.hex) assertEquals(null, result?.relay) assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) - assertEquals(31337L, result?.kind) + assertEquals(31337, result?.kind) } @Test @@ -71,7 +71,7 @@ class NIP19ParserTest { assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", result?.hex) assertEquals(null, result?.relay) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023L, result?.kind) + assertEquals(30023, result?.kind) } @Test @@ -81,7 +81,7 @@ class NIP19ParserTest { assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result?.hex) assertEquals(null, result?.relay) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023L, result?.kind) + assertEquals(30023, result?.kind) } @Test @@ -101,7 +101,7 @@ class NIP19ParserTest { assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) assertEquals("wss://nostr.mom", result?.relay) assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) - assertEquals(1L, result?.kind) + assertEquals(1, result?.kind) } @Test @@ -112,7 +112,7 @@ class NIP19ParserTest { assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) assertEquals("wss://relay.damus.io", result?.relay) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1L, result?.kind) + assertEquals(1, result?.kind) } @Test @@ -123,7 +123,7 @@ class NIP19ParserTest { assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) assertEquals("wss://relay.damus.io", result?.relay) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1L, result?.kind) + assertEquals(1, result?.kind) } @Test From 920ce54fe3fe4ff4e5dd6a916d3b9530a9d2f0be Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 10 May 2023 11:20:28 -0400 Subject: [PATCH 13/15] v0.45.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index bec364b6f..b1921914d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 151 - versionName "0.44.0" + versionCode 152 + versionName "0.45.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 0b2e5de7527384b4efef0d558269fdfdcb25f683 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 10 May 2023 13:30:52 -0400 Subject: [PATCH 14/15] Uppercase --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cab4a7e44..f2ceb9b0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -372,6 +372,6 @@ Zaps Received Notifies you when somebody zaps you %1$s sats - from %1$s + From %1$s for %1$s From c1d127ac1e251f37a72adec547f32692272ca977 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 10 May 2023 13:31:25 -0400 Subject: [PATCH 15/15] Fixing blank messages and Zapped DM content --- .../service/notifications/EventNotificationConsumer.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index e971ed0a6..0d7fcd26f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -74,10 +74,12 @@ class EventNotificationConsumer(private val applicationContext: Context) { } } - val zappedContent = noteZapped?.event?.content()?.split("\n")?.get(0)?.take(50) + val zappedContent = + noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0)?.take(50) } + val user = senderInfo?.first?.toBestDisplayName() ?: "" var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount) - senderInfo?.second?.let { + senderInfo?.second?.ifBlank { null }?.let { title += " ($it)" } var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)