Compare commits

...
14 Commits
86 changed files with 2001 additions and 671 deletions
+1
View File
@@ -7,6 +7,7 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
.DS_Store
/build
/captures
+12 -3
View File
@@ -11,13 +11,22 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Notifications Feed
- [x] Global Feed
- [x] Reactions (like, boost, reply)
- [x] Image Preview
- [x] Image Preview (gifs, svgs)
- [x] Url Preview
- [x] View Threads
- [ ] Private Messages
- [ ] Communities
- [x] Private Messages (NIP-04)
- [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
+8 -3
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 6
versionName "0.6"
versionCode 8
versionName "0.8"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -99,9 +99,14 @@ dependencies {
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.2'
// view gifs
implementation "io.coil-kt:coil-gif:2.2.2"
// view svgs
implementation("io.coil-kt:coil-svg:2.2.2")
// tabs for user profiles
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version" // Pager Indicators
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
@@ -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" />
@@ -1,4 +1,4 @@
package com.vitorpamplona.amethyst.ui.components
package com.vitorpamplona.amethyst.lnurl
import java.math.BigDecimal
import java.util.Locale
@@ -6,7 +6,7 @@ import java.util.regex.Pattern
/** based on litecoinj */
object LnInvoiceUtil {
private val invoicePattern = Pattern.compile("lnbc((?<amount>\\d+)(?<multiplier>[munp])?)?1[^1\\s]+")
private val invoicePattern = Pattern.compile("lnbc((?<amount>\\d+)(?<multiplier>[munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE)
/** The Bech32 character set for encoding. */
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
@@ -13,13 +14,21 @@ import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
class Account(val loggedIn: Persona) {
val DefaultChannels = setOf(
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb" // -> Anigma's Nostr
)
class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet()) {
var seeReplies: Boolean = true
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey)
}
fun followingChannels(): List<Channel> {
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
}
fun isWriteable(): Boolean {
return loggedIn.privKey != null
}
@@ -33,7 +42,7 @@ class Account(val loggedIn: Persona) {
}
note.event?.let {
val event = ReactionEvent.create(it, loggedIn.privKey!!)
val event = ReactionEvent.createLike(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
@@ -81,13 +90,17 @@ class Account(val loggedIn: Persona) {
}
}
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,
@@ -101,7 +114,7 @@ class Account(val loggedIn: Persona) {
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = null,
mentions = null,
mentions = modifiedMentions?.map { it.pubkeyHex } ?: emptyList(),
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
@@ -109,6 +122,18 @@ class Account(val loggedIn: Persona) {
}
}
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null) {
if (!isWriteable()) return
val signedEvent = ChannelMessageEvent.create(
message = message,
channel = toChannel,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
@@ -0,0 +1,58 @@
package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import nostr.postr.events.ContactListEvent
class Channel(val id: ByteArray) {
val idHex = id.toHexKey()
val idDisplayHex = id.toShortenHex()
var info = ChannelCreateEvent.ChannelData(null, null, null)
var updatedMetadataAt: Long = 0;
val notes = ConcurrentHashMap<HexKey, Note>()
@Synchronized
fun getOrCreateNote(idHex: String): Note {
return notes[idHex] ?: run {
val answer = Note(idHex)
notes.put(idHex, answer)
answer
}
}
fun updateChannelInfo(channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
info = channelInfo
updatedMetadataAt = updatedAt
live.refresh()
}
fun profilePicture(): String {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture ?: "https://robohash.org/${idHex}.png"
}
// Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this)
private fun refreshObservers() {
live.refresh()
}
}
class ChannelLiveData(val channel: Channel): LiveData<ChannelState>(ChannelState(channel)) {
fun refresh() {
postValue(ChannelState(channel))
}
}
class ChannelState(val channel: Channel)
@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern
import nostr.postr.Bech32
@@ -22,7 +22,7 @@ fun HexKey.toByteArray(): ByteArray {
}
fun HexKey.toDisplayHexKey(): String {
return this.toDisplayHex()
return this.toShortenHex()
}
fun decodePublicKey(key: String): ByteArray {
@@ -4,6 +4,11 @@ import android.util.Log
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.io.ByteArrayInputStream
@@ -28,6 +33,7 @@ object LocalCache {
val users = ConcurrentHashMap<HexKey, User>()
val notes = ConcurrentHashMap<HexKey, Note>()
val channels = ConcurrentHashMap<HexKey, Channel>()
@Synchronized
fun getOrCreateUser(pubkey: ByteArray): User {
@@ -48,6 +54,16 @@ object LocalCache {
}
}
@Synchronized
fun getOrCreateChannel(key: String): Channel {
return channels[key] ?: run {
val answer = Channel(key.toByteArray())
channels.put(key, answer)
answer
}
}
fun consume(event: MetadataEvent) {
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
@@ -116,7 +132,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 {
@@ -229,6 +245,77 @@ object LocalCache {
}
}
fun consume(event: ChannelCreateEvent) {
// new event
val oldChannel = getOrCreateChannel(event.id.toHex())
if (event.createdAt > oldChannel.updatedMetadataAt) {
oldChannel.updateChannelInfo(event.channelInfo, event.createdAt)
} else {
// older data, does nothing
}
}
fun consume(event: ChannelMetadataEvent) {
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
if (event.channel.isNullOrBlank()) return
// new event
val oldChannel = getOrCreateChannel(event.channel)
if (event.createdAt > oldChannel.updatedMetadataAt) {
oldChannel.updateChannelInfo(event.channelInfo, event.createdAt)
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
}
fun consume(event: ChannelMessageEvent) {
if (event.channel.isNullOrBlank()) return
val channel = getOrCreateChannel(event.channel)
val note = channel.getOrCreateNote(event.id.toHex())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) })
val replyTo = Collections.synchronizedList(event.replyTos.map { getOrCreateNote(it) }.toMutableList())
note.channel = channel
note.loadEvent(event, author, mentions, replyTo)
//Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.taggedPosts.add(note)
}
replyTo.forEach {
it.author?.taggedPosts?.add(note)
}
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
UrlCachedPreviewer.preloadPreviewsFor(note)
refreshObservers()
}
fun consume(event: ChannelHideMessageEvent) {
}
fun consume(event: ChannelMuteUserEvent) {
}
fun findUsersStartingWith(username: String): List<User> {
return users.values.filter { it.info.anyNameStartsWith(username) }
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
@@ -2,7 +2,7 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.time.Instant
import java.time.ZoneId
@@ -18,7 +18,7 @@ class Note(val idHex: String) {
// These fields are always available.
// They are immutable
val id = Hex.decode(idHex)
val idDisplayHex = id.toDisplayHex()
val idDisplayHex = id.toShortenHex()
// These fields are only available after the Text Note event is received.
// They are immutable after that.
@@ -32,6 +32,8 @@ class Note(val idHex: String) {
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
var channel: Channel? = null
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
this.event = event
this.author = author
@@ -2,14 +2,14 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import nostr.postr.events.ContactListEvent
class User(val pubkey: ByteArray) {
val pubkeyHex = pubkey.toHexKey()
val pubkeyDisplayHex = pubkey.toDisplayHex()
val pubkeyDisplayHex = pubkey.toShortenHex()
var info = UserMetadata()
@@ -67,9 +67,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)
}
@@ -89,6 +92,12 @@ class User(val pubkey: ByteArray) {
live.refresh()
}
fun isFollowing(user: User): Boolean {
return synchronized(follows) {
follows.contains(user)
}
}
// Observers line up here.
val live: UserLiveData = UserLiveData(this)
@@ -115,6 +124,11 @@ 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)) {
@@ -6,7 +6,7 @@ object Constants {
val defaultRelays = arrayOf(
Relay("wss://nostr.bitcoiner.social", read = true, write = true),
Relay("wss://relay.nostr.bg", read = true, write = true),
//Relay("wss://brb.io", read = true, write = true),
Relay("wss://brb.io", read = true, write = true),
Relay("wss://nostr.v0l.io", read = true, write = true),
Relay("wss://nostr.rocks", read = true, write = true),
Relay("wss://relay.damus.io", read = true, write = true),
@@ -17,8 +17,9 @@ object Constants {
Relay("wss://nostr-pub.wellorder.net", read = true, write = true),
Relay("wss://nostr.mom", read = true, write = true),
Relay("wss://nostr.orangepill.dev", read = true, write = true),
//Relay("wss://nostr-pub.semisol.dev", read = true, write = true),
Relay("wss://nostr-pub.semisol.dev", read = true, write = true),
Relay("wss://nostr.onsats.org", read = true, write = true),
Relay("wss://nostr.sandwich.farm", read = true, write = true)
Relay("wss://nostr.sandwich.farm", read = true, write = true),
Relay("wss://relay.nostr.ch", read = true, write = true)
)
}
@@ -57,10 +57,15 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
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()
}
@@ -0,0 +1,31 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import nostr.postr.JsonFilter
object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
var channel: com.vitorpamplona.amethyst.model.Channel? = null
fun loadMessagesBetween(channelId: String) {
channel = LocalCache.channels[channelId]
}
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
)
val messagesChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel?.notes?.values?.sortedBy { it.event!!.createdAt } ?: emptyList()
}
override fun updateChannelFilters() {
messagesChannel.filter = createMessagesToChannelFilter()
}
}
@@ -2,6 +2,9 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
@@ -18,21 +21,50 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
authors = listOf(account.userProfile().pubkeyHex)
)
fun createMyChannelsFilter() = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList()
)
fun createMyChannelsInfoFilter() = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to account.followingChannels.toList())
)
fun createMessagesToMyChannelsFilter() = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to account.followingChannels.toList()),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
)
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
val myChannelsChannel = requestNewChannel()
val myChannelsInfoChannel = requestNewChannel()
val myChannelsMessagesChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages
val messagingWith = messages.keys().toList()
return messagingWith.mapNotNull {
messages[it]?.sortedBy { it.event?.createdAt }?.last { it.event != null }
}.sortedBy { it.event?.createdAt }.reversed()
val privateMessages = messagingWith.mapNotNull {
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
}
val publicChannels = account.followingChannels().map {
it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
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()
}
}
@@ -1,6 +1,11 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
@@ -33,6 +38,12 @@ abstract class NostrDataSource<T>(val debugName: String) {
else -> when (event.kind) {
RepostEvent.kind -> LocalCache.consume(RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
}
}
}
@@ -29,17 +29,21 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
}
fun createFollowAccountsFilter(): JsonFilter? {
val follows = listOf(account.userProfile().pubkeyHex.substring(0, 6)).plus(
account.userProfile().follows?.map {
it.pubkey.toHex().substring(0, 6)
} ?: emptyList()
)
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))
if (followSet.isEmpty()) return null
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
authors = follows,
authors = followSet,
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
)
}
@@ -64,10 +68,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()
}
@@ -32,10 +32,12 @@ object NostrNotificationDataSource: NostrDataSource<Note>("GlobalFeed") {
}
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() {
@@ -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 = listOf<String>()
fun createRepliesAndReactionsFilter(): JsonFilter? {
private fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
if (reactionsToWatch.isEmpty()) {
@@ -65,12 +65,12 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
}
fun add(eventId: String) {
eventsToWatch.add(eventId)
eventsToWatch = eventsToWatch.plus(eventId)
resetFilters()
}
fun remove(eventId: String) {
eventsToWatch.remove(eventId)
eventsToWatch = eventsToWatch.minus(eventId)
resetFilters()
}
}
@@ -7,7 +7,7 @@ import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
val usersToWatch = Collections.synchronizedList(mutableListOf<String>())
var usersToWatch = listOf<String>()
fun createUserFilter(): JsonFilter? {
if (usersToWatch.isEmpty()) return null
@@ -31,12 +31,12 @@ object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
}
fun add(userId: String) {
usersToWatch.add(userId)
usersToWatch = usersToWatch.plus(userId)
resetFilters()
}
fun remove(userId: String) {
usersToWatch.remove(userId)
usersToWatch = usersToWatch.minus(userId)
resetFilters()
}
}
@@ -35,7 +35,11 @@ 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() {
@@ -22,7 +22,11 @@ object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFo
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() {
@@ -0,0 +1,44 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
class ChannelCreateEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channelInfo: ChannelData
init {
try {
channelInfo = MetadataEvent.gson.fromJson(content, ChannelData::class.java)
} catch (e: Exception) {
throw Error("can't parse $content", e)
}
}
companion object {
const val kind = 40
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
val content = if (channelInfo != null)
gson.toJson(channelInfo)
else
""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = emptyList<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
}
}
data class ChannelData(var name: String?, var about: String?, var picture: String?)
}
@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class ChannelHideMessageEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val eventsToHide: List<String>
init {
eventsToHide = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 43
fun create(reason: String, messagesToHide: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
val content = reason
val pubKey = Utils.pubkeyCreate(privateKey)
val tags =
messagesToHide?.map {
listOf("e", it)
} ?: emptyList()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
}
}
}
@@ -0,0 +1,47 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class ChannelMessageEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channel: String?
@Transient val replyTos: List<String>
@Transient val mentions: List<String>
init {
channel = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
replyTos = tags.filter { it.firstOrNull() == "e" && (it.size < 3 || (it.size > 3 && it[3] != "root")) }.mapNotNull { it.getOrNull(1) }
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 42
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = mutableListOf(
listOf("e", channel, "", "root")
)
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
}
}
}
@@ -0,0 +1,46 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.ContactMetaData
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toHex
class ChannelMetadataEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channel: String?
@Transient val channelInfo: ChannelCreateEvent.ChannelData
init {
channel = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
try {
channelInfo = MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
} catch (e: Exception) {
throw Error("can't parse $content", e)
}
}
companion object {
const val kind = 41
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannel: ChannelCreateEvent, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
val content = if (newChannelInfo != null)
gson.toJson(newChannelInfo)
else
""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf( listOf("e", originalChannel.id.toHex(), "", "root") )
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
}
}
}
@@ -0,0 +1,39 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toHex
class ChannelMuteUserEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val usersToMute: List<String>
init {
usersToMute = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 44
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent {
val content = reason
val pubKey = Utils.pubkeyCreate(privateKey)
val tags =
usersToMute?.map {
listOf("p", it)
} ?: emptyList()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
}
}
}
@@ -25,8 +25,11 @@ class ReactionEvent (
companion object {
const val kind = 7
fun create(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
val content = "+"
fun createLike(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
return create("+", originalNote, privateKey, createdAt)
}
fun create(content: String, originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf( listOf("e", originalNote.id.toHex()), listOf("p", originalNote.pubKey.toHex()))
val id = generateId(pubKey, createdAt, kind, tags, content)
@@ -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 {
/**
@@ -2,7 +2,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 +15,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 +102,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 {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -8,6 +9,11 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.KeyStorage
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
@@ -29,6 +35,17 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Coil.setImageLoader {
ImageLoader.Builder(this).components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
}.build()
}
setContent {
AmethystTheme {
// A surface container using the 'background' color from the theme
@@ -1,16 +1,24 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.border
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
@@ -29,9 +37,12 @@ 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.text.AnnotatedString
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
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
@@ -39,19 +50,24 @@ import coil.compose.AsyncImage
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.note.UserDisplay
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 +115,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 +156,86 @@ 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 ->
Column(modifier = Modifier.fillMaxWidth().clickable(onClick = {
postViewModel.autocompleteWithUser(item)
})) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp)
) {
AsyncImage(
model = item.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) {
UserDisplay(item)
}
Text(
item.info.about?.take(100) ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
}
}
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 +261,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()
@@ -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,26 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@Composable
fun ClickableNoteTag(
note: Note,
navController: NavController
) {
val innerNoteState by note.live.observeAsState()
ClickableText(
text = AnnotatedString("@${innerNoteState?.note?.id?.toNote()?.toShortenHex()} "),
onClick = { navController.navigate("Note/${innerNoteState?.note?.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
@@ -0,0 +1,19 @@
package com.vitorpamplona.amethyst.ui.components
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.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@Composable
fun ClickableUrl(urlText: String, url: String) {
val uri = LocalUriHandler.current
ClickableText(
text = AnnotatedString("$urlText "),
onClick = { runCatching { uri.openUri(url) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
@@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.User
@Composable
fun ClickableUserTag(
user: User,
navController: NavController
) {
val innerUserState by user.live.observeAsState()
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
@@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
@Composable
fun InvoicePreview(lnInvoice: String) {
@@ -3,29 +3,23 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Patterns
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
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.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp)$")
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm)$")
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*")
@@ -60,7 +54,7 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, note: Note, accou
} else if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ExtendedImageView(word)
ZoomableImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} else {
@@ -100,22 +94,19 @@ fun TagLink(word: String, tags: List<List<String>>, navController: NavController
if (tags[index][0] == "p") {
val user = LocalCache.users[tags[index][1]]
if (user != null) {
val innerUserState by user.live.observeAsState()
Text(
"@${innerUserState?.user?.toBestDisplayName()} "
)
ClickableUserTag(user, navController)
} else {
Text(text = "${tags[index][1].toShortenHex()} ")
}
} else if (tags[index][0] == "e") {
val note = LocalCache.notes[tags[index][1]]
if (note != null) {
val innerNoteState by note.live.observeAsState()
ClickableText(
text = AnnotatedString("@${innerNoteState?.note?.id?.toNote()?.toDisplayHex()} "),
onClick = { navController.navigate("Note/${note.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
ClickableNoteTag(note, navController)
} else {
Text(text = "${tags[index][1].toShortenHex()} ")
}
} else
Text(text = "$word ")
}
}
}
@@ -1,45 +1,21 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
val uri = LocalUriHandler.current
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {
UrlCachedPreviewer.previewInfo(url, object : IUrlPreviewCallback {
@@ -59,50 +35,15 @@ fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
Crossfade(targetState = urlPreviewState) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
Row(
modifier = Modifier.clickable { runCatching { uri.openUri(url) } }
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column {
AsyncImage(
model = state.previewInfo.image,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Text(
text = state.previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top= 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = state.previewInfo.description,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
UrlPreviewCard(url, state.previewInfo)
}
else -> {
if (showUrlIfError) {
ClickableText(
text = AnnotatedString("$urlText "),
onClick = { runCatching { uri.openUri(url) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
ClickableUrl(urlText, url)
}
}
}
}
}
}
@@ -0,0 +1,66 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.baha.url.preview.UrlInfoItem
@Composable
fun UrlPreviewCard(
url: String,
previewInfo: UrlInfoItem
) {
val uri = LocalUriHandler.current
Row(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column {
AsyncImage(
model = previewInfo.image,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Text(
text = previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = previewInfo.description,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
@@ -44,7 +44,6 @@ fun ZoomableAsyncImage(imageUrl: String) {
}
}
) {
AsyncImage(
model = imageUrl,
contentDescription = "Profile Image",
@@ -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)
fun ExtendedImageView(word: String) {
@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 ExtendedImageView(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)) },
)
)
@@ -38,13 +38,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 +68,19 @@ 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)
}
}
) {
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(
@@ -10,6 +10,7 @@ import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.HomeScreen
@@ -45,6 +46,11 @@ sealed class Route(
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
buildScreen = { acc, nav -> { ChatroomScreen(it.arguments?.getString("id"), acc, nav) }}
)
object Channel : Route("Channel/{id}", R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
buildScreen = { acc, nav -> { ChannelScreen(it.arguments?.getString("id"), acc, nav) }}
)
}
val Routes = listOf(
@@ -57,7 +63,8 @@ val Routes = listOf(
//drawer
Route.Profile,
Route.Note,
Route.Room
Route.Room,
Route.Channel
)
@Composable
@@ -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
@@ -16,12 +17,16 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.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.model.User
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) {
@@ -33,6 +38,27 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
if (note?.event == null) {
BlankNote(Modifier)
} else if (note.channel != null) {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
val channelState by note.channel!!.live.observeAsState()
val channel = channelState?.channel
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 {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
@@ -50,50 +76,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 = { UserDisplay(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
)
}
}
@@ -1,10 +1,19 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
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
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
@@ -12,19 +21,28 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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
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
fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
@@ -33,49 +51,89 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
val accountUserState by accountViewModel.userLiveData.observeAsState()
val accountUser = accountUserState?.user
var popupExpanded by remember { mutableStateOf(false) }
if (note?.event == null) {
BlankNote(Modifier)
} else {
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 = 10.dp)
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),
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
if (author != accountUser && note.event is ChannelMessageEvent) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = alignment,
modifier = Modifier.padding(top = 5.dp)
) {
AsyncImage(
model = author?.profilePicture(),
contentDescription = "Profile Image",
modifier = Modifier
.width(25.dp)
.height(25.dp)
.clip(shape = CircleShape)
.clickable(onClick = {
author?.let {
navController.navigate("User/${it.pubkeyHex}")
}
})
)
Text(
" ${author?.toBestDisplayName()}",
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable(onClick = {
author?.let {
navController.navigate("User/${it.pubkeyHex}")
}
})
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
val eventContent = accountViewModel.decrypt(note)
if (eventContent != null)
@@ -94,21 +152,25 @@ 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)
}
}
}
@@ -109,7 +109,7 @@ 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 {
note.let {
navController.navigate("Note/${note.idHex}")
}
})
@@ -133,7 +133,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) {
@@ -2,11 +2,11 @@ package com.vitorpamplona.amethyst.ui.note
import nostr.postr.toHex
fun ByteArray.toDisplayHex(): String {
return toHex().toDisplayHex()
fun ByteArray.toShortenHex(): String {
return toHex().toShortenHex()
}
fun String.toDisplayHex(): String {
fun String.toShortenHex(): String {
return replaceRange(6, length-6, ":")
}
@@ -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 {
@@ -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) }
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.lifecycle.ViewModel
import androidx.security.crypto.EncryptedSharedPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
@@ -27,41 +28,47 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
init {
// pulls account from storage.
loadFromEncryptedStorage()?.let { login(it) }
loadFromEncryptedStorage()?.let {
login(it)
}
}
fun login(key: String) {
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
login(
val account =
if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes())
Account(Persona(privKey = key.bechToBytes()))
} else if (key.startsWith("npub")) {
Persona(pubKey = key.bechToBytes())
Account(Persona(pubKey = key.bechToBytes()))
} else if (pattern.matcher(key).matches()) {
// Evaluate NIP-5
Persona()
Account(Persona())
} else {
Persona(Hex.decode(key))
Account(Persona(Hex.decode(key)))
}
)
saveToEncryptedStorage(account)
login(account)
}
fun login(person: Persona) {
val loggedIn = Account(person)
fun newKey() {
val account = Account(Persona())
saveToEncryptedStorage(account)
login(account)
}
if (person.privKey != null)
_accountContent.update { AccountState.LoggedIn ( loggedIn ) }
fun login(account: Account) {
if (account.loggedIn.privKey != null)
_accountContent.update { AccountState.LoggedIn ( account ) }
else
_accountContent.update { AccountState.LoggedInViewOnly ( Account(person) ) }
_accountContent.update { AccountState.LoggedInViewOnly ( account ) }
saveToEncryptedStorage(person)
NostrAccountDataSource.account = loggedIn
NostrHomeDataSource.account = loggedIn
NostrNotificationDataSource.account = loggedIn
NostrChatroomListDataSource.account = loggedIn
NostrAccountDataSource.account = account
NostrHomeDataSource.account = account
NostrNotificationDataSource.account = account
NostrChatroomListDataSource.account = account
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
@@ -73,10 +80,6 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
NostrChatroomListDataSource.start()
}
fun newKey() {
login(Persona())
}
fun logOff() {
_accountContent.update { AccountState.LoggedOff }
@@ -90,20 +93,22 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
}.apply()
}
fun saveToEncryptedStorage(login: Persona) {
fun saveToEncryptedStorage(account: Account) {
encryptedPreferences.edit().apply {
login.privKey?.let { putString("nostr_privkey", it.toHex()) }
login.pubKey.let { putString("nostr_pubkey", it.toHex()) }
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
account.followingChannels.let { putStringSet("following_channels", account.followingChannels) }
}.apply()
}
fun loadFromEncryptedStorage(): Persona? {
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences.apply {
val privKey = getString("nostr_privkey", null)
val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet()
if (pubKey != null) {
return Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray())
return Account(Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), followingChannels)
} else {
return null
}
@@ -21,7 +21,7 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChatroomFeedView(userId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
var isRefreshing by remember { mutableStateOf(false) }
@@ -0,0 +1,149 @@
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
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.width
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.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.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
fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
if (account != null && channelId != null) {
val newPost = remember { mutableStateOf(TextFieldValue("")) }
NostrChannelDataSource.loadMessagesBetween(channelId)
val channelState by NostrChannelDataSource.channel!!.live.observeAsState()
val channel = channelState?.channel ?: return
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChannelDataSource ) }
Column(Modifier.fillMaxHeight()) {
ChannelHeader(
channel,
accountViewModel = accountViewModel,
navController = navController
)
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true)
) {
ChatroomFeedView(feedViewModel, accountViewModel, navController)
}
//LAST ROW
Row(modifier = Modifier.padding(10.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = newPost.value,
onValueChange = { newPost.value = it },
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier.weight(1f, true).padding(end = 10.dp),
placeholder = {
Text(
text = "reply here.. ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
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() {
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)
)
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),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp
)
}
}
}
}
Divider(
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
}
}
@@ -15,6 +15,7 @@ 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.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -59,7 +60,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true)
) {
ChatroomFeedView(userId, feedViewModel, accountViewModel, navController)
ChatroomFeedView(feedViewModel, accountViewModel, navController)
}
//LAST ROW
@@ -67,27 +68,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 +103,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)
UserDisplay(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
)
}
@@ -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) }
@@ -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