mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-06 06:44:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e8106aa9 | ||
|
|
e86ae4ac41 | ||
|
|
12570d3e26 | ||
|
|
8fe2c895de | ||
|
|
f251d56b1a | ||
|
|
e5518e23a8 | ||
|
|
d6f3e9540f | ||
|
|
79e7bb2931 | ||
|
|
3e3d683234 | ||
|
|
429f54848a | ||
|
|
4085285f90 | ||
|
|
b3d219b2de | ||
|
|
797eda2ae2 | ||
|
|
2478fdbcce | ||
|
|
d7cf198134 | ||
|
|
681e3eb44e | ||
|
|
5cc851ecc8 | ||
|
|
2a0426b845 | ||
|
|
567cf83b92 | ||
|
|
c7debb272d | ||
|
|
cf7464b5b6 | ||
|
|
9cb3406d7d | ||
|
|
3613fce68d | ||
|
|
1b62574eca | ||
|
|
fbd5a3296c | ||
|
|
c5e61400b7 | ||
|
|
42dd900491 | ||
|
|
295f1f53cd | ||
|
|
0b2b2d58ab | ||
|
|
c1637de63a | ||
|
|
566b44f4ef | ||
|
|
6275051857 | ||
|
|
58e0aa5f8f | ||
|
|
d6b5af6d77 | ||
|
|
9432135847 | ||
|
|
e7be1cc8e7 | ||
|
|
c0bdb40c7d | ||
|
|
bb59f68253 | ||
|
|
8961675a3e | ||
|
|
3dd5d5c4b2 | ||
|
|
bd8e9da966 | ||
|
|
6067a7d890 | ||
|
|
42a27a6d8d | ||
|
|
75901d83a8 | ||
|
|
f34dea868d | ||
|
|
e07400409f | ||
|
|
8d7897601b |
@@ -29,6 +29,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [x] Identity Verification (NIP-05)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [ ] Local Database
|
||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
@@ -38,7 +39,6 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [ ] Generic Tags (NIP-12)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Events with a Subject (NIP-14)
|
||||
- [ ] Online Relay Search (NIP-50)
|
||||
- [ ] Workspaces
|
||||
- [ ] Expiration Support (NIP-40)
|
||||
- [ ] Internationalization
|
||||
|
||||
+12
-6
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 86
|
||||
versionName "0.22.3"
|
||||
versionCode 89
|
||||
versionName "0.24.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -58,11 +58,14 @@ dependencies {
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
// Needs this to open gallery / image upload
|
||||
implementation "androidx.fragment:fragment-ktx:$fragment_version"
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:$nav_version")
|
||||
|
||||
// Observe Live data as State
|
||||
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-beta01'
|
||||
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-beta02'
|
||||
|
||||
implementation 'androidx.compose.material:material:1.4.0-beta02'
|
||||
implementation "androidx.compose.material:material-icons-extended:1.4.0-beta02"
|
||||
@@ -73,6 +76,9 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Swipe Refresh
|
||||
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.1-alpha'
|
||||
|
||||
@@ -86,12 +92,12 @@ dependencies {
|
||||
exclude module: 'guava-testlib'
|
||||
}
|
||||
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
// Websockets API
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// Json Serialization
|
||||
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
|
||||
// Robohash for Avatars
|
||||
implementation group: 'com.github.vitorpamplona', name: 'android-robohash', version: 'master-SNAPSHOT', ext: 'aar'
|
||||
@@ -103,7 +109,7 @@ dependencies {
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.3'
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.4'
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:2.2.2"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.gson.JsonElement
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EventSigCheck {
|
||||
|
||||
val payload1 = "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nI’ll give you one final explanation to rule them all. First, let’s go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays it’s 500, others 1000, some as high as 5000. Let’s say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That won’t change if you have 20,000 followers or 100,000. You may get back a “different” 5000 each time, but you’ll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesn’t mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]"
|
||||
|
||||
@Test
|
||||
fun testUnicode2028and2029ShouldNotBeEscaped() {
|
||||
val msg = Event.gson.fromJson(payload1, JsonElement::class.java).asJsonArray
|
||||
val event = Event.fromJson(msg[2], Client.lenient)
|
||||
|
||||
// Should pass
|
||||
event.checkSignature()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,104 +14,109 @@ import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
import nostr.postr.toHex
|
||||
|
||||
class LocalPreferences(context: Context) {
|
||||
val encryptedPreferences = EncryptedStorage().preferences(context)
|
||||
val gson = GsonBuilder().create()
|
||||
private object PrefKeys {
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val FOLLOWING_CHANNELS = "following_channels"
|
||||
const val HIDDEN_USERS = "hidden_users"
|
||||
const val RELAYS = "relays"
|
||||
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_${route}" }
|
||||
}
|
||||
|
||||
fun clearEncryptedStorage() {
|
||||
encryptedPreferences.edit().apply {
|
||||
remove("nostr_privkey")
|
||||
remove("nostr_pubkey")
|
||||
remove("following_channels")
|
||||
remove("hidden_users")
|
||||
remove("relays")
|
||||
remove("dontTranslateFrom")
|
||||
remove("languagePreferences")
|
||||
remove("translateTo")
|
||||
remove("zapAmounts")
|
||||
remove("latestContactList")
|
||||
}.apply()
|
||||
}
|
||||
private val encryptedPreferences = EncryptedStorage().preferences(context)
|
||||
private val gson = GsonBuilder().create()
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
encryptedPreferences.edit().apply {
|
||||
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
|
||||
account.followingChannels.let { putStringSet("following_channels", it) }
|
||||
account.hiddenUsers.let { putStringSet("hidden_users", it) }
|
||||
account.localRelays.let { putString("relays", gson.toJson(it)) }
|
||||
account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) }
|
||||
account.languagePreferences.let { putString("languagePreferences", gson.toJson(it)) }
|
||||
account.translateTo.let { putString("translateTo", it) }
|
||||
account.zapAmountChoices.let { putString("zapAmounts", gson.toJson(it)) }
|
||||
account.backupContactList.let { putString("latestContactList", Event.gson.toJson(it)) }
|
||||
}.apply()
|
||||
}
|
||||
fun clearEncryptedStorage() {
|
||||
encryptedPreferences.edit().apply {
|
||||
encryptedPreferences.all.keys.forEach { remove(it) }
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences.apply {
|
||||
val privKey = getString("nostr_privkey", null)
|
||||
val pubKey = getString("nostr_pubkey", null)
|
||||
val followingChannels = getStringSet("following_channels", null) ?: setOf()
|
||||
val hiddenUsers = getStringSet("hidden_users", emptySet()) ?: setOf()
|
||||
val localRelays = gson.fromJson(
|
||||
getString("relays", "[]"),
|
||||
object : TypeToken<Set<RelaySetupInfo>>() {}.type
|
||||
) ?: setOf<RelaySetupInfo>()
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
encryptedPreferences.edit().apply {
|
||||
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
|
||||
account.followingChannels.let { putStringSet(PrefKeys.FOLLOWING_CHANNELS, it) }
|
||||
account.hiddenUsers.let { putStringSet(PrefKeys.HIDDEN_USERS, it) }
|
||||
account.localRelays.let { putString(PrefKeys.RELAYS, gson.toJson(it)) }
|
||||
account.dontTranslateFrom.let { putStringSet(PrefKeys.DONT_TRANSLATE_FROM, it) }
|
||||
account.languagePreferences.let { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(it)) }
|
||||
account.translateTo.let { putString(PrefKeys.TRANSLATE_TO, it) }
|
||||
account.zapAmountChoices.let { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(it)) }
|
||||
account.backupContactList.let { putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(it)) }
|
||||
}.apply()
|
||||
}
|
||||
|
||||
val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf()
|
||||
val translateTo = getString("translateTo", null) ?: Locale.getDefault().language
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences.apply {
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null)
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
|
||||
val localRelays = gson.fromJson(
|
||||
getString(PrefKeys.RELAYS, "[]"),
|
||||
object : TypeToken<Set<RelaySetupInfo>>() {}.type
|
||||
) ?: setOf<RelaySetupInfo>()
|
||||
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString("zapAmounts", "[]"),
|
||||
object : TypeToken<List<Long>>() {}.type
|
||||
) ?: listOf(500L, 1000L, 5000L)
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
|
||||
val latestContactList = try {
|
||||
getString("latestContactList", null)?.let {
|
||||
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
|
||||
object : TypeToken<List<Long>>() {}.type
|
||||
) ?: listOf(500L, 1000L, 5000L)
|
||||
|
||||
val latestContactList = try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val languagePreferences = try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
gson.fromJson(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String>
|
||||
} ?: mapOf<String,String>()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
mapOf<String,String>()
|
||||
}
|
||||
|
||||
if (pubKey != null) {
|
||||
return Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
localRelays,
|
||||
dontTranslateFrom,
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
latestContactList
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val languagePreferences = try {
|
||||
getString("languagePreferences", null)?.let {
|
||||
gson.fromJson(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String>
|
||||
} ?: mapOf<String,String>()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
mapOf<String,String>()
|
||||
}
|
||||
|
||||
if (pubKey != null) {
|
||||
return Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
localRelays,
|
||||
dontTranslateFrom,
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
latestContactList
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLastRead(route: String, timestampInSecs: Long) {
|
||||
encryptedPreferences.edit().apply {
|
||||
putLong("last_read_route_${route}", timestampInSecs)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadLastRead(route: String): Long {
|
||||
encryptedPreferences.run {
|
||||
return getLong("last_read_route_${route}", 0)
|
||||
fun saveLastRead(route: String, timestampInSecs: Long) {
|
||||
encryptedPreferences.edit().apply {
|
||||
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadLastRead(route: String): Long {
|
||||
encryptedPreferences.run {
|
||||
return getLong(PrefKeys.LAST_READ(route), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -452,11 +452,25 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
// Takes a User's relay list and adds the types of feeds they are active for.
|
||||
fun activeRelays(): Array<Relay>? {
|
||||
return userProfile().relays?.map {
|
||||
var usersRelayList = userProfile().relays?.map {
|
||||
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
|
||||
}?.toTypedArray()
|
||||
} ?: return null
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
// TODO: Remove when search becomes more available.
|
||||
if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) {
|
||||
usersRelayList = usersRelayList + Relay(
|
||||
Constants.forcedRelayForSearch.url,
|
||||
Constants.forcedRelayForSearch.read,
|
||||
Constants.forcedRelayForSearch.write,
|
||||
Constants.forcedRelayForSearch.feedTypes
|
||||
)
|
||||
}
|
||||
|
||||
return usersRelayList.toTypedArray()
|
||||
}
|
||||
|
||||
fun convertLocalRelays(): Array<Relay> {
|
||||
@@ -479,19 +493,19 @@ class Account(
|
||||
fun isAcceptable(user: User): Boolean {
|
||||
return !isHidden(user) // if user hasn't hided this author
|
||||
&& user.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
|
||||
&& user.reportAuthorsBy( userProfile().follows ).size < 5
|
||||
&& user.countReportAuthorsBy( userProfile().follows ) < 5
|
||||
}
|
||||
|
||||
fun isAcceptableDirect(note: Note): Boolean {
|
||||
return note.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
|
||||
&& note.reportAuthorsBy( userProfile().follows ).size < 5 // if it has 5 reports by reliable users
|
||||
&& note.countReportAuthorsBy( userProfile().follows ) < 5 // if it has 5 reports by reliable users
|
||||
}
|
||||
|
||||
fun isAcceptable(note: Note): Boolean {
|
||||
return note.author?.let { isAcceptable(it) } ?: true // if user hasn't hided this author
|
||||
&& isAcceptableDirect(note)
|
||||
&& (note.event !is RepostEvent
|
||||
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
|
||||
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
|
||||
) // is not a reaction about a blocked post
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import nostr.postr.toHex
|
||||
|
||||
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
|
||||
|
||||
@@ -24,9 +23,6 @@ class AntiSpamFilter {
|
||||
fun isSpam(event: Event): Boolean {
|
||||
val idHex = event.id
|
||||
|
||||
// if already processed, ok
|
||||
if (LocalCache.notes[idHex] != null) return false
|
||||
|
||||
// if short message, ok
|
||||
if (event.content.length < 50) return false
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
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.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
@@ -39,7 +42,6 @@ import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.toHex
|
||||
import nostr.postr.toNpub
|
||||
|
||||
|
||||
@@ -75,6 +77,9 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun checkGetOrCreateNote(key: String): Note? {
|
||||
if (key.startsWith("naddr1")) {
|
||||
return checkGetOrCreateAddressableNote(key)
|
||||
}
|
||||
return try {
|
||||
val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex
|
||||
getOrCreateNote(key)
|
||||
@@ -136,7 +141,6 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun consume(event: MetadataEvent) {
|
||||
// new event
|
||||
val oldUser = getOrCreateUser(event.pubKey)
|
||||
@@ -166,13 +170,6 @@ object LocalCache {
|
||||
|
||||
|
||||
fun consume(event: TextNoteEvent, relay: Relay? = null) {
|
||||
if (antiSpam.isSpam(event)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
@@ -184,6 +181,13 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
@@ -212,13 +216,6 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun consume(event: LongTextNoteEvent, relay: Relay?) {
|
||||
if (antiSpam.isSpam(event)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
@@ -230,6 +227,13 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event?.id == event.id) return
|
||||
|
||||
if (antiSpam.isSpam(event)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
@@ -250,6 +254,71 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id == event.id) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList<User>(), emptyList<Note>())
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeProfilesEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id == event.id) return
|
||||
|
||||
val replyTo = event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList(), replyTo)
|
||||
|
||||
author.updateAcceptedBadges(note)
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeAwardEvent) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, awardees, awardDefinition)
|
||||
|
||||
// Adds notifications to users.
|
||||
awardees.forEach {
|
||||
it.addTaggedPost(note)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
awardees.forEach {
|
||||
it.addBadgeAward(note)
|
||||
}
|
||||
|
||||
// Replies of an Badge Definition are Award Events
|
||||
awardDefinition.forEach {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
|
||||
private fun findCitations(event: Event): Set<String> {
|
||||
var citations = mutableSetOf<String>()
|
||||
// Removes citations from replies:
|
||||
@@ -268,15 +337,29 @@ object LocalCache {
|
||||
}
|
||||
|
||||
private fun replyToWithoutCitations(event: TextNoteEvent): List<String> {
|
||||
val repliesTo = event.replyTos()
|
||||
if (repliesTo.isEmpty()) return repliesTo
|
||||
|
||||
val citations = findCitations(event)
|
||||
|
||||
return event.replyTos().filter { it !in citations }
|
||||
return if (citations.isEmpty()) {
|
||||
repliesTo
|
||||
} else {
|
||||
repliesTo.filter { it !in citations }
|
||||
}
|
||||
}
|
||||
|
||||
private fun replyToWithoutCitations(event: LongTextNoteEvent): List<String> {
|
||||
val repliesTo = event.replyTos()
|
||||
if (repliesTo.isEmpty()) return repliesTo
|
||||
|
||||
val citations = findCitations(event)
|
||||
|
||||
return event.replyTos().filter { it !in citations }
|
||||
return if (citations.isEmpty()) {
|
||||
repliesTo
|
||||
} else {
|
||||
repliesTo.filter { it !in citations }
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: RecommendRelayEvent) {
|
||||
@@ -521,6 +604,7 @@ object LocalCache {
|
||||
// older data, does nothing
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: ChannelMetadataEvent) {
|
||||
val channelId = event.channel()
|
||||
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
|
||||
@@ -548,12 +632,6 @@ object LocalCache {
|
||||
val channelId = event.channel()
|
||||
|
||||
if (channelId.isNullOrBlank()) return
|
||||
if (antiSpam.isSpam(event)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val channel = checkGetOrCreateChannel(channelId) ?: return
|
||||
|
||||
@@ -570,6 +648,13 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event)) {
|
||||
relay?.let {
|
||||
it.spamCounter++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
|
||||
val replyTo = event.replyTos()
|
||||
.mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
@@ -229,6 +229,10 @@ open class Note(val idHex: String) {
|
||||
return reports.keys.filter { it in users }
|
||||
}
|
||||
|
||||
fun countReportAuthorsBy(users: Set<User>): Int {
|
||||
return reports.keys.count { it in users }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<User>): List<Note> {
|
||||
return reportAuthorsBy(users).mapNotNull {
|
||||
reports[it]
|
||||
|
||||
@@ -56,7 +56,7 @@ object UrlCachedPreviewer {
|
||||
fun preloadPreviewsFor(note: Note) {
|
||||
note.event?.content?.let {
|
||||
findUrlsInMessage(it).forEach {
|
||||
val removedParamsFromUrl = it.split("?")[0].toLowerCase()
|
||||
val removedParamsFromUrl = it.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
// Preload Images? Isn't this too heavy?
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
@@ -69,4 +69,4 @@ object UrlCachedPreviewer {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import nostr.postr.toNpub
|
||||
|
||||
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
|
||||
|
||||
class Badges(val definition: Note, val awardees: Set<Note>)
|
||||
|
||||
class User(val pubkeyHex: String) {
|
||||
var info: UserMetadata? = null
|
||||
|
||||
@@ -43,6 +45,7 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
|
||||
var latestReportTime: Long = 0
|
||||
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
@@ -57,6 +60,11 @@ class User(val pubkeyHex: String) {
|
||||
var privateChatrooms = mapOf<User, Chatroom>()
|
||||
private set
|
||||
|
||||
var badgeAwards = setOf<Note>()
|
||||
private set
|
||||
|
||||
var acceptedBadges: AddressableNote? = null
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
fun pubkeyNpub() = pubkey().toNpub()
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
@@ -175,6 +183,23 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun addBadgeAward(note: Note) {
|
||||
if (note !in badgeAwards) {
|
||||
badgeAwards = badgeAwards + note
|
||||
liveSet?.badges?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeBadgeAward(deleteNote: Note) {
|
||||
badgeAwards = badgeAwards - deleteNote
|
||||
liveSet?.badges?.invalidateData()
|
||||
}
|
||||
|
||||
fun updateAcceptedBadges(note: AddressableNote) {
|
||||
acceptedBadges = note
|
||||
liveSet?.badges?.invalidateData()
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
@@ -201,6 +226,10 @@ class User(val pubkeyHex: String) {
|
||||
return reports.keys.filter { it in users }
|
||||
}
|
||||
|
||||
fun countReportAuthorsBy(users: Set<User>): Int {
|
||||
return reports.keys.count { it in users }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<User>): List<Note> {
|
||||
return reportAuthorsBy(users).mapNotNull {
|
||||
reports[it]
|
||||
@@ -259,7 +288,7 @@ class User(val pubkeyHex: String) {
|
||||
info?.latestMetadata = latestMetadata
|
||||
info?.updatedMetadataAt = latestMetadata.createdAt
|
||||
|
||||
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.toLowerCase()?.startsWith("lnurl") == true) {
|
||||
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
|
||||
try {
|
||||
val url = String(Bech32.decodeBytes(newUserInfo.lud06!!, false).second)
|
||||
|
||||
@@ -312,6 +341,8 @@ class User(val pubkeyHex: String) {
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
class UserLiveSet(u: User) {
|
||||
@@ -323,6 +354,7 @@ class UserLiveSet(u: User) {
|
||||
val relayInfo: UserLiveData = UserLiveData(u)
|
||||
val metadata: UserLiveData = UserLiveData(u)
|
||||
val zaps: UserLiveData = UserLiveData(u)
|
||||
val badges: UserLiveData = UserLiveData(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return follows.hasObservers()
|
||||
@@ -332,6 +364,7 @@ class UserLiveSet(u: User) {
|
||||
|| relayInfo.hasObservers()
|
||||
|| metadata.hasObservers()
|
||||
|| zaps.hasObservers()
|
||||
|| badges.hasObservers()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
@@ -8,7 +10,7 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
@@ -38,6 +40,17 @@ object NostrAccountDataSource: NostrDataSource("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
@@ -52,7 +65,7 @@ object NostrAccountDataSource: NostrDataSource("AccountData") {
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, ChannelMessageEvent.kind
|
||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, ChannelMessageEvent.kind, BadgeAwardEvent.kind
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 200
|
||||
@@ -67,7 +80,8 @@ object NostrAccountDataSource: NostrDataSource("AccountData") {
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createNotificationFilter(),
|
||||
createAccountReportsFilter()
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
|
||||
object NostrChannelDataSource: NostrDataSource("ChatroomFeed") {
|
||||
var channel: Channel? = null
|
||||
|
||||
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
|
||||
object NostrChatroomDataSource: NostrDataSource("ChatroomFeed") {
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
|
||||
object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
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.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
@@ -62,6 +65,9 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is BadgeAwardEvent -> LocalCache.consume(event)
|
||||
is BadgeDefinitionEvent -> LocalCache.consume(event)
|
||||
is BadgeProfilesEvent -> LocalCache.consume(event)
|
||||
is ChannelCreateEvent -> LocalCache.consume(event)
|
||||
is ChannelHideMessageEvent -> LocalCache.consume(event)
|
||||
is ChannelMessageEvent -> LocalCache.consume(event, relay)
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object NostrHomeDataSource: NostrDataSource("HomeFeed") {
|
||||
|
||||
+45
-17
@@ -1,17 +1,40 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
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.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import nostr.postr.bechToBytes
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import nostr.postr.toHex
|
||||
|
||||
object NostrSearchEventOrUserDataSource: NostrDataSource("SingleEventFeed") {
|
||||
private var hexToWatch: String? = null
|
||||
private var searchString: String? = null
|
||||
|
||||
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
|
||||
val mySearchString = searchString
|
||||
if (mySearchString == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val hexToWatch = try {
|
||||
if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) {
|
||||
decodePublicKey(mySearchString).toHex()
|
||||
} else if (mySearchString.startsWith("note")) {
|
||||
mySearchString.bechToBytes().toHex()
|
||||
} else {
|
||||
mySearchString
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Usually when people add an incomplete npub or note.
|
||||
null
|
||||
}
|
||||
|
||||
if (hexToWatch == null) {
|
||||
return null
|
||||
}
|
||||
@@ -30,6 +53,22 @@ object NostrSearchEventOrUserDataSource: NostrDataSource("SingleEventFeed") {
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20,
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -40,23 +79,12 @@ object NostrSearchEventOrUserDataSource: NostrDataSource("SingleEventFeed") {
|
||||
searchChannel.typedFilters = createAnythingWithIDFilter()
|
||||
}
|
||||
|
||||
fun search(eventId: String) {
|
||||
try {
|
||||
val hex = if (eventId.startsWith("npub") || eventId.startsWith("nsec")) {
|
||||
decodePublicKey(eventId).toHex()
|
||||
} else if (eventId.startsWith("note")) {
|
||||
eventId.bechToBytes().toHex()
|
||||
} else {
|
||||
eventId
|
||||
}
|
||||
hexToWatch = hex
|
||||
invalidateFilters()
|
||||
} catch (e: Exception) {
|
||||
// Usually when people add an incomplete npub or note.
|
||||
}
|
||||
fun search(searchString: String) {
|
||||
this.searchString = searchString
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
hexToWatch = null
|
||||
searchString = null
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
|
||||
object NostrSingleChannelDataSource: NostrDataSource("SingleChannelFeed") {
|
||||
private var channelsToWatch = setOf<String>()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
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.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
@@ -13,14 +17,14 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import java.util.Date
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
private var addressesToWatch = setOf<Note>()
|
||||
|
||||
private fun createAddressFilter(): List<TypedFilter>? {
|
||||
private fun createTagToAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
|
||||
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
@@ -38,7 +42,10 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -48,6 +55,32 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = addressesToWatch.filter { it.event == null }
|
||||
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex.substring(0,8))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
|
||||
val reactionsToWatch = eventsToWatch
|
||||
|
||||
@@ -81,7 +114,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
val threadingEventsToLoad = eventsToWatch
|
||||
.mapNotNull { it.replyTo }
|
||||
.flatten()
|
||||
.filter { it.event == null }
|
||||
.filter { it !is AddressableNote && it.event == null }
|
||||
|
||||
val interestedEvents =
|
||||
(directEventsToLoad + threadingEventsToLoad)
|
||||
@@ -98,7 +131,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
@@ -119,8 +152,9 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val addresses = createAddressFilter()
|
||||
val addressReactions = createTagToAddressFilter()
|
||||
|
||||
singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses).flatten().ifEmpty { null }
|
||||
singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: Note) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
|
||||
object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") {
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
|
||||
object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
|
||||
private var eventToWatch: String? = null
|
||||
|
||||
@@ -2,11 +2,13 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
@@ -77,6 +79,28 @@ object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createAcceptedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createReceivedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeAwardEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
limit = 20
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val userInfoChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
@@ -85,7 +109,9 @@ object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") {
|
||||
createUserPostsFilter(),
|
||||
createFollowFilter(),
|
||||
createFollowersFilter(),
|
||||
createUserReceivedZapsFilter()
|
||||
createUserReceivedZapsFilter(),
|
||||
createAcceptedAwardsFilter(),
|
||||
createReceivedAwardsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -24,7 +24,7 @@ class LightningAddressResolver {
|
||||
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
|
||||
}
|
||||
|
||||
if (lnaddress.toLowerCase().startsWith("lnurl")) {
|
||||
if (lnaddress.lowercase().startsWith("lnurl")) {
|
||||
return try {
|
||||
String(Bech32.decodeBytes(lnaddress, false).second)
|
||||
} catch (e: Exception) {
|
||||
@@ -160,4 +160,4 @@ class LightningAddressResolver {
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ object LnInvoiceUtil {
|
||||
}
|
||||
|
||||
private fun multiplier(multiplier: String): BigDecimal {
|
||||
return when (multiplier.toLowerCase()) {
|
||||
return when (multiplier.lowercase()) {
|
||||
"m" -> BigDecimal("0.001")
|
||||
"u" -> BigDecimal("0.000001")
|
||||
"n" -> BigDecimal("0.000000001")
|
||||
@@ -153,4 +153,4 @@ object LnInvoiceUtil {
|
||||
matcher.group()
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
class BadgeAwardEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
companion object {
|
||||
const val kind = 8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
class BadgeDefinitionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag())
|
||||
|
||||
fun name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
|
||||
companion object {
|
||||
const val kind = 30009
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
class BadgeProfilesEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag())
|
||||
|
||||
companion object {
|
||||
const val kind = 30008
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,15 @@ open class Event(
|
||||
tags,
|
||||
content
|
||||
)
|
||||
|
||||
// GSON decided to hardcode these replacements.
|
||||
// They break Nostr's hash check.
|
||||
// These lines revert their code.
|
||||
// https://github.com/google/gson/issues/2295
|
||||
val rawEventJson = gson.toJson(rawEvent)
|
||||
.replace("\\u2028", "\u2028")
|
||||
.replace("\\u2029", "\u2029")
|
||||
|
||||
return sha256.digest(rawEventJson.toByteArray()).toHexKey()
|
||||
}
|
||||
|
||||
@@ -153,6 +161,10 @@ open class Event(
|
||||
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
|
||||
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
@@ -56,8 +56,8 @@ class ReportEvent (
|
||||
fun create(reportedPost: Event, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
val reportPostTag = listOf("e", reportedPost.id, type.name.toLowerCase())
|
||||
val reportAuthorTag = listOf("p", reportedPost.pubKey, type.name.toLowerCase())
|
||||
val reportPostTag = listOf("e", reportedPost.id, type.name.lowercase())
|
||||
val reportAuthorTag = listOf("p", reportedPost.pubKey, type.name.lowercase())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags:List<List<String>> = listOf(reportPostTag, reportAuthorTag)
|
||||
@@ -74,7 +74,7 @@ class ReportEvent (
|
||||
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
val reportAuthorTag = listOf("p", reportedUser, type.name.toLowerCase())
|
||||
val reportAuthorTag = listOf("p", reportedUser, type.name.lowercase())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags:List<List<String>> = listOf(reportAuthorTag)
|
||||
@@ -92,4 +92,4 @@ class ReportEvent (
|
||||
NUDITY,
|
||||
PROFANITY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
|
||||
class RepostEvent (
|
||||
id: HexKey,
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
object Constants {
|
||||
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
|
||||
val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
val activeTypesSearch = setOf(FeedType.SEARCH)
|
||||
|
||||
fun convertDefaultRelays(): Array<Relay> {
|
||||
return defaultRelays.map {
|
||||
@@ -44,5 +45,10 @@ object Constants {
|
||||
RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
|
||||
// Supporting NIP-50
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
)
|
||||
|
||||
val forcedRelayForSearch = RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import java.io.Serializable
|
||||
import java.util.*
|
||||
|
||||
interface Filter {
|
||||
fun match(event: Event): Boolean
|
||||
fun toShortString(): String
|
||||
}
|
||||
|
||||
class JsonFilter(
|
||||
val ids: List<String>? = null,
|
||||
val authors: List<String>? = null,
|
||||
val kinds: List<Int>? = null,
|
||||
val tags: Map<String, List<String>>? = null,
|
||||
val since: Long? = null,
|
||||
val until: Long? = null,
|
||||
val limit: Int? = null,
|
||||
val search: String? = null,
|
||||
) : Filter, Serializable {
|
||||
fun toJson(): String {
|
||||
val jsonObject = JsonObject()
|
||||
ids?.run {
|
||||
jsonObject.add("ids", JsonArray().apply { ids.forEach { add(it) } })
|
||||
}
|
||||
authors?.run {
|
||||
jsonObject.add("authors", JsonArray().apply { authors.forEach { add(it) } })
|
||||
}
|
||||
kinds?.run {
|
||||
jsonObject.add("kinds", JsonArray().apply { kinds.forEach { add(it) } })
|
||||
}
|
||||
tags?.run {
|
||||
entries.forEach { kv ->
|
||||
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
|
||||
}
|
||||
}
|
||||
since?.run {
|
||||
jsonObject.addProperty("since", since)
|
||||
}
|
||||
until?.run {
|
||||
jsonObject.addProperty("until", until)
|
||||
}
|
||||
limit?.run {
|
||||
jsonObject.addProperty("limit", limit)
|
||||
}
|
||||
search?.run {
|
||||
jsonObject.addProperty("search", search)
|
||||
}
|
||||
return gson.toJson(jsonObject)
|
||||
}
|
||||
|
||||
override fun match(event: Event): Boolean {
|
||||
if (ids?.any { event.id == it } == false) return false
|
||||
if (kinds?.any { event.kind == it } == false) return false
|
||||
if (authors?.any { event.pubKey == it } == false) return false
|
||||
tags?.forEach { tag ->
|
||||
if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false
|
||||
}
|
||||
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE))
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun toString(): String = "JsonFilter${toJson()}"
|
||||
|
||||
override fun toShortString(): String {
|
||||
val list = ArrayList<String>()
|
||||
ids?.run {
|
||||
list.add("ids")
|
||||
}
|
||||
authors?.run {
|
||||
list.add("authors")
|
||||
}
|
||||
kinds?.run {
|
||||
list.add("kinds[${kinds.joinToString()}]")
|
||||
}
|
||||
tags?.run {
|
||||
list.add("tags")
|
||||
}
|
||||
since?.run {
|
||||
list.add("since")
|
||||
}
|
||||
until?.run {
|
||||
list.add("until")
|
||||
}
|
||||
limit?.run {
|
||||
list.add("limit")
|
||||
}
|
||||
search?.run {
|
||||
list.add("search")
|
||||
}
|
||||
return list.joinToString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder().create()
|
||||
|
||||
fun fromJson(json: String): JsonFilter {
|
||||
val jsonFilter = gson.fromJson(json, JsonObject::class.java)
|
||||
return fromJson(jsonFilter)
|
||||
}
|
||||
|
||||
val declaredFields = JsonFilter::class.java.declaredFields.map { it.name }
|
||||
fun fromJson(json: JsonObject): JsonFilter {
|
||||
// sanity check
|
||||
if (json.keySet().any { !(it.startsWith("#") || it in declaredFields) }) {
|
||||
println("Filter $json contains unknown parameters.")
|
||||
}
|
||||
return JsonFilter(
|
||||
ids = if (json.has("ids")) json.getAsJsonArray("ids").map { it.asString } else null,
|
||||
authors = if (json.has("authors")) json.getAsJsonArray("authors")
|
||||
.map { it.asString } else null,
|
||||
kinds = if (json.has("kinds")) json.getAsJsonArray("kinds")
|
||||
.map { it.asInt } else null,
|
||||
tags = json
|
||||
.entrySet()
|
||||
.filter { it.key.startsWith("#") }
|
||||
.associate {
|
||||
it.key.substring(1) to it.value.asJsonArray.map { it.asString }
|
||||
}
|
||||
.ifEmpty { null },
|
||||
since = if (json.has("since")) json.get("since").asLong else null,
|
||||
until = if (json.has("until")) json.get("until").asLong else null,
|
||||
limit = if (json.has("limit")) json.get("limit").asInt else null,
|
||||
search = if (json.has("search")) json.get("search").asString else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
|
||||
enum class FeedType {
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH
|
||||
}
|
||||
|
||||
class Relay(
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
|
||||
class TypedFilter(
|
||||
val types: Set<FeedType>,
|
||||
@@ -22,7 +22,7 @@ class TypedFilter(
|
||||
}
|
||||
|
||||
fun typesToJson(types: Set<FeedType>): JsonArray {
|
||||
return JsonArray().apply { types.forEach { add(it.name.toLowerCase()) } }
|
||||
return JsonArray().apply { types.forEach { add(it.name.lowercase()) } }
|
||||
}
|
||||
|
||||
fun filterToJson(filter: JsonFilter): JsonObject {
|
||||
@@ -52,4 +52,4 @@ class TypedFilter(
|
||||
}
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
@@ -22,7 +22,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
Row(modifier = Modifier.padding(top = 5.dp)) {
|
||||
if (isValidURL(myUrlPreview)) {
|
||||
val removedParamsFromUrl =
|
||||
myUrlPreview.split("?")[0].toLowerCase()
|
||||
myUrlPreview.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SyncProblem
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -117,6 +118,7 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
|
||||
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
|
||||
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
|
||||
onToggleGlobal = { postViewModel.toggleGlobal(it) },
|
||||
onToggleSearch = { postViewModel.toggleSearch(it) },
|
||||
|
||||
onDelete = { postViewModel.deleteRelay(it) }
|
||||
)
|
||||
@@ -213,6 +215,7 @@ fun ServerConfig(
|
||||
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
|
||||
onTogglePublicChats: (RelaySetupInfo) -> Unit,
|
||||
onToggleGlobal: (RelaySetupInfo) -> Unit,
|
||||
onToggleSearch: (RelaySetupInfo) -> Unit,
|
||||
|
||||
onDelete: (RelaySetupInfo) -> Unit) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
@@ -309,6 +312,22 @@ fun ServerConfig(
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = { onToggleSearch(item) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
stringResource(R.string.search_feed),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.feedTypes.contains(FeedType.SEARCH)) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -31,7 +34,15 @@ class NewRelayListViewModel: ViewModel() {
|
||||
|
||||
fun clear(ctx: Context) {
|
||||
_relays.update {
|
||||
val relayFile = account.userProfile().relays
|
||||
var relayFile = account.userProfile().relays
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
// TODO: Remove when search becomes more available.
|
||||
if (relayFile?.none { it.key == Constants.forcedRelayForSearch.url } == true) {
|
||||
relayFile = relayFile + Pair(
|
||||
Constants.forcedRelayForSearch.url, ContactListEvent.ReadWrite(Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write)
|
||||
)
|
||||
}
|
||||
|
||||
if (relayFile != null)
|
||||
relayFile.map {
|
||||
@@ -112,6 +123,13 @@ class NewRelayListViewModel: ViewModel() {
|
||||
it.updated(relay, relay.copy( feedTypes = newTypes ))
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSearch(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH)
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy( feedTypes = newTypes ))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }
|
||||
|
||||
@@ -141,7 +141,7 @@ fun RichTextViewer(
|
||||
if (lnInvoice != null) {
|
||||
InvoicePreview(lnInvoice)
|
||||
} else if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
ZoomableImageView(word)
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
|
||||
+7
-1
@@ -206,7 +206,13 @@ fun TranslateableRichTextViewer(
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text("${stringResource(id = R.string.show_in)} ${Locale(target).displayName} ${R.string.first}")
|
||||
Text(
|
||||
"${stringResource(R.string.show_in)} ${Locale(target).displayName} ${
|
||||
stringResource(
|
||||
R.string.first
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.baha.url.preview.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import java.net.URL
|
||||
|
||||
@Composable
|
||||
fun UrlPreviewCard(
|
||||
@@ -36,8 +37,14 @@ fun UrlPreviewCard(
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||
) {
|
||||
Column {
|
||||
// correctly treating relative images
|
||||
val imageUrl = if (previewInfo.image.startsWith("/"))
|
||||
URL(URL(previewInfo.url), previewInfo.image).toString()
|
||||
else
|
||||
previewInfo.image
|
||||
|
||||
AsyncImage(
|
||||
model = previewInfo.image,
|
||||
model = imageUrl,
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, url),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
||||
@@ -57,29 +57,32 @@ fun ZoomableImageView(word: String) {
|
||||
)
|
||||
|
||||
if (dialogOpen) {
|
||||
Dialog(
|
||||
onDismissRequest = { dialogOpen = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
ZoomableImageDialog(word, onDismiss = { dialogOpen = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
dialogOpen = false
|
||||
})
|
||||
CloseButton(onCancel = onDismiss)
|
||||
|
||||
SaveToGallery(url = word)
|
||||
}
|
||||
|
||||
ZoomableAsyncImage(word)
|
||||
SaveToGallery(url = imageUrl)
|
||||
}
|
||||
|
||||
ZoomableAsyncImage(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ object ChannelFeedFilter: FeedFilter<Note>() {
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
return channel.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.createdAt() }?.reversed() ?: emptyList()
|
||||
return channel.notes
|
||||
.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,13 @@ object ChatroomFeedFilter: FeedFilter<Note>() {
|
||||
|
||||
if (myAccount == null || myUser == null) return emptyList()
|
||||
|
||||
val messages = myAccount.userProfile().privateChatrooms[myUser] ?: return emptyList()
|
||||
val messages = myAccount
|
||||
.userProfile()
|
||||
.privateChatrooms[myUser] ?: return emptyList()
|
||||
|
||||
return messages.roomMessages.filter { myAccount.isAcceptable(it) }.sortedBy { it.createdAt() }.reversed()
|
||||
return messages.roomMessages
|
||||
.filter { myAccount.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-10
@@ -15,19 +15,24 @@ object ChatroomListKnownFeedFilter: FeedFilter<Note>() {
|
||||
me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
||||
}
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull {
|
||||
privateChatrooms[it]?.roomMessages?.sortedBy {
|
||||
it.createdAt()
|
||||
}?.lastOrNull {
|
||||
it.event != null
|
||||
}
|
||||
val privateMessages = messagingWith.mapNotNull { it ->
|
||||
privateChatrooms[it]
|
||||
?.roomMessages
|
||||
?.sortedBy { it.createdAt() }
|
||||
?.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
val publicChannels = account.followingChannels().map {
|
||||
it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.createdAt() }.lastOrNull { it.event != null }
|
||||
val publicChannels = account.followingChannels().map { it ->
|
||||
it.notes.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.createdAt() }.reversed()
|
||||
return (privateMessages + publicChannels)
|
||||
.filterNotNull()
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,15 @@ object ChatroomListNewFeedFilter: FeedFilter<Note>() {
|
||||
!me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
||||
}
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull {
|
||||
privateChatrooms[it]?.roomMessages?.sortedBy {
|
||||
it.createdAt()
|
||||
}?.lastOrNull {
|
||||
it.event != null
|
||||
}
|
||||
val privateMessages = messagingWith.mapNotNull { it ->
|
||||
privateChatrooms[it]
|
||||
?.roomMessages
|
||||
?.sortedBy { it.createdAt() }
|
||||
?.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
return privateMessages.sortedBy { it.createdAt() }.reversed()
|
||||
return privateMessages
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ abstract class FeedFilter<T>() {
|
||||
feed().take(1000)
|
||||
}
|
||||
|
||||
Log.d("Time","${this.javaClass.simpleName} Feed in ${elapsed} with ${feed.size} objects")
|
||||
Log.d("Time", "${this.javaClass.simpleName} Feed in ${elapsed} with ${feed.size} objects")
|
||||
return feed
|
||||
}
|
||||
|
||||
abstract fun feed(): List<T>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ object GlobalFeedFilter: FeedFilter<Note>() {
|
||||
}
|
||||
.filter {
|
||||
// does not show events already in the public chat list
|
||||
(it.channel() == null || it.channel() !in account.followingChannels())
|
||||
// does not show people the user already follows
|
||||
(it.channel() == null || it.channel() !in account.followingChannels())
|
||||
// does not show people the user already follows
|
||||
&& (it.author !in account.userProfile().follows)
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ object HiddenAccountsFeedFilter: FeedFilter<User>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed() = account.hiddenUsers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,4 @@ object HomeConversationsFeedFilter: FeedFilter<Note>() {
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ object HomeNewThreadFeedFilter: FeedFilter<Note>() {
|
||||
val user = account.userProfile()
|
||||
|
||||
val notes = LocalCache.notes.values
|
||||
.filter {
|
||||
.filter { it ->
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent)
|
||||
&& it.author in user.follows
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
@@ -23,7 +23,7 @@ object HomeNewThreadFeedFilter: FeedFilter<Note>() {
|
||||
}
|
||||
|
||||
val longFormNotes = LocalCache.addressables.values
|
||||
.filter {
|
||||
.filter { it ->
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent)
|
||||
&& it.author in user.follows
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
@@ -35,4 +35,4 @@ object HomeNewThreadFeedFilter: FeedFilter<Note>() {
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,68 +2,43 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
|
||||
object NotificationFeedFilter: FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return account.userProfile().taggedPosts
|
||||
return account.userProfile()
|
||||
.taggedPosts
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.author == null
|
||||
|| (!account.isHidden(it.author!!) && it.author != account.userProfile())
|
||||
it.author == null
|
||||
|| (!account.isHidden(it.author!!) && it.author != account.userProfile())
|
||||
}
|
||||
.filter {
|
||||
it.event !is ChannelCreateEvent
|
||||
&& it.event !is ChannelMetadataEvent
|
||||
&& it.event !is LnZapRequestEvent
|
||||
it.event !is ChannelCreateEvent
|
||||
&& it.event !is ChannelMetadataEvent
|
||||
&& it.event !is LnZapRequestEvent
|
||||
&& it.event !is BadgeDefinitionEvent
|
||||
&& it.event !is BadgeProfilesEvent
|
||||
}
|
||||
.filter {
|
||||
.filter { it ->
|
||||
it.event !is TextNoteEvent
|
||||
||
|
||||
(
|
||||
it.event is TextNoteEvent
|
||||
&&
|
||||
(
|
||||
it.replyTo?.any { it.author == account.userProfile() } == true
|
||||
||
|
||||
account.userProfile() in it.directlyCiteUsers()
|
||||
)
|
||||
)
|
||||
|| it.replyTo?.any { it.author == account.userProfile() } == true
|
||||
|| account.userProfile() in it.directlyCiteUsers()
|
||||
}
|
||||
.filter {
|
||||
it.event !is ReactionEvent
|
||||
||
|
||||
(
|
||||
it.event is ReactionEvent
|
||||
&&
|
||||
(
|
||||
it.replyTo?.lastOrNull()?.author == account.userProfile()
|
||||
||
|
||||
account.userProfile() in it.directlyCiteUsers()
|
||||
)
|
||||
)
|
||||
|| it.replyTo?.lastOrNull()?.author == account.userProfile()
|
||||
|| account.userProfile() in it.directlyCiteUsers()
|
||||
}
|
||||
.filter {
|
||||
it.event !is RepostEvent
|
||||
||
|
||||
(
|
||||
it.event is RepostEvent
|
||||
&&
|
||||
(
|
||||
it.replyTo?.lastOrNull()?.author == account.userProfile()
|
||||
||
|
||||
account.userProfile() in it.directlyCiteUsers()
|
||||
)
|
||||
)
|
||||
|| it.replyTo?.lastOrNull()?.author == account.userProfile()
|
||||
|| account.userProfile() in it.directlyCiteUsers()
|
||||
}
|
||||
|
||||
.sortedBy { it.createdAt() }
|
||||
.toList()
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@ object ThreadFeedFilter: FeedFilter<Note>() {
|
||||
fun loadThread(noteId: String?) {
|
||||
this.noteId = noteId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -18,7 +18,6 @@ object UserProfileConversationsFeedFilter: FeedFilter<Note>() {
|
||||
return user?.notes
|
||||
?.filter { account?.isAcceptable(it) == true && !it.isNewThread() }
|
||||
?.sortedBy { it.createdAt() }
|
||||
?.reversed()
|
||||
?: emptyList()
|
||||
?.reversed() ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -14,6 +14,7 @@ object UserProfileFollowersFeedFilter: FeedFilter<User>() {
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return user?.followers?.filter { account.isAcceptable(it) } ?: emptyList()
|
||||
return user?.followers
|
||||
?.filter { account.isAcceptable(it) } ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ object UserProfileFollowsFeedFilter: FeedFilter<User>() {
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return user?.follows?.filter { account.isAcceptable(it) }?.reversed() ?: emptyList()
|
||||
return user?.follows
|
||||
?.filter { account.isAcceptable(it) }
|
||||
?.reversed() ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -17,10 +17,10 @@ object UserProfileNewThreadFeedFilter: FeedFilter<Note>() {
|
||||
override fun feed(): List<Note> {
|
||||
val longFormNotes = LocalCache.addressables.values.filter { it.author == user }
|
||||
|
||||
return user?.notes?.plus(longFormNotes)
|
||||
return user?.notes
|
||||
?.plus(longFormNotes)
|
||||
?.filter { account?.isAcceptable(it) == true && it.isNewThread() }
|
||||
?.sortedBy { it.createdAt() }
|
||||
?.reversed()
|
||||
?: emptyList()
|
||||
?.reversed() ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ object UserProfileReportsFeedFilter: FeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return user?.reports?.values?.flatten()?.sortedBy { it.createdAt() }?.reversed() ?: emptyList()
|
||||
return user?.reports
|
||||
?.values
|
||||
?.flatten()
|
||||
?.sortedBy { it.createdAt() }
|
||||
?.reversed() ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,18 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
|
||||
object UserProfileZapsFeedFilter: FeedFilter<Pair<Note,Note>>() {
|
||||
object UserProfileZapsFeedFilter: FeedFilter<Pair<Note, Note>>() {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
user = LocalCache.checkGetOrCreateUser(userId)
|
||||
}
|
||||
|
||||
override fun feed(): List<Pair<Note,Note>> {
|
||||
return (user?.zaps?.filter { it.value != null }?.toList()?.sortedBy { (it.second?.event as? LnZapEvent)?.amount }?.reversed() ?: emptyList()) as List<Pair<Note, Note>>
|
||||
override fun feed(): List<Pair<Note, Note>> {
|
||||
return (user?.zaps
|
||||
?.filter { it.value != null }
|
||||
?.toList()
|
||||
?.sortedBy { (it.second?.event as? LnZapEvent)?.amount }
|
||||
?.reversed() ?: emptyList()) as List<Pair<Note, Note>>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ fun ListContent(
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
IconRow(
|
||||
"Logout",
|
||||
stringResource(R.string.log_out),
|
||||
R.drawable.ic_logout,
|
||||
MaterialTheme.colors.onBackground,
|
||||
onClick = { accountViewModel.logOff() }
|
||||
|
||||
@@ -40,7 +40,7 @@ sealed class Route(
|
||||
hasNewItems = { acc, cache, ctx -> homeHasNewItems(acc, cache, ctx) },
|
||||
buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } }
|
||||
)
|
||||
object Search : Route("Search", R.drawable.ic_search,
|
||||
object Search : Route("Search", R.drawable.ic_globe,
|
||||
buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }}
|
||||
)
|
||||
object Notification : Route("Notification", R.drawable.ic_notifications,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Badge
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.MilitaryTech
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun BadgeCompose(likeSetCard: BadgeCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by likeSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = likeSetCard) {
|
||||
isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead, context)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt(), context)
|
||||
}
|
||||
|
||||
var backgroundColor = if (isNew) {
|
||||
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
|
||||
} else {
|
||||
MaterialTheme.colors.background
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent !is ChannelMessageEvent) {
|
||||
navController.navigate("Note/${note.idHex}"){
|
||||
launchSingleTop = true
|
||||
}
|
||||
} else {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(modifier = Modifier
|
||||
.padding(
|
||||
start = if (!isInnerNote) 12.dp else 0.dp,
|
||||
end = if (!isInnerNote) 12.dp else 0.dp,
|
||||
top = 10.dp)
|
||||
) {
|
||||
|
||||
// Draws the like picture outside the boosted card.
|
||||
if (!isInnerNote) {
|
||||
Box(modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MilitaryTech,
|
||||
null,
|
||||
modifier = Modifier.size(25.dp).align(Alignment.TopEnd),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.new_badge_award_notif),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
|
||||
note.replyTo?.firstOrNull()?.let {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
routeForLastRead = null,
|
||||
isBoostedNote = true,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
|
||||
channel?.let { channel ->
|
||||
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = notificationCache) {
|
||||
LaunchedEffect(key1 = notificationCache, key2 = note) {
|
||||
note.createdAt()?.let {
|
||||
hasNewMessages = it > notificationCache.cache.load("Channel/${channel.idHex}", context)
|
||||
}
|
||||
@@ -125,7 +125,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
|
||||
userToComposeOn.let { user ->
|
||||
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = notificationCache) {
|
||||
LaunchedEffect(key1 = notificationCache, key2 = note) {
|
||||
noteEvent?.let {
|
||||
hasNewMessages = it.createdAt > notificationCache.cache.load("Room/${userToComposeOn.pubkeyHex}", context)
|
||||
}
|
||||
|
||||
+126
-121
@@ -38,41 +38,41 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State<Boolean?> {
|
||||
var nip05Verified = remember { mutableStateOf<Boolean?>(null) }
|
||||
var nip05Verified = remember { mutableStateOf<Boolean?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = user) {
|
||||
withContext(Dispatchers.IO) {
|
||||
user.nip05?.ifBlank { null }?.let { nip05 ->
|
||||
val now = Date().time / 1000
|
||||
if ((user.nip05LastVerificationTime ?: 0) > (now - 60 * 60)) { // 1hour
|
||||
nip05Verified.value = user.nip05Verified
|
||||
} else {
|
||||
Nip05Verifier().verifyNip05(
|
||||
nip05,
|
||||
onSuccess = {
|
||||
// Marks user as verified
|
||||
if (it == pubkeyHex) {
|
||||
user.nip05Verified = true
|
||||
user.nip05LastVerificationTime = now
|
||||
nip05Verified.value = true
|
||||
} else {
|
||||
user.nip05Verified = false
|
||||
user.nip05LastVerificationTime = 0
|
||||
nip05Verified.value = false
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
user.nip05LastVerificationTime = 0
|
||||
user.nip05Verified = false
|
||||
nip05Verified.value = false
|
||||
LaunchedEffect(key1 = user) {
|
||||
withContext(Dispatchers.IO) {
|
||||
user.nip05?.ifBlank { null }?.let { nip05 ->
|
||||
val now = Date().time / 1000
|
||||
if ((user.nip05LastVerificationTime ?: 0) > (now - 60 * 60)) { // 1hour
|
||||
nip05Verified.value = user.nip05Verified
|
||||
} else {
|
||||
Nip05Verifier().verifyNip05(
|
||||
nip05,
|
||||
onSuccess = {
|
||||
// Marks user as verified
|
||||
if (it == pubkeyHex) {
|
||||
user.nip05Verified = true
|
||||
user.nip05LastVerificationTime = now
|
||||
nip05Verified.value = true
|
||||
} else {
|
||||
user.nip05Verified = false
|
||||
user.nip05LastVerificationTime = 0
|
||||
nip05Verified.value = false
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
user.nip05LastVerificationTime = 0
|
||||
user.nip05Verified = false
|
||||
nip05Verified.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nip05Verified
|
||||
return nip05Verified
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -82,68 +82,68 @@ fun ObserveDisplayNip05Status(baseNote: Note) {
|
||||
|
||||
val author = note.author
|
||||
if (author != null)
|
||||
ObserveDisplayNip05Status(author)
|
||||
ObserveDisplayNip05Status(author)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveDisplayNip05Status(baseUser: User) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
user.nip05()?.let { nip05 ->
|
||||
if (nip05.split("@").size == 2) {
|
||||
user.nip05()?.let { nip05 ->
|
||||
if (nip05.split("@").size == 2) {
|
||||
|
||||
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (nip05.split("@")[0] != "_")
|
||||
Text(
|
||||
text = AnnotatedString(nip05.split("@")[0]),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (nip05.split("@")[0] != "_")
|
||||
Text(
|
||||
text = AnnotatedString(nip05.split("@")[0]),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (nip05Verified == null) {
|
||||
Icon(
|
||||
tint = Color.Yellow,
|
||||
imageVector = Icons.Default.Downloading,
|
||||
contentDescription = "Downloading",
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
} else if (nip05Verified == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
"NIP-05 Verified",
|
||||
tint = Nip05.copy(0.52f),
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
tint = Color.Red,
|
||||
imageVector = Icons.Default.Report,
|
||||
contentDescription = "Invalid Nip05",
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
if (nip05Verified == null) {
|
||||
Icon(
|
||||
tint = Color.Yellow,
|
||||
imageVector = Icons.Default.Downloading,
|
||||
contentDescription = "Downloading",
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
} else if (nip05Verified == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
"NIP-05 Verified",
|
||||
tint = Nip05.copy(0.52f),
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
tint = Color.Red,
|
||||
imageVector = Icons.Default.Report,
|
||||
contentDescription = "Invalid Nip05",
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(nip05.split("@")[1]),
|
||||
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(nip05.split("@")[1]),
|
||||
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -153,46 +153,51 @@ fun DisplayNip05ProfileStatus(user: User) {
|
||||
user.nip05()?.let { nip05 ->
|
||||
if (nip05.split("@").size == 2) {
|
||||
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (nip05Verified == null) {
|
||||
Icon(
|
||||
tint = Color.Yellow,
|
||||
imageVector = Icons.Default.Downloading,
|
||||
contentDescription = "Downloading",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
} else if (nip05Verified == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
"NIP-05 Verified",
|
||||
tint = Nip05,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
tint = Color.Red,
|
||||
imageVector = Icons.Default.Report,
|
||||
contentDescription = "Invalid Nip05",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (nip05Verified == null) {
|
||||
Icon(
|
||||
tint = Color.Yellow,
|
||||
imageVector = Icons.Default.Downloading,
|
||||
contentDescription = "Downloading",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
} else if (nip05Verified == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
"NIP-05 Verified",
|
||||
tint = Nip05,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
tint = Color.Red,
|
||||
imageVector = Icons.Default.Report,
|
||||
contentDescription = "Invalid Nip05",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
var domainPadStart = 5.dp
|
||||
|
||||
if (nip05.split("@")[0] != "_") {
|
||||
Text(
|
||||
text = AnnotatedString(nip05.split("@")[0] + "@"),
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
domainPadStart = 0.dp
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(nip05.split("@")[1]),
|
||||
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = AnnotatedString(nip05.split("@")[0] + "@"),
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(nip05.split("@")[1]),
|
||||
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.CutCornerShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -38,6 +40,8 @@ import com.vitorpamplona.amethyst.RoboHashCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
@@ -106,6 +110,8 @@ fun NoteCompose(
|
||||
)
|
||||
} else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) {
|
||||
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
|
||||
} else if (noteEvent is BadgeDefinitionEvent) {
|
||||
BadgeDisplay(baseNote = note)
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
@@ -354,6 +360,39 @@ fun NoteCompose(
|
||||
|
||||
ReactionsRow(note, accountViewModel)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
} else if (noteEvent is BadgeAwardEvent && !note.replyTo.isNullOrEmpty()) {
|
||||
Text(text = stringResource(R.string.award_granted_to))
|
||||
|
||||
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
|
||||
note.mentions?.forEach {
|
||||
UserPicture(
|
||||
user = it,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
note.replyTo?.firstOrNull()?.let {
|
||||
NoteCompose(
|
||||
it,
|
||||
modifier = Modifier,
|
||||
isBoostedNote = false,
|
||||
isQuotedNote = true,
|
||||
unPackReply = false,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
ReactionsRow(note, accountViewModel)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
@@ -393,6 +432,61 @@ fun NoteCompose(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgeDisplay(baseNote: Note) {
|
||||
val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.clip(shape = CutCornerShape(20, 20, 0, 0))
|
||||
.border(
|
||||
5.dp,
|
||||
MaterialTheme.colors.primary.copy(alpha = 0.32f),
|
||||
CutCornerShape(20)
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
badgeData.image()?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = stringResource(
|
||||
R.string.badge_award_image_for,
|
||||
it
|
||||
),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
badgeData.name()?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.body1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
badgeData.description()?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.caption,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
|
||||
Row(
|
||||
|
||||
@@ -8,6 +8,14 @@ abstract class Card() {
|
||||
abstract fun id(): String
|
||||
}
|
||||
|
||||
class BadgeCard(val note: Note): Card() {
|
||||
override fun createdAt(): Long {
|
||||
return note.createdAt() ?: 0
|
||||
}
|
||||
|
||||
override fun id() = note.idHex
|
||||
}
|
||||
|
||||
class NoteCard(val note: Note): Card() {
|
||||
override fun createdAt(): Long {
|
||||
return note.createdAt() ?: 0
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.vitorpamplona.amethyst.ui.note.BadgeCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.BoostSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.LikeSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
|
||||
@@ -127,6 +128,12 @@ private fun FeedLoaded(
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is BadgeCard -> BadgeCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
@@ -106,7 +107,12 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>): ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map { NoteCard(it) }
|
||||
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is LnZapEvent }.map {
|
||||
if (it.event is BadgeAwardEvent)
|
||||
BadgeCard(it)
|
||||
else
|
||||
NoteCard(it)
|
||||
}
|
||||
|
||||
return (multiCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
|
||||
}
|
||||
|
||||
@@ -9,21 +9,30 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun ChatroomListFeedView(
|
||||
viewModel: FeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
markAsRead: MutableState<Boolean>
|
||||
) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
@@ -45,21 +54,27 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
|
||||
},
|
||||
) {
|
||||
Column() {
|
||||
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
Crossfade(
|
||||
targetState = feedState,
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
) { state ->
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
isRefreshing = true
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
isRefreshing = true
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
FeedLoaded(state, accountViewModel, navController)
|
||||
FeedLoaded(state, accountViewModel, navController, markAsRead)
|
||||
}
|
||||
|
||||
FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
}
|
||||
@@ -73,10 +88,44 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
|
||||
private fun FeedLoaded(
|
||||
state: FeedState.Loaded,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
navController: NavController,
|
||||
markAsRead: MutableState<Boolean>,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val notificationCacheState = NotificationCache.live.observeAsState()
|
||||
val notificationCache = notificationCacheState.value ?: return
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
LaunchedEffect(key1 = markAsRead.value) {
|
||||
if (markAsRead.value) {
|
||||
for (note in state.feed.value) {
|
||||
note.event?.let {
|
||||
var route = ""
|
||||
val channel = note.channel()
|
||||
|
||||
if (channel != null) {
|
||||
route = "Channel/${channel.idHex}"
|
||||
} else {
|
||||
val replyAuthorBase = note.mentions?.first()
|
||||
var userToComposeOn = note.author!!
|
||||
if (replyAuthorBase != null) {
|
||||
if (note.author == account.userProfile()) {
|
||||
userToComposeOn = replyAuthorBase
|
||||
}
|
||||
}
|
||||
route = "Room/${userToComposeOn.pubkeyHex}"
|
||||
}
|
||||
|
||||
notificationCache.cache.markAsRead(route, it.createdAt, context)
|
||||
}
|
||||
}
|
||||
markAsRead.value = false
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
@@ -84,7 +133,9 @@ private fun FeedLoaded(
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { index, item -> if (index == 0) index else item.idHex }) { index, item ->
|
||||
itemsIndexed(
|
||||
state.feed.value,
|
||||
key = { index, item -> if (index == 0) index else item.idHex }) { index, item ->
|
||||
ChatroomCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -58,7 +59,9 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
|
||||
|
||||
@Composable
|
||||
fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
@@ -173,7 +176,7 @@ fun Modifier.drawReplyLevel(level: Int, color: Color, selected: Color): Modifier
|
||||
|
||||
repeat(level) {
|
||||
this.drawLine(
|
||||
if (it == level-1) selected else color,
|
||||
if (it == level - 1) selected else color,
|
||||
Offset(padding + it * levelWidth, 0f),
|
||||
Offset(padding + it * levelWidth, size.height),
|
||||
strokeWidth = strokeWidth
|
||||
@@ -267,7 +270,10 @@ fun NoteMaster(baseNote: Note,
|
||||
}
|
||||
}
|
||||
|
||||
if (noteEvent is LongTextNoteEvent) {
|
||||
if (noteEvent is BadgeDefinitionEvent) {
|
||||
Spacer(modifier = Modifier.padding(top=10.dp))
|
||||
BadgeDisplay(baseNote = note)
|
||||
} else if (noteEvent is LongTextNoteEvent) {
|
||||
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)) {
|
||||
Column {
|
||||
noteEvent.image()?.let {
|
||||
|
||||
+92
-15
@@ -1,9 +1,16 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.widget.Toast
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -24,6 +31,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -31,6 +39,7 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||
@@ -38,6 +47,7 @@ import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.toNsec
|
||||
|
||||
@@ -66,7 +76,7 @@ fun AccountBackupDialog(account: Account, onClose: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
.padding(horizontal = 30.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
@@ -78,7 +88,7 @@ fun AccountBackupDialog(account: Account, onClose: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
|
||||
NSecCopyButton(account)
|
||||
}
|
||||
@@ -98,26 +108,93 @@ private fun NSecCopyButton(
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {
|
||||
account.loggedIn.privKey?.let {
|
||||
clipboardManager.setText(AnnotatedString(it.toNsec()))
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.secret_key_copied_to_clipboard),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
authenticatedCopyNSec(context, scope, account, clipboardManager)
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp), colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
tint = Color.White,
|
||||
imageVector = Icons.Default.Key,
|
||||
contentDescription = stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup)
|
||||
contentDescription = stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup),
|
||||
modifier = Modifier.padding(end = 5.dp)
|
||||
)
|
||||
Text("Copy Secret Key", color = MaterialTheme.colors.onPrimary)
|
||||
Text("Copy Secret Key", color = MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.getFragmentActivity(): FragmentActivity? {
|
||||
var currentContext = this
|
||||
while (currentContext is ContextWrapper) {
|
||||
if (currentContext is FragmentActivity) {
|
||||
return currentContext
|
||||
}
|
||||
currentContext = currentContext.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun authenticatedCopyNSec(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
account: Account,
|
||||
clipboardManager: ClipboardManager,
|
||||
) {
|
||||
val fragmentContext = context.getFragmentActivity()!!
|
||||
val authenticators = BIOMETRIC_STRONG or DEVICE_CREDENTIAL
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(context.getString(R.string.app_name_release))
|
||||
.setSubtitle(context.getString(R.string.copy_my_secret_key))
|
||||
.setAllowedAuthenticators(authenticators)
|
||||
.build()
|
||||
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
fragmentContext,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.biometric_authentication_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
copyNSec(context, scope, account, clipboardManager)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val canAuth = biometricManager.canAuthenticate(authenticators)
|
||||
if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
} else {
|
||||
copyNSec(context, scope, account, clipboardManager)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyNSec(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
account: Account,
|
||||
clipboardManager: ClipboardManager,
|
||||
) {
|
||||
account.loggedIn.privKey?.let {
|
||||
clipboardManager.setText(AnnotatedString(it.toNsec()))
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.secret_key_copied_to_clipboard),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,4 @@ class AccountViewModel(private val account: Account): ViewModel() {
|
||||
fun follow(user: User) {
|
||||
account.follow(user)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
+121
-38
@@ -1,19 +1,34 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Tab
|
||||
import androidx.compose.material.TabRow
|
||||
import androidx.compose.material.TabRowDefaults
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -26,6 +41,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
|
||||
@@ -33,7 +49,6 @@ import com.vitorpamplona.amethyst.ui.screen.ChatroomListFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
@@ -41,40 +56,70 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
|
||||
val pagerState = rememberPagerState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
TabRow(
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
},
|
||||
) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 0,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
|
||||
text = {
|
||||
Text(text = stringResource(R.string.known))
|
||||
}
|
||||
)
|
||||
var moreActionsExpanded by remember { mutableStateOf(false) }
|
||||
val markKnownAsRead = remember { mutableStateOf(false) }
|
||||
val markNewAsRead = remember { mutableStateOf(false) }
|
||||
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 1,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
|
||||
text = {
|
||||
Text(text = stringResource(R.string.new_requests))
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
TabRow(
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
},
|
||||
) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 0,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
|
||||
text = {
|
||||
Text(text = stringResource(R.string.known))
|
||||
}
|
||||
)
|
||||
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 1,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
|
||||
text = {
|
||||
Text(text = stringResource(R.string.new_requests))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier
|
||||
.padding(end=5.dp)
|
||||
.size(40.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
onClick = { moreActionsExpanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
)
|
||||
|
||||
ChatroomTabMenu(
|
||||
moreActionsExpanded,
|
||||
{ moreActionsExpanded = false },
|
||||
{ markKnownAsRead.value = true },
|
||||
{ markNewAsRead.value = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(count = 2, state = pagerState) {
|
||||
when (pagerState.currentPage) {
|
||||
0 -> TabKnown(accountViewModel, navController, markKnownAsRead,)
|
||||
1 -> TabNew(accountViewModel, navController, markNewAsRead)
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalPager(count = 2, state = pagerState) {
|
||||
when (pagerState.currentPage) {
|
||||
0 -> TabKnown(accountViewModel, navController)
|
||||
1 -> TabNew(accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +127,11 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun TabKnown(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
markAsRead: MutableState<Boolean>,
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
@@ -113,13 +162,17 @@ fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
|
||||
ChatroomListFeedView(feedViewModel, accountViewModel, navController, markAsRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabNew(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun TabNew(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
markAsRead: MutableState<Boolean>
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
@@ -150,7 +203,37 @@ fun TabNew(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
|
||||
ChatroomListFeedView(feedViewModel, accountViewModel, navController, markAsRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatroomTabMenu(
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onMarkKnownAsRead: () -> Unit,
|
||||
onMarkNewAsRead: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
DropdownMenuItem(onClick = {
|
||||
onMarkKnownAsRead()
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.mark_all_known_as_read))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
onMarkNewAsRead()
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.mark_all_new_as_read))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
onMarkKnownAsRead()
|
||||
onMarkNewAsRead()
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.mark_all_as_read))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
@@ -34,6 +36,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -41,26 +44,34 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.RoboHashCache
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
|
||||
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedView
|
||||
@@ -256,8 +267,8 @@ private fun ProfileHeader(
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
val accountUserState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountUserState?.user ?: return
|
||||
@@ -317,7 +328,9 @@ private fun ProfileHeader(
|
||||
MaterialTheme.colors.background,
|
||||
CircleShape),
|
||||
onClick = {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
if (baseUser.profilePicture() != null) {
|
||||
zoomImageDialogOpen = true
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
ResizeImage(it.info?.picture, 100.dp).proxyUrl()?.let { it1 ->
|
||||
@@ -353,18 +366,26 @@ private fun ProfileHeader(
|
||||
}
|
||||
}
|
||||
|
||||
DrawAdditionalInfo(baseUser, account)
|
||||
DrawAdditionalInfo(baseUser, account, navController)
|
||||
|
||||
Divider(modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (zoomImageDialogOpen) {
|
||||
ZoomableImageDialog(baseUser.profilePicture()!!, onDismiss = { zoomImageDialogOpen = false })
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DrawAdditionalInfo(baseUser: User, account: Account) {
|
||||
private fun DrawAdditionalInfo(baseUser: User, account: Account, navController: NavController) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val userBadgeState by baseUser.live().badges.observeAsState()
|
||||
val userBadge = userBadgeState?.user ?: return
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
@@ -406,7 +427,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
|
||||
}
|
||||
}
|
||||
|
||||
var ZapExpanded by remember { mutableStateOf(false) }
|
||||
var zapExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val lud16 = user.info?.lud16?.trim() ?: user.info?.lud06?.trim()
|
||||
|
||||
@@ -421,7 +442,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(lud16),
|
||||
onClick = { ZapExpanded = !ZapExpanded },
|
||||
onClick = { zapExpanded = !zapExpanded },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
|
||||
@@ -429,10 +450,28 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
|
||||
)
|
||||
}
|
||||
|
||||
if (ZapExpanded) {
|
||||
if (zapExpanded) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
InvoiceRequest(lud16, baseUser.pubkeyHex, account) {
|
||||
ZapExpanded = false
|
||||
zapExpanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userBadge.acceptedBadges?.let { note ->
|
||||
(note.event as? BadgeProfilesEvent)?.let { event ->
|
||||
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
event.badgeAwardEvents().forEach { badgeAwardEvent ->
|
||||
val baseNote = LocalCache.notes[badgeAwardEvent]
|
||||
if (baseNote != null) {
|
||||
val badgeAwardState by baseNote.live().metadata.observeAsState()
|
||||
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
|
||||
|
||||
if (baseBadgeDefinition != null) {
|
||||
BadgeThumb(baseBadgeDefinition, navController, 50.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,13 +479,76 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
|
||||
|
||||
user.info?.about?.let {
|
||||
Text(
|
||||
"$it",
|
||||
it,
|
||||
color = MaterialTheme.colors.onSurface,
|
||||
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgeThumb(
|
||||
note: Note,
|
||||
navController: NavController,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier
|
||||
) {
|
||||
BadgeThumb(note, size, pictureModifier) {
|
||||
navController.navigate("Note/${it.idHex}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun BadgeThumb(
|
||||
baseNote: Note,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier,
|
||||
onClick: ((Note) -> Unit)? = null
|
||||
) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
val event = (note.event as? BadgeDefinitionEvent)
|
||||
val image = event?.thumb() ?: event?.image()
|
||||
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.width(size)
|
||||
.height(size)) {
|
||||
if (image == null) {
|
||||
Image(
|
||||
painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")),
|
||||
contentDescription = stringResource(R.string.unknown_author),
|
||||
modifier = pictureModifier
|
||||
.fillMaxSize(1f)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = image,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
placeholder = BitmapPainter(RoboHashCache.get(ctx, note.idHex)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(ctx, note.idHex)),
|
||||
error = BitmapPainter(RoboHashCache.get(ctx, note.idHex)),
|
||||
modifier = pictureModifier
|
||||
.fillMaxSize(1f)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.run {
|
||||
if (onClick != null)
|
||||
this.clickable(onClick = { onClick(note) } )
|
||||
else
|
||||
this
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun DrawBanner(baseUser: User) {
|
||||
@@ -455,21 +557,28 @@ private fun DrawBanner(baseUser: User) {
|
||||
|
||||
val banner = user.info?.banner
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
if (banner != null && banner.isNotBlank()) {
|
||||
if (!banner.isNullOrBlank()) {
|
||||
AsyncImageProxy(
|
||||
model = ResizeImage(banner, 125.dp),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(125.dp).combinedClickable(
|
||||
.height(125.dp)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = {
|
||||
clipboardManager.setText(AnnotatedString(banner))
|
||||
}
|
||||
)
|
||||
.clickable { zoomImageDialogOpen = true }
|
||||
)
|
||||
|
||||
if (zoomImageDialogOpen) {
|
||||
ZoomableImageDialog(imageUrl = banner, onDismiss = {zoomImageDialogOpen = false})
|
||||
}
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.profile_banner),
|
||||
@@ -803,35 +912,35 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
|
||||
}
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(user, ReportEvent.ReportType.SPAM);
|
||||
accountViewModel.report(user, ReportEvent.ReportType.SPAM)
|
||||
user.let { accountViewModel.hide(it, context) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(id = R.string.report_spam_scam))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(user, ReportEvent.ReportType.PROFANITY);
|
||||
accountViewModel.report(user, ReportEvent.ReportType.PROFANITY)
|
||||
user.let { accountViewModel.hide(it, context) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.report_hateful_speech))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION);
|
||||
accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION)
|
||||
user.let { accountViewModel.hide(it, context) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(id = R.string.report_impersonation))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(user, ReportEvent.ReportType.NUDITY);
|
||||
accountViewModel.report(user, ReportEvent.ReportType.NUDITY)
|
||||
user.let { accountViewModel.hide(it, context) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.report_nudity_porn))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL);
|
||||
accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL)
|
||||
user.let { accountViewModel.hide(it, context) }
|
||||
onDismiss()
|
||||
}) {
|
||||
|
||||
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
@@ -150,12 +151,12 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
.filter { it.isNotBlank() }
|
||||
.distinctUntilChanged()
|
||||
.debounce(300)
|
||||
.collect {
|
||||
.collectLatest {
|
||||
if (it.removePrefix("npub").removePrefix("note").length >= 4)
|
||||
onlineSearch.search(it)
|
||||
onlineSearch.search(it.trim())
|
||||
|
||||
searchResults.value = LocalCache.findUsersStartingWith(it)
|
||||
searchResultsNotes.value = LocalCache.findNotesStartingWith(it)
|
||||
searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed()
|
||||
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,17 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.AutofillNode
|
||||
import androidx.compose.ui.autofill.AutofillType
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalAutofill
|
||||
import androidx.compose.ui.platform.LocalAutofillTree
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -55,6 +63,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun LoginPage(accountViewModel: AccountStateViewModel) {
|
||||
val key = remember { mutableStateOf(TextFieldValue("")) }
|
||||
@@ -95,7 +104,27 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val autofillNode = AutofillNode(
|
||||
autofillTypes = listOf(AutofillType.Password),
|
||||
onFill = { key.value = TextFieldValue(it) }
|
||||
)
|
||||
val autofill = LocalAutofill.current
|
||||
LocalAutofillTree.current += autofillNode
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned { coordinates ->
|
||||
autofillNode.boundingBox = coordinates.boundsInWindow()
|
||||
}
|
||||
.onFocusChanged { focusState ->
|
||||
autofill?.run {
|
||||
if (focusState.isFocused) {
|
||||
requestAutofillForNode(autofillNode)
|
||||
} else {
|
||||
cancelAutofillForNode(autofillNode)
|
||||
}
|
||||
}
|
||||
},
|
||||
value = key.value,
|
||||
onValueChange = { key.value = it },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,185 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name_release" translatable="false">Amethyst</string>
|
||||
<string name="app_name_debug" translatable="false">Amethyst Debug</string>
|
||||
<string name="point_to_the_qr_code">Pointer vers le QR code</string>
|
||||
<string name="show_qr">Montrer le QR code</string>
|
||||
<string name="profile_image">Image de profil</string>
|
||||
<string name="scan_qr">Scanner le QR code</string>
|
||||
<string name="show_anyway">Montrer quand même</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Post signalé innaproprié par</string>
|
||||
<string name="post_not_found">Post non trouvé</string>
|
||||
<string name="channel_image">Image du canal</string>
|
||||
<string name="referenced_event_not_found">référence de l\'évènement non trouvée</string>
|
||||
<string name="could_not_decrypt_the_message">Impossibe de déchiffrer le message</string>
|
||||
<string name="group_picture">Image de groupe</string>
|
||||
<string name="explicit_content">Contenu choquant</string>
|
||||
<string name="spam">Spam</string>
|
||||
<string name="impersonation">Falsification d\'identité</string>
|
||||
<string name="illegal_behavior">Comportement illégal</string>
|
||||
<string name="unknown">inconnu</string>
|
||||
<string name="relay_icon">Icône de relais</string>
|
||||
<string name="unknown_author">Auteur inconnu</string>
|
||||
<string name="copy_text">Copier le texte</string>
|
||||
<string name="copy_user_pubkey">Copier la clé publique de l\'utilisateur</string>
|
||||
<string name="copy_note_id">Copier l\'ID de la note</string>
|
||||
<string name="broadcast">Diffuser</string>
|
||||
<string name="block_hide_user">Bloquer et cacher l\'utilisateur</string>
|
||||
<string name="report_spam_scam">Signaler un Spam / Scam</string>
|
||||
<string name="report_impersonation">Signaler une falsification d\'identité</string>
|
||||
<string name="report_explicit_content">Signaler un contenu choquant</string>
|
||||
<string name="report_illegal_behaviour">Signaler un comportement illégal</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_reply">Connectez vous avec une clé privée pour pouvoir répondre</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Connectez vous avec une clé privée pour pouvoir booster des posts</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">Connectez vous avec une clé privée pour pouvoir aimer des posts</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">Aucun montant sélectionné pour le zap. Appui long pour modifier</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Connectez vous avec une clé privée pour pouvoir envoyer des zaps</string>
|
||||
<string name="zaps">Zaps</string>
|
||||
<string name="view_count">Nombre de vues</string>
|
||||
<string name="boost">Boost</string>
|
||||
<string name="boosted">boosté</string>
|
||||
<string name="quote">Citation</string>
|
||||
<string name="new_amount_in_sats">Nouveau montant en Sats</string>
|
||||
<string name="add">Ajouter</string>
|
||||
<string name="replying_to">"répondre à "</string>
|
||||
<string name="and">" et "</string>
|
||||
<string name="in_channel">"dans le canal "</string>
|
||||
<string name="profile_banner">Bannière du profil</string>
|
||||
<string name="following">" Abonnements"</string>
|
||||
<string name="followers">" Abonnés"</string>
|
||||
<string name="profile">Profile</string>
|
||||
<string name="security_filters">Filtres de sécurité</string>
|
||||
<string name="log_out">Déconnexion</string>
|
||||
<string name="show_more">Voir plus</string>
|
||||
<string name="lightning_invoice">Facturer Lightning</string>
|
||||
<string name="pay">Payer</string>
|
||||
<string name="lightning_tips">Pourboire Lightning</string>
|
||||
<string name="note_to_receiver">Note au destinataire</string>
|
||||
<string name="thank_you_so_much">Merci beaucoup!</string>
|
||||
<string name="amount_in_sats">Montant en Sats</string>
|
||||
<string name="send_sats">Envoyer des Sats</string>
|
||||
<string name="never_translate_from">"Ne jamais traduire depuis "</string>
|
||||
<string name="error_parsing_preview_for">"Erreur d\'analyse de l\'aperçu pour %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Aperçu de l\'image pour %1$s"</string>
|
||||
<string name="new_channel">Nouveau canal</string>
|
||||
<string name="channel_name">Nom de canal</string>
|
||||
<string name="my_awesome_group">Mon groupe spectaculaire</string>
|
||||
<string name="picture_url">Url de l\'image</string>
|
||||
<string name="description">Description</string>
|
||||
<string name="about_us">"à propos.. "</string>
|
||||
<string name="what_s_on_your_mind">Qu\'es ce qui vous préoccupe ?</string>
|
||||
<string name="post">Envoyer</string>
|
||||
<string name="save">Sauvegarder</string>
|
||||
<string name="create">Créer</string>
|
||||
<string name="cancel">Annuler</string>
|
||||
<string name="failed_to_upload_the_image">Échec du téléchargement de l\'image</string>
|
||||
<string name="relay_address">Adresse du relais</string>
|
||||
<string name="posts">Publications</string>
|
||||
<string name="errors">Erreurs</string>
|
||||
<string name="home_feed">Vos notifications</string>
|
||||
<string name="private_message_feed">Flux de messages privés</string>
|
||||
<string name="public_chat_feed">Flux de chat public</string>
|
||||
<string name="global_feed">Flux mondial</string>
|
||||
<string name="add_a_relay">Ajouter un relais</string>
|
||||
<string name="display_name">Nom visible du public</string>
|
||||
<string name="my_display_name">Mon nom visible du public</string>
|
||||
<string name="username">Nom d\'utilisateur</string>
|
||||
<string name="my_username">Mon nom d\'utilisateur</string>
|
||||
<string name="about_me">à propos de moi</string>
|
||||
<string name="avatar_url">URL de l\'avatar</string>
|
||||
<string name="banner_url">URL de la bannière</string>
|
||||
<string name="website_url">URL de site</string>
|
||||
<string name="ln_address">Adresse LN</string>
|
||||
<string name="ln_url_outdated">URL LN (obsolete)</string>
|
||||
<string name="image_saved_to_the_gallery">Image enregistrée dans la galerie</string>
|
||||
<string name="failed_to_save_the_image">Échec de l\'enregistrement de l\'image</string>
|
||||
<string name="upload_image">Charger une image</string>
|
||||
<string name="uploading">Chargement en cours...</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats</string>
|
||||
<string name="reply_here">"Répondre ici.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copie l\'ID de note dans le presse-papiers pour le partage</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">Copier l\'ID de la chaîne (Note) dans le presse-papiers</string>
|
||||
<string name="edits_the_channel_metadata">Modifie les métadonnées du canal</string>
|
||||
<string name="join">Joindre</string>
|
||||
<string name="known">Connu</string>
|
||||
<string name="new_requests">Nouvelles demandes</string>
|
||||
<string name="blocked_users">Utilisateurs bloqués</string>
|
||||
<string name="new_threads">Nouveaux sujets</string>
|
||||
<string name="conversations">Conversations</string>
|
||||
<string name="notes">Notes</string>
|
||||
<string name="replies">Réponses</string>
|
||||
<string name="follows">"Suivis"</string>
|
||||
<string name="reports">"Signalements"</string>
|
||||
<string name="more_options">Plus d\'options</string>
|
||||
<string name="relays">" Relais"</string>
|
||||
<string name="website">Site Web</string>
|
||||
<string name="lightning_address">Adresse Lightning</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Copie l\'ID Nsec (votre mot de passe) dans le presse-papiers pour le sauvegarder</string>
|
||||
<string name="copy_private_key_to_the_clipboard">Copier la clé secrète dans le presse-papiers</string>
|
||||
<string name="copies_the_public_key_to_the_clipboard_for_sharing">Copie la clé publique dans le presse-papiers pour la partager</string>
|
||||
<string name="copy_public_key_npub_to_the_clipboard">Copier la clé publique (NPub) dans le presse-papiers</string>
|
||||
<string name="send_a_direct_message">Envoyer un message direct</string>
|
||||
<string name="edits_the_user_s_metadata">Modifie les métadonnées de l\'utilisateur</string>
|
||||
<string name="follow">Suivre</string>
|
||||
<string name="unblock">Débloquer</string>
|
||||
<string name="copy_user_id">Copier l\'ID utilisateur</string>
|
||||
<string name="unblock_user">Débloquer l\'utilisateur</string>
|
||||
<string name="npub_hex_username">"npub, hex, nom d\'utilisateur "</string>
|
||||
<string name="clear">Nettoyer</string>
|
||||
<string name="app_logo">Logo de l\'App</string>
|
||||
<string name="nsec_npub_hex_private_key">nsec / npub / hex private key</string>
|
||||
<string name="show_password">Révéler le mot de passe</string>
|
||||
<string name="hide_password">Cacher le mot de passe</string>
|
||||
<string name="invalid_key">Clé non valide</string>
|
||||
<string name="i_accept_the">"J\accepte le(s) "</string>
|
||||
<string name="terms_of_use">conditions d\'utilisation</string>
|
||||
<string name="acceptance_of_terms_is_required">L\'acceptation des conditions est requise</string>
|
||||
<string name="key_is_required">La clé est requise</string>
|
||||
<string name="login">Connexion</string>
|
||||
<string name="generate_a_new_key">Générer une nouvelle clé</string>
|
||||
<string name="loading_feed">Chargement du flux</string>
|
||||
<string name="error_loading_replies">"Erreur lors du chargement des réponses : "</string>
|
||||
<string name="try_again">essayer à nouveau</string>
|
||||
<string name="feed_is_empty">Le flux est vide.</string>
|
||||
<string name="refresh">Rafraîchir</string>
|
||||
<string name="created">créé</string>
|
||||
<string name="with_description_of">avec le descriptif de</string>
|
||||
<string name="and_picture">et photo</string>
|
||||
<string name="changed_chat_name_to">changé le nom du chat en</string>
|
||||
<string name="description_to">descriptif à</string>
|
||||
<string name="and_picture_to">et image à</string>
|
||||
<string name="leave">Partir</string>
|
||||
<string name="unfollow">Ne plus suivre</string>
|
||||
<string name="channel_created">Canal créé</string>
|
||||
<string name="channel_information_changed_to">"Les informations du canal ont été remplacées par"</string>
|
||||
<string name="public_chat">Chat public</string>
|
||||
<string name="posts_received">messages reçus</string>
|
||||
<string name="remove">Retirer</string>
|
||||
<string name="sats" translatable="false">sats</string>
|
||||
<string name="auto">Auto</string>
|
||||
<string name="translated_from">traduit de</string>
|
||||
<string name="to">vers</string>
|
||||
<string name="show_in">Montre</string>
|
||||
<string name="first">premier</string>
|
||||
<string name="always_translate_to">Toujours traduire en</string>
|
||||
<string name="nip_05" translatable="false">NIP-05</string>
|
||||
<string name="lnurl" translatable="false">LNURL...</string>
|
||||
<string name="never">jamais</string>
|
||||
<string name="now">maintenant</string>
|
||||
<string name="h">h</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">d</string>
|
||||
<string name="nudity">Nudité</string>
|
||||
<string name="profanity_hateful_speech">Blasphème / Discours haineux</string>
|
||||
<string name="report_hateful_speech">Signaler Discours haineux</string>
|
||||
<string name="report_nudity_porn">Signaler Nudité / Porno</string>
|
||||
<string name="others">autres</string>
|
||||
<string name="account_backup_tips_md">
|
||||
## Sauvegarde des clés et conseils de sécurité
|
||||
\n\nVotre compte est sécurisé par une clé secrète. La clé est une longue chaîne aléatoire commençant par **nsec1**. Toute personne ayant accès à votre clé secrète peut publier du contenu en utilisant votre identité.
|
||||
\n\n- Ne communiquez **jamais** votre clé secrète à un site Web ou un logiciel auquel vous ne faites pas confiance.
|
||||
\n- Les développeurs d\'Amethyst ne vous demanderont **jamais** votre clé secrète.
|
||||
\n- **Conservez** une sauvegarde sécurisée de votre clé secrète pour la récupération de compte. Nous vous recommandons d\'utiliser un gestionnaire de mots de passe.
|
||||
</string>
|
||||
<string name="secret_key_copied_to_clipboard">Clé secrète (nsec) copiée dans le presse-papiers</string>
|
||||
</resources>
|
||||
@@ -48,7 +48,7 @@
|
||||
<string name="followers">" Followers"</string>
|
||||
<string name="profile">Profile</string>
|
||||
<string name="security_filters">Security Filters</string>
|
||||
<string name="log_out">Log out</string>
|
||||
<string name="log_out">Logout</string>
|
||||
<string name="show_more">Show More</string>
|
||||
<string name="lightning_invoice">Lightning Invoice</string>
|
||||
<string name="pay">Pay</string>
|
||||
@@ -79,6 +79,7 @@
|
||||
<string name="private_message_feed">Private Message Feed</string>
|
||||
<string name="public_chat_feed">Public Chat Feed</string>
|
||||
<string name="global_feed">Global Feed</string>
|
||||
<string name="search_feed">Search Feed</string>
|
||||
<string name="add_a_relay">Add a Relay</string>
|
||||
<string name="display_name">Display Name</string>
|
||||
<string name="my_display_name">My display name</string>
|
||||
@@ -173,6 +174,9 @@
|
||||
<string name="report_hateful_speech">Report Hateful speech</string>
|
||||
<string name="report_nudity_porn">Report Nudity / Porn</string>
|
||||
<string name="others">others</string>
|
||||
<string name="mark_all_known_as_read">Mark all Known as read</string>
|
||||
<string name="mark_all_new_as_read">Mark all New as read</string>
|
||||
<string name="mark_all_as_read">Mark all as read</string>
|
||||
<string name="account_backup_tips_md">
|
||||
## Key Backup and Safety Tips
|
||||
\n\nYour account is secured by a secret key. The key is long random string starting with **nsec1**. Anyone who has access to your secret key can publish content using your identity.
|
||||
@@ -181,4 +185,11 @@
|
||||
\n- **Do** keep a secure backup of your secret key for account recovery. We recommend using a password manager.
|
||||
</string>
|
||||
<string name="secret_key_copied_to_clipboard">Secret key (nsec) copied to clipboard</string>
|
||||
<string name="copy_my_secret_key">Copy my secret key</string>
|
||||
<string name="biometric_authentication_failed">Authentication failed</string>
|
||||
|
||||
<string name="badge_created_by">"Created by %1$s"</string>
|
||||
<string name="badge_award_image_for">"Badge award image for %1$s"</string>
|
||||
<string name="new_badge_award_notif">You Received a new Badge Award</string>
|
||||
<string name="award_granted_to">Badge award granted to</string>
|
||||
</resources>
|
||||
@@ -1,5 +1,6 @@
|
||||
buildscript {
|
||||
ext {
|
||||
fragment_version = "1.5.5"
|
||||
lifecycle_version = '2.6.0-rc1'
|
||||
compose_ui_version = '1.4.0-beta02'
|
||||
nav_version = "2.5.3"
|
||||
|
||||
Reference in New Issue
Block a user