Compare commits

...

43 Commits

Author SHA1 Message Date
Vitor Pamplona
c3f0f7c59c Bugfix 2023-02-06 18:53:43 -05:00
Vitor Pamplona
387c5cdffa v0.13.2 2023-02-06 18:39:45 -05:00
Vitor Pamplona
660f11cfc4 Adjustments to video playback 2023-02-06 18:38:59 -05:00
Vitor Pamplona
2a6a6f459c Using the explore button to present information about the relay 2023-02-06 18:15:54 -05:00
Vitor Pamplona
12c330f8f6 Correctly handling Atomic Boolean 2023-02-06 18:15:37 -05:00
Vitor Pamplona
d252297ba4 List relays in Private DMs 2023-02-06 18:15:18 -05:00
Vitor Pamplona
ba5eca648f Adds individual relay information per chat message. 2023-02-06 17:51:55 -05:00
Vitor Pamplona
ca3ed88b78 Tracks relays used per Note 2023-02-06 17:16:27 -05:00
Vitor Pamplona
433881fa21 Adjusts final relays for release 2023-02-06 17:16:07 -05:00
Vitor Pamplona
7b3a23825f Updating Semisol's relay. 2023-02-06 14:50:59 -05:00
Vitor Pamplona
2819c7efcf Shipping Amethyst with Paid relays for Global and Public Chats. 2023-02-06 14:34:44 -05:00
Vitor Pamplona
e645029ddf Crops Posts in 350 chars. 2023-02-06 14:34:21 -05:00
Vitor Pamplona
f160a024aa Since refresh is mostly a CPU/Memory process and not really IO, Default is recommended. 2023-02-06 12:29:17 -05:00
Vitor Pamplona
074cac3021 Removes unnecessary elvis operator 2023-02-06 12:28:27 -05:00
Vitor Pamplona
e7c6292cdc Fixing test case after https://github.com/vitorpamplona/amethyst/pull/87 2023-02-06 12:18:52 -05:00
Vitor Pamplona
6daeb487d3 Fixing unassignable variable from PR https://github.com/vitorpamplona/amethyst/pull/88 2023-02-06 11:30:57 -05:00
Vitor Pamplona
2e2e54d642 Merge pull request #87 from greenart7c3/main
add debug configuration
2023-02-06 11:19:25 -05:00
Vitor Pamplona
d2ce108bf3 Merge pull request #88 from middlingphys/remove_trailing_slash
remove trailing slash from relay URI
2023-02-06 11:18:48 -05:00
Vitor Pamplona
a6dcf64f04 Refactoring Channel -> Subscription 2023-02-06 10:56:10 -05:00
middlingphys
a2f85dd060 remove trailing slash from addedWSS 2023-02-06 23:25:45 +09:00
greenart7c3
7c8c79819a add debug configuration 2023-02-06 11:15:18 -03:00
Vitor Pamplona
a5f156ec49 Merge remote-tracking branch 'origin/main' 2023-02-06 08:38:48 -05:00
Vitor Pamplona
43a6fefa17 Merge pull request #84 from Radiokot/fix/83
Fix #83
2023-02-06 08:38:10 -05:00
Vitor Pamplona
de3431e374 Fixing the order of url text and link 2023-02-06 08:32:35 -05:00
Oleg Koretsky
09f3a440d3 Fix switching tabs when changing the pager height 2023-02-06 14:46:54 +02:00
Oleg Koretsky
49c93c11e6 Make the profile header scrollable a, keep the bs â fixed 2023-02-06 13:47:48 +02:00
Vitor Pamplona
8ce96257d2 v0.13.1 2023-02-05 20:26:47 -05:00
Vitor Pamplona
94b525dd1f Fixing add button color. 2023-02-05 18:55:09 -05:00
Vitor Pamplona
e04843fe96 Using Main to update screen seems needed otherwise I get a 1 minute delay in the loading page before the screen updates. It looks like it waits for all the Pictures to load. Not sure why. 2023-02-05 18:39:54 -05:00
Vitor Pamplona
bb50099021 Merge pull request #75 from clackbib/habib/threading-perf-fixes
Scroll Perf Improvements.
2023-02-05 18:18:29 -05:00
Vitor Pamplona
459c195113 Merge pull request #76 from clackbib/habib/search-perf
A couple proposed improvements to search.
2023-02-05 18:16:58 -05:00
Vitor Pamplona
933fa9bf4d Merge pull request #77 from clackbib/habib/scrollabletabs
Tiny change to profile page:
2023-02-05 18:14:59 -05:00
Vitor Pamplona
bd94544c9b Breaking filters down by feed type: Home, DMs, Public Chats and Global. 2023-02-05 18:14:41 -05:00
Vitor Pamplona
72aad26c03 Fixing a bug where the last message is remembered by the Compose when forcing index = 0 on the first element. 2023-02-05 18:13:08 -05:00
Vitor Pamplona
ff087e3dea Correctly addressing Preview of images when posts have been reported. 2023-02-05 18:12:11 -05:00
Vitor Pamplona
eb41f22bae Correctly addresses a report to a note (and not to a user) 2023-02-05 18:10:32 -05:00
Vitor Pamplona
f1a37afaf1 Testing to see if the use of PlayServices dependencies uses less cache from the app. 2023-02-05 18:09:49 -05:00
Vitor Pamplona
9035fda17a Simplifying dependencies 2023-02-05 12:59:35 -05:00
Vitor Pamplona
c16f175ebc Not display any previews if the note has a report. 2023-02-05 12:59:15 -05:00
Vitor Pamplona
5c5aa222c5 Quick refactoring 2023-02-05 12:58:26 -05:00
Habib Okanla
14bda01a70 Tiny change to profile page:
Use scrollable tabs instead of fixed tab to avoid bad text wrap.
2023-02-05 02:05:54 -05:00
Habib Okanla
cd9f359add A couple proposed improvements to search.
The UX and performance of search is relatively poor because each keystroke triggers a search & update. With this changes:

1. Search queries are moved into an IO thread so that typing is smooth and data sends happend in the background.

2. Text changed are debounced by 300 millis, meaning search will not be fired off on every keystroke, but rather only when we the user stops typing for 300 millis.
2023-02-05 01:56:16 -05:00
Habib Okanla
f40060bb36 There are very visible perf issues during app usage and scrolling that clearly indicates that the app is doing too much on the main thread. After digging for instances where Dispatchers.Main is used, it's an easy fix to switch to Dispatchers.IO, which visibility improve perf.
A few thoughts about perf considerations

1. There is no need to force Dispatchers.Main for data that is consumed as state by compose, since flows consumed as state will always flow on main, so we can use a background thread to guarantee best performance.

2. Using Dispatchers.IO is appropriate for disk/network operations to have a device-constrained thread pool that will avoid draining IO-related device resources. Using Dispatchers.Default is more appropriate for computational tasks (bitmap manipulation, delays, etc..)

3. There are a few instances of methods creating coroutine scopes in their body just to launch something that will delay. This is creating a lot of loose scopes, and you can avoid this by just moving scope creation to a class-level field and reusing it, or better yet, make your method suspending so that scope is controlled by the caller.
2023-02-05 00:41:37 -05:00
47 changed files with 1289 additions and 687 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 36
versionName "0.13.0"
versionCode 38
versionName "0.13.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -24,6 +24,12 @@ android {
release {
//minifyEnabled false
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
resValue "string", "app_name", "@string/app_name_release"
}
debug {
applicationIdSuffix '.debug'
versionNameSuffix '-DEBUG'
resValue "string", "app_name", "@string/app_name_debug"
}
}
compileOptions {
@@ -118,14 +124,13 @@ dependencies {
// For QR Scanning
implementation 'com.google.mlkit:vision-common:17.3.0'
implementation 'com.google.mlkit:barcode-scanning:17.0.3'
// Use this dependency to use the dynamically downloaded model in Google Play Services
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
// Local Barcode Scanning model
implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.1.0'
// Use this dependency to use the translate text
implementation 'com.google.mlkit:translate:17.0.1'
implementation 'com.google.mlkit:language-id-common:16.1.0'
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'

View File

@@ -23,7 +23,7 @@ class EUrlUserTagTransformationTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.vitorpamplona.amethyst", appContext.packageName)
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
}
@Test

View File

@@ -0,0 +1,4 @@
<resources>
<string name="app_name_release">Amethyst</string>
<string name="app_name_debug">Amethyst Debug</string>
</resources>

View File

@@ -19,7 +19,7 @@
android:usesCleartextTraffic="true"
tools:targetApi="33">
<activity
android:label="Amethyst"
android:label="@string/app_name"
android:name=".ui.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"

View File

@@ -1,17 +1,23 @@
package com.vitorpamplona.amethyst
import android.content.Context
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import com.vitorpamplona.amethyst.ui.navigation.Route
import nostr.postr.Persona
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.toHex
class LocalPreferences(context: Context) {
val encryptedPreferences = EncryptedStorage().preferences(context)
val gson = GsonBuilder().create()
fun clearEncryptedStorage() {
encryptedPreferences.edit().apply {
@@ -19,6 +25,7 @@ class LocalPreferences(context: Context) {
remove("nostr_pubkey")
remove("following_channels")
remove("hidden_users")
remove("relays")
}.apply()
}
@@ -28,6 +35,7 @@ class LocalPreferences(context: Context) {
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)) }
}.apply()
}
@@ -37,12 +45,17 @@ class LocalPreferences(context: Context) {
val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", null)?.toMutableSet() ?: mutableSetOf()
val hiddenUsers = getStringSet("hidden_users", emptySet())?.toMutableSet() ?: mutableSetOf()
val localRelays = gson.fromJson(
getString("relays", "[]"),
object : TypeToken<Set<NewRelayListViewModel.Relay>>() {}.type
) ?: setOf<NewRelayListViewModel.Relay>()
if (pubKey != null) {
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers
hiddenUsers,
localRelays
)
} else {
return null

View File

@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
@@ -29,7 +29,7 @@ object ServiceManager {
val myAccount = account
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: Constants.defaultRelays)
Client.connect(myAccount.convertLocalRelays())
// start services
NostrAccountDataSource.account = myAccount
@@ -55,7 +55,7 @@ object ServiceManager {
NostrChatroomListDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect()
Client.connect(Constants.convertDefaultRelays())
}
}

View File

@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -9,8 +9,10 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -34,8 +36,9 @@ val DefaultChannels = setOf(
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels.toMutableSet(),
var hiddenUsers: Set<String> = mutableSetOf()
var followingChannels: Set<String> = DefaultChannels,
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<NewRelayListViewModel.Relay> = Constants.defaultRelays.toSet()
) {
fun userProfile(): User {
@@ -224,7 +227,7 @@ class Account(
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
@@ -239,7 +242,7 @@ class Account(
advertiseNip18 = false
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendCreateNewChannel(name: String, about: String, picture: String) {
@@ -330,14 +333,23 @@ class Account(
}
fun activeRelays(): Array<Relay>? {
return userProfile().relays?.map { Relay(it.key, it.value.read, it.value.write) }?.toTypedArray()
return 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()
}
fun convertLocalRelays(): Array<Relay> {
return localRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
}.toTypedArray()
}
init {
userProfile().subscribe(object: User.Listener() {
override fun onRelayChange() {
Client.disconnect()
Client.connect(activeRelays() ?: Constants.defaultRelays)
Client.connect(activeRelays() ?: convertLocalRelays())
RelayPool.requestAndWatch()
}
})
@@ -396,6 +408,11 @@ class Account(
innerReports).toSet()
}
fun saveRelayList(value: List<NewRelayListViewModel.Relay>) {
localRelays = value.toSet()
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
}
}
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {

View File

@@ -99,8 +99,10 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null)
if (relay != null) {
author.addRelay(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
@@ -162,6 +164,7 @@ object LocalCache {
event.content,
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
)
user.updateRelays(relays)
}
} catch (e: Exception) {
@@ -172,13 +175,18 @@ object LocalCache {
}
}
fun consume(event: PrivateDmEvent) {
fun consume(event: PrivateDmEvent, relay: Relay?) {
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelay(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val recipient = event.recipientPubKey?.let { getOrCreateUser(it.toHexKey()) }
//Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
@@ -292,8 +300,10 @@ object LocalCache {
//Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addReport(note)
if (repliesTo.isEmpty()) {
mentions.forEach {
it.addReport(note)
}
}
repliesTo.forEach {
it.addReport(note)
@@ -343,7 +353,7 @@ object LocalCache {
}
}
fun consume(event: ChannelMessageEvent) {
fun consume(event: ChannelMessageEvent, relay: Relay?) {
if (event.channel.isNullOrBlank()) return
val channel = getOrCreateChannel(event.channel)
@@ -351,10 +361,16 @@ object LocalCache {
val note = getOrCreateNote(event.id.toHex())
channel.addNote(note)
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelay(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.mentions.map { getOrCreateUser(it) }
val replyTo = event.replyTos
.map { getOrCreateNote(it) }

View File

@@ -46,6 +46,9 @@ class Note(val idHex: String) {
var reports = setOf<Note>()
private set
var relays = setOf<String>()
private set
var channel: Channel? = null
var lastReactionsDownloadTime: Long? = null
@@ -114,6 +117,13 @@ class Note(val idHex: String) {
}
}
fun addRelay(relay: Relay) {
if (relay.url !in relays) {
relays = relays + relay.url
liveRelays.invalidateData()
}
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author == user }
}
@@ -130,6 +140,12 @@ class Note(val idHex: String) {
return reports.filter { it.author in users }
}
fun hasAnyReports(): Boolean {
val dayAgo = Date().time / 1000 - 24*60*60
return author?.reports?.filter { it.event?.createdAt ?: 0 > dayAgo }?.isNotEmpty() ?: false
|| reports.isNotEmpty()
}
fun directlyCiteUsers(): Set<User> {
val matcher = tagSearch.matcher(event?.content ?: "")
val returningList = mutableSetOf<User>()
@@ -173,6 +189,7 @@ class Note(val idHex: String) {
val liveBoosts: NoteLiveData = NoteLiveData(this)
val liveReplies: NoteLiveData = NoteLiveData(this)
val liveReports: NoteLiveData = NoteLiveData(this)
val liveRelays: NoteLiveData = NoteLiveData(this)
}
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
@@ -178,6 +179,8 @@ class User(val pubkeyHex: String) {
updatedFollowsAt = updateAt
}
data class RelayMetadata(val read: Boolean, val write: Boolean, val activeTypes: Set<FeedType>)
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
if (relays != relayUse) {
relays = relayUse

View File

@@ -1,15 +0,0 @@
package com.vitorpamplona.amethyst.service
import java.util.UUID
import nostr.postr.JsonFilter
data class Channel (
val id: String = UUID.randomUUID().toString().substring(0,4),
val onEOSE: ((Long) -> Unit)? = null
) {
var filter: List<JsonFilter>? = null // Inactive when null
fun updateEOSE(l: Long) {
onEOSE?.let { it(l) }
}
}

View File

@@ -1,27 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.relays.Relay
object Constants {
val defaultRelays = arrayOf(
Relay("wss://nostr.bitcoiner.social", read = true, write = true),
Relay("wss://relay.nostr.bg", read = true, write = true),
Relay("wss://brb.io", read = true, write = true),
Relay("wss://relay.snort.social", read = true, write = true),
Relay("wss://nostr.rocks", read = true, write = true),
Relay("wss://relay.damus.io", read = true, write = true),
Relay("wss://nostr.fmt.wiz.biz", read = true, write = true),
Relay("wss://nostr.oxtr.dev", read = true, write = true),
Relay("wss://eden.nostr.land", read = true, write = true),
Relay("wss://nostr.zebedee.cloud", read = true, write = true),
Relay("wss://nostr-pub.wellorder.net", read = true, write = true),
Relay("wss://nostr.mom", read = true, write = true),
Relay("wss://nostr.orangepill.dev", read = true, write = true),
Relay("wss://nostr-pub.semisol.dev", read = true, write = true),
Relay("wss://nostr.onsats.org", read = true, write = true),
Relay("wss://nostr.sandwich.farm", read = true, write = true),
Relay("wss://relay.nostr.ch", read = true, write = true),
Relay("wss://no.str.cr", read = true, write = true),
Relay("wss://nos.lol", read = true, write = true)
)
}

View File

@@ -3,9 +3,10 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UserState
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 nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
@@ -14,32 +15,44 @@ import nostr.postr.events.TextNoteEvent
object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
lateinit var account: Account
fun createAccountContactListFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
fun createAccountContactListFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
fun createAccountMetadataFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
fun createAccountMetadataFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
fun createAccountReportsFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(ReportEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
)
}
fun createNotificationFilter() = JsonFilter(
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 200
fun createNotificationFilter() = TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 200
)
)
val accountChannel = requestNewChannel()
@@ -55,7 +68,7 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
override fun updateChannelFilters() {
// gets everthing about the user logged in
accountChannel.filter = listOf(
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createNotificationFilter(),

View File

@@ -4,6 +4,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
@@ -15,12 +17,15 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
resetFilters()
}
fun createMessagesToChannelFilter(): JsonFilter? {
fun createMessagesToChannelFilter(): TypedFilter? {
if (channel != null) {
return JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200
)
)
}
return null
@@ -34,6 +39,6 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
}
override fun updateChannelFilters() {
messagesChannel.filter = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
}
}

View File

@@ -4,6 +4,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
@@ -16,28 +18,34 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
withUser = LocalCache.users[userId]
}
fun createMessagesToMeFilter(): JsonFilter? {
fun createMessagesToMeFilter(): TypedFilter? {
val myPeer = withUser
return if (myPeer != null) {
JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(myPeer.pubkeyHex) ,
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(myPeer.pubkeyHex) ,
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
)
} else {
null
}
}
fun createMessagesFromMeFilter(): JsonFilter? {
fun createMessagesFromMeFilter(): TypedFilter? {
val myPeer = withUser
return if (myPeer != null) {
JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
)
)
} else {
null
@@ -54,6 +62,6 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
}
override fun updateChannelFilters() {
inandoutChannel.filter = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
}
}

View File

@@ -1,53 +1,72 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
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.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
lateinit var account: Account
fun createMessagesToMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
fun createMessagesToMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
)
fun createMessagesFromMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
fun createMessagesFromMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
)
fun createChannelsCreatedbyMeFilter() = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
fun createChannelsCreatedbyMeFilter() = TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
)
fun createMyChannelsFilter() = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList()
fun createMyChannelsFilter() = TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList()
)
)
fun createLastChannelInfoFilter(): List<JsonFilter> {
fun createLastChannelInfoFilter(): List<TypedFilter> {
return account.followingChannels.map {
JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
)
)
}
}
fun createLastMessageOfEachChannelFilter(): List<JsonFilter> {
fun createLastMessageOfEachChannelFilter(): List<TypedFilter> {
return account.followingChannels.map {
JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
)
)
}
}
@@ -77,7 +96,7 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
createMyChannelsFilter()
)
chatroomListChannel.filter = listOfNotNull(
chatroomListChannel.typedFilters = listOfNotNull(
list,
createLastChannelInfoFilter(),
createLastMessageOfEachChannelFilter()

View File

@@ -1,8 +1,5 @@
package com.vitorpamplona.amethyst.service
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
@@ -16,11 +13,9 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.Subscription
import java.util.Date
import java.util.UUID
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -35,7 +30,7 @@ import nostr.postr.events.RecommendRelayEvent
import nostr.postr.events.TextNoteEvent
abstract class NostrDataSource<T>(val debugName: String) {
private var channels = mapOf<String, Channel>()
private var subscriptions = mapOf<String, Subscription>()
private var eventCounter = mapOf<String, Int>()
fun printCounter() {
@@ -46,7 +41,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in channels.keys) {
if (subscriptionId in subscriptions.keys) {
val key = "${debugName} ${subscriptionId} ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
@@ -61,7 +56,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
is TextNoteEvent -> LocalCache.consume(event, relay)
is RecommendRelayEvent -> LocalCache.consume(event)
is ContactListEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay)
is DeletionEvent -> LocalCache.consume(event)
else -> when (event.kind) {
RepostEvent.kind -> {
@@ -75,7 +70,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
}
@@ -101,7 +96,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
if (type == Relay.Type.EOSE && channel != null) {
// updates a per subscripton since date
channels[channel]?.updateEOSE(Date().time / 1000)
subscriptions[channel]?.updateEOSE(Date().time / 1000)
}
}
@@ -119,8 +114,8 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
open fun stop() {
channels.values.forEach { channel ->
if (channel.filter != null) // if it is active, close
subscriptions.values.forEach { channel ->
if (channel.typedFilters != null) // if it is active, close
Client.close(channel.id)
}
}
@@ -145,15 +140,15 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
}
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Channel {
val newChannel = Channel(UUID.randomUUID().toString().substring(0,4), onEOSE)
channels = channels + Pair(newChannel.id, newChannel)
return newChannel
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Subscription {
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0,4), onEOSE)
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
return newSubscription
}
fun dismissChannel(channel: Channel) {
Client.close(channel.id)
channels = channels.minus(channel.id)
fun dismissChannel(subscription: Subscription) {
Client.close(subscription.id)
subscriptions = subscriptions.minus(subscription.id)
}
var handlerWaiting = false
@@ -179,37 +174,37 @@ abstract class NostrDataSource<T>(val debugName: String) {
fun resetFiltersSuspend() {
// saves the channels that are currently active
val activeChannels = channels.values.filter { it.filter != null }
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
val currentFilter = activeChannels.associate { it.id to it.filter!!.joinToString("|") { it.toJson() } }
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
updateChannelFilters()
// Makes sure to only send an updated filter when it actually changes.
channels.values.forEach { channel ->
val channelsNewFilter = channel.filter
subscriptions.values.forEach { updatedSubscription ->
val updatedSubscriotionNewFilters = updatedSubscription.typedFilters
if (channel in activeChannels) {
if (channelsNewFilter == null) {
if (updatedSubscription.id in currentFilters.keys) {
if (updatedSubscriotionNewFilters == null) {
// was active and is not active anymore, just close.
Client.close(channel.id)
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (channelsNewFilter.joinToString("|") { it.toJson() } != currentFilter[channel.id]) {
Client.close(channel.id)
Client.sendFilter(channel.id, channelsNewFilter)
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.close(updatedSubscription.id)
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
} else {
// hasn't changed, does nothing.
Client.sendFilterOnlyIfDisconnected(channel.id, channelsNewFilter)
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters)
}
}
} else {
if (channelsNewFilter == null) {
if (updatedSubscriotionNewFilters == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (channelsNewFilter.joinToString("|") { it.toJson() } != currentFilter[channel.id]) {
Client.sendFilter(channel.id, channelsNewFilter)
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
}
}
}

View File

@@ -4,14 +4,19 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
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 nostr.postr.events.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind),
limit = 200
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind),
limit = 200
)
)
val globalFeedChannel = requestNewChannel()
@@ -26,6 +31,6 @@ object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
.reversed()
override fun updateChannelFilters() {
globalFeedChannel.filter = listOf(createGlobalFilter()).ifEmpty { null }
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
}
}

View File

@@ -5,6 +5,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
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 nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
@@ -30,7 +32,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
account.userProfile().unsubscribe(cacheListener)
}
fun createFollowAccountsFilter(): JsonFilter {
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.userProfile().follows
val followKeys = follows.map {
@@ -39,31 +41,18 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
authors = followSet,
limit = 400
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
authors = followSet,
limit = 400
)
)
}
val followAccountChannel = requestNewChannel()
fun <T> equalsIgnoreOrder(list1:List<T>?, list2:List<T>?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return list1.size == list2.size && list1.toSet() == list2.toSet()
}
fun equalAuthors(list1:JsonFilter?, list2:JsonFilter?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return equalsIgnoreOrder(list1.authors, list2.authors)
}
override fun feed(): List<Note> {
val user = account.userProfile()
@@ -75,10 +64,6 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
}
override fun updateChannelFilters() {
val newFollowAccountsFilter = createFollowAccountsFilter()
if (!equalAuthors(newFollowAccountsFilter, followAccountChannel.filter?.firstOrNull())) {
followAccountChannel.filter = listOf(newFollowAccountsFilter).ifEmpty { null }
}
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
}
}

View File

@@ -1,32 +1,33 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.bechToBytes
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var hexToWatch: String? = null
private fun createAnythingWithIDFilter(): List<JsonFilter>? {
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
if (hexToWatch == null) {
return null
}
// downloads all the reactions to a given event.
return listOf(
JsonFilter(
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = listOfNotNull(hexToWatch)
),
JsonFilter(
)),
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
authors = listOfNotNull(hexToWatch)
)
))
)
}
@@ -37,7 +38,7 @@ object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed"
}
override fun updateChannelFilters() {
searchChannel.filter = createAnythingWithIDFilter()
searchChannel.typedFilters = createAnythingWithIDFilter()
}
fun search(eventId: String) {

View File

@@ -3,18 +3,15 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
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.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed") {
private var channelsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): JsonFilter? {
private fun createRepliesAndReactionsFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
@@ -22,13 +19,16 @@ object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed")
}
// downloads all the reactions to a given event.
return JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to reactionsToWatch)
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to reactionsToWatch)
)
)
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch
.map { LocalCache.getOrCreateChannel(it) }
.filter { it.notes.isEmpty() }
@@ -40,9 +40,12 @@ object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed")
}
// downloads linked events to this event.
return JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = interestedEvents.toList()
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = interestedEvents.toList()
)
)
}
@@ -56,7 +59,7 @@ object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed")
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
singleChannelChannel.filter = listOfNotNull(reactions, missing).ifEmpty { null }
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
}
fun add(eventId: String) {

View File

@@ -8,16 +8,16 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import java.util.Date
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var eventsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): List<JsonFilter>? {
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
val reactionsToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
if (reactionsToWatch.isEmpty()) {
@@ -30,17 +30,20 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
val lastTime = it.lastReactionsDownloadTime;
lastTime == null || lastTime < (now - 10)
}.map {
JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
)
)
}
}
fun createLoadEventsIfNotLoadedFilter(): List<JsonFilter>? {
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = eventsToWatch
.map { LocalCache.getOrCreateNote(it) }
.filter { it.event == null }
@@ -60,13 +63,18 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
}
// downloads linked events to this event.
return listOf(JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
),
ids = interestedEvents.toList()
))
return listOf(
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
),
ids = interestedEvents.toList()
)
)
)
}
val singleEventChannel = requestNewChannel() { time ->
@@ -90,7 +98,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
singleEventChannel.filter = listOfNotNull(reactions, missing).flatten().ifEmpty { null }
singleEventChannel.typedFilters = listOfNotNull(reactions, missing).flatten().ifEmpty { null }
}
fun add(eventId: String) {

View File

@@ -1,35 +1,41 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
var usersToWatch = setOf<String>()
fun createUserFilter(): List<JsonFilter>? {
fun createUserFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.filter { LocalCache.getOrCreateUser(it).latestMetadata == null }.map {
JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it),
limit = 1
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it),
limit = 1
)
)
}
}
fun createUserReportFilter(): List<JsonFilter>? {
fun createUserReportFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it))
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it))
)
)
}
}
@@ -49,7 +55,7 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
}
override fun updateChannelFilters() {
userChannel.filter = listOfNotNull(createUserFilter(), createUserReportFilter()).flatten()
userChannel.typedFilters = listOfNotNull(createUserFilter(), createUserReportFilter()).flatten()
}
fun add(userId: String) {

View File

@@ -4,25 +4,29 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
private var eventsToWatch = setOf<String>()
fun createRepliesAndReactionsFilter(): JsonFilter? {
fun createRepliesAndReactionsFilter(): TypedFilter? {
if (eventsToWatch.isEmpty()) {
return null
}
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
tags = mapOf("e" to eventsToWatch.toList())
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
tags = mapOf("e" to eventsToWatch.toList())
)
)
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val nodes = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
val eventsToLoad = nodes
@@ -33,8 +37,11 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
return null
}
return JsonFilter(
ids = eventsToLoad
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = eventsToLoad
)
)
}
@@ -50,7 +57,7 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
}
override fun updateChannelFilters() {
loadEventsChannel.filter = listOfNotNull(createLoadEventsIfNotLoadedFilter(), createRepliesAndReactionsFilter()).ifEmpty { null }
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter(), createRepliesAndReactionsFilter()).ifEmpty { null }
}
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {

View File

@@ -4,7 +4,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
@@ -19,33 +20,45 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
resetFilters()
}
fun createUserInfoFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
fun createUserInfoFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
)
)
}
fun createUserPostsFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(TextNoteEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 100
fun createUserPostsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 100
)
)
}
fun createFollowFilter(): JsonFilter {
return JsonFilter(
fun createFollowFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
)
)
}
fun createFollowersFilter() = TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
tags = mapOf("p" to listOf(user!!.pubkeyHex))
)
}
fun createFollowersFilter() = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(user!!.pubkeyHex))
)
val userInfoChannel = requestNewChannel()
@@ -59,7 +72,7 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
}
override fun updateChannelFilters() {
userInfoChannel.filter = listOf(
userInfoChannel.typedFilters = listOf(
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),

View File

@@ -1,10 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.Constants
import java.util.Collections
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import nostr.postr.JsonFilter
import nostr.postr.events.Event
/**
@@ -24,10 +20,10 @@ object Client: RelayPool.Listener {
**/
var lenient: Boolean = false
private var listeners = setOf<Listener>()
private var relays = Constants.defaultRelays
private var subscriptions = mapOf<String, List<JsonFilter>>()
private var relays = Constants.convertDefaultRelays()
private var subscriptions = mapOf<String, List<TypedFilter>>()
fun connect(relays: Array<Relay> = Constants.defaultRelays) {
fun connect(relays: Array<Relay>) {
RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList())
@@ -36,7 +32,7 @@ object Client: RelayPool.Listener {
fun sendFilter(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<JsonFilter> = listOf(JsonFilter())
filters: List<TypedFilter> = listOf()
) {
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId)
@@ -44,7 +40,7 @@ object Client: RelayPool.Listener {
fun sendFilterOnlyIfDisconnected(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<JsonFilter> = listOf(JsonFilter())
filters: List<TypedFilter> = listOf()
) {
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected()
@@ -92,7 +88,7 @@ object Client: RelayPool.Listener {
return subscriptions.keys.toList()
}
fun getSubscriptionFilters(subId: String): List<JsonFilter> {
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
return subscriptions[subId] ?: emptyList()
}

View File

@@ -0,0 +1,48 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
fun convertDefaultRelays(): Array<Relay> {
return defaultRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
}.toTypedArray()
}
val defaultRelays = arrayOf(
// Free relays
NewRelayListViewModel.Relay("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
// Less Reliable
//NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
// Paid relays
NewRelayListViewModel.Relay("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
)
}

View File

@@ -11,10 +11,15 @@ import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
enum class FeedType {
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL
}
class Relay(
var url: String,
var read: Boolean = true,
var write: Boolean = true
var url: String,
var read: Boolean = true,
var write: Boolean = true,
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
) {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(100, TimeUnit.SECONDS)
@@ -159,21 +164,22 @@ class Relay(
fun sendFilter(requestId: String) {
if (read) {
if (socket == null) {
// waits 10 seconds
if (Date().time / 1000 > closingTime + 10) {
requestAndWatch()
}
} else {
if (isConnected()) {
if (isReady) {
val filters = Client.getSubscriptionFilters(requestId)
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT ${url} " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]"""
//println("FILTERSSENT ${url} ${request}")
socket?.send(request)
}
}
} else {
// waits 10 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 10) {
// sends all filters after connection is successful.
requestAndWatch()
}
}
}
}

View File

@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -12,6 +11,9 @@ import nostr.postr.events.Event
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
*/
object RelayPool: Relay.Listener {
val scope = CoroutineScope(Job() + Dispatchers.IO)
private var relays = listOf<Relay>()
private var listeners = setOf<Listener>()
@@ -27,11 +29,11 @@ object RelayPool: Relay.Listener {
return relays.firstOrNull() { it.url == url }
}
fun loadRelays(relayList: List<Relay>? = null){
fun loadRelays(relayList: List<Relay>){
if (!relayList.isNullOrEmpty()){
relayList.forEach { addRelay(it) }
} else {
Constants.defaultRelays.forEach { addRelay(it) }
Constants.convertDefaultRelays().forEach { addRelay(it) }
}
}
@@ -115,7 +117,6 @@ object RelayPool: Relay.Listener {
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
live.refresh()
}

View File

@@ -0,0 +1,30 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import java.util.UUID
data class Subscription (
val id: String = UUID.randomUUID().toString().substring(0,4),
val onEOSE: ((Long) -> Unit)? = null
) {
var typedFilters: List<TypedFilter>? = null // Inactive when null
fun updateEOSE(l: Long) {
onEOSE?.let { it(l) }
}
fun toJson(): String {
return GsonBuilder().create().toJson(toJsonObject())
}
fun toJsonObject(): JsonObject {
val jsonObject = JsonObject()
jsonObject.addProperty("id", id)
typedFilters?.run {
jsonObject.add("typedFilters", JsonArray().apply { typedFilters?.forEach { add(it.toJsonObject()) } })
}
return jsonObject
}
}

View File

@@ -0,0 +1,56 @@
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 nostr.postr.JsonFilter
class TypedFilter(
val types: Set<FeedType>,
val filter: JsonFilter
) {
fun toJson(): String {
return GsonBuilder().create().toJson(toJsonObject())
}
fun toJsonObject(): JsonObject {
val jsonObject = JsonObject()
jsonObject.add("types", typesToJson(types))
jsonObject.add("filter", filterToJson(filter))
return jsonObject
}
fun typesToJson(types: Set<FeedType>): JsonArray {
return JsonArray().apply { types.forEach { add(it.name.toLowerCase()) } }
}
fun filterToJson(filter: JsonFilter): JsonObject {
val jsonObject = JsonObject()
filter.ids?.run {
jsonObject.add("ids", JsonArray().apply { filter.ids?.forEach { add(it) } })
}
filter.authors?.run {
jsonObject.add("authors", JsonArray().apply { filter.authors?.forEach { add(it) } })
}
filter.kinds?.run {
jsonObject.add("kinds", JsonArray().apply { filter.kinds?.forEach { add(it) } })
}
filter.tags?.run {
entries.forEach { kv ->
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
}
}
filter.since?.run {
jsonObject.addProperty("since", filter.since)
}
filter.until?.run {
jsonObject.addProperty("until", filter.until)
}
filter.limit?.run {
jsonObject.addProperty("limit", filter.limit)
}
return jsonObject
}
}

View File

@@ -33,6 +33,9 @@ import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.outlined.BarChart
@@ -48,6 +51,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -55,18 +60,21 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.FeedType
import java.lang.Math.round
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
val postViewModel: NewRelayListViewModel = viewModel()
val ctx = LocalContext.current.applicationContext
val feedState by postViewModel.relays.collectAsState()
LaunchedEffect(Unit) {
postViewModel.load(account)
postViewModel.load(account, ctx)
}
Dialog(
@@ -82,19 +90,18 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear()
postViewModel.clear(ctx)
onClose()
})
PostButton(
onPost = {
postViewModel.create()
postViewModel.create(ctx)
onClose()
},
true
@@ -103,7 +110,7 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
@@ -114,15 +121,15 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
if (index == 0)
ServerConfigHeader()
ServerConfig(item,
onToggleDownload = {
postViewModel.toggleDownload(it)
},
onToggleUpload = {
postViewModel.toggleUpload(it)
},
onDelete = {
postViewModel.deleteRelay(it)
}
onToggleDownload = { postViewModel.toggleDownload(it) },
onToggleUpload = { postViewModel.toggleUpload(it) },
onToggleFollows = { postViewModel.toggleFollows(it) },
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
onToggleGlobal = { postViewModel.toggleGlobal(it) },
onDelete = { postViewModel.deleteRelay(it) }
)
}
}
@@ -156,7 +163,7 @@ fun ServerConfigHeader() {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.size(25.dp))
Text(
text = "Posts",
@@ -186,7 +193,7 @@ fun ServerConfigHeader() {
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.size(5.dp))
}
}
}
@@ -202,9 +209,32 @@ fun ServerConfig(
item: NewRelayListViewModel.Relay,
onToggleDownload: (NewRelayListViewModel.Relay) -> Unit,
onToggleUpload: (NewRelayListViewModel.Relay) -> Unit,
onToggleFollows: (NewRelayListViewModel.Relay) -> Unit,
onTogglePrivateDMs: (NewRelayListViewModel.Relay) -> Unit,
onTogglePublicChats: (NewRelayListViewModel.Relay) -> Unit,
onToggleGlobal: (NewRelayListViewModel.Relay) -> Unit,
onDelete: (NewRelayListViewModel.Relay) -> Unit) {
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp)
) {
Column() {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onDelete(item) }
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(end = 5.dp).size(15.dp),
tint = Color.Red
)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -214,75 +244,126 @@ fun ServerConfig(
overflow = TextOverflow.Ellipsis
)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleDownload(item) }
) {
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.read) Color.Green else Color.Red
)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleFollows(item) }
) {
Icon(
painterResource(R.drawable.ic_home),
"Home Feed",
modifier = Modifier.padding(end = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onTogglePrivateDMs(item) }
) {
Icon(
painterResource(R.drawable.ic_dm),
"Private Message Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onTogglePublicChats(item) }
) {
Icon(
imageVector = Icons.Default.Groups,
"Public Chat Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleGlobal(item) }
) {
Icon(
imageVector = Icons.Default.Public,
"Global Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
}
}
Text(
text = "${countToHumanReadable(item.downloadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleDownload(item) }
) {
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.read) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleUpload(item) }
) {
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.write) Color.Green else Color.Red
)
}
Text(
text = "${countToHumanReadable(item.downloadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Text(
text = "${countToHumanReadable(item.uploadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleUpload(item) }
) {
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.write) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
Icon(
imageVector = Icons.Default.SyncProblem,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
Text(
text = "${countToHumanReadable(item.uploadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Text(
text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Icon(
imageVector = Icons.Default.SyncProblem,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onDelete(item) }
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = Color.Red
)
Text(
text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
}
}
}
@@ -323,7 +404,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
modifier = Modifier
.size(35.dp)
.padding(horizontal = 5.dp),
tint = if (read) Color.Green else Color.Red
tint = if (read) Color.Green else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@@ -334,15 +415,16 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
modifier = Modifier
.size(35.dp)
.padding(horizontal = 5.dp),
tint = if (write) Color.Green else Color.Red
tint = if (write) Color.Green else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Button(
onClick = {
if (url.isNotBlank()) {
val addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write))
if (url.isNotBlank() && url != "/") {
var addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
url = ""
write = true
read = true
@@ -351,7 +433,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else Color.Gray
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
) {
Text(text = "Add", color = Color.White)

View File

@@ -1,8 +1,12 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -18,48 +22,51 @@ class NewRelayListViewModel: ViewModel() {
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0
val uploadCount: Int = 0,
val feedTypes: Set<FeedType>
)
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
val relays = _relays.asStateFlow()
fun load(account: Account) {
fun load(account: Account, ctx: Context) {
this.account = account
clear()
clear(ctx)
}
fun create() {
fun create(ctx: Context) {
relays.let {
account.sendNewRelayList(it.value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
account.saveRelayList(it.value)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
clear()
clear(ctx)
}
fun clear() {
fun clear(ctx: Context) {
_relays.update {
val relayFile = account.userProfile().relays
if (relayFile != null)
relayFile.map {
val liveRelay = RelayPool.getRelay(it.key)
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter)
Relay(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed()
else
Constants.defaultRelays.map {
account.localRelays.map {
val liveRelay = RelayPool.getRelay(it.url)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter)
Relay(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed()
}
}
@@ -89,6 +96,38 @@ class NewRelayListViewModel: ViewModel() {
it.updated(relay, relay.copy(write = !relay.write))
}
}
fun toggleFollows(relay: Relay) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleMessages(relay: Relay) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun togglePublicChats(relay: Relay) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleGlobal(relay: Relay) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
_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 }
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }
fun <T> togglePresenceInSet(set: Set<T>, item: T): Set<T> {
return if (set.contains(item)) set.minus(item) else set.plus(item)
}

View File

@@ -3,10 +3,19 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import android.util.LruCache
import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -16,7 +25,10 @@ import androidx.compose.runtime.getValue
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
@@ -64,12 +76,13 @@ fun isValidURL(url: String?): Boolean {
}
@Composable
fun RichTextViewer(content: String, tags: List<List<String>>?, navController: NavController) {
fun RichTextViewer(content: String, canPreview: Boolean, tags: List<List<String>>?, navController: NavController) {
val translatedTextState = remember {
mutableStateOf(ResultOrError(content, null, null, null))
}
var showOriginal by remember { mutableStateOf(false) }
var showFullText by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
LanguageTranslatorService.autoTranslate(content).addOnCompleteListener { task ->
@@ -79,42 +92,99 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, navController: Na
}
}
val text = if (showOriginal) content else translatedTextState.value.result
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
val text = if (showFullText) toBeViewed else toBeViewed.take(350)
Column(modifier = Modifier.padding(top = 5.dp)) {
// FlowRow doesn't work well with paragraphs. So we need to split them
text?.split('\n')?.forEach { paragraph ->
FlowRow() {
paragraph.split(' ').forEach { word: String ->
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} else {
UrlPreview(word, word)
Box(contentAlignment = Alignment.BottomCenter) {
Column(Modifier.fillMaxWidth().animateContentSize()) {
// FlowRow doesn't work well with paragraphs. So we need to split them
text.split('\n').forEach { paragraph ->
FlowRow() {
paragraph.split(' ').forEach { word: String ->
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} else {
UrlPreview(word, word)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
} else {
if (isValidURL(word)) {
ClickableUrl("$word ", word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
}
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
}
}
}
if (toBeViewed.length > 350 && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colors.background.copy(alpha = 0f),
MaterialTheme.colors.background
)
)
)
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { showFullText = !showFullText },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Show More", color = Color.White)
}
}
}
@@ -125,13 +195,13 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, navController: Na
if (source != null && target != null) {
if (source != target) {
Row() {
FlowRow() {
Text(
text = "Auto-translated from ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
ClickableText(
text = AnnotatedString("${Locale(source).displayName}"),
text = AnnotatedString(Locale(source).displayName),
onClick = { showOriginal = true },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
)
@@ -140,7 +210,7 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, navController: Na
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
ClickableText(
text = AnnotatedString("${Locale(target).displayName}"),
text = AnnotatedString(Locale(target).displayName),
onClick = { showOriginal = false },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
)

View File

@@ -5,6 +5,7 @@ import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
@@ -19,14 +20,17 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
fun VideoView(videoUri: String) {
val context = LocalContext.current
val exoPlayer = ExoPlayer.Builder(LocalContext.current).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaItem(MediaItem.fromUri(videoUri))
prepare()
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
}
}
DisposableEffect(exoPlayer) {
exoPlayer.setMediaItem(MediaItem.fromUri(videoUri))
exoPlayer.prepare()
onDispose {
exoPlayer.release()
}

View File

@@ -116,7 +116,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
Client.allSubscriptions().map {
"${it} ${
Client.getSubscriptionFilters(it)
.joinToString { it.toJson() }
.joinToString { it.filter.toJson() }
}"
}.forEach {
Log.d("CURRENT FILTERS", it)
@@ -203,7 +203,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
},
actions = {
IconButton(
onClick = {}, modifier = Modifier
onClick = { wantsToEditRelays = true }, modifier = Modifier
) {
Icon(
painter = painterResource(R.drawable.ic_trends),

View File

@@ -1,24 +1,34 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -29,14 +39,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
@@ -201,19 +216,25 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
} else {
val eventContent = accountViewModel.decrypt(note)
if (eventContent != null)
val canPreview = note.author == accountUser
|| (note.author?.let { accountUser.isFollowing(it) } ?: true )
|| !noteForReports.hasAnyReports()
if (eventContent != null) {
RichTextViewer(
eventContent,
canPreview,
note.event?.tags,
navController
)
else
} else {
RichTextViewer(
"Could Not decrypt the message",
true,
note.event?.tags,
navController
)
}
}
}
@@ -227,6 +248,8 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 12.sp
)
RelayBadges(note)
}
}
}
@@ -239,3 +262,51 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
}
}
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.liveRelays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
var expanded by remember { mutableStateOf(false) }
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
val uri = LocalUriHandler.current
FlowRow(Modifier.padding(start = 10.dp)) {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://")
Box(Modifier.size(15.dp).padding(1.dp)) {
AsyncImage(
model = "https://${url}/favicon.ico",
placeholder = rememberAsyncImagePainter("https://robohash.org/$url.png"),
fallback = rememberAsyncImagePainter("https://robohash.org/$url.png"),
error = rememberAsyncImagePainter("https://robohash.org/$url.png"),
contentDescription = "Relay Icon",
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://" + url) } )
)
}
}
if (noteRelays.size > 3 && !expanded) {
IconButton(
modifier = Modifier.then(Modifier.size(15.dp)),
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ChevronRight,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
}
}
}

View File

@@ -5,11 +5,13 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -19,10 +21,14 @@ import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentColor
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -33,9 +39,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -45,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -142,57 +152,67 @@ fun NoteCompose(
top = 10.dp)
) {
// Draws the boosted picture outside the boosted card.
if (!isInnerNote) {
Box(modifier = Modifier
.width(55.dp)
.padding(0.dp)) {
Column(Modifier.width(55.dp)) {
// Draws the boosted picture outside the boosted card.
Box(modifier = Modifier
.width(55.dp)
.padding(0.dp)) {
NoteAuthorPicture(note, navController, account.userProfile(), 55.dp)
NoteAuthorPicture(note, navController, account.userProfile(), 55.dp)
if (note.event is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
NoteAuthorPicture(it, navController, account.userProfile(), 35.dp,
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
if (note.event is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
NoteAuthorPicture(it, navController, account.userProfile(), 35.dp,
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
}
}
}
// boosted picture
val baseChannel = note.channel
if (note.event is ChannelMessageEvent && baseChannel != null) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel
if (channel != null) {
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
AsyncImage(
model = channel.profilePicture(),
placeholder = null,
contentDescription = "Group Picture",
modifier = Modifier
.width(30.dp)
.height(30.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.border(
2.dp,
MaterialTheme.colors.background,
CircleShape
)
)
}
}
}
}
// boosted picture
val baseChannel = note.channel
if (note.event is ChannelMessageEvent && baseChannel != null) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel
if (channel != null) {
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
AsyncImage(
model = channel.profilePicture(),
placeholder = null,
contentDescription = "Group Picture",
modifier = Modifier
.width(30.dp)
.height(30.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.border(
2.dp,
MaterialTheme.colors.background,
CircleShape
)
)
}
if (note.event is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
RelayBadges(it)
}
} else {
RelayBadges(baseNote)
}
}
}
@@ -246,18 +266,12 @@ fun NoteCompose(
}
} else {
val eventContent = note.event?.content
val canPreview = note.author == account.userProfile()
|| (note.author?.let { account.userProfile().isFollowing(it) } ?: true )
|| !noteForReports.hasAnyReports()
if (eventContent != null) {
if (note.reports.size > 0) {
// Doesn't load images
Row() {
Text(
text = eventContent,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
} else {
RichTextViewer(eventContent, note.event?.tags, navController)
}
RichTextViewer(eventContent, canPreview, note.event?.tags, navController)
}
ReactionsRow(note, accountViewModel)
@@ -275,6 +289,55 @@ fun NoteCompose(
}
}
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.liveRelays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
var expanded by remember { mutableStateOf(false) }
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
val uri = LocalUriHandler.current
FlowRow(Modifier.padding(top = 10.dp, start = 5.dp, end = 4.dp)) {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://")
Box(Modifier.size(15.dp).padding(1.dp)) {
AsyncImage(
model = "https://${url}/favicon.ico",
placeholder = rememberAsyncImagePainter("https://robohash.org/$url.png"),
fallback = rememberAsyncImagePainter("https://robohash.org/$url.png"),
error = rememberAsyncImagePainter("https://robohash.org/$url.png"),
contentDescription = "Relay Icon",
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://" + url) } )
)
}
}
}
if (noteRelays.size > 3 && !expanded) {
Row(Modifier.fillMaxWidth().height(25.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Top) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
}
}
}
@Composable
fun NoteAuthorPicture(
@@ -356,6 +419,8 @@ fun UserPicture(
model = user.profilePicture(),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
fallback = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
error = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)

View File

@@ -2,34 +2,18 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.security.crypto.EncryptedSharedPreferences
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.MainActivity
import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import nostr.postr.Persona
import nostr.postr.bechToBytes
import nostr.postr.toHex
class AccountStateViewModel(private val localPreferences: LocalPreferences): ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)

View File

@@ -13,6 +13,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
@@ -35,7 +37,6 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
refreshSuspended()
}
}
private fun refreshSuspended() {
val notes = dataSource.loadTop()
@@ -83,7 +84,7 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
return (reactionCards + boostCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
}
fun updateFeed(notes: List<Card>) {
private fun updateFeed(notes: List<Card>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
@@ -99,19 +100,18 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
}
}
var handlerWaiting = AtomicBoolean()
var handlerWaiting = false
fun invalidateData() {
synchronized(handlerWaiting) {
if (handlerWaiting) return
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
}
}

View File

@@ -50,6 +50,11 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
}
}
is FeedState.Loaded -> {
LaunchedEffect(state.feed.value.firstOrNull()) {
if (listState.firstVisibleItemIndex <= 1)
listState.animateScrollToItem(0)
}
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
@@ -59,7 +64,7 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
state = listState
) {
var previousDate: String = ""
itemsIndexed(state.feed.value, key = { index, item -> if (index == 0) index else item.idHex }) { index, item ->
itemsIndexed(state.feed.value, key = { index, item -> item.idHex }) { index, item ->
ChatroomMessageCompose(item, routeForLastRead, accountViewModel = accountViewModel, navController = navController)
}
}

View File

@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.TextNoteEvent
import java.util.concurrent.atomic.AtomicBoolean
class NostrChannelFeedViewModel: FeedViewModel(NostrChannelDataSource)
class NostrChatRoomFeedViewModel: FeedViewModel(NostrChatRoomDataSource)
@@ -100,7 +101,7 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
}
}
fun updateFeed(notes: List<Note>) {
private fun updateFeed(notes: List<Note>) {
val currentState = feedContent.value
if (notes.isEmpty()) {
@@ -113,18 +114,17 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
}
}
var handlerWaiting = false
fun invalidateData() {
synchronized(handlerWaiting) {
if (handlerWaiting) return
private var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -33,6 +34,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.text.style.TextDirection
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
@@ -218,8 +220,14 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
Row(modifier = Modifier.padding(horizontal = 12.dp)) {
Column() {
val eventContent = note.event?.content
if (eventContent != null)
RichTextViewer(eventContent, note.event?.tags, navController)
val canPreview = note.author == account.userProfile()
|| (note.author?.let { account.userProfile().isFollowing(it) } ?: true )
|| !noteForReports.hasAnyReports()
if (eventContent != null) {
RichTextViewer(eventContent, canPreview, note.event?.tags, navController)
}
ReactionsRow(note, accountViewModel)

View File

@@ -22,6 +22,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
class NostrUserProfileFollowsUserFeedViewModel(): UserFeedViewModel(
NostrUserProfileFollowsDataSource
@@ -46,6 +48,7 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
}
private fun refreshSuspended() {
val notes = dataSource.loadTop()
@@ -59,11 +62,10 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
}
fun updateFeed(notes: List<User>) {
private fun updateFeed(notes: List<User>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
if (notes.isEmpty()) {
_feedContent.update { UserFeedState.Empty }
} else if (currentState is UserFeedState.Loaded) {
@@ -75,18 +77,18 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
}
var handlerWaiting = false
fun invalidateData() {
synchronized(handlerWaiting) {
if (handlerWaiting) return
var handlerWaiting = AtomicBoolean()
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
}
}

View File

@@ -1,70 +1,40 @@
package com.vitorpamplona.amethyst.ui.screen
import android.content.Context
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
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.Surface
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Password
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.*
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.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
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.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
@@ -72,15 +42,11 @@ import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -110,76 +76,117 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val baseUser = NostrUserProfileDataSource.user ?: return
var columnSize by remember { mutableStateOf(IntSize.Zero) }
var tabsSize by remember { mutableStateOf(IntSize.Zero) }
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.background
) {
Column() {
ProfileHeader(baseUser, navController, account, accountViewModel)
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
Column(modifier = Modifier.padding()) {
TabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
Column(modifier = Modifier
.fillMaxSize()
.onSizeChanged {
columnSize = it
}
) {
Box(
modifier = Modifier
.verticalScroll(scrollState)
.nestedScroll(object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
// When scrolling vertically, scroll the container first.
return if (available.y < 0 && scrollState.canScrollForward) {
coroutineScope.launch {
scrollState.scrollBy(-available.y)
}
Offset(0f, available.y)
} else {
Offset.Zero
}
}
})
.fillMaxHeight()
) {
Column(modifier = Modifier.padding()) {
ProfileHeader(baseUser, navController, account, accountViewModel)
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
},
edgePadding = 8.dp,
modifier = Modifier.onSizeChanged {
tabsSize = it
}
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Notes")
}
)
},
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Notes")
Tab(
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
val userState by baseUser.liveFollows.observeAsState()
val userFollows = userState?.user?.follows?.size ?: "--"
Text(text = "$userFollows Follows")
}
)
Tab(
selected = pagerState.currentPage == 2,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
text = {
val userState by baseUser.liveFollows.observeAsState()
val userFollows = userState?.user?.followers?.size ?: "--"
Text(text = "$userFollows Followers")
}
)
Tab(
selected = pagerState.currentPage == 3,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } },
text = {
val userState by baseUser.liveRelays.observeAsState()
val userRelaysBeingUsed =
userState?.user?.relaysBeingUsed?.size ?: "--"
val userStateRelayInfo by baseUser.liveRelayInfo.observeAsState()
val userRelays = userStateRelayInfo?.user?.relays?.size ?: "--"
Text(text = "$userRelaysBeingUsed / $userRelays Relays")
}
)
}
HorizontalPager(
count = 4,
state = pagerState,
modifier = with(LocalDensity.current) {
Modifier.height((columnSize.height - tabsSize.height).toDp())
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
val userState by baseUser.liveFollows.observeAsState()
val userFollows = userState?.user?.follows?.size ?: "--"
Text(text = "$userFollows Follows")
) {
when (pagerState.currentPage) {
0 -> TabNotes(baseUser, accountViewModel, navController)
1 -> TabFollows(baseUser, accountViewModel, navController)
2 -> TabFollowers(baseUser, accountViewModel, navController)
3 -> TabRelays(baseUser, accountViewModel, navController)
}
)
Tab(
selected = pagerState.currentPage == 2,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
text = {
val userState by baseUser.liveFollows.observeAsState()
val userFollows = userState?.user?.followers?.size ?: "--"
Text(text = "$userFollows Followers")
}
)
Tab(
selected = pagerState.currentPage == 3,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } },
text = {
val userState by baseUser.liveRelays.observeAsState()
val userRelaysBeingUsed = userState?.user?.relaysBeingUsed?.size ?: "--"
val userStateRelayInfo by baseUser.liveRelayInfo.observeAsState()
val userRelays = userStateRelayInfo?.user?.relays?.size ?: "--"
Text(text = "$userRelaysBeingUsed / $userRelays Relays")
}
)
}
HorizontalPager(count = 4, state = pagerState) {
when (pagerState.currentPage) {
0 -> TabNotes(baseUser, accountViewModel, navController)
1 -> TabFollows(baseUser, accountViewModel, navController)
2 -> TabFollowers(baseUser, accountViewModel, navController)
3 -> TabRelays(baseUser, accountViewModel, navController)
}
}
}
@@ -579,7 +586,7 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub() ?: "")); onDismiss() }) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())); onDismiss() }) {
Text("Copy User ID")
}

View File

@@ -8,13 +8,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
@@ -34,20 +31,18 @@ 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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
@@ -55,13 +50,21 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.channels.Channel as CoroutineChannel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun SearchScreen(accountViewModel: AccountViewModel, navController: NavController) {
@@ -81,21 +84,47 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle
}
}
@OptIn(FlowPreview::class)
@Composable
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
val searchValue = remember { mutableStateOf(TextFieldValue("")) }
var searchValue by remember { mutableStateOf("") }
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
val scope = rememberCoroutineScope()
val onlineSearch = NostrSearchEventOrUserDataSource
val isTrailingIconVisible by remember {
derivedStateOf {
searchValue.value.text.isNotBlank()
searchValue.isNotBlank()
}
}
// Create a channel for processing search queries.
val searchTextChanges = remember {
CoroutineChannel<String>(CoroutineChannel.CONFLATED)
}
LaunchedEffect(Unit) {
// Wait for text changes to stop for 300 ms before firing off search.
withContext(Dispatchers.IO){
searchTextChanges.receiveAsFlow()
.filter { it.isNotBlank() }
.distinctUntilChanged()
.debounce(300)
.collect {
if (it.removePrefix("npub").removePrefix("note").length >= 4)
onlineSearch.search(it)
searchResults.value = LocalCache.findUsersStartingWith(it)
searchResultsNotes.value = LocalCache.findNotesStartingWith(it)
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
}
}
}
DisposableEffect(Unit) {
onDispose {
NostrSearchEventOrUserDataSource.clear()
@@ -111,16 +140,12 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = searchValue.value,
value = searchValue,
onValueChange = {
searchValue.value = it
if (it.text.removePrefix("npub").removePrefix("note").length >= 4)
onlineSearch.search(it.text)
searchResults.value = LocalCache.findUsersStartingWith(it.text)
searchResultsNotes.value = LocalCache.findNotesStartingWith(it.text)
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it.text)
searchValue = it
scope.launch(Dispatchers.IO) {
searchTextChanges.trySend(it)
}
},
shape = RoundedCornerShape(25.dp),
keyboardOptions = KeyboardOptions.Default.copy(
@@ -147,7 +172,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
if (isTrailingIconVisible) {
IconButton(
onClick = {
searchValue.value = TextFieldValue("")
searchValue = ""
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
@@ -169,7 +194,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
}
if (searchValue.value.text.isNotBlank()) {
if (searchValue.isNotBlank()) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(

View File

@@ -1,3 +1,4 @@
<resources>
<string name="app_name">Amethyst</string>
<string name="app_name_release">Amethyst</string>
<string name="app_name_debug">Amethyst Debug</string>
</resources>