Compare commits

...
47 Commits
Author SHA1 Message Date
Vitor Pamplona 43e8106aa9 0.24.0 2023-03-05 18:34:52 -05:00
Vitor Pamplona e86ae4ac41 Read only support for Badges. 2023-03-05 18:34:11 -05:00
Vitor Pamplona 12570d3e26 Fixing for https://github.com/vitorpamplona/amethyst/pull/201#pullrequestreview-1325255540 2023-03-05 13:02:26 -05:00
Vitor PamplonaandGitHub 8fe2c895de Merge pull request #202 from maxmoney21m/feature/134-full-size-pfp
Allow tapping on PFP and banner to open zoom dialog
2023-03-05 12:57:08 -05:00
Vitor Pamplona f251d56b1a Merge remote-tracking branch 'origin/HEAD' 2023-03-05 12:43:57 -05:00
maxmoney21m e5518e23a8 Small code cleanups 2023-03-06 01:33:16 +08:00
maxmoney21m d6f3e9540f Allow zooming on profile picture and banner #134 2023-03-06 01:32:53 +08:00
maxmoney21m 79e7bb2931 Extract ZoomableImageDialog for reuse 2023-03-06 01:39:42 +08:00
Vitor PamplonaandGitHub 3e3d683234 Merge pull request #201 from Chemaclass/refactor-ui-dal
Refactor ui/dal/*Filters
2023-03-05 12:18:32 -05:00
Chemaclass 429f54848a Refactor ui/dal Filters
Focus on normalising the code-style to simplify the readability
2023-03-05 18:08:49 +01:00
Vitor Pamplona 4085285f90 Fixes showing the R.string.first id instead of text. 2023-03-05 09:06:54 -05:00
Vitor Pamplona b3d219b2de reverting order in the search screen. 2023-03-04 22:49:49 -05:00
Vitor Pamplona 797eda2ae2 v0.23.1 2023-03-04 20:56:41 -05:00
Vitor Pamplona 2478fdbcce Fixes crash in Image upload 2023-03-04 20:55:52 -05:00
Vitor Pamplona d7cf198134 v0.23.0 2023-03-04 19:12:48 -05:00
Vitor Pamplona 681e3eb44e Support for NIP-50: Search 2023-03-04 19:11:49 -05:00
Vitor Pamplona 5cc851ecc8 Adds some spacing in the private key export screen. 2023-03-04 17:38:04 -05:00
Vitor Pamplona 2a0426b845 Adds notes to decide between Gson and Jackson in the future. 2023-03-04 17:37:38 -05:00
Vitor Pamplona 567cf83b92 Centralized Mark All as Read button 2023-03-04 17:31:46 -05:00
Vitor PamplonaandGitHub c7debb272d Merge pull request #177 from maxmoney21m/feature/172-mark-all-read
Fix #172, add "Mark all as read" feature for DMs and chat rooms
2023-03-04 17:12:53 -05:00
Vitor PamplonaandGitHub cf7464b5b6 Merge pull request #191 from maxmoney21m/feature/biometric-backup-nsec
Require biometric with device lock fallback to copy nsec
2023-03-04 17:11:35 -05:00
Vitor PamplonaandGitHub 9cb3406d7d Merge pull request #195 from Radiokot/feature/login_autofill
Add autofill support to the login screen
2023-03-04 17:10:37 -05:00
Vitor PamplonaandGitHub 3613fce68d Merge pull request #194 from maxmoney21m/bugfix/157-nip05-profile-underscore
Bugfix/157 nip05 profile underscore
2023-03-04 17:10:21 -05:00
Vitor PamplonaandGitHub 1b62574eca Merge pull request #193 from bkBrunobk/patch-1
French translation for Amethyst ;)
2023-03-04 17:09:47 -05:00
Vitor PamplonaandGitHub fbd5a3296c Merge pull request #192 from maxmoney21m/bugfix/global-bottom-icon
Change magnifying glass to globe for global/search tab on AppBottomBar
2023-03-04 17:09:31 -05:00
Vitor PamplonaandGitHub c5e61400b7 Merge pull request #189 from Chemaclass/refactor-lowercase
Use lowercase() instead of toLowerCase() [deprecated]
2023-03-04 17:09:02 -05:00
Vitor Pamplona 42dd900491 BugFix: Unverifiable Events with Unicode 2028 and 2029 characters. 2023-03-04 17:03:43 -05:00
Oleg Koretsky 295f1f53cd Add autofill support to the login screen 2023-03-04 21:36:00 +02:00
maxmoney21m 0b2b2d58ab Reindent with 4 spaces 2023-03-05 01:56:42 +08:00
maxmoney21m c1637de63a Remove leading underscore from profile page #157 2023-03-05 01:55:59 +08:00
BackTo2009andGitHub 566b44f4ef French translation for Amethyst ;) 2023-03-04 18:42:16 +01:00
maxmoney21m 6275051857 Change magnifying glass to globe #183 2023-03-05 01:30:55 +08:00
Vitor Pamplona 58e0aa5f8f Fixing relative paths on OG:IMAGE tags to correctly see a preview card 2023-03-04 10:49:36 -05:00
maxmoney21m d6b5af6d77 Require biometric with device lock fallback to copy nsec 2023-03-04 22:26:06 +08:00
Vitor Pamplona 9432135847 Moving spam checks to be after the check if already processed events. 2023-03-04 08:23:12 -05:00
Vitor Pamplona e7be1cc8e7 Speeding up findCitations algorithm 2023-03-04 08:04:14 -05:00
Vitor Pamplona c0bdb40c7d Faster way to count reports. 2023-03-04 07:12:42 -05:00
Chemaclass bb59f68253 Use lowercase() instead of toLowerCase() [deprecated] 2023-03-04 11:27:27 +01:00
maxmoney21m 8961675a3e Fix string resource use from earlier PR (already merged) 2023-03-04 15:18:07 +08:00
maxmoney21m 3dd5d5c4b2 Add LaunchedEffect key to fix pre-existing bug 2023-03-04 15:03:46 +08:00
maxmoney21m bd8e9da966 Swap LaunchedEffect key for clarity 2023-03-04 15:03:11 +08:00
maxmoney21m 6067a7d890 Use new channel() method 2023-03-04 14:33:09 +08:00
maxmoney21mandGitHub 42a27a6d8d Merge branch 'main' into feature/172-mark-all-read 2023-03-04 14:17:31 +08:00
maxmoney21m 75901d83a8 reindent LocalPreferences with 4 spaces 2023-03-03 21:17:27 +08:00
maxmoney21m f34dea868d extract LocalPreferences preference keys 2023-03-03 21:17:04 +08:00
maxmoney21m e07400409f set clearEncryptedStorage to remove all keys including lastRead timestamps 2023-03-03 21:12:15 +08:00
maxmoney21m 8d7897601b Add buttons to mark DMs and chatrooms as read #172 2023-03-03 12:35:05 +08:00
85 changed files with 1874 additions and 513 deletions
+1 -1
View File
@@ -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
View File
@@ -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\\nIll give you one final explanation to rule them all. First, lets 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 its 500, others 1000, some as high as 5000. Lets 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 wont change if you have 20,000 followers or 100,000. You may get back a “different” 5000 each time, but youll 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 doesnt 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") {
@@ -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
}
}
@@ -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 }
}
}
@@ -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()) {
@@ -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,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
}
}
}
@@ -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()
}
}
}
@@ -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()
}
}
}
@@ -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)
}
@@ -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 {
@@ -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)
}
}
@@ -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

+185
View File
@@ -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>
+12 -1
View File
@@ -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
View File
@@ -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"