Merge branch 'main' into main

This commit is contained in:
greenart7c3
2023-05-10 14:42:37 -03:00
committed by GitHub
52 changed files with 1033 additions and 337 deletions
+8 -3
View File
@@ -2,6 +2,7 @@ plugins {
id 'com.android.application' id 'com.android.application'
id 'org.jetbrains.kotlin.android' id 'org.jetbrains.kotlin.android'
id 'org.jlleitschuh.gradle.ktlint' version "11.3.1" id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
id 'com.google.gms.google-services'
} }
android { android {
@@ -12,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 33 targetSdk 33
versionCode 150 versionCode 152
versionName "0.43.2" versionName "0.45.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -86,7 +87,7 @@ android {
dependencies { dependencies {
implementation 'androidx.core:core-ktx:1.10.0' 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:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -172,6 +173,10 @@ dependencies {
// Google services model the translate text // Google services model the translate text
playImplementation 'com.google.mlkit:translate:17.0.1' 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 // Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
+68
View File
@@ -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"
}
@@ -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())
}
}
@@ -0,0 +1,8 @@
package com.vitorpamplona.amethyst.service.notifications
import com.vitorpamplona.amethyst.AccountInfo
class PushNotificationUtils {
fun init(accounts: List<AccountInfo>) {
}
}
+1
View File
@@ -72,6 +72,7 @@
android:name="com.journeyapps.barcodescanner.CaptureActivity" android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor" android:screenOrientation="fullSensor"
tools:replace="screenOrientation" /> tools:replace="screenOrientation" />
</application> </application>
</manifest> </manifest>
@@ -51,6 +51,7 @@ private object PrefKeys {
const val DEFAULT_FILE_SERVER = "defaultFileServer" const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList" const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
@@ -206,6 +207,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer)) putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList) 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.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
@@ -216,7 +218,13 @@ object LocalPreferences {
} }
fun loadFromEncryptedStorage(): Account? { 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 pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null) val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf() val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
@@ -230,6 +238,7 @@ object LocalPreferences {
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_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( val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"), getString(PrefKeys.ZAP_AMOUNTS, "[]"),
@@ -296,6 +305,7 @@ object LocalPreferences {
defaultFileServer, defaultFileServer,
defaultHomeFollowList, defaultHomeFollowList,
defaultStoriesFollowList, defaultStoriesFollowList,
defaultNotificationFollowList,
zapPaymentRequestServer, zapPaymentRequestServer,
hideDeleteRequestDialog, hideDeleteRequestDialog,
hideBlockAlertDialog, hideBlockAlertDialog,
@@ -38,19 +38,15 @@ object NotificationCache {
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) { class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) { private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) { if (hasActiveObservers()) {
refresh() postValue(NotificationState(cache))
} }
} }
fun invalidateData() { fun invalidateData() {
bundler.invalidate() bundler.invalidate()
} }
fun refresh() {
postValue(NotificationState(cache))
}
} }
class NotificationState(val cache: NotificationCache) class NotificationState(val cache: NotificationCache)
@@ -56,6 +56,7 @@ class Account(
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR, var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
var defaultHomeFollowList: String = KIND3_FOLLOWS, var defaultHomeFollowList: String = KIND3_FOLLOWS,
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS, var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
var zapPaymentRequest: Nip47URI? = null, var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false, var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false,
@@ -754,6 +755,12 @@ class Account(
saveable.invalidateData() saveable.invalidateData()
} }
fun changeDefaultNotificationFollowList(name: String) {
defaultNotificationFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) { fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts zapAmountChoices = newAmounts
live.invalidateData() live.invalidateData()
@@ -1034,14 +1041,7 @@ class Account(
saveable.invalidateData() saveable.invalidateData()
} }
init { fun registerObservers() {
backupContactList?.let {
println("Loading saved contacts ${it.toJson()}")
if (userProfile().latestContactList == null) {
LocalCache.consume(it)
}
}
// Observes relays to restart connections // Observes relays to restart connections
userProfile().live().relays.observeForever { userProfile().live().relays.observeForever {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
@@ -1068,6 +1068,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>(AccountState(account)) { class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
@@ -55,19 +55,15 @@ class AntiSpamFilter {
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) { class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) { private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) { if (hasActiveObservers()) {
refresh() postValue(AntiSpamState(cache))
} }
} }
fun invalidateData() { fun invalidateData() {
bundler.invalidate() bundler.invalidate()
} }
private fun refresh() {
postValue(AntiSpamState(cache))
}
} }
class AntiSpamState(val cache: AntiSpamFilter) class AntiSpamState(val cache: AntiSpamFilter)
@@ -74,9 +74,9 @@ class Channel(val idHex: String) {
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) { class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) { private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) { if (hasActiveObservers()) {
refresh() postValue(ChannelState(channel))
} }
} }
@@ -84,10 +84,6 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
bundler.invalidate() bundler.invalidate()
} }
private fun refresh() {
postValue(ChannelState(channel))
}
override fun onActive() { override fun onActive() {
super.onActive() super.onActive()
NostrSingleChannelDataSource.add(channel.idHex) NostrSingleChannelDataSource.add(channel.idHex)
@@ -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 note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey) val author = getOrCreateUser(event.pubKey)
@@ -357,7 +357,7 @@ object LocalCache {
} }
// Already processed this event. // Already processed this event.
if (note.event != null) return if (note.event != null) return note
val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) } val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }
@@ -374,6 +374,8 @@ object LocalCache {
} }
refreshObservers(note) refreshObservers(note)
return note
} }
fun consume(event: DeletionEvent) { fun consume(event: DeletionEvent) {
@@ -736,20 +738,24 @@ object LocalCache {
note.addRelay(relay) 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. // Already processed this event.
if (note.event != null) return 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. // 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) val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
@@ -907,12 +913,66 @@ object LocalCache {
private fun refreshObservers(newNote: Note) { private fun refreshObservers(newNote: Note) {
live.invalidateData(newNote) 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<Set<Note>>(setOf<Note>()) { class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledInsert<Note>(300, Dispatchers.Main) private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
fun invalidateData(newNote: Note) { fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes -> bundler.invalidateList(newNote) { bundledNewNotes ->
@@ -410,9 +410,9 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) { class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) { private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) { if (hasActiveObservers()) {
refresh() postValue(NoteState(note))
} }
} }
@@ -420,10 +420,6 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
bundler.invalidate() bundler.invalidate()
} }
private fun refresh() {
postValue(NoteState(note))
}
override fun onActive() { override fun onActive() {
super.onActive() super.onActive()
if (note is AddressableNote) { if (note is AddressableNote) {
@@ -396,9 +396,9 @@ class UserMetadata {
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) { class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) { private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) { if (hasActiveObservers()) {
refresh() postValue(UserState(user))
} }
} }
@@ -406,10 +406,6 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
bundler.invalidate() bundler.invalidate()
} }
private fun refresh() {
postValue(UserState(user))
}
override fun onActive() { override fun onActive() {
super.onActive() super.onActive()
NostrSingleUserDataSource.add(user) NostrSingleUserDataSource.add(user)
@@ -75,7 +75,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(ReportEvent.kind), kinds = listOf(ReportEvent.kind),
authors = listOf(account.userProfile().pubkeyHex), 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)), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 400, limit = 400,
since = latestEOSEs.users[account.userProfile()]?.relayList since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
) )
) )
val accountChannel = requestNewChannel { time, relayUrl -> val accountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time) latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
} }
override fun updateChannelFilters() { override fun updateChannelFilters() {
@@ -15,13 +15,14 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
lateinit var account: Account lateinit var account: Account
val latestEOSEs = EOSEAccount() val latestEOSEs = EOSEAccount()
val chatRoomList = "ChatroomList"
fun createMessagesToMeFilter() = TypedFilter( fun createMessagesToMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS), types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind), kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), 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( filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind), kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex), 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( filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind), kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex), 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( filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind), kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList(), 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( filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind), kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)), 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 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 -> val chatroomListChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time) latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
} }
override fun updateChannelFilters() { override fun updateChannelFilters() {
@@ -3,28 +3,7 @@ package com.vitorpamplona.amethyst.service
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.* 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.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.Client
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.Subscription import com.vitorpamplona.amethyst.service.relays.Subscription
@@ -52,8 +31,6 @@ abstract class NostrDataSource(val debugName: String) {
private val clientListener = object : Client.Listener() { private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) { override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in subscriptions.keys) { if (subscriptionId in subscriptions.keys) {
if (!event.hasValidSignature()) return
val key = "$debugName $subscriptionId ${event.kind}" val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key) val keyValue = eventCounter.get(key)
if (keyValue != null) { if (keyValue != null) {
@@ -62,51 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter = eventCounter + Pair(key, Counter(1)) eventCounter = eventCounter + Pair(key, Counter(1))
} }
try { LocalCache.consume(event, relay)
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()
}
} }
} }
@@ -59,7 +59,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = followSet, authors = followSet,
limit = 400, 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() }.flatten()
), ),
limit = 100, limit = 100,
since = latestEOSEs.users[account.userProfile()]?.relayList since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
) )
) )
} }
val followAccountChannel = requestNewChannel { time, relayUrl -> val followAccountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time) latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
} }
override fun updateChannelFilters() { override fun updateChannelFilters() {
@@ -82,7 +82,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter( TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind), kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
authors = listOf(it.pubkeyHex), authors = listOf(it.pubkeyHex),
limit = 1 limit = 1
) )
@@ -21,7 +21,7 @@ class PrivateDmEvent(
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used * nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages. * 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() fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
@@ -13,14 +13,14 @@ object Nip19 {
USER, NOTE, EVENT, RELAY, ADDRESS 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( data class Return(
val type: Type, val type: Type,
val hex: String, val hex: String,
val relay: String? = null, val relay: String? = null,
val author: String? = null, val author: String? = null,
val kind: Long? = null, val kind: Int? = null,
val additionalChars: String = "" val additionalChars: String = ""
) )
@@ -109,7 +109,7 @@ object Nip19 {
val kind = tlv.get(Tlv.Type.KIND.id) val kind = tlv.get(Tlv.Type.KIND.id)
?.get(0) ?.get(0)
?.let { Tlv.toInt32(it) }?.toLong() ?.let { Tlv.toInt32(it) }
return Return(Type.EVENT, hex, relay, author, kind) return Return(Type.EVENT, hex, relay, author, kind)
} }
@@ -140,7 +140,7 @@ object Nip19 {
val kind = tlv.get(Tlv.Type.KIND.id) val kind = tlv.get(Tlv.Type.KIND.id)
?.get(0) ?.get(0)
?.let { Tlv.toInt32(it) }?.toLong() ?.let { Tlv.toInt32(it) }
return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind) return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
} }
@@ -0,0 +1,99 @@
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?.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?.ifBlank { null }?.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
}
}
@@ -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()
}
}
@@ -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<AccountInfo>
) {
// creates proof that it controls all accounts
private fun signEventsToProveControlOfAccounts(
accounts: List<AccountInfo>,
notificationToken: String
): List<RelayAuthEvent> {
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<RelayAuthEvent>) {
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)
)
}
}
}
@@ -19,13 +19,28 @@ class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
} }
} }
class EOSEAccount(var users: Map<User, EOSERelayList> = emptyMap()) { class EOSEFollowList(var followList: Map<String, EOSERelayList> = emptyMap()) {
fun addOrUpdate(user: User, relayUrl: String, time: Long) { fun addOrUpdate(listCode: String, relayUrl: String, time: Long) {
val relayList = users[user] val relayList = followList[listCode]
if (relayList == null) { 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 { } else {
relayList.addOrUpdate(relayUrl, time) relayList.addOrUpdate(relayUrl, time)
} }
} }
} }
class EOSEAccount(var users: Map<User, EOSEFollowList> = 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)
}
}
}
@@ -20,7 +20,12 @@ import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache 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.nip19.Nip19
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.muted import com.vitorpamplona.amethyst.ui.components.muted
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
@@ -39,15 +44,30 @@ class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val nip19 = Nip19.uriToRoute(intent?.data?.toString()) val uri = intent?.data?.toString()
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}" val startingPage = if (uri.equals("nostr:Notifications", true)) {
Nip19.Type.NOTE -> "Note/${nip19.hex}" Route.Notification.route.replace("{scrollToTop}", "true")
Nip19.Type.EVENT -> "Event/${nip19.hex}" } else {
Nip19.Type.ADDRESS -> "Note/${nip19.hex}" val nip19 = Nip19.uriToRoute(uri)
else -> null 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 { } ?: try {
intent?.data?.toString()?.let { uri?.let {
Nip47.parse(it) Nip47.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString()) val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri Route.Home.base + "?nip47=" + encodedUri
@@ -100,6 +120,8 @@ class MainActivity : FragmentActivity() {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start() ServiceManager.start()
} }
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
} }
override fun onPause() { override fun onPause() {
@@ -9,10 +9,11 @@ object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
// returns the last Note of each user. // returns the last Note of each user.
override fun feed(): List<Note> { override fun feed(): List<Note> {
val me = account.userProfile() val me = account.userProfile()
val followingKeySet = account.followingKeySet()
val privateChatrooms = me.privateChatrooms val privateChatrooms = me.privateChatrooms
val messagingWith = privateChatrooms.keys.filter { 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 -> val privateMessages = messagingWith.mapNotNull { it ->
@@ -8,11 +8,12 @@ object ChatroomListNewFeedFilter : FeedFilter<Note>() {
// returns the last Note of each user. // returns the last Note of each user.
override fun feed(): List<Note> { override fun feed(): List<Note> {
val me = ChatroomListKnownFeedFilter.account.userProfile() val me = account.userProfile()
val followingKeySet = ChatroomListKnownFeedFilter.account.followingKeySet()
val privateChatrooms = account.userProfile().privateChatrooms val privateChatrooms = account.userProfile().privateChatrooms
val messagingWith = privateChatrooms.keys.filter { 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 -> val privateMessages = messagingWith.mapNotNull { it ->
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
@@ -18,6 +19,10 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
} }
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> { private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val isGlobal = account.defaultNotificationFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultNotificationFollowList) ?: emptySet()
val loggedInUser = account.userProfile() val loggedInUser = account.userProfile()
val loggedInUserHex = loggedInUser.pubkeyHex val loggedInUserHex = loggedInUser.pubkeyHex
@@ -28,6 +33,7 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
it.event !is BadgeDefinitionEvent && it.event !is BadgeDefinitionEvent &&
it.event !is BadgeProfilesEvent && it.event !is BadgeProfilesEvent &&
it.author !== loggedInUser && it.author !== loggedInUser &&
(isGlobal || it.author?.pubkeyHex in followingKeySet) &&
it.event?.isTaggedUser(loggedInUserHex) ?: false && it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) && (it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
tagsAnEventByUser(it, loggedInUser) tagsAnEventByUser(it, loggedInUser)
@@ -4,6 +4,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
object UserProfileNewThreadFeedFilter : FeedFilter<Note>() { object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
var account: Account? = null var account: Account? = null
@@ -15,7 +17,8 @@ object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
} }
override fun feed(): List<Note> { override fun feed(): List<Note> {
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 return user?.notes
?.plus(longFormNotes) ?.plus(longFormNotes)
@@ -81,6 +81,7 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
// Route.Profile.route -> TopBarWithBackButton(navController) // Route.Profile.route -> TopBarWithBackButton(navController)
Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel) Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel)
Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel) Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel)
Route.Notification.base -> NotificationTopBar(scaffoldState, accountViewModel)
else -> MainTopBar(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 @Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { GenericTopBar(scaffoldState, accountViewModel) {
@@ -273,13 +283,13 @@ fun SimpleTextSpinner(
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) } var optionsShowing by remember { mutableStateOf(false) }
var currentText by remember { mutableStateOf(placeholder) } var currentText by remember(placeholder) { mutableStateOf(placeholder) }
Box( Box(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(currentText) Text(placeholder)
Box( Box(
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
@@ -75,10 +75,12 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
.background(backgroundColor) .background(backgroundColor)
.combinedClickable( .combinedClickable(
onClick = { onClick = {
routeFor( scope.launch {
note, routeFor(
accountViewModel.userProfile() note,
)?.let { navController.navigate(it) } accountViewModel.userProfile()
)?.let { navController.navigate(it) }
}
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
) )
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.BoostSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@@ -45,6 +47,8 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
val noteEvent = note?.event val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) } var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) { if (note == null) {
BlankNote(Modifier, isInnerNote) BlankNote(Modifier, isInnerNote)
} else { } else {
@@ -65,12 +69,19 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
} }
Column( Column(
modifier = Modifier.background(backgroundColor).combinedClickable( modifier = Modifier
onClick = { .background(backgroundColor)
routeFor(note, account.userProfile())?.let { navController.navigate(it) } .combinedClickable(
}, onClick = {
onLongClick = { popupExpanded = true } scope.launch {
) routeFor(
note,
account.userProfile()
)?.let { navController.navigate(it) }
}
},
onLongClick = { popupExpanded = true }
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -90,7 +101,9 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
Icon( Icon(
painter = painterResource(R.drawable.ic_retweeted), painter = painterResource(R.drawable.ic_retweeted),
null, null,
modifier = Modifier.size(16.dp).align(Alignment.TopEnd), modifier = Modifier
.size(16.dp)
.align(Alignment.TopEnd),
tint = Color.Unspecified tint = Color.Unspecified
) )
} }
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.LikeSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@@ -44,6 +46,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
val noteEvent = note?.event val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) } var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) { if (note == null) {
BlankNote(Modifier, isInnerNote) BlankNote(Modifier, isInnerNote)
@@ -65,12 +68,19 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
} }
Column( Column(
modifier = Modifier.background(backgroundColor).combinedClickable( modifier = Modifier
onClick = { .background(backgroundColor)
routeFor(note, account.userProfile())?.let { navController.navigate(it) } .combinedClickable(
}, onClick = {
onLongClick = { popupExpanded = true } scope.launch {
) routeFor(
note,
account.userProfile()
)?.let { navController.navigate(it) }
}
},
onLongClick = { popupExpanded = true }
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -90,7 +100,9 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
Icon( Icon(
painter = painterResource(R.drawable.ic_liked), painter = painterResource(R.drawable.ic_liked),
null, null,
modifier = Modifier.size(16.dp).align(Alignment.TopEnd), modifier = Modifier
.size(16.dp)
.align(Alignment.TopEnd),
tint = Color.Unspecified tint = Color.Unspecified
) )
} }
@@ -69,7 +69,12 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
Column( Column(
modifier = Modifier.background(backgroundColor).combinedClickable( modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = { onClick = {
routeFor(note, accountViewModel.userProfile())?.let { navController.navigate(it) } scope.launch {
routeFor(
note,
accountViewModel.userProfile()
)?.let { navController.navigate(it) }
}
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
) )
@@ -89,7 +89,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
.background(backgroundColor) .background(backgroundColor)
.combinedClickable( .combinedClickable(
onClick = { onClick = {
routeFor(note, account.userProfile())?.let { navController.navigate(it) } scope.launch {
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
}
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
) )
@@ -274,7 +274,9 @@ fun NoteComposeInner(
modifier = modifier modifier = modifier
.combinedClickable( .combinedClickable(
onClick = { onClick = {
routeFor(note, loggedIn)?.let { navController.navigate(it) } scope.launch {
routeFor(note, loggedIn)?.let { navController.navigate(it) }
}
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
) )
@@ -19,6 +19,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@@ -46,6 +48,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor
val noteEvent = note?.event val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) } var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) { if (note == null) {
BlankNote(Modifier, isInnerNote) BlankNote(Modifier, isInnerNote)
@@ -67,12 +70,19 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor
} }
Column( Column(
modifier = Modifier.background(backgroundColor).combinedClickable( modifier = Modifier
onClick = { .background(backgroundColor)
routeFor(note, account.userProfile())?.let { navController.navigate(it) } .combinedClickable(
}, onClick = {
onLongClick = { popupExpanded = true } scope.launch {
) routeFor(
note,
account.userProfile()
)?.let { navController.navigate(it) }
}
},
onLongClick = { popupExpanded = true }
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -38,7 +38,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private var lastAccount: Account? = null private var lastAccount: Account? = null
private var lastNotes: List<Note>? = null private var lastNotes: List<Note>? = null
private fun refresh() { fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default) val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch { scope.launch {
refreshSuspended() refreshSuspended()
@@ -67,7 +67,7 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
} }
} }
} }
FeedState.Loading -> { is FeedState.Loading -> {
LoadingFeed() LoadingFeed()
} }
} }
@@ -72,8 +72,8 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon
} }
) )
} }
HorizontalPager(pageCount = 2, state = pagerState) { HorizontalPager(pageCount = 2, state = pagerState) { page ->
when (pagerState.currentPage) { when (page) {
0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null) 0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null)
1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null) 1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null)
} }
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf 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.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.ChatroomListFeedView 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.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -58,6 +60,48 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
val markKnownAsRead = remember { mutableStateOf(false) } val markKnownAsRead = remember { mutableStateOf(false) }
val markNewAsRead = 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()) { Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxHeight()) { Column(Modifier.fillMaxHeight()) {
Column( Column(
@@ -68,21 +112,17 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
backgroundColor = MaterialTheme.colors.background, backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage selectedTabIndex = pagerState.currentPage
) { ) {
Tab( tabs.forEachIndexed { index, tab ->
selected = pagerState.currentPage == 0, Tab(
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, selected = pagerState.currentPage == index,
text = { text = {
Text(text = stringResource(R.string.known)) Text(text = stringResource(tab.resource))
} },
) onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
Tab( }
selected = pagerState.currentPage == 1, )
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, }
text = {
Text(text = stringResource(R.string.new_requests))
}
)
} }
IconButton( IconButton(
@@ -107,102 +147,20 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
} }
} }
HorizontalPager(pageCount = 2, state = pagerState) { HorizontalPager(pageCount = 2, state = pagerState) { page ->
when (pagerState.currentPage) { ChatroomListFeedView(
0 -> TabKnown(accountViewModel, navController, markKnownAsRead) viewModel = tabs[page].viewModel,
1 -> TabNew(accountViewModel, navController, markNewAsRead) accountViewModel = accountViewModel,
} navController = navController,
markAsRead = tabs[page].markAsRead
)
} }
} }
} }
} }
} }
@Composable class ChatroomListTabItem(val resource: Int, val viewModel: FeedViewModel, val markAsRead: MutableState<Boolean>)
fun TabKnown(
accountViewModel: AccountViewModel,
navController: NavController,
markAsRead: MutableState<Boolean>
) {
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<Boolean>
) {
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)
}
}
}
@Composable @Composable
fun ChatroomTabMenu( fun ChatroomTabMenu(
@@ -53,8 +53,8 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont
} }
) )
} }
HorizontalPager(pageCount = 1, state = pagerState) { HorizontalPager(pageCount = 1, state = pagerState) { page ->
when (pagerState.currentPage) { when (page) {
0 -> UserFeedView(feedViewModel, accountViewModel, navController) 0 -> UserFeedView(feedViewModel, accountViewModel, navController)
} }
} }
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
import com.vitorpamplona.amethyst.ui.screen.FeedView 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.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
@@ -50,12 +51,12 @@ fun HomeScreen(
nip47: String? = null nip47: String? = null
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val account = accountViewModel.accountLiveData.value?.account ?: return
var wantsToAddNip47 by remember { mutableStateOf(nip47) } 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 HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account HomeConversationsFeedFilter.account = account
NostrHomeDataSource.resetFilters() NostrHomeDataSource.resetFilters()
@@ -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.fillMaxHeight()) {
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
@@ -93,28 +103,31 @@ fun HomeScreen(
backgroundColor = MaterialTheme.colors.background, backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage selectedTabIndex = pagerState.currentPage
) { ) {
Tab( tabs.forEachIndexed { index, tab ->
selected = pagerState.currentPage == 0, Tab(
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, selected = pagerState.currentPage == index,
text = { text = {
Text(text = stringResource(R.string.new_threads)) Text(text = stringResource(tab.resource))
} },
) onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
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)
} }
} }
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)
@@ -41,7 +41,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) { fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) {
val coroutineScope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val navController = rememberNavController() val navController = rememberNavController()
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
val sheetState = rememberModalBottomSheetState( val sheetState = rememberModalBottomSheetState(
@@ -69,7 +69,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
drawerContent = { drawerContent = {
DrawerContent(navController, scaffoldState, sheetState, accountViewModel) DrawerContent(navController, scaffoldState, sheetState, accountViewModel)
BackHandler(enabled = scaffoldState.drawerState.isOpen) { BackHandler(enabled = scaffoldState.drawerState.isOpen) {
coroutineScope.launch { scaffoldState.drawerState.close() } scope.launch { scaffoldState.drawerState.close() }
} }
}, },
floatingActionButton = { floatingActionButton = {
@@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NavController import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.CardFeedView import com.vitorpamplona.amethyst.ui.screen.CardFeedView
@@ -34,9 +35,11 @@ fun NotificationScreen(
notifFeedViewModel.clear() notifFeedViewModel.clear()
} }
LaunchedEffect(accountViewModel) { LaunchedEffect(account.userProfile().pubkeyHex, account.defaultNotificationFollowList) {
NostrAccountDataSource.resetFilters()
NotificationFeedFilter.account = account NotificationFeedFilter.account = account
notifFeedViewModel.invalidateData() notifFeedViewModel.clear()
notifFeedViewModel.refresh()
} }
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
@@ -288,8 +288,8 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController:
modifier = with(LocalDensity.current) { modifier = with(LocalDensity.current) {
Modifier.height((columnSize.height - tabsSize.height).toDp()) Modifier.height((columnSize.height - tabsSize.height).toDp())
} }
) { ) { page ->
when (pagerState.currentPage) { when (page) {
0 -> TabNotesNewThreads(accountViewModel, navController) 0 -> TabNotesNewThreads(accountViewModel, navController)
1 -> TabNotesConversations(accountViewModel, navController) 1 -> TabNotesConversations(accountViewModel, navController)
2 -> TabFollows(baseUser, accountViewModel, navController) 2 -> TabFollows(baseUser, accountViewModel, navController)
+13 -1
View File
@@ -362,7 +362,6 @@
<string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string> <string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string>
<string name="follow_list_selection">Follow List</string> <string name="follow_list_selection">Follow List</string>
<string name="follow_list_kind3follows">All Follows</string> <string name="follow_list_kind3follows">All Follows</string>
<string name="follow_list_global">Global</string> <string name="follow_list_global">Global</string>
@@ -378,4 +377,17 @@
<string name="orbot_socks_port">Orbot Socks Port</string> <string name="orbot_socks_port">Orbot Socks Port</string>
<string name="invalid_port_number">Invalid port number</string> <string name="invalid_port_number">Invalid port number</string>
<string name="app_notification_channel_id" translatable="false">DefaultChannelID</string>
<string name="app_notification_private_message" translatable="false">New notification arrived</string>
<string name="app_notification_dms_channel_id" translatable="false">PrivateMessagesID</string>
<string name="app_notification_dms_channel_name">Private Messages</string>
<string name="app_notification_dms_channel_description">Notifies you when a private message arrives</string>
<string name="app_notification_zaps_channel_id" translatable="false">ZapsID</string>
<string name="app_notification_zaps_channel_name">Zaps Received</string>
<string name="app_notification_zaps_channel_description">Notifies you when somebody zaps you</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">From %1$s</string>
<string name="app_notification_zaps_channel_message_for">for %1$s</string>
</resources> </resources>
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:name=".Amethyst">
<service
android:name=".service.notifications.PushNotificationReceiverService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
<meta-data android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/amethyst" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/purple_500" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/app_notification_channel_id" />
</application>
</manifest>
@@ -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)
}
}
@@ -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<AccountInfo>) {
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)
}
)
}
}
}
@@ -61,7 +61,7 @@ class NIP19ParserTest {
assertEquals("31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", result?.hex) assertEquals("31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", result?.hex)
assertEquals(null, result?.relay) assertEquals(null, result?.relay)
assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author)
assertEquals(31337L, result?.kind) assertEquals(31337, result?.kind)
} }
@Test @Test
@@ -71,7 +71,7 @@ class NIP19ParserTest {
assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", result?.hex) assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", result?.hex)
assertEquals(null, result?.relay) assertEquals(null, result?.relay)
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author)
assertEquals(30023L, result?.kind) assertEquals(30023, result?.kind)
} }
@Test @Test
@@ -81,7 +81,7 @@ class NIP19ParserTest {
assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result?.hex) assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result?.hex)
assertEquals(null, result?.relay) assertEquals(null, result?.relay)
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author)
assertEquals(30023L, result?.kind) assertEquals(30023, result?.kind)
} }
@Test @Test
@@ -101,7 +101,7 @@ class NIP19ParserTest {
assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex)
assertEquals("wss://nostr.mom", result?.relay) assertEquals("wss://nostr.mom", result?.relay)
assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author)
assertEquals(1L, result?.kind) assertEquals(1, result?.kind)
} }
@Test @Test
@@ -112,7 +112,7 @@ class NIP19ParserTest {
assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex)
assertEquals("wss://relay.damus.io", result?.relay) assertEquals("wss://relay.damus.io", result?.relay)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author)
assertEquals(1L, result?.kind) assertEquals(1, result?.kind)
} }
@Test @Test
@@ -123,7 +123,7 @@ class NIP19ParserTest {
assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex)
assertEquals("wss://relay.damus.io", result?.relay) assertEquals("wss://relay.damus.io", result?.relay)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author)
assertEquals(1L, result?.kind) assertEquals(1, result?.kind)
} }
@Test @Test
+6 -3
View File
@@ -2,16 +2,19 @@ buildscript {
ext { ext {
fragment_version = "1.5.7" fragment_version = "1.5.7"
lifecycle_version = '2.6.1' lifecycle_version = '2.6.1'
compose_ui_version = '1.4.2' compose_ui_version = '1.4.3'
nav_version = "2.5.3" nav_version = "2.5.3"
room_version = "2.4.3" room_version = "2.4.3"
accompanist_version = '0.30.0' accompanist_version = '0.30.0'
coil_version = '2.3.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. }// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id 'com.android.application' version '8.0.0' apply false id 'com.android.application' version '8.0.1' apply false
id 'com.android.library' version '8.0.0' 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.android' version '1.8.10' apply false
id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false id 'org.jetbrains.kotlin.jvm' version '1.8.10' apply false
} }