Compare commits

...
21 Commits
Author SHA1 Message Date
Vitor Pamplona f1dd5669a1 v0.8.2 2023-01-18 17:13:08 -05:00
Vitor Pamplona 4271ae77d2 Batch updates to enhance performance. 2023-01-18 17:12:43 -05:00
Vitor Pamplona 3846ae2af5 Search Bar 2023-01-18 16:50:03 -05:00
Vitor Pamplona a4885e643e Add threading for the LazyColumn refresh. 2023-01-18 15:55:35 -05:00
Vitor Pamplona 3441f7987b fix for copying the encrypted part of the message instead of the plain text. 2023-01-18 15:55:10 -05:00
Vitor Pamplona 9792733f72 New Observer/Observable performance improvements. 2023-01-18 15:30:39 -05:00
Vitor Pamplona bf8a0f0e76 Additional Performance Improvements 2023-01-18 14:24:04 -05:00
Vitor Pamplona 381711b72e Version 0.8.1 2023-01-18 12:21:29 -05:00
Vitor Pamplona ff529a920e Moving URL Preview fetcher to thread + running over a given feed list. 2023-01-18 12:21:00 -05:00
Vitor Pamplona 7d18f36e3e Improving performance with more precise filters. 2023-01-18 11:44:31 -05:00
Vitor Pamplona f2b913f7c8 Updating dependencies 2023-01-18 09:18:34 -05:00
Vitor Pamplona d171552efd Clickable Phone and Emails 2023-01-18 09:18:26 -05:00
Vitor Pamplona cae674ee99 v0.8 2023-01-18 08:37:57 -05:00
Vitor Pamplona 3ee39887a8 Tags users during message compose. 2023-01-18 08:36:42 -05:00
Vitor Pamplona 8f45293be9 New design (Visual Identity and Logo) by Lula Rocha 2023-01-17 18:25:38 -05:00
Vitor Pamplona d0b6e96c2f Possibility to remove mentions from replies. 2023-01-17 11:16:50 -05:00
Vitor Pamplona 0789752112 Putting user tags in the reply Information box 2023-01-17 10:43:18 -05:00
Vitor Pamplona 21b16984bb onLong click in the Image now copies the URL 2023-01-17 10:18:45 -05:00
Vitor Pamplona ee9422077e Improvement to chat bubbles layout 2023-01-17 10:18:30 -05:00
Vitor Pamplona 0667a822f1 Makes lists either immutable or puts them into a synchronized block for thread safety 2023-01-17 08:42:00 -05:00
Vitor Pamplona 2037b35af6 Adding new tasks. 2023-01-16 17:30:06 -05:00
84 changed files with 1777 additions and 864 deletions
+1
View File
@@ -7,6 +7,7 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
.DS_Store
/build
/captures
+4
View File
@@ -18,11 +18,15 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] User Profiles (follow/unfollow)
- [x] Public Chats (NIP-28)
- [ ] Notification Bubbles
- [ ] Infinity Scroll
- [ ] Dropdown to Link Users/Posts when writting
- [ ] Identity Verification (NIP-05)
- [ ] Event Delegation (NIP-09)
- [ ] Filter messages from Unknown People
- [ ] Profile Edit
- [ ] Relay Edit
- [ ] Account Creation / Backup Guidance
- [ ] Message Sent feedback
# Development Overview
+5 -5
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 7
versionName "0.7"
versionCode 10
versionName "0.8.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -48,7 +48,7 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha04'
implementation 'androidx.activity:activity-compose:1.6.1'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -61,7 +61,7 @@ dependencies {
implementation "androidx.compose.material:material-icons-extended:1.3.1"
// Input
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha04'
// Swipe Refresh
implementation 'com.google.accompanist:accompanist-swiperefresh:0.24.13-rc'
@@ -81,7 +81,7 @@ dependencies {
implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1'
@@ -1,22 +0,0 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.vitorpamplona.amethyst", appContext.packageName)
}
}
@@ -0,0 +1,96 @@
package com.vitorpamplona.amethyst
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class EUrlUserTagTransformationTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.vitorpamplona.amethyst", appContext.packageName)
}
@Test
fun transformationText() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"))
user.info.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red
)
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N
assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @
assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n
assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4))
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8))
assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9))
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23))
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24))
}
@Test
fun transformationTextTwoKeys() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"))
user.info.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red
)
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11))
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12))
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d
assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space>
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @
assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n
assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d
assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space>
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @
}
}
+1 -3
View File
@@ -9,9 +9,8 @@
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/amethyst_logo"
android:icon="@drawable/amethyst"
android:label="@string/app_name"
android:roundIcon="@drawable/amethyst_logo"
android:enableOnBackInvokedCallback="true"
android:supportsRtl="true"
android:theme="@style/Theme.Amethyst"
@@ -19,7 +18,6 @@
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Amethyst">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -90,13 +90,17 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
}
}
fun sendPost(message: String, replyingTo: Note?) {
fun sendPost(message: String, originalNote: Note?, modifiedMentions: List<User>?) {
if (!isWriteable()) return
val replyToEvent = replyingTo?.event
val replyToEvent = originalNote?.event
if (replyToEvent is TextNoteEvent) {
val modifiedMentionsHex = modifiedMentions?.map { it.pubkeyHex }?.toSet() ?: emptySet()
val repliesTo = replyToEvent.replyTos.plus(replyToEvent.id.toHex())
val mentions = replyToEvent.mentions.plus(replyToEvent.pubKey.toHex())
val mentions = replyToEvent.mentions.plus(replyToEvent.pubKey.toHex()).plus(modifiedMentionsHex).filter {
it in modifiedMentionsHex
}
val signedEvent = TextNoteEvent.create(
msg = message,
@@ -110,7 +114,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = null,
mentions = null,
mentions = modifiedMentions?.map { it.pubkeyHex } ?: emptyList(),
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
@@ -24,6 +24,7 @@ import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.RecommendRelayEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
import nostr.postr.toNpub
object LocalCache {
@@ -65,11 +66,11 @@ object LocalCache {
fun consume(event: MetadataEvent) {
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
// new event
val oldUser = getOrCreateUser(event.pubKey)
if (event.createdAt > oldUser.updatedMetadataAt) {
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
val newUser = try {
metadataParser.readValue<UserMetadata>(ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)), UserMetadata::class.java)
} catch (e: Exception) {
@@ -101,7 +102,7 @@ object LocalCache {
note.loadEvent(event, author, mentions, replyTo)
//Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}")
//Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.notes.add(note)
@@ -119,8 +120,6 @@ object LocalCache {
it.addReply(note)
}
UrlCachedPreviewer.preloadPreviewsFor(note)
refreshObservers()
}
@@ -132,7 +131,7 @@ object LocalCache {
val user = getOrCreateUser(event.pubKey)
if (event.createdAt > user.updatedFollowsAt) {
Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
//Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
user.updateFollows(
event.follows.map {
try {
@@ -188,7 +187,7 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor.map { getOrCreateUser(decodePublicKey(it)) }.toList()
@@ -299,8 +298,6 @@ object LocalCache {
it.addReply(note)
}
UrlCachedPreviewer.preloadPreviewsFor(note)
refreshObservers()
}
@@ -312,6 +309,14 @@ object LocalCache {
}
fun findUsersStartingWith(username: String): List<User> {
return users.values.filter {
it.info.anyNameStartsWith(username)
|| it.pubkeyHex.startsWith(username, true)
|| it.pubkey.toNpub().startsWith(username, true)
}
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.model
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -40,7 +42,7 @@ class Note(val idHex: String) {
this.mentions = mentions
this.replyTo = replyTo
refreshObservers()
invalidateData()
}
fun formattedDateTime(timestamp: Long): String {
@@ -72,17 +74,17 @@ class Note(val idHex: String) {
fun addReply(note: Note) {
if (replies.add(note))
refreshObservers()
invalidateData()
}
fun addBoost(note: Note) {
if (boosts.add(note))
refreshObservers()
invalidateData()
}
fun addReaction(note: Note) {
if (reactions.add(note))
refreshObservers()
invalidateData()
}
fun isReactedBy(user: User): Boolean {
@@ -96,20 +98,29 @@ class Note(val idHex: String) {
// Observers line up here.
val live: NoteLiveData = NoteLiveData(this)
private fun refreshObservers() {
live.refresh()
// Refreshes observers in batches.
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
live.refresh()
handlerWaiting = false
}, 100)
}
}
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
fun refresh() {
postValue(NoteState(note))
}
override fun onActive() {
super.onActive()
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
NostrSingleEventDataSource.add(note.idHex)
}
@@ -117,6 +128,7 @@ class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
override fun onInactive() {
super.onInactive()
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
NostrSingleEventDataSource.remove(note.idHex)
}
@@ -8,6 +8,10 @@ import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.videoExtension
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object UrlCachedPreviewer {
val cache = ConcurrentHashMap<String, UrlInfoItem>()
@@ -18,16 +22,19 @@ object UrlCachedPreviewer {
return
}
BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache.put(url, urlInfo)
callback?.onComplete(urlInfo)
}
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache.put(url, urlInfo)
callback?.onComplete(urlInfo)
}
override fun onFailed(throwable: Throwable) {
callback?.onFailed(throwable)
}
}).fetchUrlPreview()
override fun onFailed(throwable: Throwable) {
callback?.onFailed(throwable)
}
}).fetchUrlPreview()
}
}
fun findUrlsInMessage(message: String): List<String> {
@@ -1,10 +1,17 @@
package com.vitorpamplona.amethyst.model
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import nostr.postr.events.ContactListEvent
class User(val pubkey: ByteArray) {
@@ -23,7 +30,6 @@ class User(val pubkey: ByteArray) {
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
val followers = Collections.synchronizedSet(mutableSetOf<User>())
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
fun toBestDisplayName(): String {
@@ -46,10 +52,16 @@ class User(val pubkey: ByteArray) {
fun follow(user: User) {
follows.add(user)
user.followers.add(this)
invalidateData()
user.invalidateData()
}
fun unfollow(user: User) {
follows.remove(user)
user.followers.remove(this)
invalidateData()
user.invalidateData()
}
@Synchronized
@@ -67,9 +79,12 @@ class User(val pubkey: ByteArray) {
}
fun updateFollows(newFollows: List<User>, updateAt: Long) {
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
val toBeAdded = synchronized(follows) {
newFollows - follows
}
val toBeRemoved = synchronized(follows) {
follows - newFollows
}
toBeAdded.forEach {
follow(it)
}
@@ -78,22 +93,36 @@ class User(val pubkey: ByteArray) {
}
updatedFollowsAt = updateAt
live.refresh()
}
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
info = newUserInfo
updatedMetadataAt = updateAt
live.refresh()
invalidateData()
}
fun isFollowing(user: User): Boolean {
return synchronized(follows) {
follows.contains(user)
}
}
// Observers line up here.
val live: UserLiveData = UserLiveData(this)
private fun refreshObservers() {
live.refresh()
// Refreshes observers in batches.
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
live.refresh()
handlerWaiting = false
}, 100)
}
}
@@ -115,21 +144,32 @@ class UserMetadata {
var iris: String? = null
var main_relay: String? = null
var twitter: String? = null
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.filter { it.startsWith(prefix, true) }.isNotEmpty()
}
}
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
fun refresh() {
postValue(UserState(user))
}
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user.pubkeyHex)
scope.launch {
NostrSingleUserDataSource.add(user.pubkeyHex)
}
}
override fun onInactive() {
super.onInactive()
NostrSingleUserDataSource.remove(user.pubkeyHex)
scope.launch {
NostrSingleUserDataSource.remove(user.pubkeyHex)
}
}
}
@@ -6,5 +6,5 @@ import nostr.postr.JsonFilter
data class Channel (
val id: String = UUID.randomUUID().toString().substring(0,4)
) {
var filter: JsonFilter? = null // Inactive when null
var filter: List<JsonFilter>? = null // Inactive when null
}
@@ -29,48 +29,46 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
account.userProfile().live.removeObserver(cacheListener)
}
fun createAccountFilter(): JsonFilter {
fun createAccountContactListFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(MetadataEvent.kind, ContactListEvent.kind),
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 4 days
limit = 1
)
}
val accountChannel = 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 createAccountMetadataFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
}
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)
}
val accountMetadataChannel = requestNewChannel()
val accountContactListChannel = requestNewChannel()
override fun feed(): List<Note> {
val user = account.userProfile()
val follows = user.follows.map { it.pubkeyHex }.plus(user.pubkeyHex).toSet()
val follows = user.follows
val followKeys = synchronized(follows) {
follows.map { it.pubkeyHex }
}
val allowSet = followKeys.plus(user.pubkeyHex).toSet()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in follows }
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.sortedBy { it.event!!.createdAt }
.reversed()
}
override fun updateChannelFilters() {
// gets everthing about the user logged in
val newAccountFilter = createAccountFilter()
val newAccountMetadataFilter = createAccountMetadataFilter()
accountMetadataChannel.filter = listOf(newAccountMetadataFilter).ifEmpty { null }
if (!equalAuthors(newAccountFilter, accountChannel.filter)) {
accountChannel.filter = newAccountFilter
}
val newAccountContactListEvent = createAccountContactListFilter()
accountContactListChannel.filter = listOf(newAccountContactListEvent).ifEmpty { null }
}
}
@@ -15,7 +15,7 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
fun createMessagesToChannelFilter() = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(channel?.idHex).filterNotNull()),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
limit = 100
)
val messagesChannel = requestNewChannel()
@@ -26,6 +26,6 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
}
override fun updateChannelFilters() {
messagesChannel.filter = createMessagesToChannelFilter()
messagesChannel.filter = listOf(createMessagesToChannelFilter()).ifEmpty { null }
}
}
@@ -39,7 +39,7 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
}
override fun updateChannelFilters() {
incomingChannel.filter = createMessagesToMeFilter()
outgoingChannel.filter = createMessagesFromMeFilter()
incomingChannel.filter = listOf(createMessagesToMeFilter()).ifEmpty { null }
outgoingChannel.filter = listOf(createMessagesFromMeFilter()).ifEmpty { null }
}
}
@@ -26,16 +26,25 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
ids = account.followingChannels.toList()
)
fun createMyChannelsInfoFilter() = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to account.followingChannels.toList())
)
fun createLastChannelInfoFilter(): List<JsonFilter> {
return account.followingChannels.map {
JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
)
}
}
fun createMessagesToMyChannelsFilter() = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to account.followingChannels.toList()),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
)
fun createLastMessageOfEachChannelFilter(): List<JsonFilter> {
return account.followingChannels.map {
JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
)
}
}
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
@@ -50,21 +59,21 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
val messagingWith = messages.keys().toList()
val privateMessages = messagingWith.mapNotNull {
messages[it]?.sortedBy { it.event?.createdAt }?.last { it.event != null }
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
}
val publicChannels = account.followingChannels().map {
it.notes.values.sortedBy { it.event?.createdAt }.last { it.event != null }
it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
return (privateMessages + publicChannels).sortedBy { it.event?.createdAt }.reversed()
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
incomingChannel.filter = createMessagesToMeFilter()
outgoingChannel.filter = createMessagesFromMeFilter()
myChannelsChannel.filter = createMyChannelsFilter()
myChannelsInfoChannel.filter = createMyChannelsInfoFilter()
myChannelsMessagesChannel.filter = createMessagesToMyChannelsFilter()
incomingChannel.filter = listOf(createMessagesToMeFilter()).ifEmpty { null }
outgoingChannel.filter = listOf(createMessagesFromMeFilter()).ifEmpty { null }
myChannelsChannel.filter = listOf(createMyChannelsFilter()).ifEmpty { null }
myChannelsInfoChannel.filter = createLastChannelInfoFilter().ifEmpty { null }
myChannelsMessagesChannel.filter = createLastMessageOfEachChannelFilter().ifEmpty { null }
}
}
@@ -1,6 +1,10 @@
package com.vitorpamplona.amethyst.service
import android.os.Handler
import android.os.Looper
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -11,6 +15,12 @@ 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 kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
@@ -23,9 +33,25 @@ abstract class NostrDataSource<T>(val debugName: String) {
private val channels = Collections.synchronizedSet(mutableSetOf<Channel>())
private val channelIds = Collections.synchronizedSet(mutableSetOf<String>())
private val eventCounter = mutableMapOf<String, Int>()
fun printCounter() {
eventCounter.forEach {
println("AAA Count ${it.key}: ${it.value}")
}
}
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in channelIds) {
val key = "${debugName} ${subscriptionId} ${event.kind}"
if (eventCounter.contains(key)) {
eventCounter.put(key, eventCounter.get(key)!! + 1)
} else {
eventCounter.put(key, 1)
}
//println("AAA ${debugName} ${subscriptionId} ${event.kind}")
when (event) {
is MetadataEvent -> LocalCache.consume(event)
is TextNoteEvent -> LocalCache.consume(event)
@@ -88,8 +114,25 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
}
@OptIn(ExperimentalTime::class)
fun loadTop(): List<T> {
return feed().take(100)
val returningList = feed().take(100)
// prepare previews
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
loadPreviews(returningList)
}
return returningList
}
fun loadPreviews(list: List<T>) {
list.forEach {
if (it is Note) {
UrlCachedPreviewer.preloadPreviewsFor(it)
}
}
}
fun requestNewChannel(): Channel {
@@ -105,11 +148,24 @@ abstract class NostrDataSource<T>(val debugName: String) {
channelIds.remove(channel.id)
}
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateFilters() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
resetFilters()
handlerWaiting = false
}, 200)
}
fun resetFilters() {
// saves the channels that are currently active
val activeChannels = channels.filter { it.filter != null }
// saves the current content to only update if it changes
val currentFilter = activeChannels.associate { it.id to it.filter!!.toJson() }
val currentFilter = activeChannels.associate { it.id to it.filter!!.joinToString("|") { it.toJson() } }
updateChannelFilters()
@@ -123,12 +179,12 @@ abstract class NostrDataSource<T>(val debugName: String) {
Client.close(channel.id)
} else {
// was active and is still active, check if it has changed.
if (channelsNewFilter.toJson() != currentFilter[channel.id]) {
if (channelsNewFilter.joinToString("|") { it.toJson() } != currentFilter[channel.id]) {
Client.close(channel.id)
Client.sendFilter(channel.id, mutableListOf(channelsNewFilter))
Client.sendFilter(channel.id, channelsNewFilter)
} else {
// hasn't changed, does nothing.
Client.sendFilterOnlyIfDisconnected(channel.id, mutableListOf(channelsNewFilter))
Client.sendFilterOnlyIfDisconnected(channel.id, channelsNewFilter)
}
}
} else {
@@ -136,8 +192,8 @@ abstract class NostrDataSource<T>(val debugName: String) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (channelsNewFilter.toJson() != currentFilter[channel.id]) {
Client.sendFilter(channel.id, mutableListOf(channelsNewFilter))
if (channelsNewFilter.joinToString("|") { it.toJson() } != currentFilter[channel.id]) {
Client.sendFilter(channel.id, channelsNewFilter)
}
}
}
@@ -6,31 +6,13 @@ import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
val fifteenMinutes = (60*15) // 15 mins
fun createGlobalFilter() = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
since = System.currentTimeMillis() / 1000 - fifteenMinutes
)
kinds = listOf(TextNoteEvent.kind),
limit = 50
)
val globalFeedChannel = requestNewChannel()
fun equalTime(list1:Long?, list2:Long?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return Math.abs(list1 - list2) < (4*fifteenMinutes)
}
fun equalFilters(list1:JsonFilter?, list2:JsonFilter?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return equalTime(list1.since, list2.since)
}
override fun feed() = LocalCache.notes.values
.filter {
it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()
@@ -39,10 +21,6 @@ object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
.reversed()
override fun updateChannelFilters() {
val newFilter = createGlobalFilter()
if (!equalFilters(newFilter, globalFeedChannel.filter)) {
globalFeedChannel.filter = newFilter
}
globalFeedChannel.filter = listOf(createGlobalFilter()).ifEmpty { null }
}
}
@@ -28,19 +28,21 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
account.userProfile().live.removeObserver(cacheListener)
}
fun createFollowAccountsFilter(): JsonFilter? {
val follows = listOf(account.userProfile().pubkeyHex.substring(0, 6)).plus(
account.userProfile().follows?.map {
it.pubkey.toHex().substring(0, 6)
} ?: emptyList()
)
fun createFollowAccountsFilter(): JsonFilter {
val follows = account.userProfile().follows ?: emptySet()
if (follows.isEmpty()) return null
val followKeys = synchronized(follows) {
follows.map {
it.pubkey.toHex().substring(0, 6)
}
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
authors = follows,
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
authors = followSet,
limit = 200
)
}
@@ -64,10 +66,16 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
override fun feed(): List<Note> {
val user = account.userProfile()
val follows = user.follows.map { it.pubkeyHex }.plus(user.pubkeyHex).toSet()
val follows = user.follows
val followKeys = synchronized(follows) {
follows.map { it.pubkeyHex }
}
val allowSet = followKeys.plus(user.pubkeyHex).toSet()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in follows }
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.sortedBy { it.event!!.createdAt }
.reversed()
}
@@ -75,8 +83,8 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
override fun updateChannelFilters() {
val newFollowAccountsFilter = createFollowAccountsFilter()
if (!equalAuthors(newFollowAccountsFilter, followAccountChannel.filter)) {
followAccountChannel.filter = newFollowAccountsFilter
if (!equalAuthors(newFollowAccountsFilter, followAccountChannel.filter?.firstOrNull())) {
followAccountChannel.filter = listOf(newFollowAccountsFilter).ifEmpty { null }
}
}
}
@@ -4,45 +4,26 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import nostr.postr.JsonFilter
object NostrNotificationDataSource: NostrDataSource<Note>("GlobalFeed") {
object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
lateinit var account: Account
fun createGlobalFilter() = JsonFilter(
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 7 days
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex).filterNotNull())
fun createNotificationFilter() = JsonFilter(
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 100
)
val notificationChannel = 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 equalFilters(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.tags?.get("p"), list2.tags?.get("p"))
&& equalsIgnoreOrder(list1.tags?.get("e"), list2.tags?.get("e"))
}
override fun feed(): List<Note> {
return account.userProfile().taggedPosts
.filter { it.event != null }
.sortedBy { it.event!!.createdAt }
.reversed()
val set = account.userProfile().taggedPosts
val filtered = synchronized(set) {
set.filter { it.event != null }
}
return filtered.sortedBy { it.event!!.createdAt }.reversed()
}
override fun updateChannelFilters() {
val newFilter = createGlobalFilter()
if (!equalFilters(newFilter, notificationChannel.filter)) {
notificationChannel.filter = newFilter
}
notificationChannel.filter = listOf(createNotificationFilter()).ifEmpty { null }
}
}
@@ -9,9 +9,9 @@ import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
private var eventsToWatch = setOf<String>()
fun createRepliesAndReactionsFilter(): JsonFilter? {
private fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
if (reactionsToWatch.isEmpty()) {
@@ -46,6 +46,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
// downloads linked events to this event.
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
ids = interestedEvents
)
}
@@ -60,17 +61,20 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
}
override fun updateChannelFilters() {
repliesAndReactionsChannel.filter = createRepliesAndReactionsFilter()
loadEventsChannel.filter = createLoadEventsIfNotLoadedFilter()
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
repliesAndReactionsChannel.filter = listOfNotNull(reactions).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(missing).ifEmpty { null }
}
fun add(eventId: String) {
eventsToWatch.add(eventId)
resetFilters()
eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters()
}
fun remove(eventId: String) {
eventsToWatch.remove(eventId)
resetFilters()
eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters()
}
}
@@ -7,15 +7,18 @@ import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
val usersToWatch = Collections.synchronizedList(mutableListOf<String>())
var usersToWatch = setOf<String>()
fun createUserFilter(): JsonFilter? {
fun createUserFilter(): List<JsonFilter>? {
if (usersToWatch.isEmpty()) return null
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = usersToWatch.map { it.substring(0, 8) }
)
return usersToWatch.map {
JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.substring(0, 8)),
limit = 1
)
}
}
val userChannel = requestNewChannel()
@@ -31,12 +34,12 @@ object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
}
fun add(userId: String) {
usersToWatch.add(userId)
resetFilters()
usersToWatch = usersToWatch.plus(userId)
invalidateFilters()
}
fun remove(userId: String) {
usersToWatch.remove(userId)
resetFilters()
usersToWatch = usersToWatch.minus(userId)
invalidateFilters()
}
}
@@ -2,8 +2,11 @@ package com.vitorpamplona.amethyst.service
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 nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
@@ -16,6 +19,7 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
}
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
tags = mapOf("e" to reactionsToWatch)
)
}
@@ -46,8 +50,8 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
}
override fun updateChannelFilters() {
repliesAndReactionsChannel.filter = createRepliesAndReactionsFilter()
loadEventsChannel.filter = createLoadEventsIfNotLoadedFilter()
repliesAndReactionsChannel.filter = listOfNotNull(createRepliesAndReactionsFilter()).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
}
fun loadThread(noteId: String) {
@@ -19,7 +19,7 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(user!!.pubkeyHex),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7)
limit = 1
)
}
@@ -27,7 +27,7 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
return JsonFilter(
kinds = listOf(TextNoteEvent.kind),
authors = listOf(user!!.pubkeyHex),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 4)
limit = 100
)
}
@@ -35,11 +35,15 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
val notesChannel = requestNewChannel()
override fun feed(): List<Note> {
return user?.notes?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
val notes = user?.notes ?: return emptyList()
val sortedNotes = synchronized(notes) {
notes.sortedBy { it.event?.createdAt }
}
return sortedNotes.reversed()
}
override fun updateChannelFilters() {
userInfoChannel.filter = createUserInfoFilter()
notesChannel.filter = createUserPostsFilter()
userInfoChannel.filter = listOf(createUserInfoFilter()).ifEmpty { null }
notesChannel.filter = listOf(createUserPostsFilter()).ifEmpty { null }
}
}
@@ -15,17 +15,20 @@ object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFo
fun createFollowersFilter() = JsonFilter(
kinds = listOf(ContactListEvent.kind),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 7 days
tags = mapOf("p" to listOf(user!!.pubkeyHex).filterNotNull())
tags = mapOf("p" to listOf(user!!.pubkeyHex))
)
val followerChannel = requestNewChannel()
override fun feed(): List<User> {
return user?.followers?.toList() ?: emptyList()
val followers = user?.followers ?: emptyList()
return synchronized(followers) {
followers.toList()
}
}
override fun updateChannelFilters() {
followerChannel.filter = createFollowersFilter()
followerChannel.filter = listOf(createFollowersFilter()).ifEmpty { null }
}
}
@@ -17,7 +17,7 @@ object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFoll
return JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(user!!.pubkeyHex),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 4 days
limit = 1
)
}
@@ -28,6 +28,6 @@ object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFoll
}
override fun updateChannelFilters() {
followChannel.filter = createFollowFilter()
followChannel.filter = listOf(createFollowFilter()).ifEmpty { null }
}
}
@@ -21,7 +21,7 @@ class ChannelMuteUserEvent (
}
companion object {
const val kind = 43
const val kind = 44
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent {
val content = reason
@@ -23,9 +23,9 @@ object Client: RelayPool.Listener {
* something.
**/
var lenient: Boolean = false
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
internal var relays = Constants.defaultRelays
internal val subscriptions = ConcurrentHashMap<String, MutableList<JsonFilter>>()
private var listeners = setOf<Listener>()
private var relays = Constants.defaultRelays
private val subscriptions = mutableMapOf<String, List<JsonFilter>>()
fun connect(
relays: Array<Relay> = Constants.defaultRelays
@@ -35,17 +35,9 @@ object Client: RelayPool.Listener {
this.relays = relays
}
fun requestAndWatch(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
) {
subscriptions[subscriptionId] = filters
RelayPool.requestAndWatch()
}
fun sendFilter(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
filters: List<JsonFilter> = listOf(JsonFilter())
) {
subscriptions[subscriptionId] = filters
RelayPool.sendFilter(subscriptionId)
@@ -53,10 +45,10 @@ object Client: RelayPool.Listener {
fun sendFilterOnlyIfDisconnected(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
filters: List<JsonFilter> = listOf(JsonFilter())
) {
subscriptions[subscriptionId] = filters
RelayPool.sendFilterOnlyIfDisconnected(subscriptionId)
RelayPool.sendFilterOnlyIfDisconnected()
}
fun send(signedEvent: Event) {
@@ -90,13 +82,22 @@ object Client: RelayPool.Listener {
}
fun subscribe(listener: Listener) {
listeners.add(listener)
listeners = listeners.plus(listener)
}
fun unsubscribe(listener: Listener): Boolean {
return listeners.remove(listener)
fun unsubscribe(listener: Listener) {
listeners = listeners.minus(listener)
}
fun allSubscriptions(): List<String> {
return synchronized(subscriptions) {
subscriptions.keys.toList()
}
}
fun getSubscriptionFilters(subId: String): List<JsonFilter> {
return subscriptions[subId] ?: emptyList()
}
abstract class Listener {
/**
@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.JsonElement
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.events.Event
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -16,27 +14,29 @@ class Relay(
var write: Boolean = true
) {
private val httpClient = OkHttpClient()
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
fun register(listener: Listener) {
listeners.add(listener)
listeners = listeners.plus(listener)
}
fun unregister(listener: Listener) {
listeners = listeners.minus(listener)
}
fun isConnected(): Boolean {
return socket != null
}
fun unregister(listener: Listener) = listeners.remove(listener)
fun requestAndWatch(reconnectTs: Long? = null) {
fun requestAndWatch() {
val request = Request.Builder().url(url).build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// Sends everything.
Client.subscriptions.forEach {
sendFilter(requestId = it.key, reconnectTs = reconnectTs)
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT) }
}
@@ -101,28 +101,22 @@ class Relay(
socket?.close(1000, "Normal close")
}
fun sendFilter(requestId: String, reconnectTs: Long? = null) {
fun sendFilter(requestId: String) {
if (socket == null) {
requestAndWatch(reconnectTs)
requestAndWatch()
} else {
val filters = if (reconnectTs != null) {
Client.subscriptions[requestId]?.let {
it.map { filter ->
JsonFilter(filter.ids, filter.authors, filter.kinds, filter.tags, since = reconnectTs)
}
} ?: error("No filter(s) found.")
} else {
Client.subscriptions[requestId] ?: error("No filter(s) found.")
val filters = Client.getSubscriptionFilters(requestId)
if (filters.isNotEmpty()) {
val request = """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
socket!!.send(request)
}
val request = """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
socket!!.send(request)
}
}
fun sendFilterOnlyIfDisconnected(requestId: String, reconnectTs: Long? = null) {
fun sendFilterOnlyIfDisconnected() {
if (socket == null) {
requestAndWatch(reconnectTs)
requestAndWatch()
}
}
@@ -9,8 +9,8 @@ import nostr.postr.events.Event
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
*/
object RelayPool: Relay.Listener {
private val relays = Collections.synchronizedList(ArrayList<Relay>())
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
private var relays = listOf<Relay>()
private var listeners = setOf<Listener>()
fun availableRelays(): Int {
return relays.size
@@ -29,7 +29,8 @@ object RelayPool: Relay.Listener {
}
fun unloadRelays() {
relays.toList().forEach { removeRelay(it) }
relays.forEach { it.unregister(this) }
relays = listOf()
}
fun requestAndWatch() {
@@ -40,8 +41,8 @@ object RelayPool: Relay.Listener {
relays.forEach { it.sendFilter(subscriptionId) }
}
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) }
fun sendFilterOnlyIfDisconnected() {
relays.forEach { it.sendFilterOnlyIfDisconnected() }
}
fun send(signedEvent: Event) {
@@ -61,19 +62,17 @@ object RelayPool: Relay.Listener {
relays += relay
}
fun removeRelay(relay: Relay): Boolean {
fun removeRelay(relay: Relay) {
relay.unregister(this)
return relays.remove(relay)
relays = relays.minus(relay)
}
fun getRelays(): List<Relay> = relays
fun register(listener: Listener) {
listeners.add(listener)
listeners = listeners.plus(listener)
}
fun unregister(listener: Listener): Boolean {
return listeners.remove(listener)
fun unregister(listener: Listener) {
listeners = listeners.minus(listener)
}
interface Listener {
@@ -16,6 +16,7 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.KeyStorage
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
@@ -71,6 +72,7 @@ class MainActivity : ComponentActivity() {
override fun onPause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
@@ -3,14 +3,19 @@ package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
@@ -29,29 +34,35 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
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 coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.UrlPreview
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.imageExtension
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.videoExtension
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.UserLine
import kotlinx.coroutines.delay
import nostr.postr.events.TextNoteEvent
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account) {
val postViewModel: NewPostViewModel = viewModel<NewPostViewModel>().apply {
this.replyingTo = replyingTo
this.account = account
}
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account) {
val postViewModel: NewPostViewModel = viewModel()
postViewModel.load(account, baseReplyTo)
val context = LocalContext.current
@@ -99,30 +110,20 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
postViewModel.sendPost()
onClose()
},
postViewModel.message.isNotBlank()
postViewModel.message.text.isNotBlank()
)
}
if (replyingTo != null && replyingTo.event is TextNoteEvent) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
val replyList = replyingTo.replyTo!!.plus(replyingTo).joinToString(", ", "", "", 2) { it.idDisplayHex }
val withList = replyingTo.mentions!!.plus(replyingTo.author!!).joinToString(", ", "", "", 2) { it.toBestDisplayName() }
Text(
"in reply to ${replyList} with ${withList}",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, "") {
postViewModel.removeFromReplyList(it)
}
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = {
postViewModel.message = it
postViewModel.urlPreview = postViewModel.findUrlInMessage()
postViewModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
@@ -150,30 +151,53 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
)
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary)
)
val userSuggestions = postViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
)
) {
itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { index, item ->
UserLine(item) {
postViewModel.autocompleteWithUser(item)
}
}
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Column(modifier = Modifier.padding(top = 5.dp)) {
val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
)
} else {
UrlPreview("https://$myUrlPreview", myUrlPreview, false)
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(myUrlPreview)
} else {
UrlPreview(myUrlPreview, myUrlPreview)
}
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
}
}
}
@@ -199,8 +223,9 @@ fun CloseButton(onCancel: () -> Unit) {
}
@Composable
fun PostButton(onPost: () -> Unit = {}, isActive: Boolean) {
fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
@@ -215,3 +240,27 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean) {
Text(text = "Post", color = Color.White)
}
}
@Composable
fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Icon(
painter = painterResource(R.drawable.ic_search),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}
@@ -8,22 +8,89 @@ import android.provider.MediaStore
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
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.decodePublicKey
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import nostr.postr.toNpub
class NewPostViewModel: ViewModel() {
var account: Account? = null
var replyingTo: Note? = null
private var account: Account? = null
private var originalNote: Note? = null
var message by mutableStateOf("")
var mentions by mutableStateOf<List<User>?>(null)
var replyTos by mutableStateOf<MutableList<Note>?>(null)
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
fun load(account: Account, replyingTo: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: mutableListOf()).plus(replyNote).toMutableList()
replyNote.author?.let { replyUser ->
this.mentions = (replyNote.mentions ?: emptyList()).plus(replyUser)
}
}
this.account = account
}
fun addUserToMentionsIfNotInAndReturnIndex(user: User): Int {
val replyToSize = replyTos?.size ?: 0
var myMentions = mentions
if (myMentions == null) {
mentions = listOf(user)
return replyToSize + 0 // position of the user
}
val index = myMentions.indexOf(user)
if (index >= 0) return replyToSize + index
myMentions = myMentions.plus(user)
mentions = myMentions
return replyToSize + myMentions.indexOf(user)
}
fun sendPost() {
account?.sendPost(message, replyingTo)
message = ""
// Moves @npub to mentions
val newMessage = message.text.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key)
val index = addUserToMentionsIfNotInAndReturnIndex(user)
val newWord = "#[${index}]"
newWord + restOfWord
} else {
word
}
} catch (e: Exception) {
// if it can't parse the key, don't try to change.
word
}
}.joinToString(" ")
}.joinToString("\n")
account?.sendPost(newMessage, originalNote, mentions)
message = TextFieldValue("")
urlPreview = null
}
@@ -36,22 +103,56 @@ class NewPostViewModel: ViewModel() {
img?.let {
ImageUploader.uploadImage(img) {
message = message + "\n\n" + it
message = TextFieldValue(message.text + "\n\n" + it)
urlPreview = findUrlInMessage()
}
}
}
fun cancel() {
message = ""
message = TextFieldValue("")
urlPreview = null
}
fun findUrlInMessage(): String? {
return message.split('\n').firstNotNullOfOrNull { paragraph ->
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
}
}
}
fun removeFromReplyList(it: User) {
mentions = mentions?.minus(it)
}
fun updateMessage(it: TextFieldValue) {
message = it
urlPreview = findUrlInMessage()
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
if (lastWord.startsWith("@") && lastWord.length > 2) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
} else {
userSuggestions = emptyList()
}
}
}
fun autocompleteWithUser(item: User) {
userSuggestionAnchor?.let {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkey.toNpub()} "
message = TextFieldValue(
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
userSuggestionAnchor = null
userSuggestions = emptyList()
}
}
}
@@ -0,0 +1,143 @@
package com.vitorpamplona.amethyst.ui.actions
import android.util.Patterns
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import kotlin.math.roundToInt
data class RangesChanges(val original: TextRange, val modified: TextRange)
class UrlUserTagTransformation(val color: Color) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return buildAnnotatedStringWithUrlHighlighting(text, color)
}
}
fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color): TransformedText {
val substitutions = mutableListOf<RangesChanges>()
val newText = buildAnnotatedString {
val builderBefore = StringBuilder() // important to correctly measure Tag start and end
val builderAfter = StringBuilder() // important to correctly measure Tag start and end
append(
text.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
val startIndex = builderBefore.toString().length
builderBefore.append("$keyB32$restOfWord ") // accounts for the \n at the end of each paragraph
val endIndex = startIndex + keyB32.length
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key)
val newWord = "@${user.toBestDisplayName()}"
val startNew = builderAfter.toString().length
builderAfter.append("$newWord$restOfWord ") // accounts for the \n at the end of each paragraph
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, startNew + newWord.length)
)
)
newWord + restOfWord
} else {
builderBefore.append(word + " ")
builderAfter.append(word + " ")
word
}
} catch (e: Exception) {
// if it can't parse the key, don't try to change.
builderBefore.append(word + " ")
builderAfter.append(word + " ")
word
}
}.joinToString(" ")
}.joinToString("\n")
)
val newText = toAnnotatedString()
newText.split("\\s+".toRegex()).filter { word ->
Patterns.WEB_URL.matcher(word).matches()
}.forEach {
val startIndex = text.indexOf(it)
val endIndex = startIndex + it.length
addStyle(
style = SpanStyle(
color = color,
textDecoration = TextDecoration.None
),
start = startIndex, end = endIndex
)
}
substitutions.forEach {
addStyle(
style = SpanStyle(
color = color,
textDecoration = TextDecoration.None
),
start = it.modified.start, end = it.modified.end
)
}
}
val numberOffsetTranslator = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
val inInsideRange = substitutions.filter { offset > it.original.start && offset < it.original.end }.firstOrNull()
if (inInsideRange != null) {
val percentInRange = (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat())
return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).roundToInt()
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end }
if (lastRangeThrough != null) {
return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
} else {
return offset
}
}
override fun transformedToOriginal(offset: Int): Int {
val inInsideRange = substitutions.filter { offset > it.modified.start && offset < it.modified.end }.firstOrNull()
if (inInsideRange != null) {
val percentInRange = (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat())
return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).roundToInt()
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end }
if (lastRangeThrough != null) {
return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
} else {
return offset
}
}
}
return TransformedText(
newText,
numberOffsetTranslator
)
}
@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@Composable
fun ClickableEmail(email: String) {
val context = LocalContext.current
ClickableText(
text = AnnotatedString("$email "),
onClick = { runCatching { context.sendMail(email) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
fun Context.sendMail(to: String, subject: String? = null) {
try {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "vnd.android.cursor.item/email" // or "message/rfc822"
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to))
if (subject != null)
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
startActivity(intent)
} catch (e: ActivityNotFoundException) {
// TODO: Handle case where no email app is available
} catch (t: Throwable) {
// TODO: Handle potential other type of exceptions
}
}
@@ -0,0 +1,33 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@Composable
fun ClickablePhone(phone: String) {
val context = LocalContext.current
ClickableText(
text = AnnotatedString("$phone "),
onClick = { runCatching { context.dial(phone) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
fun Context.dial(phone: String) {
try {
val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null))
startActivity(intent)
} catch (t: Throwable) {
// TODO: Handle potential exceptions
}
}
@@ -60,6 +60,10 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, note: Note, accou
} else {
UrlPreview(word, word)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
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.Column
import androidx.compose.foundation.layout.Row
@@ -21,15 +23,20 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import nostr.postr.toNpub
@Composable
@OptIn(ExperimentalComposeUiApi::class)
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
@@ -44,8 +51,9 @@ fun ZoomableImageView(word: String) {
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.clickable(
onClick = { dialogOpen = true }
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(word)) },
)
)
@@ -31,6 +31,19 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
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.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -38,13 +51,10 @@ import kotlinx.coroutines.launch
@Composable
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
when (currentRoute(navController)) {
//Route.Profile.route -> TopBarWithBackButton(navController)
else -> MainTopBar(scaffoldState, accountViewModel)
}
}
@Composable
@@ -71,20 +81,36 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
) {
IconButton(
onClick = {
Client.subscriptions.map { "${it.key} ${it.value.joinToString { it.toJson() }}" }.forEach {
Client.allSubscriptions().map { "${it} ${Client.getSubscriptionFilters(it).joinToString { it.toJson() }}" }.forEach {
Log.d("CURRENT FILTERS", it)
}
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatRoomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrGlobalDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrNotificationDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
NostrUserProfileFollowersDataSource.printCounter()
NostrUserProfileFollowsDataSource.printCounter()
}
) {
Icon(
painter = painterResource(R.drawable.ic_amethyst),
painter = painterResource(R.drawable.amethyst),
null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colors.primary
modifier = Modifier.size(40.dp),
tint = Color.Unspecified
)
}
}
},
navigationIcon = {
IconButton(
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
@@ -17,13 +18,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.events.TextNoteEvent
@Composable
fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
@@ -42,52 +42,18 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val channelState by note.channel!!.live.observeAsState()
val channel = channelState?.channel
Column(modifier =
Modifier.clickable(
onClick = { navController.navigate("Channel/${channel?.idHex}") }
)
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp)
) {
AsyncImage(
model = channel?.profilePicture(),
contentDescription = "Public Channel Image",
modifier = Modifier
.width(55.dp).height(55.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${channel?.info?.name}",
fontWeight = FontWeight.Bold,
)
Text(
timeAgo(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
val eventContent = accountViewModel.decrypt(note)
if (eventContent != null)
RichTextViewer("${author?.toBestDisplayName()}: " + eventContent.take(100), note.event?.tags, note, accountViewModel, navController)
else
RichTextViewer("Referenced event not found", note.event?.tags, note, accountViewModel, navController)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
channel?.let {
ChannelName(
channelPicture = it.profilePicture(),
channelTitle = {
Text(
"${it.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = note.event?.createdAt,
channelLastContent = "${author?.toBestDisplayName()}: " + note.event?.content,
onClick = { navController.navigate("Channel/${it.idHex}") })
}
} else {
@@ -107,51 +73,74 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
}
}
Column(modifier =
Modifier.clickable(
onClick = { navController.navigate("Room/${userToComposeOn?.pubkeyHex}") }
)
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp)
) {
AsyncImage(
model = userToComposeOn?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp).height(55.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (userToComposeOn != null)
UserDisplay(userToComposeOn)
Text(
timeAgo(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
val eventContent = accountViewModel.decrypt(note)
if (eventContent != null)
RichTextViewer(eventContent.take(100), note.event?.tags, note, accountViewModel, navController)
else
RichTextViewer("Referenced event not found", note.event?.tags, note, accountViewModel, navController)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
userToComposeOn?.let {
ChannelName(
channelPicture = it.profilePicture(),
channelTitle = { UsernameDisplay(it) },
channelLastTime = note.event?.createdAt,
channelLastContent = accountViewModel.decrypt(note),
onClick = { navController.navigate("Room/${it.pubkeyHex}") })
}
}
}
@Composable
private fun ChannelName(
channelPicture: String,
channelTitle: @Composable () -> Unit,
channelLastTime: Long?,
channelLastContent: String?,
onClick: () -> Unit
) {
Column(modifier = Modifier.clickable(onClick = onClick) ) {
Row(
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
) {
AsyncImage(
model = channelPicture,
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp),
verticalArrangement = Arrangement.SpaceAround) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp)
) {
channelTitle()
Text(
timeAgo(channelLastTime),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f)
)
}
if (channelLastContent != null)
Text(
channelLastContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
else
Text(
"Referenced event not found",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
@@ -6,6 +6,7 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -30,6 +31,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
@@ -37,8 +39,8 @@ import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
val ChatBubbleShapeMe = RoundedCornerShape(20.dp, 20.dp, 3.dp, 20.dp)
val ChatBubbleShapeThem = RoundedCornerShape(20.dp, 20.dp, 20.dp, 3.dp)
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -57,56 +59,60 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
var backgroundBubbleColor: Color
var alignment: Arrangement.Horizontal
var shape: Shape
if (author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End
shape = ChatBubbleShapeMe
} else {
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
alignment = Arrangement.Start
shape = ChatBubbleShapeThem
}
Column() {
var backgroundBubbleColor: Color
var alignment: Arrangement.Horizontal
var shape: Shape
if (author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End
shape = ChatBubbleShapeMe
} else {
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
alignment = Arrangement.Start
shape = ChatBubbleShapeThem
}
Row(
horizontalArrangement = alignment,
modifier = Modifier.fillMaxWidth()
.padding(
start = 12.dp,
end = 12.dp,
top = 5.dp,
bottom = 5.dp
).combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true }
)
modifier = Modifier.fillMaxWidth(1f).padding(
start = 12.dp,
end = 12.dp,
top = 5.dp,
bottom = 5.dp
),
horizontalArrangement = alignment
) {
Row(
verticalAlignment = Alignment.CenterVertically
horizontalArrangement = alignment,
modifier = Modifier.fillMaxWidth(0.85f)
) {
Surface(
color = backgroundBubbleColor,
shape = shape
shape = shape,
modifier = Modifier
.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true }
)
) {
Column(
modifier = Modifier.padding(10.dp),
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
) {
if (author != accountUser && note.event is ChannelMessageEvent) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment
horizontalArrangement = alignment,
modifier = Modifier.padding(top = 5.dp)
) {
AsyncImage(
model = author?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(25.dp).height(25.dp)
.width(25.dp)
.height(25.dp)
.clip(shape = CircleShape)
.clickable(onClick = {
author?.let {
@@ -127,9 +133,7 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
}
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val eventContent = accountViewModel.decrypt(note)
if (eventContent != null)
@@ -148,24 +152,26 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
accountViewModel,
navController
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment
horizontalArrangement = Arrangement.End,
modifier = Modifier.padding(top = 2.dp)
) {
Text(
timeAgoLong(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 12.sp
)
}
}
}
}
}
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
}
@@ -107,16 +107,10 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)
.clickable(onClick = {
note?.let {
navController.navigate("Note/${note.idHex}")
}
})
) {
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UserDisplay(author)
UsernameDisplay(author)
if (note.event !is RepostEvent) {
Text(
@@ -133,7 +127,7 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
}
if (note.event is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
ReplyInformation(note.replyTo, note.mentions)
ReplyInformation(note.replyTo, note.mentions, navController)
}
if (note.event is ReactionEvent || note.event is RepostEvent) {
@@ -184,7 +178,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.event?.content ?: "")); onDismiss() }) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
Text("Copy Text")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkey?.toNpub() ?: "")); onDismiss() }) {
@@ -1,61 +1,70 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@Composable
fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?) {
fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, navController: NavController) {
ReplyInformation(replyTo, mentions) {
navController.navigate("User/${it.pubkeyHex}")
}
}
@Composable
fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix: String = "", onUserTagClick: (User) -> Unit) {
FlowRow() {
/*
if (replyTo != null && replyTo.isNotEmpty()) {
Text(
" in reply to ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
replyTo.toSet().forEachIndexed { idx, note ->
val innerNoteState by note.live.observeAsState()
Text(
"${innerNoteState?.note?.idDisplayHex}${if (idx < replyTo.size - 1) ", " else ""}",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
*/
if (mentions != null && mentions.isNotEmpty()) {
Text(
"replying to ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
mentions.toSet().forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
if (replyTo != null && replyTo.isNotEmpty()) {
Text(
"${innerUserState?.user?.toBestDisplayName()}",
"replying to ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
if (idx < mentions.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < mentions.size - 1) {
Text(
" and ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
mentions.toSet().forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
ClickableText(
AnnotatedString("${prefix}@${myUser.toBestDisplayName()}"),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) }
)
if (idx < mentions.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < mentions.size - 1) {
Text(
" and ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
}
}
@@ -18,7 +18,7 @@ fun timeAgo(mills: Long?): String {
return "" + humanReadable
.replace(" hr. ago", "h")
.replace(" min. ago", "m")
.replace(" days. ago", "d")
.replace(" days ago", "d")
}
fun timeAgoLong(mills: Long?): String {
@@ -54,7 +54,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UserDisplay(user)
UsernameDisplay(user)
}
Text(
@@ -64,7 +64,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (accountState?.account?.userProfile()?.follows?.contains(user) == true) {
if (accountState?.account?.userProfile()?.isFollowing(user) == true) {
UnfollowButton { accountState?.account?.unfollow(user) }
} else {
FollowButton { accountState?.account?.follow(user) }
@@ -7,7 +7,7 @@ import androidx.compose.ui.text.font.FontWeight
import com.vitorpamplona.amethyst.model.User
@Composable
fun UserDisplay(user: User) {
fun UsernameDisplay(user: User) {
if (user.bestUsername() != null || user.bestDisplayName() != null) {
if (user.bestDisplayName().isNullOrBlank()) {
Text(
@@ -6,6 +6,7 @@ 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.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
@@ -70,6 +71,7 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
NostrNotificationDataSource.account = account
NostrChatroomListDataSource.account = account
NostrChannelDataSource.start()
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
NostrHomeDataSource.start()
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
@@ -8,6 +10,9 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -21,28 +26,24 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private var lastNotes: List<Note>? = null
fun refresh() {
// For some reason, view Model Scope doesn't call
viewModelScope.launch {
refreshSuspend()
}
}
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val notes = dataSource.loadTop()
fun refreshSuspend() {
val notes = dataSource.loadTop()
val lastNotesCopy = lastNotes
val lastNotesCopy = lastNotes
val oldNotesState = feedContent.value
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
val newCards = convertToCard(notes.minus(lastNotesCopy))
if (newCards.isNotEmpty()) {
val oldNotesState = feedContent.value
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
val newCards = convertToCard(notes.minus(lastNotesCopy))
if (newCards.isNotEmpty()) {
lastNotes = notes
updateFeed((oldNotesState.feed + newCards).sortedBy { it.createdAt() }.reversed())
}
} else {
val cards = convertToCard(notes)
lastNotes = notes
updateFeed((oldNotesState.feed + newCards).sortedBy { it.createdAt() }.reversed())
updateFeed(cards)
}
} else {
val cards = convertToCard(notes)
lastNotes = notes
updateFeed(cards)
}
}
@@ -89,8 +90,21 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
}
}
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
refresh()
handlerWaiting = false
}, 100)
}
private val cacheListener: (LocalCacheState) -> Unit = {
refresh()
invalidateData()
}
init {
@@ -1,11 +1,18 @@
package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrDataSource
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -18,22 +25,18 @@ class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
val feedContent = _feedContent.asStateFlow()
fun refresh() {
// For some reason, view Model Scope doesn't call
viewModelScope.launch {
refreshSuspend()
}
}
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val notes = dataSource.loadTop()
fun refreshSuspend() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
if (notes != oldNotesState.feed) {
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
if (notes != oldNotesState.feed) {
updateFeed(notes)
}
} else {
updateFeed(notes)
}
} else {
updateFeed(notes)
}
}
@@ -52,8 +55,21 @@ class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
}
}
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
refresh()
handlerWaiting = false
}, 100)
}
private val cacheListener: (LocalCacheState) -> Unit = {
refresh()
invalidateData()
}
init {
@@ -40,7 +40,7 @@ import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ReactionsRowState
import com.vitorpamplona.amethyst.ui.note.UserDisplay
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.timeAgoLong
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -185,7 +185,7 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UserDisplay(author)
UsernameDisplay(author)
}
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
@@ -8,6 +10,9 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -27,22 +32,18 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
val feedContent = _feedContent.asStateFlow()
fun refresh() {
// For some reason, view Model Scope doesn't call
viewModelScope.launch {
refreshSuspend()
}
}
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val notes = dataSource.loadTop()
fun refreshSuspend() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
if (oldNotesState is UserFeedState.Loaded) {
if (notes != oldNotesState.feed) {
val oldNotesState = feedContent.value
if (oldNotesState is UserFeedState.Loaded) {
if (notes != oldNotesState.feed) {
updateFeed(notes)
}
} else {
updateFeed(notes)
}
} else {
updateFeed(notes)
}
}
@@ -61,8 +62,21 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
}
val filterHandler = Handler(Looper.getMainLooper())
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
filterHandler.postDelayed({
refresh()
handlerWaiting = false
}, 100)
}
private val cacheListener: (LocalCacheState) -> Unit = {
refresh()
invalidateData()
}
init {
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -13,9 +12,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -26,16 +26,15 @@ import androidx.compose.ui.draw.clip
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.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.note.UserDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -53,14 +52,16 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navCon
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChannelDataSource ) }
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
channel?.let {
ChannelHeader(
it,
accountViewModel = accountViewModel,
navController = navController
)
}
ChannelHeader(
channel,
accountViewModel = accountViewModel,
navController = navController
)
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true)
@@ -73,7 +74,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navCon
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
TextField(
value = newPost.value,
onValueChange = { newPost.value = it },
keyboardOptions = KeyboardOptions.Default.copy(
@@ -85,60 +86,63 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navCon
text = "reply here.. ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
PostButton(
onPost = {
account.sendChannelMeesage(newPost.value.text, channel.idHex)
newPost.value = TextFieldValue("")
},
newPost.value.text.isNotBlank()
trailingIcon = {
PostButton(
onPost = {
account.sendChannelMeesage(newPost.value.text, channel.idHex)
newPost.value = TextFieldValue("")
},
newPost.value.text.isNotBlank(),
modifier = Modifier.padding(end = 10.dp)
)
}
)
}
}
}
}
@Composable
fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, navController: NavController) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel
Column(modifier =
Modifier
.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column() {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
AsyncImage(
model = channel?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
.clip(shape = CircleShape)
)
AsyncImage(
model = channel?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${channel?.info?.name}",
fontWeight = FontWeight.Bold,
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${channel?.info?.name}",
fontWeight = FontWeight.Bold,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${channel?.info?.about}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${channel?.info?.about}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
}
}
}
}
Divider(
modifier = Modifier.padding(top = 12.dp, start = 12.dp, end = 12.dp),
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
@@ -20,6 +21,10 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
if (account != null) {
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChatroomListDataSource ) }
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -13,9 +13,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -32,7 +33,7 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.note.UserDisplay
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -47,6 +48,10 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChatRoomDataSource ) }
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
NostrChatRoomDataSource.withUser?.let {
ChatroomHeader(
@@ -67,27 +72,29 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
TextField(
value = newPost.value,
onValueChange = { newPost.value = it },
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier.weight(1f, true).padding(end = 10.dp),
modifier = Modifier.weight(1f, true),
placeholder = {
Text(
text = "reply here.. ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
PostButton(
onPost = {
account.sendPrivateMeesage(newPost.value.text, userId)
newPost.value = TextFieldValue("")
},
newPost.value.text.isNotBlank()
trailingIcon = {
PostButton(
onPost = {
account.sendPrivateMeesage(newPost.value.text, userId)
newPost.value = TextFieldValue("")
},
newPost.value.text.isNotBlank(),
modifier = Modifier.padding(end = 10.dp)
)
}
)
}
}
@@ -100,33 +107,32 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navContro
val authorState by baseUser.live.observeAsState()
val author = authorState?.user
Column(modifier =
Modifier
.padding(12.dp)
.clickable(
onClick = { navController.navigate("User/${author?.pubkeyHex}") }
)
Column(modifier = Modifier.clickable(
onClick = { navController.navigate("User/${author?.pubkeyHex}") }
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
AsyncImage(
model = author?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
.clip(shape = CircleShape)
)
AsyncImage(
model = author?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UserDisplay(author)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UsernameDisplay(author)
}
}
}
}
Divider(
modifier = Modifier.padding(top = 12.dp, start = 12.dp, end = 12.dp),
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
}
@@ -4,14 +4,21 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.lang.System.currentTimeMillis
@Composable
fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) {
@@ -20,6 +27,10 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
if (accountState != null) {
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrHomeDataSource ) }
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
@@ -21,6 +22,10 @@ fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavCon
val feedViewModel: CardFeedViewModel =
viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -143,7 +143,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
if (accountUser == user) {
EditButton()
} else {
if (accountUser.follows?.contains(user) == true) {
if (accountUser.isFollowing(user) == true) {
UnfollowButton { account.unfollow(user) }
} else {
FollowButton { account.follow(user) }
@@ -293,7 +293,7 @@ private fun MessageButton(user: User, navController: NavController) {
painter = painterResource(R.drawable.ic_dm),
"Send a Direct Message",
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
tint = Color.White
)
}
}
@@ -1,25 +1,206 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.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.text.ClickableText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.AnnotatedString
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SearchButton
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun SearchScreen(accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrGlobalDataSource ) }
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
SearchBar(navController)
FeedView(feedViewModel, accountViewModel, navController)
}
}
}
@Composable
private fun SearchBar(navController: NavController) {
val searchValue = remember { mutableStateOf(TextFieldValue("")) }
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
val isTrailingIconVisible by remember {
derivedStateOf {
searchValue.value.text.isNotBlank()
}
}
//LAST ROW
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = searchValue.value,
onValueChange = {
searchValue.value = it
searchResults.value = LocalCache.findUsersStartingWith(it.text)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
leadingIcon = {
Icon(
painter = painterResource(R.drawable.ic_search),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
},
modifier = Modifier
.weight(1f, true)
.defaultMinSize(minHeight = 20.dp),
placeholder = {
Text(
text = "npub, hex, username ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
if (isTrailingIconVisible) {
IconButton(
onClick = {
searchValue.value = TextFieldValue("")
searchResults.value = emptyList()
}
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "Clear"
)
}
}
}
)
}
if (searchValue.value.text.isNotBlank()) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
)
) {
itemsIndexed(searchResults.value, key = { _, item -> item.pubkeyHex }) { index, item ->
UserLine(item) {
navController.navigate("User/${item.pubkeyHex}")
}
}
}
}
}
@Composable
fun UserLine(
baseUser: User,
onClick: () -> Unit
) {
val userState by baseUser.live.observeAsState()
val user = userState?.user ?: return
Column(modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
) {
AsyncImage(
model = user.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp)
.height(55.dp)
.clip(shape = CircleShape)
)
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(user)
}
Text(
user.info.about?.take(100) ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
@@ -70,7 +70,7 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
var errorMessage by remember { mutableStateOf("") }
Image(
painterResource(id = R.drawable.amethyst_logo),
painterResource(id = R.drawable.amethyst),
contentDescription = "App Logo",
modifier = Modifier.size(300.dp),
contentScale = ContentScale.Inside
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

+30
View File
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="192.62dp"
android:height="288.93dp"
android:viewportWidth="192.62"
android:viewportHeight="288.93">
<group>
<clip-path
android:pathData="m111.18,103.42c-4.68,-0 -8.47,-3.8 -8.47,-8.48 0,-4.68 3.79,-8.48 8.47,-8.48 4.68,-0 8.48,3.8 8.48,8.48 0,4.68 -3.79,8.48 -8.48,8.48zM96.31,-0 L0,91.3L0,197.63l19.9,18.86c0.63,-43.4 14.08,-106.43 37.41,-146.62 0,-0 6.05,5.23 16.74,5.93 11.02,0.73 36.71,-4.37 48.32,0.36 10.87,4.43 11.66,17.34 10.79,27.84 -1.66,20.02 8.14,25.99 25.54,34.66 8.67,4.32 10.67,9.28 7.87,14.44 -3.09,5.68 -17.49,3.05 -33.55,1.1 -17.16,-2.08 -26.79,9.45 -43.37,8.39 -23.4,30.62 -0.84,73.09 -16.14,104.73l22.81,21.62 96.31,-91.31l0,-106.32z"/>
<path
android:pathData="m111.18,103.42c-4.68,-0 -8.47,-3.8 -8.47,-8.48 0,-4.68 3.79,-8.48 8.47,-8.48 4.68,-0 8.48,3.8 8.48,8.48 0,4.68 -3.79,8.48 -8.48,8.48zM96.31,-0 L0,91.3L0,197.63l19.9,18.86c0.63,-43.4 14.08,-106.43 37.41,-146.62 0,-0 6.05,5.23 16.74,5.93 11.02,0.73 36.71,-4.37 48.32,0.36 10.87,4.43 11.66,17.34 10.79,27.84 -1.66,20.02 8.14,25.99 25.54,34.66 8.67,4.32 10.67,9.28 7.87,14.44 -3.09,5.68 -17.49,3.05 -33.55,1.1 -17.16,-2.08 -26.79,9.45 -43.37,8.39 -23.4,30.62 -0.84,73.09 -16.14,104.73L96.31,288.93 192.62,197.63L192.62,91.3L96.31,-0"
android:strokeWidth="0.04"
android:strokeColor="#00000000"
android:fillType="nonZero">
<aapt:attr name="android:fillColor">
<gradient
android:startX="33.75"
android:startY="252.82"
android:endX="158.87"
android:endY="36.12"
android:tileMode="clamp"
android:type="linear">
<item android:offset="0" android:color="#FF550066"/>
<item android:offset="0" android:color="#FF550066"/>
<item android:offset="1" android:color="#FFA75CCC"/>
</gradient>
</aapt:attr>
</path>
</group>
</vector>
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.
+140
View File
@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg2"
xml:space="preserve"
width="1600"
height="800"
viewBox="0 0 1600 800"
sodipodi:docname="Amethyst Assets.eps"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs6"><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath34"><path
d="m 6325.09,3708.81 c -41.34,0 -74.81,33.5 -74.81,74.84 0,41.34 33.47,74.84 74.81,74.84 41.34,0 74.84,-33.5 74.84,-74.84 0,-41.34 -33.5,-74.84 -74.84,-74.84 z m -131.25,913.13 -850.41,-806.19 v -938.77 l 175.68,-166.57 c 5.58,383.19 124.36,939.72 330.28,1294.63 0,0 53.43,-46.15 147.82,-52.38 97.33,-6.47 324.17,38.62 426.65,-3.14 95.93,-39.1 102.91,-153.14 95.23,-245.81 -14.66,-176.73 71.83,-229.49 225.55,-306 76.6,-38.12 94.21,-81.97 69.5,-127.47 -27.26,-50.17 -154.45,-26.89 -296.25,-9.7 -151.52,18.34 -236.5,-83.41 -382.97,-74.13 -206.62,-270.4 -7.45,-645.38 -142.47,-924.72 l 201.39,-190.93 850.38,806.22 v 938.77 z"
id="path32" /></clipPath><linearGradient
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1104.69,1913.39,1913.39,-1104.69,5641.47,2389.67)"
spreadMethod="pad"
id="linearGradient42"><stop
style="stop-opacity:1;stop-color:#550066"
offset="0"
id="stop36" /><stop
style="stop-opacity:1;stop-color:#550066"
offset="0.00046311"
id="stop38" /><stop
style="stop-opacity:1;stop-color:#a75ccc"
offset="1"
id="stop40" /></linearGradient><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath52"><path
d="m 2583.97,3539.5 c -61.53,0 -111.36,49.88 -111.36,111.4 0,61.53 49.83,111.41 111.36,111.41 61.53,0 111.4,-49.88 111.4,-111.41 0,-61.52 -49.87,-111.4 -111.4,-111.4 z M 2388.6,4898.68 1122.8,3698.69 V 2301.35 l 261.5,-247.92 c 8.3,570.35 185.1,1398.74 491.61,1927 0,0 79.53,-68.68 220.02,-77.96 144.88,-9.62 482.52,57.49 635.05,-4.68 142.8,-58.18 153.19,-227.93 141.75,-365.87 -21.82,-263.06 106.93,-341.59 335.73,-455.47 114.01,-56.74 140.23,-122.02 103.44,-189.74 -40.57,-74.68 -229.89,-40.03 -440.95,-14.44 -225.54,27.3 -352.03,-124.15 -570.05,-110.33 -307.55,-402.49 -11.09,-960.64 -212.06,-1376.43 l 299.76,-284.19 1265.77,1200.03 v 1397.34 z"
id="path50" /></clipPath><linearGradient
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1644.31,2848.02,2848.02,-1644.31,1566.43,1576)"
spreadMethod="pad"
id="linearGradient60"><stop
style="stop-opacity:1;stop-color:#550066"
offset="0"
id="stop54" /><stop
style="stop-opacity:1;stop-color:#550066"
offset="0.00046311"
id="stop56" /><stop
style="stop-opacity:1;stop-color:#a75ccc"
offset="1"
id="stop58" /></linearGradient><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath74"><path
d="m 10040.1,3110.82 c -12.6,0 -22.8,10.24 -22.8,22.88 0,12.64 10.2,22.88 22.8,22.88 12.7,0 22.9,-10.24 22.9,-22.88 0,-12.64 -10.2,-22.88 -22.9,-22.88 z M 10000,3390 9740,3143.52 v -287.03 l 53.71,-50.92 c 1.71,117.15 38.03,287.31 100.98,395.82 0,0 16.34,-14.11 45.2,-16.02 29.76,-1.97 99.11,11.81 130.41,-0.96 29.4,-11.95 31.5,-46.82 29.1,-75.15 -4.4,-54.04 22,-70.17 69,-93.56 23.4,-11.65 28.8,-25.06 21.3,-38.97 -8.4,-15.34 -47.3,-8.22 -90.6,-2.97 -46.3,5.61 -72.3,-25.5 -117.11,-22.66 -63.17,-82.67 -2.28,-197.32 -43.56,-282.73 l 61.57,-58.37 260,246.49 v 287.03 z"
id="path72" /></clipPath><linearGradient
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(337.75,585,585,-337.75,9831.12,2707.5)"
spreadMethod="pad"
id="linearGradient82"><stop
style="stop-opacity:1;stop-color:#550066"
offset="0"
id="stop76" /><stop
style="stop-opacity:1;stop-color:#550066"
offset="0.00046311"
id="stop78" /><stop
style="stop-opacity:1;stop-color:#a75ccc"
offset="1"
id="stop80" /></linearGradient></defs><sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0" /><g
id="g8"
inkscape:groupmode="layer"
inkscape:label="ink_ext_XXXXXX"
transform="matrix(1.3333333,0,0,-1.3333333,0,800)"><g
id="g10"
transform="scale(0.1)"><path
d="m 5128.65,1507.17 -42.72,126.17 -42.22,-126.17 z m 21.6,-64.8 H 5021.62 L 5001,1381.49 h -87.87 l 124.69,344.64 h 97.21 l 124.69,-344.64 h -88.85 l -20.62,60.88"
style="fill:#660066;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path12" /><path
d="m 5676.91,1726.13 v -344.64 h -83.94 v 206.69 l -77.08,-206.69 h -67.75 l -77.56,207.18 v -207.18 h -83.95 v 344.64 h 99.16 l 96.72,-238.59 95.73,238.59 h 98.67"
style="fill:#660066;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path14" /><path
d="m 5820.66,1658.87 v -69.71 h 112.42 v -64.8 h -112.42 v -75.61 h 127.15 v -67.26 h -211.1 v 344.64 h 211.1 v -67.26 h -127.15"
style="fill:#660066;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path16" /><path
d="m 6240.15,1726.13 v -67.26 h -91.31 v -277.38 h -83.95 v 277.38 h -91.31 v 67.26 h 266.57"
style="fill:#660066;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path18" /><path
d="m 6567.55,1726.13 v -344.64 h -83.95 v 141.88 h -130.59 v -141.88 h -83.95 v 344.64 h 83.95 v -135.01 h 130.59 v 135.01 h 83.95"
style="fill:#660066;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path20" /><path
d="m 6914.48,1726.13 -119.3,-230.74 v -113.9 h -83.95 v 113.9 l -119.29,230.74 h 95.24 l 66.77,-144.34 66.27,144.34 h 94.26"
style="fill:#660066;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path22" /><path
d="m 6983.89,1390.33 c -19.96,8.18 -35.92,20.29 -47.86,36.33 -11.96,16.03 -18.25,35.35 -18.9,57.93 h 89.34 c 1.31,-12.76 5.73,-22.51 13.26,-29.21 7.52,-6.71 17.34,-10.06 29.46,-10.06 12.43,0 22.25,2.86 29.45,8.59 7.2,5.72 10.8,13.66 10.8,23.81 0,8.5 -2.87,15.54 -8.59,21.11 -5.73,5.56 -12.76,10.14 -21.11,13.74 -8.35,3.6 -20.21,7.69 -35.59,12.28 -22.26,6.87 -40.43,13.74 -54.5,20.62 -14.07,6.87 -26.18,17.01 -36.32,30.43 -10.15,13.42 -15.22,30.93 -15.22,52.53 0,32.08 11.61,57.2 34.85,75.36 23.24,18.16 53.51,27.25 90.83,27.25 37.96,0 68.56,-9.09 91.8,-27.25 23.23,-18.16 35.67,-43.45 37.31,-75.85 h -90.82 c -0.66,11.12 -4.75,19.88 -12.28,26.27 -7.53,6.38 -17.18,9.57 -28.96,9.57 -10.15,0 -18.33,-2.7 -24.55,-8.1 -6.22,-5.4 -9.32,-13.18 -9.32,-23.32 0,-11.13 5.23,-19.81 15.71,-26.02 10.47,-6.22 26.83,-12.93 49.09,-20.13 22.25,-7.53 40.33,-14.73 54.25,-21.6 13.9,-6.87 25.93,-16.86 36.08,-29.95 10.14,-13.09 15.22,-29.94 15.22,-50.56 0,-19.64 -5,-37.48 -14.98,-53.51 -9.98,-16.04 -24.46,-28.81 -43.44,-38.29 -18.99,-9.5 -41.41,-14.24 -67.26,-14.24 -25.21,0 -47.79,4.09 -67.75,12.27"
style="fill:#660066;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path24" /><path
d="m 7464.31,1726.13 v -67.26 H 7373 v -277.38 h -83.95 v 277.38 h -91.32 v 67.26 h 266.58"
style="fill:#660066;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path26" /><g
id="g28"><g
id="g30"
clip-path="url(#clipPath34)"><path
d="m 6325.09,3708.81 c -41.34,0 -74.81,33.5 -74.81,74.84 0,41.34 33.47,74.84 74.81,74.84 41.34,0 74.84,-33.5 74.84,-74.84 0,-41.34 -33.5,-74.84 -74.84,-74.84 z m -131.25,913.13 -850.41,-806.19 v -938.77 l 175.68,-166.57 c 5.58,383.19 124.36,939.72 330.28,1294.63 0,0 53.43,-46.15 147.82,-52.38 97.33,-6.47 324.17,38.62 426.65,-3.14 95.93,-39.1 102.91,-153.14 95.23,-245.81 -14.66,-176.73 71.83,-229.49 225.55,-306 76.6,-38.12 94.21,-81.97 69.5,-127.47 -27.26,-50.17 -154.45,-26.89 -296.25,-9.7 -151.52,18.34 -236.5,-83.41 -382.97,-74.13 -206.62,-270.4 -7.45,-645.38 -142.47,-924.72 l 201.39,-190.93 850.38,806.22 v 938.77 l -850.38,806.19"
style="fill:url(#linearGradient42);fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path44" /></g></g><g
id="g46"><g
id="g48"
clip-path="url(#clipPath52)"><path
d="m 2583.97,3539.5 c -61.53,0 -111.36,49.88 -111.36,111.4 0,61.53 49.83,111.41 111.36,111.41 61.53,0 111.4,-49.88 111.4,-111.41 0,-61.52 -49.87,-111.4 -111.4,-111.4 z M 2388.6,4898.68 1122.8,3698.69 V 2301.35 l 261.5,-247.92 c 8.3,570.35 185.1,1398.74 491.61,1927 0,0 79.53,-68.68 220.02,-77.96 144.88,-9.62 482.52,57.49 635.05,-4.68 142.8,-58.18 153.19,-227.93 141.75,-365.87 -21.82,-263.06 106.93,-341.59 335.73,-455.47 114.01,-56.74 140.23,-122.02 103.44,-189.74 -40.57,-74.68 -229.89,-40.03 -440.95,-14.44 -225.54,27.3 -352.03,-124.15 -570.05,-110.33 -307.55,-402.49 -11.09,-960.64 -212.06,-1376.43 L 2388.6,1101.32 3654.37,2301.35 V 3698.69 L 2388.6,4898.68"
style="fill:url(#linearGradient60);fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path62" /></g></g><path
d="m 8000,0 h 4000 V 6000 H 8000 V 0"
style="fill:#e6e6e6;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path64" /><path
d="m 10480,3000 c 0,-265.1 -214.9,-480 -480,-480 -265.1,0 -480,214.9 -480,480 0,265.1 214.9,480 480,480 265.1,0 480,-214.9 480,-480"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path66" /><g
id="g68"><g
id="g70"
clip-path="url(#clipPath74)"><path
d="m 10040.1,3110.82 c -12.6,0 -22.8,10.24 -22.8,22.88 0,12.64 10.2,22.88 22.8,22.88 12.7,0 22.9,-10.24 22.9,-22.88 0,-12.64 -10.2,-22.88 -22.9,-22.88 z M 10000,3390 9740,3143.52 v -287.03 l 53.71,-50.92 c 1.71,117.15 38.03,287.31 100.98,395.82 0,0 16.34,-14.11 45.2,-16.02 29.76,-1.97 99.11,11.81 130.41,-0.96 29.4,-11.95 31.5,-46.82 29.1,-75.15 -4.4,-54.04 22,-70.17 69,-93.56 23.4,-11.65 28.8,-25.06 21.3,-38.97 -8.4,-15.34 -47.3,-8.22 -90.6,-2.97 -46.3,5.61 -72.3,-25.5 -117.11,-22.66 -63.17,-82.67 -2.28,-197.32 -43.56,-282.73 l 61.57,-58.37 260,246.49 v 287.03 L 10000,3390"
style="fill:url(#linearGradient82);fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path84" /></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

+106
View File
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="amethysts.svg"
inkscape:version="1.0beta1 (32d4812, 2019-09-19)"
id="svg1496"
version="1.1"
viewBox="0 0 192.62152 288.93265"
height="288.93265mm"
width="192.62152mm">
<defs
id="defs1490">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath52">
<path
inkscape:connector-curvature="0"
d="m 2583.97,3539.5 c -61.53,0 -111.36,49.88 -111.36,111.4 0,61.53 49.83,111.41 111.36,111.41 61.53,0 111.4,-49.88 111.4,-111.41 0,-61.52 -49.87,-111.4 -111.4,-111.4 z M 2388.6,4898.68 1122.8,3698.69 V 2301.35 l 261.5,-247.92 c 8.3,570.35 185.1,1398.74 491.61,1927 0,0 79.53,-68.68 220.02,-77.96 144.88,-9.62 482.52,57.49 635.05,-4.68 142.8,-58.18 153.19,-227.93 141.75,-365.87 -21.82,-263.06 106.93,-341.59 335.73,-455.47 114.01,-56.74 140.23,-122.02 103.44,-189.74 -40.57,-74.68 -229.89,-40.03 -440.95,-14.44 -225.54,27.3 -352.03,-124.15 -570.05,-110.33 -307.55,-402.49 -11.09,-960.64 -212.06,-1376.43 l 299.76,-284.19 1265.77,1200.03 v 1397.34 z"
id="path50" />
</clipPath>
<linearGradient
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1644.31,2848.02,2848.02,-1644.31,1566.43,1576)"
spreadMethod="pad"
id="linearGradient60">
<stop
style="stop-opacity:1;stop-color:#550066"
offset="0"
id="stop54" />
<stop
style="stop-opacity:1;stop-color:#550066"
offset="0.00046311"
id="stop56" />
<stop
style="stop-opacity:1;stop-color:#a75ccc"
offset="1"
id="stop58" />
</linearGradient>
</defs>
<sodipodi:namedview
inkscape:window-maximized="0"
inkscape:window-y="25"
inkscape:window-x="0"
inkscape:window-height="1209"
inkscape:window-width="1600"
fit-margin-bottom="0"
fit-margin-right="0"
fit-margin-left="0"
fit-margin-top="0"
showgrid="false"
inkscape:document-rotation="0"
inkscape:current-layer="layer1"
inkscape:document-units="mm"
inkscape:cy="544.58587"
inkscape:cx="360.19993"
inkscape:zoom="0.35"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#ffffff"
id="base" />
<metadata
id="metadata1493">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
transform="translate(-10.530434,-4.0783229)"
id="layer1"
inkscape:groupmode="layer"
inkscape:label="Layer 1">
<g
style="stroke-width:0.463646"
transform="matrix(0.07608777,0,0,-0.07608777,-74.900914,376.80796)"
id="g46">
<g
style="stroke-width:0.463646"
id="g48"
clip-path="url(#clipPath52)">
<path
inkscape:connector-curvature="0"
d="m 2583.97,3539.5 c -61.53,0 -111.36,49.88 -111.36,111.4 0,61.53 49.83,111.41 111.36,111.41 61.53,0 111.4,-49.88 111.4,-111.41 0,-61.52 -49.87,-111.4 -111.4,-111.4 z M 2388.6,4898.68 1122.8,3698.69 V 2301.35 l 261.5,-247.92 c 8.3,570.35 185.1,1398.74 491.61,1927 0,0 79.53,-68.68 220.02,-77.96 144.88,-9.62 482.52,57.49 635.05,-4.68 142.8,-58.18 153.19,-227.93 141.75,-365.87 -21.82,-263.06 106.93,-341.59 335.73,-455.47 114.01,-56.74 140.23,-122.02 103.44,-189.74 -40.57,-74.68 -229.89,-40.03 -440.95,-14.44 -225.54,27.3 -352.03,-124.15 -570.05,-110.33 -307.55,-402.49 -11.09,-960.64 -212.06,-1376.43 L 2388.6,1101.32 3654.37,2301.35 V 3698.69 L 2388.6,4898.68"
style="fill:url(#linearGradient60);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.463646"
id="path62" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB