- adds an account switcher to the notification click
- removes the use of nostr: for notifications - enables a new screen to be routed when new logins happen
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import java.net.URI
|
||||
|
||||
class URIParserTest {
|
||||
@Test
|
||||
fun testNEventWithAccount() {
|
||||
val test = "nevent1qqsp9wg5r3vkuv5al0459h3h44yxh6r0y7chgvu3pkdxhcj99t3msrgpzdmhxue69uhhwmm59e6hg7r09ehkuef0qgsdhcrqt2w8x9et446j8ge8kgmd2h4ykc6wsrnc4yqnmdu3lr74ktqrqsqqqqqp578kku?account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
|
||||
val testUri = URI(test)
|
||||
|
||||
// assertEquals("nostr", testUri.scheme)
|
||||
assertEquals(null, testUri.authority)
|
||||
assertEquals(null, testUri.host)
|
||||
assertEquals(-1, testUri.port)
|
||||
assertEquals(null, testUri.userInfo)
|
||||
assertEquals("nevent1qqsp9wg5r3vkuv5al0459h3h44yxh6r0y7chgvu3pkdxhcj99t3msrgpzdmhxue69uhhwmm59e6hg7r09ehkuef0qgsdhcrqt2w8x9et446j8ge8kgmd2h4ykc6wsrnc4yqnmdu3lr74ktqrqsqqqqqp578kku", testUri.path)
|
||||
assertEquals(null, testUri.fragment)
|
||||
assertEquals("account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.rawQuery)
|
||||
|
||||
assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.findParameterValue("account"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNotificationsWithAccount() {
|
||||
val test = "notifications?account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
|
||||
val testUri = URI(test)
|
||||
|
||||
// assertEquals("nostr", testUri.scheme)
|
||||
assertEquals(null, testUri.authority)
|
||||
assertEquals(null, testUri.host)
|
||||
assertEquals(-1, testUri.port)
|
||||
assertEquals(null, testUri.userInfo)
|
||||
assertEquals("notifications", testUri.path)
|
||||
assertEquals(null, testUri.fragment)
|
||||
assertEquals("account=npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.rawQuery)
|
||||
|
||||
assertEquals("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", testUri.findParameterValue("account"))
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDM
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
@@ -176,7 +177,7 @@ class EventNotificationConsumer(
|
||||
val content = chatNote.event?.content() ?: ""
|
||||
val user = chatNote.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = chatNote.author?.profilePicture()
|
||||
val noteUri = chatNote.toNEvent()
|
||||
val noteUri = chatNote.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub()
|
||||
notificationManager()
|
||||
.sendDMNotification(
|
||||
event.id,
|
||||
@@ -216,7 +217,7 @@ class EventNotificationConsumer(
|
||||
decryptContent(note, signer) { content ->
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
val noteUri = note.toNEvent() + "?account=" + acc.keyPair.pubKey.toNpub()
|
||||
notificationManager()
|
||||
.sendDMNotification(event.id, content, user, event.createdAt, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
@@ -322,7 +323,7 @@ class EventNotificationConsumer(
|
||||
)
|
||||
}
|
||||
val userPicture = senderInfo.first.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
val noteUri = "notifications?account=" + acc.keyPair.pubKey.toNpub()
|
||||
|
||||
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
|
||||
|
||||
@@ -353,9 +354,9 @@ class EventNotificationConsumer(
|
||||
)
|
||||
|
||||
val userPicture = senderInfo.first.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
val noteUri = "notifications?account=" + acc.keyPair.pubKey.toNpub()
|
||||
|
||||
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
|
||||
Log.d(TAG, "Notify ${event.id} $title $noteUri")
|
||||
|
||||
notificationManager()
|
||||
.sendZapNotification(
|
||||
|
||||
-6
@@ -221,9 +221,6 @@ object NotificationUtils {
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
// .setGroup(messageTitle)
|
||||
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
|
||||
// activate this
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
@@ -239,9 +236,6 @@ object NotificationUtils {
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(messageBody)
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
// .setGroup(messageTitle)
|
||||
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we
|
||||
// activate this
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPublicVersion(builderPublic.build())
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
|
||||
@@ -256,11 +256,11 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
fun uriToRoute(uri: String?): String? =
|
||||
if (uri.equals("nostr:Notifications", true)) {
|
||||
if (uri?.startsWith("notifications", true) == true || uri?.startsWith("nostr:notifications", true) == true) {
|
||||
Route.Notification.route.replace("{scrollToTop}", "true")
|
||||
} else {
|
||||
if (uri?.startsWith("nostr:Hashtag?id=") == true) {
|
||||
Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id="))
|
||||
if (uri?.startsWith("hashtag?id=") == true || uri?.startsWith("nostr:hashtag?id=") == true) {
|
||||
Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:").removePrefix("hashtag?id="))
|
||||
} else {
|
||||
val nip19 = Nip19Bech32.uriToRoute(uri)?.entity
|
||||
when (nip19) {
|
||||
@@ -280,6 +280,7 @@ fun uriToRoute(uri: String?): String? =
|
||||
"Event/${nip19.hex}"
|
||||
}
|
||||
}
|
||||
|
||||
is Nip19Bech32.NAddress -> {
|
||||
if (nip19.kind == CommunityDefinitionEvent.KIND) {
|
||||
"Community/${nip19.atag}"
|
||||
@@ -289,12 +290,14 @@ fun uriToRoute(uri: String?): String? =
|
||||
"Event/${nip19.atag}"
|
||||
}
|
||||
}
|
||||
|
||||
is Nip19Bech32.NEmbed -> {
|
||||
if (LocalCache.getNoteIfExists(nip19.event.id) == null) {
|
||||
LocalCache.verifyAndConsume(nip19.event, null)
|
||||
}
|
||||
"Event/${nip19.event.id}"
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@ class MarkdownMediaRenderer(
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
val tagWithoutHash = tag.removePrefix("#")
|
||||
renderAsCompleteLink(tag, "nostr:Hashtag?id=$tagWithoutHash", richTextStringBuilder)
|
||||
renderAsCompleteLink(tag, "nostr:nashtag?id=$tagWithoutHash", richTextStringBuilder)
|
||||
|
||||
val hashtagIcon: HashtagIcon? = checkForHashtagWithIcon(tagWithoutHash)
|
||||
if (hashtagIcon != null) {
|
||||
|
||||
@@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
|
||||
fun NavBackStackEntry.id(): String? = arguments?.getString("id")
|
||||
@@ -349,6 +350,14 @@ private fun NavigateIfIntentRequested(
|
||||
accountViewModel: AccountViewModel,
|
||||
accountStateViewModel: AccountStateViewModel,
|
||||
) {
|
||||
accountViewModel.firstRoute?.let {
|
||||
accountViewModel.firstRoute = null
|
||||
val currentRoute = getRouteWithArguments(nav.controller)
|
||||
if (!isSameRoute(currentRoute, it)) {
|
||||
nav.newStack(it)
|
||||
}
|
||||
}
|
||||
|
||||
val activity = LocalContext.current.getActivity()
|
||||
|
||||
if (activity.intent.action == Intent.ACTION_SEND) {
|
||||
@@ -394,12 +403,17 @@ private fun NavigateIfIntentRequested(
|
||||
|
||||
LaunchedEffect(intentNextPage) {
|
||||
if (actionableNextPage != null) {
|
||||
actionableNextPage?.let {
|
||||
val currentRoute = getRouteWithArguments(nav.controller)
|
||||
if (!isSameRoute(currentRoute, it)) {
|
||||
nav.newStack(it)
|
||||
actionableNextPage?.let { nextRoute ->
|
||||
val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
|
||||
if (npub != null && accountStateViewModel.currentAccount() != npub) {
|
||||
accountStateViewModel.switchUserSync(npub, nextRoute)
|
||||
} else {
|
||||
val currentRoute = getRouteWithArguments(nav.controller)
|
||||
if (!isSameRoute(currentRoute, nextRoute)) {
|
||||
nav.newStack(nextRoute)
|
||||
}
|
||||
actionableNextPage = null
|
||||
}
|
||||
actionableNextPage = null
|
||||
}
|
||||
} else if (intentNextPage.contains("ncryptsec1")) {
|
||||
// login functions
|
||||
@@ -439,14 +453,22 @@ private fun NavigateIfIntentRequested(
|
||||
}
|
||||
} else {
|
||||
val uri = intent.data?.toString()
|
||||
|
||||
if (!uri.isNullOrBlank()) {
|
||||
// navigation functions
|
||||
val newPage = uriToRoute(uri)
|
||||
|
||||
if (newPage != null) {
|
||||
val currentRoute = getRouteWithArguments(nav.controller)
|
||||
if (!isSameRoute(currentRoute, newPage)) {
|
||||
nav.newStack(newPage)
|
||||
scope.launch {
|
||||
val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
|
||||
if (npub != null && accountStateViewModel.currentAccount() != npub) {
|
||||
accountStateViewModel.switchUserSync(npub, newPage)
|
||||
} else {
|
||||
val currentRoute = getRouteWithArguments(nav.controller)
|
||||
if (!isSameRoute(currentRoute, newPage)) {
|
||||
nav.newStack(newPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (uri.contains("ncryptsec")) {
|
||||
// login functions
|
||||
@@ -508,3 +530,14 @@ val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), ta
|
||||
|
||||
val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f)
|
||||
val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f)
|
||||
|
||||
fun URI.findParameterValue(parameterName: String): String? =
|
||||
rawQuery
|
||||
?.split('&')
|
||||
?.map {
|
||||
val parts = it.split('=')
|
||||
val name = parts.firstOrNull() ?: ""
|
||||
val value = parts.drop(1).firstOrNull() ?: ""
|
||||
Pair(name, value)
|
||||
}?.firstOrNull { it.first == parameterName }
|
||||
?.second
|
||||
|
||||
@@ -97,23 +97,7 @@ fun AccountScreen(
|
||||
) {
|
||||
LoggedInPage(
|
||||
state.accountSettings,
|
||||
accountStateViewModel,
|
||||
sharedPreferencesViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(key1 = accountState) {
|
||||
onDispose {
|
||||
state.currentViewModelStore.viewModelStore.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
is AccountState.LoggedInViewOnly -> {
|
||||
CompositionLocalProvider(
|
||||
LocalViewModelStoreOwner provides state.currentViewModelStore,
|
||||
) {
|
||||
LoggedInPage(
|
||||
state.accountSettings,
|
||||
state.route,
|
||||
accountStateViewModel,
|
||||
sharedPreferencesViewModel,
|
||||
)
|
||||
@@ -132,6 +116,7 @@ fun AccountScreen(
|
||||
@Composable
|
||||
fun LoggedInPage(
|
||||
accountSettings: AccountSettings,
|
||||
route: String?,
|
||||
accountStateViewModel: AccountStateViewModel,
|
||||
sharedPreferencesViewModel: SharedPreferencesViewModel,
|
||||
) {
|
||||
@@ -145,6 +130,8 @@ fun LoggedInPage(
|
||||
),
|
||||
)
|
||||
|
||||
accountViewModel.firstRoute = route
|
||||
|
||||
LaunchedEffect(key1 = accountViewModel) {
|
||||
accountViewModel.restartServices()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
|
||||
sealed class AccountState {
|
||||
@@ -27,14 +28,10 @@ sealed class AccountState {
|
||||
|
||||
object LoggedOff : AccountState()
|
||||
|
||||
class LoggedInViewOnly(
|
||||
val accountSettings: AccountSettings,
|
||||
) : AccountState() {
|
||||
val currentViewModelStore = AccountCentricViewModelStore(accountSettings)
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LoggedIn(
|
||||
val accountSettings: AccountSettings,
|
||||
var route: String? = null,
|
||||
) : AccountState() {
|
||||
val currentViewModelStore = AccountCentricViewModelStore(accountSettings)
|
||||
}
|
||||
|
||||
+38
-29
@@ -27,7 +27,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.LocalPreferences.currentAccount
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.DefaultChannels
|
||||
import com.vitorpamplona.amethyst.model.DefaultDMRelayList
|
||||
@@ -81,10 +80,10 @@ class AccountStateViewModel : ViewModel() {
|
||||
viewModelScope.launch { tryLoginExistingAccount() }
|
||||
}
|
||||
|
||||
private suspend fun tryLoginExistingAccount() =
|
||||
private suspend fun tryLoginExistingAccount(route: String? = null) =
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalPreferences.loadCurrentAccountFromEncryptedStorage()
|
||||
}?.let { startUI(it) } ?: run { requestLoginUI() }
|
||||
}?.let { startUI(it, route) } ?: run { requestLoginUI() }
|
||||
|
||||
private suspend fun requestLoginUI() {
|
||||
_accountContent.update { AccountState.LoggedOff }
|
||||
@@ -162,22 +161,20 @@ class AccountStateViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
suspend fun startUI(accountSettings: AccountSettings) =
|
||||
withContext(Dispatchers.Main) {
|
||||
if (accountSettings.isWriteable()) {
|
||||
_accountContent.update { AccountState.LoggedIn(accountSettings) }
|
||||
} else {
|
||||
_accountContent.update { AccountState.LoggedInViewOnly(accountSettings) }
|
||||
}
|
||||
suspend fun startUI(
|
||||
accountSettings: AccountSettings,
|
||||
route: String? = null,
|
||||
) = withContext(Dispatchers.Main) {
|
||||
_accountContent.update { AccountState.LoggedIn(accountSettings, route) }
|
||||
|
||||
collectorJob?.cancel()
|
||||
collectorJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountSettings.saveable.debounce(1000).collect {
|
||||
LocalPreferences.saveToEncryptedStorage(it.accountSettings)
|
||||
}
|
||||
collectorJob?.cancel()
|
||||
collectorJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
accountSettings.saveable.debounce(1000).collect {
|
||||
LocalPreferences.saveToEncryptedStorage(it.accountSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareLogoutOrSwitch() =
|
||||
when (val state = _accountContent.value) {
|
||||
@@ -186,11 +183,6 @@ class AccountStateViewModel : ViewModel() {
|
||||
state.currentViewModelStore.viewModelStore.clear()
|
||||
}
|
||||
|
||||
is AccountState.LoggedInViewOnly -> {
|
||||
collectorJob?.cancel()
|
||||
state.currentViewModelStore.viewModelStore.clear()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
@@ -316,22 +308,39 @@ class AccountStateViewModel : ViewModel() {
|
||||
|
||||
fun switchUser(accountInfo: AccountInfo) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
prepareLogoutOrSwitch()
|
||||
LocalPreferences.switchToAccount(accountInfo)
|
||||
tryLoginExistingAccount()
|
||||
switchUserSync(accountInfo)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun switchUserSync(
|
||||
npub: String,
|
||||
route: String,
|
||||
): Boolean {
|
||||
if (npub != LocalPreferences.currentAccount()) {
|
||||
val account = LocalPreferences.allSavedAccounts().firstOrNull { it.npub == npub }
|
||||
if (account != null) {
|
||||
switchUserSync(account, route)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun switchUserSync(
|
||||
accountInfo: AccountInfo,
|
||||
route: String? = null,
|
||||
) {
|
||||
prepareLogoutOrSwitch()
|
||||
LocalPreferences.switchToAccount(accountInfo)
|
||||
tryLoginExistingAccount(route)
|
||||
}
|
||||
|
||||
fun currentAccount() =
|
||||
when (val state = _accountContent.value) {
|
||||
is AccountState.LoggedIn ->
|
||||
state.accountSettings.keyPair.pubKey
|
||||
.toNpub()
|
||||
|
||||
is AccountState.LoggedInViewOnly ->
|
||||
state.accountSettings.keyPair.pubKey
|
||||
.toNpub()
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -153,6 +153,8 @@ class AccountViewModel(
|
||||
Dao {
|
||||
val account = Account(accountSettings, accountSettings.createSigner(), viewModelScope)
|
||||
|
||||
var firstRoute: String? = null
|
||||
|
||||
// TODO: contact lists are not notes yet
|
||||
// val kind3Relays: StateFlow<ContactListEvent?> = observeByAuthor(ContactListEvent.KIND, account.signer.pubKey)
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
<service
|
||||
android:name=".service.notifications.PushNotificationReceiverService"
|
||||
android:exported="false">
|
||||
android:exported="true"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
|
||||
</intent-filter>
|
||||
|
||||
Reference in New Issue
Block a user