mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
Run linter
This commit is contained in:
@@ -6,17 +6,16 @@ import androidx.security.crypto.MasterKeys
|
||||
|
||||
class EncryptedStorage {
|
||||
|
||||
fun preferences(context: Context): EncryptedSharedPreferences {
|
||||
val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
|
||||
val preferencesName = "secret_keeper"
|
||||
fun preferences(context: Context): EncryptedSharedPreferences {
|
||||
val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
|
||||
val preferencesName = "secret_keeper"
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
preferencesName,
|
||||
secretKey,
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
) as EncryptedSharedPreferences
|
||||
}
|
||||
|
||||
}
|
||||
return EncryptedSharedPreferences.create(
|
||||
preferencesName,
|
||||
secretKey,
|
||||
context,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
) as EncryptedSharedPreferences
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import java.util.Locale
|
||||
import nostr.postr.Persona
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.toHex
|
||||
import java.util.Locale
|
||||
|
||||
class LocalPreferences(context: Context) {
|
||||
private object PrefKeys {
|
||||
@@ -25,7 +25,7 @@ class LocalPreferences(context: Context) {
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_${route}" }
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
|
||||
private val encryptedPreferences = EncryptedStorage().preferences(context)
|
||||
@@ -83,10 +83,10 @@ class LocalPreferences(context: Context) {
|
||||
val languagePreferences = try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
gson.fromJson(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String>
|
||||
} ?: mapOf<String,String>()
|
||||
} ?: mapOf<String, String>()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
mapOf<String,String>()
|
||||
mapOf<String, String>()
|
||||
}
|
||||
|
||||
if (pubKey != null) {
|
||||
@@ -118,5 +118,4 @@ class LocalPreferences(context: Context) {
|
||||
return getLong(PrefKeys.LAST_READ(route), 0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -10,60 +9,61 @@ import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object NotificationCache {
|
||||
val lastReadByRoute = mutableMapOf<String, Long>()
|
||||
val lastReadByRoute = mutableMapOf<String, Long>()
|
||||
|
||||
fun markAsRead(route: String, timestampInSecs: Long, context: Context) {
|
||||
val lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null || timestampInSecs > lastTime) {
|
||||
lastReadByRoute.put(route, timestampInSecs)
|
||||
fun markAsRead(route: String, timestampInSecs: Long, context: Context) {
|
||||
val lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null || timestampInSecs > lastTime) {
|
||||
lastReadByRoute.put(route, timestampInSecs)
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
LocalPreferences(context).saveLastRead(route, timestampInSecs)
|
||||
live.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(route: String, context: Context): Long {
|
||||
var lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null) {
|
||||
lastTime = LocalPreferences(context).loadLastRead(route)
|
||||
lastReadByRoute[route] = lastTime
|
||||
}
|
||||
return lastTime
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: NotificationLiveData = NotificationLiveData(this)
|
||||
}
|
||||
|
||||
class NotificationLiveData(val cache: NotificationCache): LiveData<NotificationState>(NotificationState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
LocalPreferences(context).saveLastRead(route, timestampInSecs)
|
||||
live.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
fun load(route: String, context: Context): Long {
|
||||
var lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null) {
|
||||
lastTime = LocalPreferences(context).loadLastRead(route)
|
||||
lastReadByRoute[route] = lastTime
|
||||
}
|
||||
return lastTime
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: NotificationLiveData = NotificationLiveData(this)
|
||||
}
|
||||
|
||||
class NotificationState(val cache: NotificationCache)
|
||||
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationState(val cache: NotificationCache)
|
||||
|
||||
@@ -7,84 +7,82 @@ import android.graphics.Paint
|
||||
import android.util.LruCache
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import java.util.UUID
|
||||
import name.neuhalfen.projects.android.robohash.buckets.VariableSizeHashing
|
||||
import name.neuhalfen.projects.android.robohash.handle.Handle
|
||||
import name.neuhalfen.projects.android.robohash.handle.HandleFactory
|
||||
import name.neuhalfen.projects.android.robohash.paths.Configuration
|
||||
import name.neuhalfen.projects.android.robohash.repository.ImageRepository
|
||||
import java.util.UUID
|
||||
|
||||
object RoboHashCache {
|
||||
|
||||
lateinit var robots: MyRoboHash
|
||||
lateinit var robots: MyRoboHash
|
||||
|
||||
lateinit var defaultAvatar: ImageBitmap
|
||||
lateinit var defaultAvatar: ImageBitmap
|
||||
|
||||
@Synchronized
|
||||
fun get(context: Context, hash: String): ImageBitmap {
|
||||
if (!this::robots.isInitialized) {
|
||||
robots = MyRoboHash(context)
|
||||
@Synchronized
|
||||
fun get(context: Context, hash: String): ImageBitmap {
|
||||
if (!this::robots.isInitialized) {
|
||||
robots = MyRoboHash(context)
|
||||
|
||||
defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap()
|
||||
defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap()
|
||||
}
|
||||
|
||||
return defaultAvatar
|
||||
}
|
||||
|
||||
return defaultAvatar
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Recreates RoboHash to use a custom configuration
|
||||
*/
|
||||
class MyRoboHash(context: Context) {
|
||||
private val configuration: Configuration = ModifiedSet1Configuration()
|
||||
private val repository: ImageRepository
|
||||
private val hashing = VariableSizeHashing(configuration.bucketSizes)
|
||||
private val configuration: Configuration = ModifiedSet1Configuration()
|
||||
private val repository: ImageRepository
|
||||
private val hashing = VariableSizeHashing(configuration.bucketSizes)
|
||||
|
||||
// Optional
|
||||
private var memoryCache: LruCache<String, Bitmap>? = null
|
||||
// Optional
|
||||
private var memoryCache: LruCache<String, Bitmap>? = null
|
||||
|
||||
init {
|
||||
repository = ImageRepository(context.assets)
|
||||
}
|
||||
|
||||
fun useCache(memoryCache: LruCache<String, Bitmap>?) {
|
||||
this.memoryCache = memoryCache
|
||||
}
|
||||
|
||||
fun calculateHandleFromUUID(uuid: UUID?): Handle {
|
||||
val data = hashing.createBuckets(uuid)
|
||||
return handleFactory.calculateHandle(data)
|
||||
}
|
||||
|
||||
fun imageForHandle(handle: Handle): Bitmap {
|
||||
if (null != memoryCache) {
|
||||
val cached = memoryCache!![handle.toString()]
|
||||
if (null != cached) return cached
|
||||
init {
|
||||
repository = ImageRepository(context.assets)
|
||||
}
|
||||
val bucketValues = handle.bucketValues()
|
||||
val paths = configuration.convertToFacetParts(bucketValues)
|
||||
val sampleSize = 1
|
||||
val buffer = repository.createBuffer(configuration.width(), configuration.height())
|
||||
val target = buffer.copy(Bitmap.Config.ARGB_8888, true)
|
||||
val merged = Canvas(target)
|
||||
val paint = Paint(0)
|
||||
|
||||
// The first image is not added as copy form the buffer
|
||||
for (i in paths.indices) {
|
||||
merged.drawBitmap(repository.getInto(buffer, paths[i], sampleSize), 0f, 0f, paint)
|
||||
fun useCache(memoryCache: LruCache<String, Bitmap>?) {
|
||||
this.memoryCache = memoryCache
|
||||
}
|
||||
repository.returnBuffer(buffer)
|
||||
if (null != memoryCache) {
|
||||
memoryCache!!.put(handle.toString(), target)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val handleFactory = HandleFactory()
|
||||
}
|
||||
fun calculateHandleFromUUID(uuid: UUID?): Handle {
|
||||
val data = hashing.createBuckets(uuid)
|
||||
return handleFactory.calculateHandle(data)
|
||||
}
|
||||
|
||||
fun imageForHandle(handle: Handle): Bitmap {
|
||||
if (null != memoryCache) {
|
||||
val cached = memoryCache!![handle.toString()]
|
||||
if (null != cached) return cached
|
||||
}
|
||||
val bucketValues = handle.bucketValues()
|
||||
val paths = configuration.convertToFacetParts(bucketValues)
|
||||
val sampleSize = 1
|
||||
val buffer = repository.createBuffer(configuration.width(), configuration.height())
|
||||
val target = buffer.copy(Bitmap.Config.ARGB_8888, true)
|
||||
val merged = Canvas(target)
|
||||
val paint = Paint(0)
|
||||
|
||||
// The first image is not added as copy form the buffer
|
||||
for (i in paths.indices) {
|
||||
merged.drawBitmap(repository.getInto(buffer, paths[i], sampleSize), 0f, 0f, paint)
|
||||
}
|
||||
repository.returnBuffer(buffer)
|
||||
if (null != memoryCache) {
|
||||
memoryCache!!.put(handle.toString(), target)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val handleFactory = HandleFactory()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,78 +90,78 @@ class MyRoboHash(context: Context) {
|
||||
* This uses the default location and ends up encoding number in the local language
|
||||
*/
|
||||
class ModifiedSet1Configuration : Configuration {
|
||||
override fun convertToFacetParts(bucketValues: ByteArray): Array<String> {
|
||||
require(bucketValues.size == BUCKET_COUNT)
|
||||
val color = INT_TO_COLOR[bucketValues[BUCKET_COLOR].toInt()]
|
||||
val paths = mutableListOf<String>()
|
||||
override fun convertToFacetParts(bucketValues: ByteArray): Array<String> {
|
||||
require(bucketValues.size == BUCKET_COUNT)
|
||||
val color = INT_TO_COLOR[bucketValues[BUCKET_COLOR].toInt()]
|
||||
val paths = mutableListOf<String>()
|
||||
|
||||
// e.g.
|
||||
// blue face #2
|
||||
// blue nose #7
|
||||
// blue
|
||||
val firstFacetBucket = BUCKET_COLOR + 1
|
||||
for (facet in 0 until FACET_COUNT) {
|
||||
val bucketValue = bucketValues[firstFacetBucket + facet].toInt()
|
||||
paths.add(generatePath(FACET_PATH_TEMPLATES[facet], color, bucketValue))
|
||||
// e.g.
|
||||
// blue face #2
|
||||
// blue nose #7
|
||||
// blue
|
||||
val firstFacetBucket = BUCKET_COLOR + 1
|
||||
for (facet in 0 until FACET_COUNT) {
|
||||
val bucketValue = bucketValues[firstFacetBucket + facet].toInt()
|
||||
paths.add(generatePath(FACET_PATH_TEMPLATES[facet], color, bucketValue))
|
||||
}
|
||||
return paths.toTypedArray()
|
||||
}
|
||||
return paths.toTypedArray()
|
||||
}
|
||||
|
||||
private fun generatePath(facetPathTemplate: String, color: String, bucketValue: Int): String {
|
||||
// TODO: Make more efficient
|
||||
return facetPathTemplate.replace("#ROOT#", ROOT).replace("#COLOR#".toRegex(), color)
|
||||
.replace("#ITEM#".toRegex(), (bucketValue + 1).toString().padStart(2, '0'))
|
||||
}
|
||||
private fun generatePath(facetPathTemplate: String, color: String, bucketValue: Int): String {
|
||||
// TODO: Make more efficient
|
||||
return facetPathTemplate.replace("#ROOT#", ROOT).replace("#COLOR#".toRegex(), color)
|
||||
.replace("#ITEM#".toRegex(), (bucketValue + 1).toString().padStart(2, '0'))
|
||||
}
|
||||
|
||||
override fun getBucketSizes(): ByteArray {
|
||||
return BUCKET_SIZES
|
||||
}
|
||||
override fun getBucketSizes(): ByteArray {
|
||||
return BUCKET_SIZES
|
||||
}
|
||||
|
||||
override fun width(): Int {
|
||||
return 300
|
||||
}
|
||||
override fun width(): Int {
|
||||
return 300
|
||||
}
|
||||
|
||||
override fun height(): Int {
|
||||
return 300
|
||||
}
|
||||
override fun height(): Int {
|
||||
return 300
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ROOT = "sets/set1"
|
||||
private const val BUCKET_COLOR = 0
|
||||
private const val COLOR_COUNT = 10
|
||||
private const val BODY_COUNT = 10
|
||||
private const val FACE_COUNT = 10
|
||||
private const val MOUTH_COUNT = 10
|
||||
private const val EYES_COUNT = 10
|
||||
private const val ACCESSORY_COUNT = 10
|
||||
private const val BUCKET_COUNT = 6
|
||||
private const val FACET_COUNT = 5
|
||||
private val BUCKET_SIZES = byteArrayOf(
|
||||
COLOR_COUNT.toByte(),
|
||||
BODY_COUNT.toByte(),
|
||||
FACE_COUNT.toByte(),
|
||||
MOUTH_COUNT.toByte(),
|
||||
EYES_COUNT.toByte(),
|
||||
ACCESSORY_COUNT.toByte()
|
||||
)
|
||||
private val INT_TO_COLOR = arrayOf(
|
||||
"blue",
|
||||
"brown",
|
||||
"green",
|
||||
"grey",
|
||||
"orange",
|
||||
"pink",
|
||||
"purple",
|
||||
"red",
|
||||
"white",
|
||||
"yellow"
|
||||
)
|
||||
private val FACET_PATH_TEMPLATES = arrayOf(
|
||||
"#ROOT#/#COLOR#/01Body/#COLOR#_body-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/02Face/#COLOR#_face-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Mouth/#COLOR#_mouth-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Eyes/#COLOR#_eyes-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Accessory/#COLOR#_accessory-#ITEM#.png"
|
||||
)
|
||||
}
|
||||
companion object {
|
||||
private const val ROOT = "sets/set1"
|
||||
private const val BUCKET_COLOR = 0
|
||||
private const val COLOR_COUNT = 10
|
||||
private const val BODY_COUNT = 10
|
||||
private const val FACE_COUNT = 10
|
||||
private const val MOUTH_COUNT = 10
|
||||
private const val EYES_COUNT = 10
|
||||
private const val ACCESSORY_COUNT = 10
|
||||
private const val BUCKET_COUNT = 6
|
||||
private const val FACET_COUNT = 5
|
||||
private val BUCKET_SIZES = byteArrayOf(
|
||||
COLOR_COUNT.toByte(),
|
||||
BODY_COUNT.toByte(),
|
||||
FACE_COUNT.toByte(),
|
||||
MOUTH_COUNT.toByte(),
|
||||
EYES_COUNT.toByte(),
|
||||
ACCESSORY_COUNT.toByte()
|
||||
)
|
||||
private val INT_TO_COLOR = arrayOf(
|
||||
"blue",
|
||||
"brown",
|
||||
"green",
|
||||
"grey",
|
||||
"orange",
|
||||
"pink",
|
||||
"purple",
|
||||
"red",
|
||||
"white",
|
||||
"yellow"
|
||||
)
|
||||
private val FACET_PATH_TEMPLATES = arrayOf(
|
||||
"#ROOT#/#COLOR#/01Body/#COLOR#_body-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/02Face/#COLOR#_face-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Mouth/#COLOR#_mouth-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Eyes/#COLOR#_eyes-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Accessory/#COLOR#_accessory-#ITEM#.png"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,64 +16,63 @@ import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
|
||||
object ServiceManager {
|
||||
private var account: Account? = null
|
||||
private var account: Account? = null
|
||||
|
||||
fun start(account: Account) {
|
||||
this.account = account
|
||||
start()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
val myAccount = account
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
// start services
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrAccountDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleChannelDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
} else {
|
||||
// if not logged in yet, start a basic service wit default relays
|
||||
Client.connect(Constants.convertDefaultRelays())
|
||||
fun start(account: Account) {
|
||||
this.account = account
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChannelDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
fun start() {
|
||||
val myAccount = account
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrSingleChannelDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
Client.disconnect()
|
||||
}
|
||||
// start services
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
|
||||
fun cleanUp() {
|
||||
LocalCache.cleanObservers()
|
||||
// Notification Elements
|
||||
NostrAccountDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
//LocalCache.pruneNonFollows(it)
|
||||
// More Info Data Sources
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleChannelDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
} else {
|
||||
// if not logged in yet, start a basic service wit default relays
|
||||
Client.connect(Constants.convertDefaultRelays())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
fun pause() {
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChannelDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrSingleChannelDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
|
||||
Client.disconnect()
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
LocalCache.cleanObservers()
|
||||
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
// LocalCache.pruneNonFollows(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,33 +9,33 @@ import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
|
||||
object VideoCache {
|
||||
|
||||
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
|
||||
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
|
||||
|
||||
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
|
||||
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
|
||||
|
||||
lateinit var exoDatabaseProvider: StandaloneDatabaseProvider
|
||||
lateinit var simpleCache: SimpleCache
|
||||
lateinit var exoDatabaseProvider: StandaloneDatabaseProvider
|
||||
lateinit var simpleCache: SimpleCache
|
||||
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
fun get(context: Context): CacheDataSource.Factory {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
fun get(context: Context): CacheDataSource.Factory {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
simpleCache = SimpleCache(
|
||||
context.cacheDir,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
simpleCache = SimpleCache(
|
||||
context.cacheDir,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.model
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.lifecycle.LiveData
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -11,80 +11,77 @@ import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
|
||||
|
||||
class AntiSpamFilter {
|
||||
val recentMessages = LruCache<Int, String>(1000)
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
val recentMessages = LruCache<Int, String>(1000)
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
|
||||
@Synchronized
|
||||
fun isSpam(event: Event): Boolean {
|
||||
val idHex = event.id
|
||||
@Synchronized
|
||||
fun isSpam(event: Event): Boolean {
|
||||
val idHex = event.id
|
||||
|
||||
// if short message, ok
|
||||
if (event.content.length < 50) return false
|
||||
// if short message, ok
|
||||
if (event.content.length < 50) return false
|
||||
|
||||
// double list strategy:
|
||||
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
|
||||
// double list strategy:
|
||||
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
|
||||
|
||||
// Considers tags so that same replies to different people don't count.
|
||||
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
|
||||
// Considers tags so that same replies to different people don't count.
|
||||
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
|
||||
|
||||
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
|
||||
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
|
||||
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
|
||||
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
|
||||
|
||||
// Log down offenders
|
||||
if (spamMessages.get(hash) == null) {
|
||||
spamMessages.put(hash, Spammer(event.pubKey, setOf(recentMessages[hash], event.id)))
|
||||
liveSpam.invalidateData()
|
||||
} else {
|
||||
val spammer = spamMessages.get(hash)
|
||||
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
|
||||
// Log down offenders
|
||||
if (spamMessages.get(hash) == null) {
|
||||
spamMessages.put(hash, Spammer(event.pubKey, setOf(recentMessages[hash], event.id)))
|
||||
liveSpam.invalidateData()
|
||||
} else {
|
||||
val spammer = spamMessages.get(hash)
|
||||
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
|
||||
|
||||
liveSpam.invalidateData()
|
||||
}
|
||||
liveSpam.invalidateData()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
recentMessages.put(hash, idHex)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
|
||||
}
|
||||
|
||||
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter): LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
recentMessages.put(hash, idHex)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
|
||||
}
|
||||
|
||||
class AntiSpamState(val cache: AntiSpamFilter) {
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
}
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class AntiSpamState(val cache: AntiSpamFilter)
|
||||
|
||||
@@ -11,7 +11,7 @@ class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
|
||||
var updatedMetadataAt: Long = 0;
|
||||
var updatedMetadataAt: Long = 0
|
||||
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
|
||||
@@ -66,8 +66,7 @@ class Channel(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ChannelLiveData(val channel: Channel): LiveData<ChannelState>(ChannelState(channel)) {
|
||||
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
|
||||
fun refresh() {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
|
||||
@@ -14,70 +14,68 @@ typealias NPubKey = String
|
||||
typealias NoteId = String
|
||||
|
||||
fun NPubKey.toDisplayKey(): String {
|
||||
return this.toShortenHex()
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun NoteId.toDisplayId(): String {
|
||||
return this.toShortenHex()
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32)
|
||||
|
||||
fun ByteArray.toHexKey(): HexKey {
|
||||
return toHex()
|
||||
return toHex()
|
||||
}
|
||||
|
||||
fun HexKey.toByteArray(): ByteArray {
|
||||
return Hex.decode(this)
|
||||
return Hex.decode(this)
|
||||
}
|
||||
|
||||
fun HexKey.toDisplayHexKey(): String {
|
||||
return this.toShortenHex()
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
fun decodePublicKey(key: String): ByteArray {
|
||||
return if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey
|
||||
} else if (key.startsWith("npub")) {
|
||||
key.bechToBytes()
|
||||
} else if (key.startsWith("note")) {
|
||||
key.bechToBytes()
|
||||
} else { //if (pattern.matcher(key).matches()) {
|
||||
//} else {
|
||||
Hex.decode(key)
|
||||
}
|
||||
return if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey
|
||||
} else if (key.startsWith("npub")) {
|
||||
key.bechToBytes()
|
||||
} else if (key.startsWith("note")) {
|
||||
key.bechToBytes()
|
||||
} else { // if (pattern.matcher(key).matches()) {
|
||||
// } else {
|
||||
Hex.decode(key)
|
||||
}
|
||||
}
|
||||
|
||||
data class DirtyKeyInfo(val type: String, val keyHex: String, val restOfWord: String)
|
||||
|
||||
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
var key = mightBeAKey
|
||||
if (key.startsWith("nostr:", true)) {
|
||||
key = key.substring("nostr:".length)
|
||||
}
|
||||
|
||||
key = key.removePrefix("@")
|
||||
|
||||
if (key.length < 63)
|
||||
return null
|
||||
|
||||
try {
|
||||
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
|
||||
if (key.startsWith("nsec1", true)) {
|
||||
return DirtyKeyInfo("npub", Persona(privKey = keyB32.bechToBytes()).pubKey.toHexKey(), restOfWord)
|
||||
} else if (key.startsWith("npub1", true)) {
|
||||
return DirtyKeyInfo("npub", keyB32.bechToBytes().toHexKey(), restOfWord)
|
||||
} else if (key.startsWith("note1", true)) {
|
||||
return DirtyKeyInfo("note", keyB32.bechToBytes().toHexKey(), restOfWord)
|
||||
var key = mightBeAKey
|
||||
if (key.startsWith("nostr:", true)) {
|
||||
key = key.substring("nostr:".length)
|
||||
}
|
||||
|
||||
key = key.removePrefix("@")
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
if (key.length < 63) {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
try {
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
|
||||
if (key.startsWith("nsec1", true)) {
|
||||
return DirtyKeyInfo("npub", Persona(privKey = keyB32.bechToBytes()).pubKey.toHexKey(), restOfWord)
|
||||
} else if (key.startsWith("npub1", true)) {
|
||||
return DirtyKeyInfo("npub", keyB32.bechToBytes().toHexKey(), restOfWord)
|
||||
} else if (key.startsWith("note1", true)) {
|
||||
return DirtyKeyInfo("note", keyB32.bechToBytes().toHexKey(), restOfWord)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,13 +6,6 @@ import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -20,11 +13,17 @@ import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
|
||||
|
||||
|
||||
class AddressableNote(val address: ATag): Note(address.toNAddr()) {
|
||||
class AddressableNote(val address: ATag) : Note(address.toNAddr()) {
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
override fun address() = address
|
||||
@@ -62,9 +61,9 @@ open class Note(val idHex: String) {
|
||||
|
||||
fun channel(): Channel? {
|
||||
val channelHex =
|
||||
(event as? ChannelMessageEvent)?.channel() ?:
|
||||
(event as? ChannelMetadataEvent)?.channel() ?:
|
||||
(event as? ChannelCreateEvent)?.let { it.id }
|
||||
(event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.let { it.id }
|
||||
|
||||
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
|
||||
}
|
||||
@@ -137,7 +136,7 @@ open class Note(val idHex: String) {
|
||||
fun removeReport(deleteNote: Note) {
|
||||
val author = deleteNote.author ?: return
|
||||
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) {
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.reports?.invalidateData()
|
||||
@@ -156,7 +155,6 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (note !in boosts) {
|
||||
boosts = boosts + note
|
||||
@@ -240,11 +238,13 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun hasAnyReports(): Boolean {
|
||||
val dayAgo = Date().time / 1000 - 24*60*60
|
||||
val dayAgo = Date().time / 1000 - 24 * 60 * 60
|
||||
return reports.isNotEmpty() ||
|
||||
(author?.reports?.values?.filter {
|
||||
it.firstOrNull { ( it.createdAt() ?: 0 ) > dayAgo } != null
|
||||
}?.isNotEmpty() ?: false)
|
||||
(
|
||||
author?.reports?.values?.filter {
|
||||
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
|
||||
}?.isNotEmpty() ?: false
|
||||
)
|
||||
}
|
||||
|
||||
fun directlyCiteUsersHex(): Set<HexKey> {
|
||||
@@ -257,7 +257,6 @@ open class Note(val idHex: String) {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
@@ -274,18 +273,17 @@ open class Note(val idHex: String) {
|
||||
returningList.add(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun directlyCites(userProfile: User): Boolean {
|
||||
return author == userProfile
|
||||
|| (userProfile in directlyCiteUsers())
|
||||
|| (event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
|
||||
|| (event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
|
||||
return author == userProfile ||
|
||||
(userProfile in directlyCiteUsers()) ||
|
||||
(event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) ||
|
||||
(event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
|
||||
}
|
||||
|
||||
fun isNewThread(): Boolean {
|
||||
@@ -306,7 +304,7 @@ open class Note(val idHex: String) {
|
||||
|
||||
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
|
||||
val currentTime = Date().time / 1000
|
||||
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
|
||||
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5) } != null // 5 minute protection
|
||||
}
|
||||
|
||||
fun boostedBy(loggedIn: User): List<Note> {
|
||||
@@ -329,7 +327,6 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class NoteLiveSet(u: Note) {
|
||||
// Observers line up here.
|
||||
val metadata: NoteLiveData = NoteLiveData(u)
|
||||
@@ -342,17 +339,17 @@ class NoteLiveSet(u: Note) {
|
||||
val zaps: NoteLiveData = NoteLiveData(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return metadata.hasObservers()
|
||||
|| reactions.hasObservers()
|
||||
|| boosts.hasObservers()
|
||||
|| replies.hasObservers()
|
||||
|| reports.hasObservers()
|
||||
|| relays.hasObservers()
|
||||
|| zaps.hasObservers()
|
||||
return metadata.hasObservers() ||
|
||||
reactions.hasObservers() ||
|
||||
boosts.hasObservers() ||
|
||||
replies.hasObservers() ||
|
||||
reports.hasObservers() ||
|
||||
relays.hasObservers() ||
|
||||
zaps.hasObservers()
|
||||
}
|
||||
}
|
||||
|
||||
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
@@ -384,7 +381,6 @@ class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
||||
} else {
|
||||
NostrSingleEventDataSource.add(note)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
|
||||
@@ -11,4 +11,4 @@ data class RelaySetupInfo(
|
||||
val uploadCount: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -6,71 +6,72 @@ import kotlin.time.measureTimedValue
|
||||
|
||||
class ThreadAssembler {
|
||||
|
||||
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
|
||||
testedNotes.add(note)
|
||||
testedNotes.add(note)
|
||||
|
||||
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
|
||||
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
|
||||
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
|
||||
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
|
||||
|
||||
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
|
||||
// recursive
|
||||
val roots = note.replyTo?.map {
|
||||
if (it !in testedNotes)
|
||||
searchRoot(it, testedNotes)
|
||||
else
|
||||
null
|
||||
}?.filterNotNull()
|
||||
// recursive
|
||||
val roots = note.replyTo?.map {
|
||||
if (it !in testedNotes) {
|
||||
searchRoot(it, testedNotes)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}?.filterNotNull()
|
||||
|
||||
if (roots != null && roots.isNotEmpty()) {
|
||||
return roots[0]
|
||||
if (roots != null && roots.isNotEmpty()) {
|
||||
return roots[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun findThreadFor(noteId: String): Set<Note> {
|
||||
val (result, elapsed) = measureTimedValue {
|
||||
val note = if (noteId.startsWith("naddr")) {
|
||||
val aTag = ATag.parse(noteId)
|
||||
if (aTag != null) {
|
||||
LocalCache.getOrCreateAddressableNote(aTag)
|
||||
} else {
|
||||
return emptySet()
|
||||
}
|
||||
} else {
|
||||
LocalCache.getOrCreateNote(noteId)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun findThreadFor(noteId: String): Set<Note> {
|
||||
val (result, elapsed) = measureTimedValue {
|
||||
val note = if (noteId.startsWith("naddr")) {
|
||||
val aTag = ATag.parse(noteId)
|
||||
if (aTag != null)
|
||||
LocalCache.getOrCreateAddressableNote(aTag)
|
||||
else
|
||||
return emptySet()
|
||||
} else {
|
||||
LocalCache.getOrCreateNote(noteId)
|
||||
}
|
||||
if (note.event != null) {
|
||||
val thread = mutableSetOf<Note>()
|
||||
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
|
||||
if (note.event != null) {
|
||||
val thread = mutableSetOf<Note>()
|
||||
loadDown(threadRoot, thread)
|
||||
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
thread.toSet()
|
||||
} else {
|
||||
setOf(note)
|
||||
}
|
||||
}
|
||||
|
||||
loadDown(threadRoot, thread)
|
||||
println("Model Refresh: Thread loaded in $elapsed")
|
||||
|
||||
thread.toSet()
|
||||
} else {
|
||||
setOf(note)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
println("Model Refresh: Thread loaded in ${elapsed}")
|
||||
fun loadDown(note: Note, thread: MutableSet<Note>) {
|
||||
if (note !in thread) {
|
||||
thread.add(note)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun loadDown(note: Note, thread: MutableSet<Note>) {
|
||||
if (note !in thread) {
|
||||
thread.add(note)
|
||||
|
||||
note.replies.forEach {
|
||||
loadDown(it, thread)
|
||||
}
|
||||
note.replies.forEach {
|
||||
loadDown(it, thread)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,60 +13,63 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object UrlCachedPreviewer {
|
||||
var cache = mapOf<String, UrlInfoItem>()
|
||||
private set
|
||||
var failures = mapOf<String, Throwable>()
|
||||
private set
|
||||
var cache = mapOf<String, UrlInfoItem>()
|
||||
private set
|
||||
var failures = mapOf<String, Throwable>()
|
||||
private set
|
||||
|
||||
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
|
||||
cache[url]?.let {
|
||||
callback?.onComplete(it)
|
||||
return
|
||||
}
|
||||
|
||||
failures[url]?.let {
|
||||
callback?.onFailed(it)
|
||||
return
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
BahaUrlPreview(url, object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache = cache + Pair(url, urlInfo)
|
||||
callback?.onComplete(urlInfo)
|
||||
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
|
||||
cache[url]?.let {
|
||||
callback?.onComplete(it)
|
||||
return
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
failures = failures + Pair(url, throwable)
|
||||
callback?.onFailed(throwable)
|
||||
failures[url]?.let {
|
||||
callback?.onFailed(it)
|
||||
return
|
||||
}
|
||||
}).fetchUrlPreview()
|
||||
}
|
||||
}
|
||||
|
||||
fun findUrlsInMessage(message: String): List<String> {
|
||||
return message.split('\n').map { paragraph ->
|
||||
paragraph.split(' ').filter { word: String ->
|
||||
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
BahaUrlPreview(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache = cache + Pair(url, urlInfo)
|
||||
callback?.onComplete(urlInfo)
|
||||
}
|
||||
|
||||
fun preloadPreviewsFor(note: Note) {
|
||||
note.event?.content()?.let {
|
||||
findUrlsInMessage(it).forEach {
|
||||
val removedParamsFromUrl = it.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
// Preload Images? Isn't this too heavy?
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
// Do nothing for now.
|
||||
} else if (isValidURL(removedParamsFromUrl)) {
|
||||
previewInfo(it)
|
||||
} else {
|
||||
previewInfo("https://${it}")
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
failures = failures + Pair(url, throwable)
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
}
|
||||
).fetchUrlPreview()
|
||||
}
|
||||
}
|
||||
|
||||
fun findUrlsInMessage(message: String): List<String> {
|
||||
return message.split('\n').map { paragraph ->
|
||||
paragraph.split(' ').filter { word: String ->
|
||||
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun preloadPreviewsFor(note: Note) {
|
||||
note.event?.content()?.let {
|
||||
findUrlsInMessage(it).forEach {
|
||||
val removedParamsFromUrl = it.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
// Preload Images? Isn't this too heavy?
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
// Do nothing for now.
|
||||
} else if (isValidURL(removedParamsFromUrl)) {
|
||||
previewInfo(it)
|
||||
} else {
|
||||
previewInfo("https://$it")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -18,9 +17,10 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Bech32
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import nostr.postr.toNpub
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
|
||||
|
||||
@@ -29,7 +29,7 @@ class Badges(val definition: Note, val awardees: Set<Note>)
|
||||
class User(val pubkeyHex: String) {
|
||||
var info: UserMetadata? = null
|
||||
|
||||
var updatedFollowsAt: Long = 0;
|
||||
var updatedFollowsAt: Long = 0
|
||||
var latestContactList: ContactListEvent? = null
|
||||
|
||||
var follows = setOf<User>()
|
||||
@@ -177,7 +177,7 @@ class User(val pubkeyHex: String) {
|
||||
fun removeReport(deleteNote: Note) {
|
||||
val author = deleteNote.author ?: return
|
||||
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) {
|
||||
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
|
||||
reports[author]?.let {
|
||||
reports = reports + Pair(author, it.minus(deleteNote))
|
||||
liveSet?.reports?.invalidateData()
|
||||
@@ -268,7 +268,7 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
liveSet?.relayInfo?.invalidateData()
|
||||
}
|
||||
|
||||
|
||||
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
|
||||
val toBeAdded = newFollows - follows
|
||||
val toBeRemoved = follows - newFollows
|
||||
@@ -321,7 +321,7 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
|
||||
return reports[loggedIn]?.firstOrNull() {
|
||||
it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
|
||||
it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
|
||||
} != null
|
||||
}
|
||||
|
||||
@@ -343,8 +343,6 @@ class User(val pubkeyHex: String) {
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
class UserLiveSet(u: User) {
|
||||
@@ -359,18 +357,18 @@ class UserLiveSet(u: User) {
|
||||
val badges: UserLiveData = UserLiveData(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return follows.hasObservers()
|
||||
|| reports.hasObservers()
|
||||
|| messages.hasObservers()
|
||||
|| relays.hasObservers()
|
||||
|| relayInfo.hasObservers()
|
||||
|| metadata.hasObservers()
|
||||
|| zaps.hasObservers()
|
||||
|| badges.hasObservers()
|
||||
return follows.hasObservers() ||
|
||||
reports.hasObservers() ||
|
||||
messages.hasObservers() ||
|
||||
relays.hasObservers() ||
|
||||
relayInfo.hasObservers() ||
|
||||
metadata.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
badges.hasObservers()
|
||||
}
|
||||
}
|
||||
|
||||
data class RelayInfo (
|
||||
data class RelayInfo(
|
||||
val url: String,
|
||||
var lastEvent: Long,
|
||||
var counter: Long
|
||||
@@ -378,7 +376,6 @@ data class RelayInfo (
|
||||
|
||||
data class Chatroom(var roomMessages: Set<Note>)
|
||||
|
||||
|
||||
class UserMetadata {
|
||||
var name: String? = null
|
||||
var username: String? = null
|
||||
@@ -402,16 +399,16 @@ class UserMetadata {
|
||||
var main_relay: String? = null
|
||||
var twitter: String? = null
|
||||
|
||||
var updatedMetadataAt: Long = 0;
|
||||
var updatedMetadataAt: Long = 0
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
|
||||
.any { it.startsWith(prefix, true) }
|
||||
.any { it.startsWith(prefix, true) }
|
||||
}
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
@@ -448,4 +445,3 @@ class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
|
||||
}
|
||||
|
||||
class UserState(val user: User)
|
||||
|
||||
|
||||
@@ -13,81 +13,83 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class Nip05Verifier {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
fun assembleUrl(nip05address: String): String? {
|
||||
val parts = nip05address.trim().split("@")
|
||||
fun assembleUrl(nip05address: String): String? {
|
||||
val parts = nip05address.trim().split("@")
|
||||
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchNip05Json(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchNip05JsonSuspend(lnaddress, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
|
||||
return
|
||||
fun fetchNip05Json(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchNip05JsonSuspend(lnaddress, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful)
|
||||
onSuccess(it.body.string())
|
||||
else
|
||||
onError("Could not resolve ${nip05}. Error: ${it.code}. Check if the server up and if the address ${nip05} is correct")
|
||||
if (url == null) {
|
||||
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
} catch (e: java.lang.Exception) {
|
||||
onError("Could not resolve '$url': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve ${url}. Check if the server up and if the address ${nip05} is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
} catch (e: java.lang.Exception) {
|
||||
onError("Could not resolve '${url}': ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchNip05Json(nip05,
|
||||
onSuccess = {
|
||||
val nip05url = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
fetchNip05Json(
|
||||
nip05,
|
||||
onSuccess = {
|
||||
val nip05url = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
|
||||
val user = nip05.split("@")[0]
|
||||
val user = nip05.split("@")[0]
|
||||
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
|
||||
if (hexKey == null) {
|
||||
onError("Username not found in the NIP05 JSON")
|
||||
} else {
|
||||
onSuccess(hexKey)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hexKey == null) {
|
||||
onError("Username not found in the NIP05 JSON")
|
||||
} else {
|
||||
onSuccess(hexKey)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,122 +7,122 @@ import java.nio.ByteOrder
|
||||
|
||||
class Nip19 {
|
||||
|
||||
enum class Type {
|
||||
USER, NOTE, RELAY, ADDRESS
|
||||
}
|
||||
|
||||
data class Return(val type: Type, val hex: String)
|
||||
|
||||
fun uriToRoute(uri: String?): Return? {
|
||||
try {
|
||||
val key = uri?.removePrefix("nostr:") ?: return null
|
||||
|
||||
val bytes = key.bechToBytes()
|
||||
if (key.startsWith("npub")) {
|
||||
return npub(bytes)
|
||||
} else if (key.startsWith("note")) {
|
||||
return note(bytes)
|
||||
} else if (key.startsWith("nprofile")) {
|
||||
return nprofile(bytes)
|
||||
} else if (key.startsWith("nevent")) {
|
||||
return nevent(bytes)
|
||||
} else if (key.startsWith("nrelay")) {
|
||||
return nrelay(bytes)
|
||||
} else if (key.startsWith("naddr")) {
|
||||
return naddr(bytes)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
println("Issue trying to Decode NIP19 ${uri}: ${e.message}")
|
||||
enum class Type {
|
||||
USER, NOTE, RELAY, ADDRESS
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
data class Return(val type: Type, val hex: String)
|
||||
|
||||
private fun npub(bytes: ByteArray): Return {
|
||||
return Return(Type.USER, bytes.toHexKey())
|
||||
}
|
||||
fun uriToRoute(uri: String?): Return? {
|
||||
try {
|
||||
val key = uri?.removePrefix("nostr:") ?: return null
|
||||
|
||||
private fun note(bytes: ByteArray): Return {
|
||||
return Return(Type.NOTE, bytes.toHexKey());
|
||||
}
|
||||
val bytes = key.bechToBytes()
|
||||
if (key.startsWith("npub")) {
|
||||
return npub(bytes)
|
||||
} else if (key.startsWith("note")) {
|
||||
return note(bytes)
|
||||
} else if (key.startsWith("nprofile")) {
|
||||
return nprofile(bytes)
|
||||
} else if (key.startsWith("nevent")) {
|
||||
return nevent(bytes)
|
||||
} else if (key.startsWith("nrelay")) {
|
||||
return nrelay(bytes)
|
||||
} else if (key.startsWith("naddr")) {
|
||||
return naddr(bytes)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
println("Issue trying to Decode NIP19 $uri: ${e.message}")
|
||||
}
|
||||
|
||||
private fun nprofile(bytes: ByteArray): Return? {
|
||||
val hex = parseTLV(bytes)
|
||||
.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toHexKey() ?: return null
|
||||
return null
|
||||
}
|
||||
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
private fun npub(bytes: ByteArray): Return {
|
||||
return Return(Type.USER, bytes.toHexKey())
|
||||
}
|
||||
|
||||
private fun nevent(bytes: ByteArray): Return? {
|
||||
val hex = parseTLV(bytes)
|
||||
.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toHexKey() ?: return null
|
||||
private fun note(bytes: ByteArray): Return {
|
||||
return Return(Type.NOTE, bytes.toHexKey())
|
||||
}
|
||||
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
private fun nprofile(bytes: ByteArray): Return? {
|
||||
val hex = parseTLV(bytes)
|
||||
.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toHexKey() ?: return null
|
||||
|
||||
private fun nrelay(bytes: ByteArray): Return? {
|
||||
val relayUrl = parseTLV(bytes)
|
||||
.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8) ?: return null
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
|
||||
return Return(Type.RELAY, relayUrl)
|
||||
}
|
||||
private fun nevent(bytes: ByteArray): Return? {
|
||||
val hex = parseTLV(bytes)
|
||||
.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toHexKey() ?: return null
|
||||
|
||||
private fun naddr(bytes: ByteArray): Return? {
|
||||
val tlv = parseTLV(bytes)
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
|
||||
val d = tlv.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8) ?: return null
|
||||
private fun nrelay(bytes: ByteArray): Return? {
|
||||
val relayUrl = parseTLV(bytes)
|
||||
.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8) ?: return null
|
||||
|
||||
val relay = tlv.get(NIP19TLVTypes.RELAY.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
return Return(Type.RELAY, relayUrl)
|
||||
}
|
||||
|
||||
val author = tlv.get(NIP19TLVTypes.AUTHOR.id)
|
||||
?.get(0)
|
||||
?.toHexKey()
|
||||
private fun naddr(bytes: ByteArray): Return? {
|
||||
val tlv = parseTLV(bytes)
|
||||
|
||||
val kind = tlv.get(NIP19TLVTypes.KIND.id)
|
||||
?.get(0)
|
||||
?.let { toInt32(it) }
|
||||
val d = tlv.get(NIP19TLVTypes.SPECIAL.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8) ?: return null
|
||||
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d")
|
||||
}
|
||||
val relay = tlv.get(NIP19TLVTypes.RELAY.id)
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
val author = tlv.get(NIP19TLVTypes.AUTHOR.id)
|
||||
?.get(0)
|
||||
?.toHexKey()
|
||||
|
||||
val kind = tlv.get(NIP19TLVTypes.KIND.id)
|
||||
?.get(0)
|
||||
?.let { toInt32(it) }
|
||||
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d")
|
||||
}
|
||||
}
|
||||
|
||||
// Classes should start with an uppercase letter in kotlin
|
||||
enum class NIP19TLVTypes(val id: Byte) {
|
||||
SPECIAL(0),
|
||||
RELAY(1),
|
||||
AUTHOR(2),
|
||||
KIND(3);
|
||||
SPECIAL(0),
|
||||
RELAY(1),
|
||||
AUTHOR(2),
|
||||
KIND(3);
|
||||
}
|
||||
|
||||
fun toInt32(bytes: ByteArray): Int {
|
||||
require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" }
|
||||
return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int
|
||||
require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" }
|
||||
return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int
|
||||
}
|
||||
|
||||
fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> {
|
||||
val result = mutableMapOf<Byte, MutableList<ByteArray>>()
|
||||
var rest = data
|
||||
while (rest.isNotEmpty()) {
|
||||
val t = rest[0]
|
||||
val l = rest[1]
|
||||
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
|
||||
rest = rest.sliceArray(IntRange(2 + l, rest.size - 1))
|
||||
if (v.size < l) continue
|
||||
val result = mutableMapOf<Byte, MutableList<ByteArray>>()
|
||||
var rest = data
|
||||
while (rest.isNotEmpty()) {
|
||||
val t = rest[0]
|
||||
val l = rest[1]
|
||||
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
|
||||
rest = rest.sliceArray(IntRange(2 + l, rest.size - 1))
|
||||
if (v.size < l) continue
|
||||
|
||||
if (!result.containsKey(t)) {
|
||||
result[t] = mutableListOf()
|
||||
if (!result.containsKey(t)) {
|
||||
result[t] = mutableListOf()
|
||||
}
|
||||
result[t]?.add(v)
|
||||
}
|
||||
result[t]?.add(v)
|
||||
}
|
||||
return result
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -4,84 +4,90 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrAccountDataSource: NostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createAccountContactListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
fun createAccountContactListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountMetadataFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
ChannelMessageEvent.kind,
|
||||
BadgeAwardEvent.kind
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountMetadataFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
val accountChannel = requestNewChannel()
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, ChannelMessageEvent.kind, BadgeAwardEvent.kind
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
val accountChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
// gets everthing about the user logged in
|
||||
accountChannel.typedFilters = listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createNotificationFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
override fun updateChannelFilters() {
|
||||
// gets everthing about the user logged in
|
||||
accountChannel.typedFilters = listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createNotificationFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,34 +4,34 @@ import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrChannelDataSource: NostrDataSource("ChatroomFeed") {
|
||||
var channel: Channel? = null
|
||||
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
var channel: Channel? = null
|
||||
|
||||
fun loadMessagesBetween(channelId: String) {
|
||||
channel = LocalCache.getOrCreateChannel(channelId)
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createMessagesToChannelFilter(): TypedFilter? {
|
||||
if (channel != null) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
fun loadMessagesBetween(channelId: String) {
|
||||
channel = LocalCache.getOrCreateChannel(channelId)
|
||||
resetFilters()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val messagesChannel = requestNewChannel()
|
||||
fun createMessagesToChannelFilter(): TypedFilter? {
|
||||
if (channel != null) {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
val messagesChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,58 +3,58 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrChatroomDataSource: NostrDataSource("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var withUser: User? = null
|
||||
object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var withUser: User? = null
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, userId: String) {
|
||||
account = accountIn
|
||||
withUser = LocalCache.users[userId]
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createMessagesToMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(myPeer.pubkeyHex) ,
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
fun loadMessagesBetween(accountIn: Account, userId: String) {
|
||||
account = accountIn
|
||||
withUser = LocalCache.users[userId]
|
||||
resetFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun createMessagesFromMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
fun createMessagesToMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(myPeer.pubkeyHex),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val inandoutChannel = requestNewChannel()
|
||||
fun createMessagesFromMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val inandoutChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
+71
-71
@@ -4,85 +4,85 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createMessagesToMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
|
||||
)
|
||||
)
|
||||
|
||||
fun createMessagesFromMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
)
|
||||
)
|
||||
|
||||
fun createChannelsCreatedbyMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
)
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList()
|
||||
)
|
||||
)
|
||||
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
fun createMessagesToMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 1
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
TypedFilter(
|
||||
fun createMessagesFromMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
)
|
||||
)
|
||||
|
||||
fun createChannelsCreatedbyMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 100 // Remember to consider spam that is being removed from the UI
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val chatroomListChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val list = listOf(
|
||||
createMessagesToMeFilter(),
|
||||
createMessagesFromMeFilter(),
|
||||
createMyChannelsFilter()
|
||||
)
|
||||
|
||||
chatroomListChannel.typedFilters = listOfNotNull(
|
||||
list,
|
||||
createLastChannelInfoFilter(),
|
||||
createLastMessageOfEachChannelFilter()
|
||||
).flatten().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
fun createMyChannelsFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList()
|
||||
)
|
||||
)
|
||||
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createLastMessageOfEachChannelFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 100 // Remember to consider spam that is being removed from the UI
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val chatroomListChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val list = listOf(
|
||||
createMessagesToMeFilter(),
|
||||
createMessagesFromMeFilter(),
|
||||
createMyChannelsFilter()
|
||||
)
|
||||
|
||||
chatroomListChannel.typedFilters = listOfNotNull(
|
||||
list,
|
||||
createLastChannelInfoFilter(),
|
||||
createLastMessageOfEachChannelFilter()
|
||||
).flatten().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,22 @@ 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.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.DeletionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.Subscription
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -29,196 +33,190 @@ import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.DeletionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
data class Counter(var counter:Int)
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
data class Counter(var counter: Int)
|
||||
|
||||
private var eventCounter = mapOf<String, Counter>()
|
||||
private var eventCounter = mapOf<String, Counter>()
|
||||
|
||||
fun printCounter() {
|
||||
eventCounter.forEach {
|
||||
println("AAA Count ${it.key}: ${it.value.counter}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val clientListener = object : Client.Listener() {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
if (subscriptionId in subscriptions.keys) {
|
||||
if (!event.hasValidSignature()) return
|
||||
|
||||
val key = "${debugName} ${subscriptionId} ${event.kind}"
|
||||
val keyValue = eventCounter.get(key)
|
||||
if (keyValue != null) {
|
||||
keyValue.counter++
|
||||
} else {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
fun printCounter() {
|
||||
eventCounter.forEach {
|
||||
println("AAA Count ${it.key}: ${it.value.counter}")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is BadgeAwardEvent -> LocalCache.consume(event)
|
||||
is BadgeDefinitionEvent -> LocalCache.consume(event)
|
||||
is BadgeProfilesEvent -> LocalCache.consume(event)
|
||||
is ChannelCreateEvent -> LocalCache.consume(event)
|
||||
is ChannelHideMessageEvent -> LocalCache.consume(event)
|
||||
is ChannelMessageEvent -> LocalCache.consume(event, relay)
|
||||
is ChannelMetadataEvent -> LocalCache.consume(event)
|
||||
is ChannelMuteUserEvent -> LocalCache.consume(event)
|
||||
is ContactListEvent -> LocalCache.consume(event)
|
||||
is DeletionEvent -> LocalCache.consume(event)
|
||||
private val clientListener = object : Client.Listener() {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
if (subscriptionId in subscriptions.keys) {
|
||||
if (!event.hasValidSignature()) return
|
||||
|
||||
is LnZapEvent -> {
|
||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
val key = "$debugName $subscriptionId ${event.kind}"
|
||||
val keyValue = eventCounter.get(key)
|
||||
if (keyValue != null) {
|
||||
keyValue.counter++
|
||||
} else {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
}
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is BadgeAwardEvent -> LocalCache.consume(event)
|
||||
is BadgeDefinitionEvent -> LocalCache.consume(event)
|
||||
is BadgeProfilesEvent -> LocalCache.consume(event)
|
||||
is ChannelCreateEvent -> LocalCache.consume(event)
|
||||
is ChannelHideMessageEvent -> LocalCache.consume(event)
|
||||
is ChannelMessageEvent -> LocalCache.consume(event, relay)
|
||||
is ChannelMetadataEvent -> LocalCache.consume(event)
|
||||
is ChannelMuteUserEvent -> LocalCache.consume(event)
|
||||
is ContactListEvent -> LocalCache.consume(event)
|
||||
is DeletionEvent -> LocalCache.consume(event)
|
||||
|
||||
is LnZapEvent -> {
|
||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is LnZapRequestEvent -> LocalCache.consume(event)
|
||||
is LongTextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event, relay)
|
||||
is ReactionEvent -> LocalCache.consume(event)
|
||||
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||
is ReportEvent -> LocalCache.consume(event, relay)
|
||||
is RepostEvent -> {
|
||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is TextNoteEvent -> LocalCache.consume(event, relay)
|
||||
else -> {
|
||||
Log.w("Event Not Supported", event.toJson())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
is LnZapRequestEvent -> LocalCache.consume(event)
|
||||
is LongTextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event, relay)
|
||||
is ReactionEvent -> LocalCache.consume(event)
|
||||
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||
is ReportEvent -> LocalCache.consume(event, relay)
|
||||
is RepostEvent -> {
|
||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
|
||||
// Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
|
||||
// Log.d("RELAY", "Relay ${relay.url} ${when (type) {
|
||||
// Relay.Type.CONNECT -> "connected."
|
||||
// Relay.Type.DISCONNECT -> "disconnected."
|
||||
// Relay.Type.DISCONNECTING -> "disconnecting."
|
||||
// Relay.Type.EOSE -> "sent all events it had stored."
|
||||
// }}")
|
||||
|
||||
if (type == Relay.Type.EOSE && channel != null) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[channel]?.updateEOSE(Date().time / 1000)
|
||||
}
|
||||
is TextNoteEvent -> LocalCache.consume(event, relay)
|
||||
else -> {
|
||||
Log.w("Event Not Supported", event.toJson())
|
||||
}
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
open fun start() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Start")
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
open fun stop() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Stop")
|
||||
subscriptions.values.forEach { channel ->
|
||||
Client.close(channel.id)
|
||||
channel.typedFilters = null
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Subscription {
|
||||
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE)
|
||||
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
|
||||
return newSubscription
|
||||
}
|
||||
|
||||
fun dismissChannel(subscription: Subscription) {
|
||||
Client.close(subscription.id)
|
||||
subscriptions = subscriptions.minus(subscription.id)
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateFilters() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(200)
|
||||
resetFiltersSuspend()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
|
||||
//Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
|
||||
//Log.d("RELAY", "Relay ${relay.url} ${when (type) {
|
||||
// Relay.Type.CONNECT -> "connected."
|
||||
// Relay.Type.DISCONNECT -> "disconnected."
|
||||
// Relay.Type.DISCONNECTING -> "disconnecting."
|
||||
// Relay.Type.EOSE -> "sent all events it had stored."
|
||||
//}}")
|
||||
|
||||
if (type == Relay.Type.EOSE && channel != null) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[channel]?.updateEOSE(Date().time / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
open fun start() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Start")
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
open fun stop() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Stop")
|
||||
subscriptions.values.forEach { channel ->
|
||||
Client.close(channel.id)
|
||||
channel.typedFilters = null
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Subscription {
|
||||
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0,4), onEOSE)
|
||||
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
|
||||
return newSubscription
|
||||
}
|
||||
|
||||
fun dismissChannel(subscription: Subscription) {
|
||||
Client.close(subscription.id)
|
||||
subscriptions = subscriptions.minus(subscription.id)
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateFilters() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(200)
|
||||
resetFiltersSuspend()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
fun resetFilters() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFilters() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
fun resetFiltersSuspend() {
|
||||
// saves the channels that are currently active
|
||||
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
|
||||
// saves the current content to only update if it changes
|
||||
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
|
||||
|
||||
fun resetFiltersSuspend() {
|
||||
// saves the channels that are currently active
|
||||
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
|
||||
// saves the current content to only update if it changes
|
||||
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
|
||||
updateChannelFilters()
|
||||
|
||||
updateChannelFilters()
|
||||
// Makes sure to only send an updated filter when it actually changes.
|
||||
subscriptions.values.forEach { updatedSubscription ->
|
||||
val updatedSubscriotionNewFilters = updatedSubscription.typedFilters
|
||||
|
||||
// Makes sure to only send an updated filter when it actually changes.
|
||||
subscriptions.values.forEach { updatedSubscription ->
|
||||
val updatedSubscriotionNewFilters = updatedSubscription.typedFilters
|
||||
|
||||
if (updatedSubscription.id in currentFilters.keys) {
|
||||
if (updatedSubscriotionNewFilters == null) {
|
||||
// was active and is not active anymore, just close.
|
||||
Client.close(updatedSubscription.id)
|
||||
} else {
|
||||
// was active and is still active, check if it has changed.
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
Client.close(updatedSubscription.id)
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
} else {
|
||||
// hasn't changed, does nothing.
|
||||
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
}
|
||||
if (updatedSubscription.id in currentFilters.keys) {
|
||||
if (updatedSubscriotionNewFilters == null) {
|
||||
// was active and is not active anymore, just close.
|
||||
Client.close(updatedSubscription.id)
|
||||
} else {
|
||||
// was active and is still active, check if it has changed.
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
Client.close(updatedSubscription.id)
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
} else {
|
||||
// hasn't changed, does nothing.
|
||||
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (updatedSubscriotionNewFilters == null) {
|
||||
// was not active and is still not active, does nothing
|
||||
} else {
|
||||
// was not active and becomes active, sends the filter.
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (updatedSubscriotionNewFilters == null) {
|
||||
// was not active and is still not active, does nothing
|
||||
} else {
|
||||
// was not active and becomes active, sends the filter.
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
}
|
||||
abstract fun updateChannelFilters()
|
||||
}
|
||||
|
||||
@@ -2,23 +2,23 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
limit = 200
|
||||
object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val globalFeedChannel = requestNewChannel()
|
||||
val globalFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
override fun updateChannelFilters() {
|
||||
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,61 +3,61 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object NostrHomeDataSource: NostrDataSource("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
private val cacheListener: (UserState) -> Unit = {
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.observeForever(cacheListener)
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.removeObserver(cacheListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.userProfile().follows
|
||||
|
||||
val followKeys = follows.map {
|
||||
it.pubkeyHex.substring(0, 6)
|
||||
private val cacheListener: (UserState) -> Unit = {
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
override fun start() {
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.observeForever(cacheListener)
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
authors = followSet,
|
||||
limit = 400
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.removeObserver(cacheListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel()
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.userProfile().follows
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
val followKeys = follows.map {
|
||||
it.pubkeyHex.substring(0, 6)
|
||||
}
|
||||
|
||||
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
authors = followSet,
|
||||
limit = 400
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
+72
-72
@@ -5,86 +5,86 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import nostr.postr.bechToBytes
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toHex
|
||||
|
||||
object NostrSearchEventOrUserDataSource: NostrDataSource("SingleEventFeed") {
|
||||
private var searchString: String? = null
|
||||
object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var searchString: String? = null
|
||||
|
||||
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
|
||||
val mySearchString = searchString
|
||||
if (mySearchString == null) {
|
||||
return null
|
||||
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
|
||||
val mySearchString = searchString
|
||||
if (mySearchString == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val hexToWatch = try {
|
||||
if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) {
|
||||
decodePublicKey(mySearchString).toHex()
|
||||
} else if (mySearchString.startsWith("note")) {
|
||||
mySearchString.bechToBytes().toHex()
|
||||
} else {
|
||||
mySearchString
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Usually when people add an incomplete npub or note.
|
||||
null
|
||||
}
|
||||
|
||||
if (hexToWatch == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch)
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val hexToWatch = try {
|
||||
if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) {
|
||||
decodePublicKey(mySearchString).toHex()
|
||||
} else if (mySearchString.startsWith("note")) {
|
||||
mySearchString.bechToBytes().toHex()
|
||||
} else {
|
||||
mySearchString
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Usually when people add an incomplete npub or note.
|
||||
null
|
||||
val searchChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
searchChannel.typedFilters = createAnythingWithIDFilter()
|
||||
}
|
||||
|
||||
if (hexToWatch == null) {
|
||||
return null
|
||||
fun search(searchString: String) {
|
||||
this.searchString = searchString
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch)
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20,
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val searchChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
searchChannel.typedFilters = createAnythingWithIDFilter()
|
||||
}
|
||||
|
||||
fun search(searchString: String) {
|
||||
this.searchString = searchString
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
searchString = null
|
||||
}
|
||||
}
|
||||
fun clear() {
|
||||
searchString = null
|
||||
}
|
||||
}
|
||||
|
||||
+48
-48
@@ -4,66 +4,66 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrSingleChannelDataSource: NostrDataSource("SingleChannelFeed") {
|
||||
private var channelsToWatch = setOf<String>()
|
||||
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
private var channelsToWatch = setOf<String>()
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): TypedFilter? {
|
||||
val reactionsToWatch = channelsToWatch.map { it }
|
||||
private fun createRepliesAndReactionsFilter(): TypedFilter? {
|
||||
val reactionsToWatch = channelsToWatch.map { it }
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to reactionsToWatch)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to reactionsToWatch)
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.map { LocalCache.getOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() }
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.map { LocalCache.getOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() }
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
// downloads linked events to this event.
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
)
|
||||
}
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
|
||||
}
|
||||
|
||||
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
|
||||
}
|
||||
fun add(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun remove(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
fun remove(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
+148
-142
@@ -14,166 +14,172 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import java.util.Date
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
private var addressesToWatch = setOf<Note>()
|
||||
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
private var addressesToWatch = setOf<Note>()
|
||||
|
||||
private fun createTagToAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
|
||||
private fun createTagToAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
|
||||
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
return null
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
private fun createAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = addressesToWatch.filter { it.event == null }
|
||||
|
||||
return addressesToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
)
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex.substring(0, 8))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
|
||||
val reactionsToWatch = eventsToWatch
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return reactionsToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
LongTextNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapRequestEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = eventsToWatch
|
||||
.filter { it.event == null }
|
||||
|
||||
val threadingEventsToLoad = eventsToWatch
|
||||
.mapNotNull { it.replyTo }
|
||||
.flatten()
|
||||
.filter { it !is AddressableNote && it.event == null }
|
||||
|
||||
val interestedEvents =
|
||||
(directEventsToLoad + threadingEventsToLoad)
|
||||
.map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = addressesToWatch.filter { it.event == null }
|
||||
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex.substring(0,8))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
|
||||
val reactionsToWatch = eventsToWatch
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
val singleEventChannel = requestNewChannel { time ->
|
||||
eventsToWatch.forEach {
|
||||
it.lastReactionsDownloadTime = time
|
||||
}
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val addresses = createAddressFilter()
|
||||
val addressReactions = createTagToAddressFilter()
|
||||
|
||||
return reactionsToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
|
||||
val directEventsToLoad = eventsToWatch
|
||||
.filter { it.event == null }
|
||||
|
||||
val threadingEventsToLoad = eventsToWatch
|
||||
.mapNotNull { it.replyTo }
|
||||
.flatten()
|
||||
.filter { it !is AddressableNote && it.event == null }
|
||||
|
||||
val interestedEvents =
|
||||
(directEventsToLoad + threadingEventsToLoad)
|
||||
.map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val singleEventChannel = requestNewChannel { time ->
|
||||
eventsToWatch.forEach {
|
||||
it.lastReactionsDownloadTime = time
|
||||
fun add(eventId: Note) {
|
||||
eventsToWatch = eventsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
val addresses = createAddressFilter()
|
||||
val addressReactions = createTagToAddressFilter()
|
||||
fun remove(eventId: Note) {
|
||||
eventsToWatch = eventsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null }
|
||||
}
|
||||
fun addAddress(aTag: Note) {
|
||||
addressesToWatch = addressesToWatch.plus(aTag)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun add(eventId: Note) {
|
||||
eventsToWatch = eventsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun remove(eventId: Note) {
|
||||
eventsToWatch = eventsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun addAddress(aTag: Note) {
|
||||
addressesToWatch = addressesToWatch.plus(aTag)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun removeAddress(aTag: Note) {
|
||||
addressesToWatch = addressesToWatch.minus(aTag)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
fun removeAddress(aTag: Note) {
|
||||
addressesToWatch = addressesToWatch.minus(aTag)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") {
|
||||
var usersToWatch = setOf<User>()
|
||||
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
var usersToWatch = setOf<User>()
|
||||
|
||||
fun createUserFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
fun createUserFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createUserReportFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
fun createUserReportFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return usersToWatch.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
since = it.latestReportTime
|
||||
)
|
||||
)
|
||||
return usersToWatch.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
since = it.latestReportTime
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val userChannel = requestNewChannel(){
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
val userChannel = requestNewChannel() {
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
val userChannelOnce = requestNewChannel()
|
||||
val userChannelOnce = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
|
||||
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
|
||||
}
|
||||
override fun updateChannelFilters() {
|
||||
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
|
||||
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(user: User) {
|
||||
usersToWatch = usersToWatch.plus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
fun add(user: User) {
|
||||
usersToWatch = usersToWatch.plus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun remove(user: User) {
|
||||
usersToWatch = usersToWatch.minus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
fun remove(user: User) {
|
||||
usersToWatch = usersToWatch.minus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,42 +2,42 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
|
||||
private var eventToWatch: String? = null
|
||||
object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
|
||||
private var eventToWatch: String? = null
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val threadToLoad = eventToWatch ?: return null
|
||||
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
|
||||
val threadToLoad = eventToWatch ?: return null
|
||||
|
||||
val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad)
|
||||
.filter { it.event == null }
|
||||
.map { it.idHex }
|
||||
.toSet()
|
||||
.ifEmpty { null } ?: return null
|
||||
val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad)
|
||||
.filter { it.event == null }
|
||||
.map { it.idHex }
|
||||
.toSet()
|
||||
.ifEmpty { null } ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
ids = eventsToLoad.map { it.substring(0, 8) }
|
||||
)
|
||||
)
|
||||
}
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
ids = eventsToLoad.map { it.substring(0, 8) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadEventsChannel = requestNewChannel(){
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
val loadEventsChannel = requestNewChannel() {
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
|
||||
}
|
||||
override fun updateChannelFilters() {
|
||||
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String?) {
|
||||
eventToWatch = noteId
|
||||
fun loadThread(noteId: String?) {
|
||||
eventToWatch = noteId
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
+95
-95
@@ -4,114 +4,114 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String?) {
|
||||
if (userId != null) {
|
||||
user = LocalCache.getOrCreateUser(userId)
|
||||
} else {
|
||||
user = null
|
||||
fun loadUserProfile(userId: String?) {
|
||||
if (userId != null) {
|
||||
user = LocalCache.getOrCreateUser(userId)
|
||||
} else {
|
||||
user = null
|
||||
}
|
||||
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
resetFilters()
|
||||
}
|
||||
fun createUserInfoFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserInfoFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createUserPostsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserPostsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createUserReceivedZapsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserReceivedZapsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createFollowFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createFollowersFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowersFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createAcceptedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAcceptedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
fun createReceivedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeAwardEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
limit = 20
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createReceivedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeAwardEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
limit = 20
|
||||
)
|
||||
)
|
||||
}
|
||||
val userInfoChannel = requestNewChannel()
|
||||
|
||||
val userInfoChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userInfoChannel.typedFilters = listOfNotNull(
|
||||
createUserInfoFilter(),
|
||||
createUserPostsFilter(),
|
||||
createFollowFilter(),
|
||||
createFollowersFilter(),
|
||||
createUserReceivedZapsFilter(),
|
||||
createAcceptedAwardsFilter(),
|
||||
createReceivedAwardsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
override fun updateChannelFilters() {
|
||||
userInfoChannel.typedFilters = listOfNotNull(
|
||||
createUserInfoFilter(),
|
||||
createUserPostsFilter(),
|
||||
createFollowFilter(),
|
||||
createFollowersFilter(),
|
||||
createUserReceivedZapsFilter(),
|
||||
createAcceptedAwardsFilter(),
|
||||
createReceivedAwardsFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
+102
-103
@@ -13,123 +13,122 @@ import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class ResultOrError(
|
||||
var result: String?,
|
||||
var sourceLang: String?,
|
||||
var targetLang: String?,
|
||||
var error: Exception?
|
||||
var result: String?,
|
||||
var sourceLang: String?,
|
||||
var targetLang: String?,
|
||||
var error: Exception?
|
||||
)
|
||||
|
||||
object LanguageTranslatorService {
|
||||
private val languageIdentification = LanguageIdentification.getClient()
|
||||
val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b")
|
||||
private val languageIdentification = LanguageIdentification.getClient()
|
||||
val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b")
|
||||
|
||||
private val translators =
|
||||
object : LruCache<TranslatorOptions, Translator>(10) {
|
||||
override fun create(options: TranslatorOptions): Translator {
|
||||
return Translation.getClient(options)
|
||||
}
|
||||
private val translators =
|
||||
object : LruCache<TranslatorOptions, Translator>(10) {
|
||||
override fun create(options: TranslatorOptions): Translator {
|
||||
return Translation.getClient(options)
|
||||
}
|
||||
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: TranslatorOptions,
|
||||
oldValue: Translator,
|
||||
newValue: Translator?
|
||||
) {
|
||||
oldValue.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun identifyLanguage(text: String): Task<String> {
|
||||
return languageIdentification.identifyLanguage(text)
|
||||
}
|
||||
|
||||
fun translate(text: String, source: String, target: String): Task<ResultOrError> {
|
||||
val sourceLangCode = TranslateLanguage.fromLanguageTag(source)
|
||||
val targetLangCode = TranslateLanguage.fromLanguageTag(target)
|
||||
|
||||
if (sourceLangCode == null || targetLangCode == null) {
|
||||
return Tasks.forCanceled()
|
||||
}
|
||||
|
||||
val options = TranslatorOptions.Builder()
|
||||
.setSourceLanguage(sourceLangCode)
|
||||
.setTargetLanguage(targetLangCode)
|
||||
.build()
|
||||
|
||||
val translator = translators[options]
|
||||
|
||||
return translator.downloadModelIfNeeded().onSuccessTask {
|
||||
val tasks = mutableListOf<Task<String>>()
|
||||
|
||||
val dict = lnDictionary(text) + urlDictionary(text)
|
||||
|
||||
for (paragraph in encodeDictionary(text, dict).split("\n")) {
|
||||
tasks.add(translator.translate(paragraph))
|
||||
}
|
||||
|
||||
Tasks.whenAll(tasks).continueWith {
|
||||
val results: MutableList<String> = ArrayList()
|
||||
for (task in tasks) {
|
||||
var fixedText = task.result.replace("# [","#[") // fixes tags that always return with a space
|
||||
results.add(decodeDictionary(fixedText, dict))
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: TranslatorOptions,
|
||||
oldValue: Translator,
|
||||
newValue: Translator?
|
||||
) {
|
||||
oldValue.close()
|
||||
}
|
||||
}
|
||||
ResultOrError(results.joinToString("\n"), source, target, null)
|
||||
}
|
||||
|
||||
fun identifyLanguage(text: String): Task<String> {
|
||||
return languageIdentification.identifyLanguage(text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeDictionary(text: String, dict: Map<String, String>): String {
|
||||
var newText = text
|
||||
for (pair in dict) {
|
||||
newText = newText.replace(pair.value, pair.key, true)
|
||||
fun translate(text: String, source: String, target: String): Task<ResultOrError> {
|
||||
val sourceLangCode = TranslateLanguage.fromLanguageTag(source)
|
||||
val targetLangCode = TranslateLanguage.fromLanguageTag(target)
|
||||
|
||||
if (sourceLangCode == null || targetLangCode == null) {
|
||||
return Tasks.forCanceled()
|
||||
}
|
||||
|
||||
val options = TranslatorOptions.Builder()
|
||||
.setSourceLanguage(sourceLangCode)
|
||||
.setTargetLanguage(targetLangCode)
|
||||
.build()
|
||||
|
||||
val translator = translators[options]
|
||||
|
||||
return translator.downloadModelIfNeeded().onSuccessTask {
|
||||
val tasks = mutableListOf<Task<String>>()
|
||||
|
||||
val dict = lnDictionary(text) + urlDictionary(text)
|
||||
|
||||
for (paragraph in encodeDictionary(text, dict).split("\n")) {
|
||||
tasks.add(translator.translate(paragraph))
|
||||
}
|
||||
|
||||
Tasks.whenAll(tasks).continueWith {
|
||||
val results: MutableList<String> = ArrayList()
|
||||
for (task in tasks) {
|
||||
var fixedText = task.result.replace("# [", "#[") // fixes tags that always return with a space
|
||||
results.add(decodeDictionary(fixedText, dict))
|
||||
}
|
||||
ResultOrError(results.joinToString("\n"), source, target, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return newText
|
||||
}
|
||||
|
||||
private fun decodeDictionary(text: String, dict: Map<String, String>): String {
|
||||
var newText = text
|
||||
for (pair in dict) {
|
||||
newText = newText.replace(pair.key, pair.value, true)
|
||||
private fun encodeDictionary(text: String, dict: Map<String, String>): String {
|
||||
var newText = text
|
||||
for (pair in dict) {
|
||||
newText = newText.replace(pair.value, pair.key, true)
|
||||
}
|
||||
return newText
|
||||
}
|
||||
return newText
|
||||
}
|
||||
|
||||
private fun lnDictionary(text: String): Map<String, String> {
|
||||
val matcher = lnRegex.matcher(text)
|
||||
val returningList = mutableMapOf<String, String>()
|
||||
val counter = 0
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val lnInvoice = matcher.group()
|
||||
val short = "Amethystlnindexer${counter}"
|
||||
returningList.put(short, lnInvoice)
|
||||
} catch (e: Exception) {
|
||||
|
||||
}
|
||||
private fun decodeDictionary(text: String, dict: Map<String, String>): String {
|
||||
var newText = text
|
||||
for (pair in dict) {
|
||||
newText = newText.replace(pair.key, pair.value, true)
|
||||
}
|
||||
return newText
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
|
||||
private fun urlDictionary(text: String): Map<String, String> {
|
||||
val parser = UrlDetector(text, UrlDetectorOptions.Default)
|
||||
val urlsInText = parser.detect()
|
||||
|
||||
val counter = 0
|
||||
|
||||
return urlsInText.filter { !it.originalUrl.contains(",") || !it.originalUrl.contains("。") }.associate {
|
||||
"Amethysturlindexer${counter}" to it.originalUrl
|
||||
private fun lnDictionary(text: String): Map<String, String> {
|
||||
val matcher = lnRegex.matcher(text)
|
||||
val returningList = mutableMapOf<String, String>()
|
||||
val counter = 0
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val lnInvoice = matcher.group()
|
||||
val short = "Amethystlnindexer$counter"
|
||||
returningList.put(short, lnInvoice)
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
}
|
||||
|
||||
fun autoTranslate(text: String, dontTranslateFrom: Set<String>, translateTo: String): Task<ResultOrError> {
|
||||
return identifyLanguage(text).onSuccessTask {
|
||||
if (it == translateTo) {
|
||||
Tasks.forCanceled()
|
||||
} else if (it != "und" && !dontTranslateFrom.contains(it)) {
|
||||
translate(text, it, translateTo)
|
||||
} else {
|
||||
Tasks.forCanceled()
|
||||
}
|
||||
private fun urlDictionary(text: String): Map<String, String> {
|
||||
val parser = UrlDetector(text, UrlDetectorOptions.Default)
|
||||
val urlsInText = parser.detect()
|
||||
|
||||
val counter = 0
|
||||
|
||||
return urlsInText.filter { !it.originalUrl.contains(",") || !it.originalUrl.contains("。") }.associate {
|
||||
"Amethysturlindexer$counter" to it.originalUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun autoTranslate(text: String, dontTranslateFrom: Set<String>, translateTo: String): Task<ResultOrError> {
|
||||
return identifyLanguage(text).onSuccessTask {
|
||||
if (it == translateTo) {
|
||||
Tasks.forCanceled()
|
||||
} else if (it != "und" && !dontTranslateFrom.contains(it)) {
|
||||
translate(text, it, translateTo)
|
||||
} else {
|
||||
Tasks.forCanceled()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+146
-138
@@ -1,7 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import java.net.URLEncoder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -13,151 +12,160 @@ import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.net.URLEncoder
|
||||
|
||||
class LightningAddressResolver {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
|
||||
}
|
||||
|
||||
if (lnaddress.lowercase().startsWith("lnurl")) {
|
||||
return try {
|
||||
String(Bech32.decodeBytes(lnaddress, false).second)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val url = assembleUrl(lnaddress)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful)
|
||||
onSuccess(it.body.string())
|
||||
else
|
||||
onError("Could not resolve ${lnaddress}. Error: ${it.code}. Check if the server up and if the lightning address ${lnaddress} is correct")
|
||||
}
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve ${url}. Check if the server up and if the lightning address ${lnaddress} is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||
|
||||
if (nostrRequest != null) {
|
||||
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful)
|
||||
onSuccess(response.body.string())
|
||||
else
|
||||
onError("Could not fetch invoice from $lnCallback")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
fetchLightningAddressJson(lnaddress,
|
||||
onSuccess = {
|
||||
onSuccess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32))
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchLightningAddressJson(lnaddress,
|
||||
onSuccess = {
|
||||
val lnurlp = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
|
||||
val callback = lnurlp?.get("callback")?.asText()
|
||||
|
||||
if (callback == null) {
|
||||
onError("Callback URL not found in the User's lightning address server configuration")
|
||||
}
|
||||
|
||||
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
|
||||
|
||||
callback?.let { callback ->
|
||||
fetchLightningInvoice(callback, milliSats, message, if (allowsNostr) nostrRequest else null,
|
||||
onSuccess = {
|
||||
val lnInvoice = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address's invoice fetch. Check the user's lightning setup")
|
||||
if (lnaddress.lowercase().startsWith("lnurl")) {
|
||||
return try {
|
||||
String(Bech32.decodeBytes(lnaddress, false).second)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.let { pr ->
|
||||
onSuccess(pr)
|
||||
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val url = assembleUrl(lnaddress)
|
||||
|
||||
if (url == null) {
|
||||
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
|
||||
|
||||
if (nostrRequest != null) {
|
||||
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(response.body.string())
|
||||
} else {
|
||||
onError("Could not fetch invoice from $lnCallback")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = {
|
||||
onSuccess(Bech32.encodeBytes("lnurl", it.toByteArray(), Bech32.Encoding.Bech32))
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = {
|
||||
val lnurlp = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
|
||||
val callback = lnurlp?.get("callback")?.asText()
|
||||
|
||||
if (callback == null) {
|
||||
onError("Callback URL not found in the User's lightning address server configuration")
|
||||
}
|
||||
|
||||
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
|
||||
|
||||
callback?.let { callback ->
|
||||
fetchLightningInvoice(
|
||||
callback,
|
||||
milliSats,
|
||||
message,
|
||||
if (allowsNostr) nostrRequest else null,
|
||||
onSuccess = {
|
||||
val lnInvoice = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address's invoice fetch. Check the user's lightning setup")
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.let { pr ->
|
||||
onSuccess(pr)
|
||||
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,151 +6,150 @@ import java.util.regex.Pattern
|
||||
|
||||
/** based on litecoinj */
|
||||
object LnInvoiceUtil {
|
||||
private val invoicePattern = Pattern.compile("lnbc((?<amount>\\d+)(?<multiplier>[munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE)
|
||||
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"
|
||||
/** The Bech32 character set for encoding. */
|
||||
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
/** The Bech32 character set for decoding. */
|
||||
private val CHARSET_REV = byteArrayOf(
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1,
|
||||
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
|
||||
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1,
|
||||
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
|
||||
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1
|
||||
)
|
||||
/** The Bech32 character set for decoding. */
|
||||
private val CHARSET_REV = byteArrayOf(
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
|
||||
15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1,
|
||||
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
|
||||
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1,
|
||||
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
|
||||
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1
|
||||
)
|
||||
|
||||
|
||||
/** Find the polynomial with value coefficients mod the generator as 30-bit. */
|
||||
private fun polymod(values: ByteArray): Int {
|
||||
var c = 1
|
||||
for (v_i in values) {
|
||||
val c0 = c ushr 25 and 0xff
|
||||
c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff)
|
||||
if (c0 and 1 != 0) c = c xor 0x3b6a57b2
|
||||
if (c0 and 2 != 0) c = c xor 0x26508e6d
|
||||
if (c0 and 4 != 0) c = c xor 0x1ea119fa
|
||||
if (c0 and 8 != 0) c = c xor 0x3d4233dd
|
||||
if (c0 and 16 != 0) c = c xor 0x2a1462b3
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
/** Expand a HRP for use in checksum computation. */
|
||||
private fun expandHrp(hrp: String): ByteArray {
|
||||
val hrpLength = hrp.length
|
||||
val ret = ByteArray(hrpLength * 2 + 1)
|
||||
for (i in 0 until hrpLength) {
|
||||
val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII
|
||||
ret[i] = (c ushr 5 and 0x07).toByte()
|
||||
ret[i + hrpLength + 1] = (c and 0x1f).toByte()
|
||||
}
|
||||
ret[hrpLength] = 0
|
||||
return ret
|
||||
}
|
||||
|
||||
/** Verify a checksum. */
|
||||
private fun verifyChecksum(hrp: String, values: ByteArray): Boolean {
|
||||
val hrpExpanded: ByteArray = expandHrp(hrp)
|
||||
val combined = ByteArray(hrpExpanded.size + values.size)
|
||||
System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size)
|
||||
System.arraycopy(values, 0, combined, hrpExpanded.size, values.size)
|
||||
return polymod(combined) == 1
|
||||
}
|
||||
|
||||
class AddressFormatException(message: String): Exception(message) {
|
||||
|
||||
}
|
||||
|
||||
fun decodeUnlimitedLength(invoice: String): Boolean {
|
||||
var lower = false
|
||||
var upper = false
|
||||
for (i in 0 until invoice.length) {
|
||||
val c = invoice[i]
|
||||
if (c.code < 33 || c.code > 126) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
if (c in 'a'..'z') {
|
||||
if (upper) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
lower = true
|
||||
}
|
||||
if (c in 'A'..'Z') {
|
||||
if (lower) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
upper = true
|
||||
}
|
||||
}
|
||||
val pos = invoice.lastIndexOf('1')
|
||||
if (pos < 1) throw AddressFormatException("Missing human-readable part")
|
||||
val dataPartLength = invoice.length - 1 - pos
|
||||
if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength")
|
||||
val values = ByteArray(dataPartLength)
|
||||
for (i in 0 until dataPartLength) {
|
||||
val c = invoice[i + pos + 1]
|
||||
if (CHARSET_REV.get(c.code).toInt() == -1) throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1))
|
||||
values[i] = CHARSET_REV.get(c.code)
|
||||
}
|
||||
val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT)
|
||||
if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses invoice amount according to
|
||||
* https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part
|
||||
* @return invoice amount in bitcoins, zero if the invoice has no amount
|
||||
* @throws RuntimeException if invoice format is incorrect
|
||||
*/
|
||||
fun getAmount(invoice: String): BigDecimal {
|
||||
try {
|
||||
decodeUnlimitedLength(invoice) // checksum must match
|
||||
} catch (e: AddressFormatException) {
|
||||
throw IllegalArgumentException("Cannot decode invoice", e)
|
||||
/** Find the polynomial with value coefficients mod the generator as 30-bit. */
|
||||
private fun polymod(values: ByteArray): Int {
|
||||
var c = 1
|
||||
for (v_i in values) {
|
||||
val c0 = c ushr 25 and 0xff
|
||||
c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff)
|
||||
if (c0 and 1 != 0) c = c xor 0x3b6a57b2
|
||||
if (c0 and 2 != 0) c = c xor 0x26508e6d
|
||||
if (c0 and 4 != 0) c = c xor 0x1ea119fa
|
||||
if (c0 and 8 != 0) c = c xor 0x3d4233dd
|
||||
if (c0 and 16 != 0) c = c xor 0x2a1462b3
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
val matcher = invoicePattern.matcher(invoice)
|
||||
require(matcher.matches()) { "Failed to match HRP pattern" }
|
||||
val amountGroup = matcher.group("amount")
|
||||
val multiplierGroup = matcher.group("multiplier")
|
||||
if (amountGroup == null) {
|
||||
return BigDecimal.ZERO
|
||||
/** Expand a HRP for use in checksum computation. */
|
||||
private fun expandHrp(hrp: String): ByteArray {
|
||||
val hrpLength = hrp.length
|
||||
val ret = ByteArray(hrpLength * 2 + 1)
|
||||
for (i in 0 until hrpLength) {
|
||||
val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII
|
||||
ret[i] = (c ushr 5 and 0x07).toByte()
|
||||
ret[i + hrpLength + 1] = (c and 0x1f).toByte()
|
||||
}
|
||||
ret[hrpLength] = 0
|
||||
return ret
|
||||
}
|
||||
val amount = BigDecimal(amountGroup)
|
||||
if (multiplierGroup == null) {
|
||||
return amount
|
||||
}
|
||||
require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { "sub-millisatoshi amount" }
|
||||
return amount.multiply(multiplier(multiplierGroup))
|
||||
}
|
||||
|
||||
fun getAmountInSats(invoice: String): BigDecimal {
|
||||
return getAmount(invoice).multiply(BigDecimal(100000000))
|
||||
}
|
||||
|
||||
private fun multiplier(multiplier: String): BigDecimal {
|
||||
return when (multiplier.lowercase()) {
|
||||
"m" -> BigDecimal("0.001")
|
||||
"u" -> BigDecimal("0.000001")
|
||||
"n" -> BigDecimal("0.000000001")
|
||||
"p" -> BigDecimal("0.000000000001")
|
||||
else -> throw IllegalArgumentException("Invalid multiplier: $multiplier")
|
||||
/** Verify a checksum. */
|
||||
private fun verifyChecksum(hrp: String, values: ByteArray): Boolean {
|
||||
val hrpExpanded: ByteArray = expandHrp(hrp)
|
||||
val combined = ByteArray(hrpExpanded.size + values.size)
|
||||
System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size)
|
||||
System.arraycopy(values, 0, combined, hrpExpanded.size, values.size)
|
||||
return polymod(combined) == 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds LN invoice in the provided input string and returns it.
|
||||
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
|
||||
* It will only return the first invoice found in the input.
|
||||
*
|
||||
* @return the invoice if it was found. null for null input or if no invoice is found
|
||||
*/
|
||||
fun findInvoice(input: String?): String? {
|
||||
if (input == null) {
|
||||
return null
|
||||
class AddressFormatException(message: String) : Exception(message)
|
||||
|
||||
fun decodeUnlimitedLength(invoice: String): Boolean {
|
||||
var lower = false
|
||||
var upper = false
|
||||
for (i in 0 until invoice.length) {
|
||||
val c = invoice[i]
|
||||
if (c.code < 33 || c.code > 126) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
if (c in 'a'..'z') {
|
||||
if (upper) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
lower = true
|
||||
}
|
||||
if (c in 'A'..'Z') {
|
||||
if (lower) throw AddressFormatException("Invalid character: $c, pos: $i")
|
||||
upper = true
|
||||
}
|
||||
}
|
||||
val pos = invoice.lastIndexOf('1')
|
||||
if (pos < 1) throw AddressFormatException("Missing human-readable part")
|
||||
val dataPartLength = invoice.length - 1 - pos
|
||||
if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength")
|
||||
val values = ByteArray(dataPartLength)
|
||||
for (i in 0 until dataPartLength) {
|
||||
val c = invoice[i + pos + 1]
|
||||
if (CHARSET_REV.get(c.code).toInt() == -1) throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1))
|
||||
values[i] = CHARSET_REV.get(c.code)
|
||||
}
|
||||
val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT)
|
||||
if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses invoice amount according to
|
||||
* https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part
|
||||
* @return invoice amount in bitcoins, zero if the invoice has no amount
|
||||
* @throws RuntimeException if invoice format is incorrect
|
||||
*/
|
||||
fun getAmount(invoice: String): BigDecimal {
|
||||
try {
|
||||
decodeUnlimitedLength(invoice) // checksum must match
|
||||
} catch (e: AddressFormatException) {
|
||||
throw IllegalArgumentException("Cannot decode invoice", e)
|
||||
}
|
||||
|
||||
val matcher = invoicePattern.matcher(invoice)
|
||||
require(matcher.matches()) { "Failed to match HRP pattern" }
|
||||
val amountGroup = matcher.group("amount")
|
||||
val multiplierGroup = matcher.group("multiplier")
|
||||
if (amountGroup == null) {
|
||||
return BigDecimal.ZERO
|
||||
}
|
||||
val amount = BigDecimal(amountGroup)
|
||||
if (multiplierGroup == null) {
|
||||
return amount
|
||||
}
|
||||
require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { "sub-millisatoshi amount" }
|
||||
return amount.multiply(multiplier(multiplierGroup))
|
||||
}
|
||||
|
||||
fun getAmountInSats(invoice: String): BigDecimal {
|
||||
return getAmount(invoice).multiply(BigDecimal(100000000))
|
||||
}
|
||||
|
||||
private fun multiplier(multiplier: String): BigDecimal {
|
||||
return when (multiplier.lowercase()) {
|
||||
"m" -> BigDecimal("0.001")
|
||||
"u" -> BigDecimal("0.000001")
|
||||
"n" -> BigDecimal("0.000000001")
|
||||
"p" -> BigDecimal("0.000000000001")
|
||||
else -> throw IllegalArgumentException("Invalid multiplier: $multiplier")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds LN invoice in the provided input string and returns it.
|
||||
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
|
||||
* It will only return the first invoice found in the input.
|
||||
*
|
||||
* @return the invoice if it was found. null for null input or if no invoice is found
|
||||
*/
|
||||
fun findInvoice(input: String?): String? {
|
||||
if (input == null) {
|
||||
return null
|
||||
}
|
||||
val matcher = invoicePattern.matcher(input)
|
||||
return if (matcher.find()) {
|
||||
matcher.group()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val matcher = invoicePattern.matcher(input)
|
||||
return if (matcher.find()) {
|
||||
matcher.group()
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,27 +21,28 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String) {
|
||||
|
||||
val fullArray =
|
||||
byteArrayOf(NIP19TLVTypes.SPECIAL.id, dTag.size.toByte()) + dTag +
|
||||
byteArrayOf(NIP19TLVTypes.AUTHOR.id, addr.size.toByte()) + addr +
|
||||
byteArrayOf(NIP19TLVTypes.KIND.id, kind.size.toByte()) + kind
|
||||
byteArrayOf(NIP19TLVTypes.AUTHOR.id, addr.size.toByte()) + addr +
|
||||
byteArrayOf(NIP19TLVTypes.KIND.id, kind.size.toByte()) + kind
|
||||
|
||||
return Bech32.encodeBytes(hrp = "naddr", fullArray, Bech32.Encoding.Bech32)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(address: String): ATag? {
|
||||
return if (address.startsWith("naddr") || address.startsWith("nostr:naddr"))
|
||||
return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) {
|
||||
parseNAddr(address)
|
||||
else
|
||||
} else {
|
||||
parseAtag(address)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseAtag(atag: String): ATag? {
|
||||
return try {
|
||||
val parts = atag.split(":")
|
||||
Hex.decode(parts[1])
|
||||
ATag(parts[0].toInt(), parts[1], parts[2])
|
||||
Hex.decode(parts[1])
|
||||
ATag(parts[0].toInt(), parts[1], parts[2])
|
||||
} catch (t: Throwable) {
|
||||
Log.w("ATag", "Error parsing A Tag: ${atag}: ${t.message}")
|
||||
Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -57,16 +58,16 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String) {
|
||||
val author = tlv.get(NIP19TLVTypes.AUTHOR.id)?.get(0)?.toHexKey()
|
||||
val kind = tlv.get(NIP19TLVTypes.KIND.id)?.get(0)?.let { toInt32(it) }
|
||||
|
||||
if (kind != null && author != null)
|
||||
if (kind != null && author != null) {
|
||||
return ATag(kind, author, d)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Throwable) {
|
||||
Log.w( "ATag", "Issue trying to Decode NIP19 ${this}: ${e.message}")
|
||||
//e.printStackTrace()
|
||||
Log.w("ATag", "Issue trying to Decode NIP19 $this: ${e.message}")
|
||||
// e.printStackTrace()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
class BadgeAwardEvent(
|
||||
id: HexKey,
|
||||
@@ -12,7 +9,7 @@ class BadgeAwardEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
class BadgeDefinitionEvent(
|
||||
id: HexKey,
|
||||
@@ -12,7 +9,7 @@ class BadgeDefinitionEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag())
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ class BadgeProfilesEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
|
||||
@@ -3,45 +3,46 @@ package com.vitorpamplona.amethyst.service.model
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class ChannelCreateEvent (
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun channelInfo() = try {
|
||||
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
|
||||
ChannelData(null, null, null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 40
|
||||
|
||||
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
|
||||
val content = try {
|
||||
if (channelInfo != null)
|
||||
gson.toJson(channelInfo)
|
||||
else
|
||||
""
|
||||
} catch (t: Throwable) {
|
||||
Log.e("ChannelCreateEvent", "Couldn't parse channel information", t)
|
||||
""
|
||||
}
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = emptyList<List<String>>()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelCreateEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
class ChannelCreateEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun channelInfo() = try {
|
||||
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
|
||||
ChannelData(null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
data class ChannelData(var name: String?, var about: String?, var picture: String?)
|
||||
}
|
||||
companion object {
|
||||
const val kind = 40
|
||||
|
||||
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
|
||||
val content = try {
|
||||
if (channelInfo != null) {
|
||||
gson.toJson(channelInfo)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.e("ChannelCreateEvent", "Couldn't parse channel information", t)
|
||||
""
|
||||
}
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = emptyList<List<String>>()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelCreateEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
data class ChannelData(var name: String?, var about: String?, var picture: String?)
|
||||
}
|
||||
|
||||
+24
-24
@@ -2,33 +2,33 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class ChannelHideMessageEvent (
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
class ChannelHideMessageEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
companion object {
|
||||
const val kind = 43
|
||||
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).toHexKey()
|
||||
val tags =
|
||||
messagesToHide?.map {
|
||||
listOf("e", it)
|
||||
} ?: emptyList()
|
||||
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).toHexKey()
|
||||
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.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelHideMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,41 +2,41 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class ChannelMessageEvent (
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
class ChannelMessageEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
|
||||
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
|
||||
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
companion object {
|
||||
const val kind = 42
|
||||
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).toHexKey()
|
||||
val tags = mutableListOf(
|
||||
listOf("e", channel, "", "root")
|
||||
)
|
||||
replyTos?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
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).toHexKey()
|
||||
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.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-35
@@ -3,41 +3,42 @@ package com.vitorpamplona.amethyst.service.model
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class ChannelMetadataEvent (
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun channelInfo() =
|
||||
try {
|
||||
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
|
||||
ChannelCreateEvent.ChannelData(null, null, null)
|
||||
class ChannelMetadataEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun channelInfo() =
|
||||
try {
|
||||
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
|
||||
ChannelCreateEvent.ChannelData(null, null, null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 41
|
||||
|
||||
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
|
||||
val content =
|
||||
if (newChannelInfo != null) {
|
||||
gson.toJson(newChannelInfo)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(listOf("e", originalChannelIdHex, "", "root"))
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelMetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 41
|
||||
|
||||
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
|
||||
val content =
|
||||
if (newChannelInfo != null)
|
||||
gson.toJson(newChannelInfo)
|
||||
else
|
||||
""
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf( listOf("e", originalChannelIdHex, "", "root") )
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelMetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-24
@@ -2,34 +2,34 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class ChannelMuteUserEvent (
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
class ChannelMuteUserEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
companion object {
|
||||
const val kind = 44
|
||||
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).toHexKey()
|
||||
val tags =
|
||||
usersToMute?.map {
|
||||
listOf("p", it)
|
||||
} ?: emptyList()
|
||||
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent {
|
||||
val content = reason
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
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.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelMuteUserEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import android.util.Log
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
data class Contact(val pubKeyHex: String, val relayUri: String?)
|
||||
|
||||
@@ -16,7 +16,7 @@ class ContactListEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun follows() = try {
|
||||
tags.filter { it[0] == "p" }.map { Contact(it[1], it.getOrNull(2)) }
|
||||
} catch (e: Exception) {
|
||||
@@ -25,10 +25,11 @@ class ContactListEvent(
|
||||
}
|
||||
|
||||
fun relayUse() = try {
|
||||
if (content.isNotEmpty())
|
||||
gson.fromJson(content, object: TypeToken<Map<String, ReadWrite>>() {}.type)
|
||||
else
|
||||
if (content.isNotEmpty()) {
|
||||
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ContactListEvent", "can't parse content as relay lists: $tags", e)
|
||||
null
|
||||
@@ -38,16 +39,18 @@ class ContactListEvent(
|
||||
const val kind = 3
|
||||
|
||||
fun create(follows: List<Contact>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
|
||||
val content = if (relayUse != null)
|
||||
val content = if (relayUse != null) {
|
||||
gson.toJson(relayUse)
|
||||
else
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = follows.map {
|
||||
if (it.relayUri != null)
|
||||
if (it.relayUri != null) {
|
||||
listOf("p", it.pubKeyHex, it.relayUri)
|
||||
else
|
||||
} else {
|
||||
listOf("p", it.pubKeyHex)
|
||||
}
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
@@ -56,4 +59,4 @@ class ContactListEvent(
|
||||
}
|
||||
|
||||
data class ReadWrite(val read: Boolean, val write: Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class DeletionEvent(
|
||||
id: HexKey,
|
||||
@@ -12,7 +12,7 @@ class DeletionEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun deleteEvents() = tags.map { it[1] }
|
||||
|
||||
companion object {
|
||||
@@ -27,4 +27,4 @@ class DeletionEvent(
|
||||
return DeletionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ open class Event(
|
||||
val tags: List<List<String>>,
|
||||
val content: String,
|
||||
val sig: HexKey
|
||||
): EventInterface {
|
||||
) : EventInterface {
|
||||
override fun id(): HexKey = id
|
||||
|
||||
override fun pubKey(): HexKey = pubKey
|
||||
override fun pubKey(): HexKey = pubKey
|
||||
|
||||
override fun createdAt(): Long = createdAt
|
||||
|
||||
@@ -45,8 +45,9 @@ open class Event(
|
||||
throw Exception(
|
||||
"""|Unexpected ID.
|
||||
| Event: ${toJson()}
|
||||
| Actual ID: ${id}
|
||||
| Generated: ${generateId()}""".trimIndent()
|
||||
| Actual ID: $id
|
||||
| Generated: ${generateId()}
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
if (!secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
|
||||
@@ -108,15 +109,20 @@ open class Event(
|
||||
addProperty("pubkey", src.pubKey)
|
||||
addProperty("created_at", src.createdAt)
|
||||
addProperty("kind", src.kind)
|
||||
add("tags", JsonArray().also { jsonTags ->
|
||||
src.tags.forEach { tag ->
|
||||
jsonTags.add(JsonArray().also { jsonTagElement ->
|
||||
tag.forEach { tagElement ->
|
||||
jsonTagElement.add(tagElement)
|
||||
}
|
||||
})
|
||||
add(
|
||||
"tags",
|
||||
JsonArray().also { jsonTags ->
|
||||
src.tags.forEach { tag ->
|
||||
jsonTags.add(
|
||||
JsonArray().also { jsonTagElement ->
|
||||
tag.forEach { tagElement ->
|
||||
jsonTagElement.add(tagElement)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
addProperty("content", src.content)
|
||||
addProperty("sig", src.sig)
|
||||
}
|
||||
|
||||
@@ -3,23 +3,23 @@ package com.vitorpamplona.amethyst.service.model
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
interface EventInterface {
|
||||
fun id(): HexKey
|
||||
fun id(): HexKey
|
||||
|
||||
fun pubKey(): HexKey
|
||||
fun pubKey(): HexKey
|
||||
|
||||
fun createdAt(): Long
|
||||
fun createdAt(): Long
|
||||
|
||||
fun kind(): Int
|
||||
fun kind(): Int
|
||||
|
||||
fun tags(): List<List<String>>
|
||||
fun tags(): List<List<String>>
|
||||
|
||||
fun content(): String
|
||||
fun content(): String
|
||||
|
||||
fun sig(): HexKey
|
||||
fun sig(): HexKey
|
||||
|
||||
fun toJson(): String
|
||||
fun toJson(): String
|
||||
|
||||
fun checkSignature()
|
||||
fun checkSignature()
|
||||
|
||||
fun hasValidSignature(): Boolean
|
||||
fun hasValidSignature(): Boolean
|
||||
}
|
||||
|
||||
@@ -6,53 +6,53 @@ import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import java.math.BigDecimal
|
||||
|
||||
class LnZapEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
override fun zappedPost() = tags
|
||||
.filter { it.firstOrNull() == "e" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
override fun zappedPost() = tags
|
||||
.filter { it.firstOrNull() == "e" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun zappedAuthor() = tags
|
||||
.filter { it.firstOrNull() == "p" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
override fun zappedAuthor() = tags
|
||||
.filter { it.firstOrNull() == "p" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun taggedAddresses(): List<ATag> = tags
|
||||
.filter { it.firstOrNull() == "a" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.mapNotNull { ATag.parse(it) }
|
||||
override fun taggedAddresses(): List<ATag> = tags
|
||||
.filter { it.firstOrNull() == "a" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.mapNotNull { ATag.parse(it) }
|
||||
|
||||
override fun amount(): BigDecimal? {
|
||||
return lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
|
||||
}
|
||||
|
||||
// Keeps this as a field because it's a heavier function used everywhere.
|
||||
val amount = lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
|
||||
|
||||
override fun containedPost(): Event? = try {
|
||||
description()?.let {
|
||||
fromJson(it, Client.lenient)
|
||||
override fun amount(): BigDecimal? {
|
||||
return lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun lnInvoice(): String? = tags
|
||||
.filter { it.firstOrNull() == "bolt11" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.firstOrNull()
|
||||
// Keeps this as a field because it's a heavier function used everywhere.
|
||||
val amount = lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
|
||||
|
||||
private fun description(): String? = tags
|
||||
.filter { it.firstOrNull() == "description" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.firstOrNull()
|
||||
override fun containedPost(): Event? = try {
|
||||
description()?.let {
|
||||
fromJson(it, Client.lenient)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 9735
|
||||
}
|
||||
private fun lnInvoice(): String? = tags
|
||||
.filter { it.firstOrNull() == "bolt11" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.firstOrNull()
|
||||
|
||||
private fun description(): String? = tags
|
||||
.filter { it.firstOrNull() == "description" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.firstOrNull()
|
||||
|
||||
companion object {
|
||||
const val kind = 9735
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
interface LnZapEventInterface: EventInterface {
|
||||
interface LnZapEventInterface : EventInterface {
|
||||
|
||||
fun zappedPost(): List<String>
|
||||
fun zappedPost(): List<String>
|
||||
|
||||
fun zappedAuthor(): List<String>
|
||||
fun zappedAuthor(): List<String>
|
||||
|
||||
fun taggedAddresses(): List<ATag>
|
||||
fun taggedAddresses(): List<ATag>
|
||||
|
||||
fun amount(): BigDecimal?
|
||||
fun amount(): BigDecimal?
|
||||
|
||||
fun containedPost(): Event?
|
||||
fun containedPost(): Event?
|
||||
}
|
||||
|
||||
@@ -2,54 +2,53 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
import java.util.Date
|
||||
|
||||
class LnZapRequestEvent (
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
class LnZapRequestEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
|
||||
fun create(originalNote: EventInterface, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("e", originalNote.id()),
|
||||
listOf("p", originalNote.pubKey()),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
tags = tags + listOf( listOf("a", originalNote.address().toTag()) )
|
||||
}
|
||||
fun create(originalNote: EventInterface, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("e", originalNote.id()),
|
||||
listOf("p", originalNote.pubKey()),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun create(userHex: String, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
listOf("p", userHex),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
fun create(userHex: String, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
listOf("p", userHex),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class LongTextNoteEvent(
|
||||
id: HexKey,
|
||||
@@ -12,7 +12,7 @@ class LongTextNoteEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
|
||||
@@ -4,14 +4,15 @@ import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
data class ContactMetaData(
|
||||
val name: String,
|
||||
val picture: String,
|
||||
val about: String,
|
||||
val nip05: String?)
|
||||
val nip05: String?
|
||||
)
|
||||
|
||||
class MetadataEvent(
|
||||
id: HexKey,
|
||||
@@ -20,7 +21,7 @@ class MetadataEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun contactMetaData() = try {
|
||||
gson.fromJson(content, ContactMetaData::class.java)
|
||||
} catch (e: Exception) {
|
||||
@@ -45,4 +46,4 @@ class MetadataEvent(
|
||||
return MetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
import java.util.Date
|
||||
|
||||
class PrivateDmEvent(
|
||||
id: HexKey,
|
||||
@@ -48,7 +48,6 @@ class PrivateDmEvent(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
const val kind = 4
|
||||
|
||||
@@ -57,7 +56,8 @@ class PrivateDmEvent(
|
||||
fun create(
|
||||
recipientPubKey: ByteArray,
|
||||
msg: String,
|
||||
replyTos: List<String>? = null, mentions: List<String>? = null,
|
||||
replyTos: List<String>? = null,
|
||||
mentions: List<String>? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
publishedRecipientPubKey: ByteArray? = null,
|
||||
@@ -66,7 +66,8 @@ class PrivateDmEvent(
|
||||
val content = Utils.encrypt(
|
||||
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
|
||||
privateKey,
|
||||
recipientPubKey)
|
||||
recipientPubKey
|
||||
)
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
publishedRecipientPubKey?.let {
|
||||
@@ -83,4 +84,4 @@ class PrivateDmEvent(
|
||||
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,45 +2,44 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
import java.util.Date
|
||||
|
||||
class ReactionEvent (
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
class ReactionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
companion object {
|
||||
const val kind = 7
|
||||
companion object {
|
||||
const val kind = 7
|
||||
|
||||
fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
|
||||
fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
return create("+", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
|
||||
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
return create("+", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
|
||||
var tags = listOf( listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
tags = tags + listOf( listOf("a", originalNote.address().toTag()) )
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.net.URI
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
|
||||
class RecommendRelayEvent(
|
||||
id: HexKey,
|
||||
@@ -14,13 +14,13 @@ class RecommendRelayEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
val lenient: Boolean = false
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun relay() = if (lenient)
|
||||
fun relay() = if (lenient) {
|
||||
URI.create(content.trim())
|
||||
else
|
||||
} else {
|
||||
URI.create(content)
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 2
|
||||
@@ -34,4 +34,4 @@ class RecommendRelayEvent(
|
||||
return RecommendRelayEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,94 +2,93 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
import java.util.Date
|
||||
|
||||
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
|
||||
|
||||
// NIP 56 event.
|
||||
class ReportEvent (
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
class ReportEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
private fun defaultReportType(): ReportType {
|
||||
// Works with old and new structures for report.
|
||||
var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
|
||||
if (reportType == null) {
|
||||
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
|
||||
}
|
||||
if (reportType == null) {
|
||||
reportType = ReportType.SPAM
|
||||
}
|
||||
return reportType
|
||||
}
|
||||
|
||||
fun reportedPost() = tags
|
||||
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: defaultReportType()
|
||||
)
|
||||
private fun defaultReportType(): ReportType {
|
||||
// Works with old and new structures for report.
|
||||
var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
|
||||
if (reportType == null) {
|
||||
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
|
||||
}
|
||||
if (reportType == null) {
|
||||
reportType = ReportType.SPAM
|
||||
}
|
||||
return reportType
|
||||
}
|
||||
|
||||
fun reportedAuthor() = tags
|
||||
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: defaultReportType()
|
||||
)
|
||||
fun reportedPost() = tags
|
||||
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
|
||||
)
|
||||
}
|
||||
|
||||
fun reportedAuthor() = tags
|
||||
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
|
||||
)
|
||||
}
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
companion object {
|
||||
const val kind = 1984
|
||||
|
||||
fun create(reportedPost: EventInterface, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
|
||||
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags: List<List<String>> = listOf(reportPostTag, reportAuthorTag)
|
||||
|
||||
if (reportedPost is LongTextNoteEvent) {
|
||||
tags = tags + listOf(listOf("a", reportedPost.address().toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
val reportAuthorTag = listOf("p", reportedUser, type.name.lowercase())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags: List<List<String>> = listOf(reportAuthorTag)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
companion object {
|
||||
const val kind = 1984
|
||||
|
||||
fun create(reportedPost: EventInterface, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
|
||||
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags:List<List<String>> = listOf(reportPostTag, reportAuthorTag)
|
||||
|
||||
if (reportedPost is LongTextNoteEvent) {
|
||||
tags = tags + listOf( listOf("a", reportedPost.address().toTag()) )
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
enum class ReportType() {
|
||||
EXPLICIT, // Not used anymore.
|
||||
ILLEGAL,
|
||||
SPAM,
|
||||
IMPERSONATION,
|
||||
NUDITY,
|
||||
PROFANITY
|
||||
}
|
||||
|
||||
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
val reportAuthorTag = listOf("p", reportedUser, type.name.lowercase())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags:List<List<String>> = listOf(reportAuthorTag)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
enum class ReportType() {
|
||||
EXPLICIT, // Not used anymore.
|
||||
ILLEGAL,
|
||||
SPAM,
|
||||
IMPERSONATION,
|
||||
NUDITY,
|
||||
PROFANITY,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,48 +3,47 @@ package com.vitorpamplona.amethyst.service.model
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class RepostEvent (
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
class RepostEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
|
||||
fun containedPost() = try {
|
||||
fromJson(content, Client.lenient)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 6
|
||||
|
||||
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
|
||||
val content = boostedPost.toJson()
|
||||
|
||||
val replyToPost = listOf("e", boostedPost.id())
|
||||
val replyToAuthor = listOf("p", boostedPost.pubKey())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags:List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor))
|
||||
|
||||
if (boostedPost is LongTextNoteEvent) {
|
||||
tags = tags + listOf( listOf("a", boostedPost.address().toTag()) )
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return RepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
fun containedPost() = try {
|
||||
fromJson(content, Client.lenient)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 6
|
||||
|
||||
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
|
||||
val content = boostedPost.toJson()
|
||||
|
||||
val replyToPost = listOf("e", boostedPost.id())
|
||||
val replyToAuthor = listOf("p", boostedPost.pubKey())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags: List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor))
|
||||
|
||||
if (boostedPost is LongTextNoteEvent) {
|
||||
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return RepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class TextNoteEvent(
|
||||
id: HexKey,
|
||||
@@ -12,7 +12,7 @@ class TextNoteEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
|
||||
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
@@ -37,4 +37,4 @@ class TextNoteEvent(
|
||||
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEventInterface
|
||||
|
||||
object UserZaps {
|
||||
fun forProfileFeed(zaps: Map<Note, Note?>?): List<Pair<Note, Note>> {
|
||||
if (zaps == null) return emptyList()
|
||||
fun forProfileFeed(zaps: Map<Note, Note?>?): List<Pair<Note, Note>> {
|
||||
if (zaps == null) return emptyList()
|
||||
|
||||
return (zaps
|
||||
.filter { it.value != null }
|
||||
.toList()
|
||||
.sortedBy { (it.second?.event as? LnZapEventInterface)?.amount() }
|
||||
.reversed()) as List<Pair<Note, Note>>
|
||||
}
|
||||
return (
|
||||
zaps
|
||||
.filter { it.value != null }
|
||||
.toList()
|
||||
.sortedBy { (it.second?.event as? LnZapEventInterface)?.amount() }
|
||||
.reversed()
|
||||
) as List<Pair<Note, Note>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import java.util.UUID
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The Nostr Client manages multiple personae the user may switch between. Events are received and
|
||||
* published through multiple relays.
|
||||
* Events are stored with their respective persona.
|
||||
*/
|
||||
object Client: RelayPool.Listener {
|
||||
object Client : RelayPool.Listener {
|
||||
/**
|
||||
* Lenient mode:
|
||||
*
|
||||
|
||||
@@ -3,52 +3,52 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
|
||||
object Constants {
|
||||
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
|
||||
val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
val activeTypesSearch = setOf(FeedType.SEARCH)
|
||||
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
|
||||
val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
val activeTypesSearch = setOf(FeedType.SEARCH)
|
||||
|
||||
fun convertDefaultRelays(): Array<Relay> {
|
||||
return defaultRelays.map {
|
||||
Relay(it.url, it.read, it.write, it.feedTypes)
|
||||
}.toTypedArray()
|
||||
}
|
||||
fun convertDefaultRelays(): Array<Relay> {
|
||||
return defaultRelays.map {
|
||||
Relay(it.url, it.read, it.write, it.feedTypes)
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
val defaultRelays = arrayOf(
|
||||
// Free relays
|
||||
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
|
||||
val defaultRelays = arrayOf(
|
||||
// Free relays
|
||||
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
|
||||
|
||||
// Less Reliable
|
||||
//NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
|
||||
//NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes = activeTypes),
|
||||
//NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, feedTypes = activeTypes),
|
||||
//NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = activeTypes),
|
||||
//NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, feedTypes = activeTypes),
|
||||
//NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = activeTypes),
|
||||
//NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes = activeTypes),
|
||||
//NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
|
||||
// Less Reliable
|
||||
// NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, feedTypes = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, feedTypes = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes = activeTypes),
|
||||
// NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
|
||||
|
||||
// Paid relays
|
||||
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
// Paid relays
|
||||
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
|
||||
|
||||
// Supporting NIP-50
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
)
|
||||
// Supporting NIP-50
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
)
|
||||
|
||||
val forcedRelayForSearch = RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
}
|
||||
val forcedRelayForSearch = RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class JsonFilter(
|
||||
val since: Long? = null,
|
||||
val until: Long? = null,
|
||||
val limit: Int? = null,
|
||||
val search: String? = null,
|
||||
val search: String? = null
|
||||
) : Filter, Serializable {
|
||||
fun toJson(): String {
|
||||
val jsonObject = JsonObject()
|
||||
@@ -61,8 +61,9 @@ class JsonFilter(
|
||||
tags?.forEach { tag ->
|
||||
if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false
|
||||
}
|
||||
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE))
|
||||
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -113,10 +114,18 @@ class JsonFilter(
|
||||
}
|
||||
return JsonFilter(
|
||||
ids = if (json.has("ids")) json.getAsJsonArray("ids").map { it.asString } else null,
|
||||
authors = if (json.has("authors")) json.getAsJsonArray("authors")
|
||||
.map { it.asString } else null,
|
||||
kinds = if (json.has("kinds")) json.getAsJsonArray("kinds")
|
||||
.map { it.asInt } else null,
|
||||
authors = if (json.has("authors")) {
|
||||
json.getAsJsonArray("authors")
|
||||
.map { it.asString }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
kinds = if (json.has("kinds")) {
|
||||
json.getAsJsonArray("kinds")
|
||||
.map { it.asInt }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
tags = json
|
||||
.entrySet()
|
||||
.filter { it.key.startsWith("#") }
|
||||
@@ -131,4 +140,4 @@ class JsonFilter(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonElement
|
||||
import java.util.Date
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -10,6 +9,7 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.Date
|
||||
|
||||
enum class FeedType {
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH
|
||||
@@ -19,12 +19,12 @@ class Relay(
|
||||
var url: String,
|
||||
var read: Boolean = true,
|
||||
var write: Boolean = true,
|
||||
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
|
||||
var activeTypes: Set<FeedType> = FeedType.values().toSet()
|
||||
) {
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build();
|
||||
.build()
|
||||
|
||||
private var listeners = setOf<Listener>()
|
||||
private var socket: WebSocket? = null
|
||||
@@ -77,26 +77,26 @@ class Relay(
|
||||
val channel = msg[1].asString
|
||||
when (type) {
|
||||
"EVENT" -> {
|
||||
//Log.w("Relay", "Relay onEVENT $url, $channel")
|
||||
// Log.w("Relay", "Relay onEVENT $url, $channel")
|
||||
eventDownloadCounter++
|
||||
val event = Event.fromJson(msg[2], Client.lenient)
|
||||
listeners.forEach { it.onEvent(this@Relay, channel, event) }
|
||||
}
|
||||
"EOSE" -> listeners.forEach {
|
||||
//Log.w("Relay", "Relay onEOSE $url, $channel")
|
||||
// Log.w("Relay", "Relay onEOSE $url, $channel")
|
||||
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
|
||||
}
|
||||
"NOTICE" -> listeners.forEach {
|
||||
//Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
// Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
// "channel" being the second string in the string array ...
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
//Log.w("Relay", "Relay onOK $url, $channel")
|
||||
// Log.w("Relay", "Relay onOK $url, $channel")
|
||||
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
|
||||
}
|
||||
else -> listeners.forEach {
|
||||
//Log.w("Relay", "Relay something else $url, $channel")
|
||||
// Log.w("Relay", "Relay something else $url, $channel")
|
||||
it.onError(
|
||||
this@Relay,
|
||||
channel,
|
||||
@@ -113,11 +113,13 @@ class Relay(
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
listeners.forEach { it.onRelayStateChange(
|
||||
this@Relay,
|
||||
Type.DISCONNECTING,
|
||||
null
|
||||
) }
|
||||
listeners.forEach {
|
||||
it.onRelayStateChange(
|
||||
this@Relay,
|
||||
Type.DISCONNECTING,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
@@ -136,10 +138,10 @@ class Relay(
|
||||
isReady = false
|
||||
closingTime = Date().time / 1000
|
||||
|
||||
Log.w("Relay", "Relay onFailure $url, ${response?.message} ${response}")
|
||||
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
|
||||
t.printStackTrace()
|
||||
listeners.forEach {
|
||||
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
|
||||
it.onError(this@Relay, "", Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,7 +157,7 @@ class Relay(
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
//httpClient.dispatcher.executorService.shutdown()
|
||||
// httpClient.dispatcher.executorService.shutdown()
|
||||
closingTime = Date().time / 1000
|
||||
socket?.close(1000, "Normal close")
|
||||
socket = null
|
||||
@@ -170,7 +172,7 @@ class Relay(
|
||||
if (filters.isNotEmpty()) {
|
||||
val request =
|
||||
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]"""
|
||||
//println("FILTERSSENT ${url} ${request}")
|
||||
// println("FILTERSSENT ${url} ${request}")
|
||||
socket?.send(request)
|
||||
}
|
||||
}
|
||||
@@ -188,7 +190,7 @@ class Relay(
|
||||
if (socket == null) {
|
||||
// waits 60 seconds to reconnect after disconnected.
|
||||
if (Date().time / 1000 > closingTime + 60) {
|
||||
//println("sendfilter Only if Disconnected ${url} ")
|
||||
// println("sendfilter Only if Disconnected ${url} ")
|
||||
requestAndWatch()
|
||||
}
|
||||
}
|
||||
@@ -201,24 +203,27 @@ class Relay(
|
||||
}
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String){
|
||||
fun close(subscriptionId: String) {
|
||||
socket?.send("""["CLOSE","$subscriptionId"]""")
|
||||
}
|
||||
|
||||
fun isSameRelayConfig(other: Relay): Boolean {
|
||||
return url == other.url
|
||||
&& write == other.write
|
||||
&& read == other.read
|
||||
&& activeTypes == other.activeTypes
|
||||
return url == other.url &&
|
||||
write == other.write &&
|
||||
read == other.read &&
|
||||
activeTypes == other.activeTypes
|
||||
}
|
||||
|
||||
enum class Type {
|
||||
// Websocket connected
|
||||
CONNECT,
|
||||
|
||||
// Websocket disconnecting
|
||||
DISCONNECTING,
|
||||
|
||||
// Websocket disconnected
|
||||
DISCONNECT,
|
||||
|
||||
// End Of Stored Events
|
||||
EOSE
|
||||
}
|
||||
@@ -232,6 +237,7 @@ class Relay(
|
||||
fun onError(relay: Relay, subscriptionId: String, error: Error)
|
||||
|
||||
fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
|
||||
|
||||
/**
|
||||
* Connected to or disconnected from a relay
|
||||
*
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
|
||||
/**
|
||||
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
|
||||
*/
|
||||
object RelayPool: Relay.Listener {
|
||||
object RelayPool : Relay.Listener {
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
|
||||
@@ -30,8 +30,8 @@ object RelayPool: Relay.Listener {
|
||||
return relays.firstOrNull() { it.url == url }
|
||||
}
|
||||
|
||||
fun loadRelays(relayList: List<Relay>){
|
||||
if (!relayList.isNullOrEmpty()){
|
||||
fun loadRelays(relayList: List<Relay>) {
|
||||
if (!relayList.isNullOrEmpty()) {
|
||||
relayList.forEach { addRelay(it) }
|
||||
} else {
|
||||
Constants.convertDefaultRelays().forEach { addRelay(it) }
|
||||
@@ -59,7 +59,7 @@ object RelayPool: Relay.Listener {
|
||||
relays.forEach { it.send(signedEvent) }
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String){
|
||||
fun close(subscriptionId: String) {
|
||||
relays.forEach { it.close(subscriptionId) }
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ object RelayPool: Relay.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
class RelayPoolLiveData(val relays: RelayPool): LiveData<RelayPoolState>(RelayPoolState(relays)) {
|
||||
class RelayPoolLiveData(val relays: RelayPool) : LiveData<RelayPoolState>(RelayPoolState(relays)) {
|
||||
fun refresh() {
|
||||
postValue(RelayPoolState(relays))
|
||||
}
|
||||
|
||||
@@ -5,26 +5,26 @@ import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import java.util.UUID
|
||||
|
||||
data class Subscription (
|
||||
val id: String = UUID.randomUUID().toString().substring(0,4),
|
||||
val onEOSE: ((Long) -> Unit)? = null
|
||||
data class Subscription(
|
||||
val id: String = UUID.randomUUID().toString().substring(0, 4),
|
||||
val onEOSE: ((Long) -> Unit)? = null
|
||||
) {
|
||||
var typedFilters: List<TypedFilter>? = null // Inactive when null
|
||||
var typedFilters: List<TypedFilter>? = null // Inactive when null
|
||||
|
||||
fun updateEOSE(l: Long) {
|
||||
onEOSE?.let { it(l) }
|
||||
}
|
||||
|
||||
fun toJson(): String {
|
||||
return GsonBuilder().create().toJson(toJsonObject())
|
||||
}
|
||||
|
||||
fun toJsonObject(): JsonObject {
|
||||
val jsonObject = JsonObject()
|
||||
jsonObject.addProperty("id", id)
|
||||
typedFilters?.run {
|
||||
jsonObject.add("typedFilters", JsonArray().apply { typedFilters?.forEach { add(it.toJsonObject()) } })
|
||||
fun updateEOSE(l: Long) {
|
||||
onEOSE?.let { it(l) }
|
||||
}
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
fun toJson(): String {
|
||||
return GsonBuilder().create().toJson(toJsonObject())
|
||||
}
|
||||
|
||||
fun toJsonObject(): JsonObject {
|
||||
val jsonObject = JsonObject()
|
||||
jsonObject.addProperty("id", id)
|
||||
typedFilters?.run {
|
||||
jsonObject.add("typedFilters", JsonArray().apply { typedFilters?.forEach { add(it.toJsonObject()) } })
|
||||
}
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,53 +3,52 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
|
||||
class TypedFilter(
|
||||
val types: Set<FeedType>,
|
||||
val filter: JsonFilter
|
||||
val types: Set<FeedType>,
|
||||
val filter: JsonFilter
|
||||
) {
|
||||
|
||||
fun toJson(): String {
|
||||
return GsonBuilder().create().toJson(toJsonObject())
|
||||
}
|
||||
fun toJson(): String {
|
||||
return GsonBuilder().create().toJson(toJsonObject())
|
||||
}
|
||||
|
||||
fun toJsonObject(): JsonObject {
|
||||
val jsonObject = JsonObject()
|
||||
jsonObject.add("types", typesToJson(types))
|
||||
jsonObject.add("filter", filterToJson(filter))
|
||||
return jsonObject
|
||||
}
|
||||
fun toJsonObject(): JsonObject {
|
||||
val jsonObject = JsonObject()
|
||||
jsonObject.add("types", typesToJson(types))
|
||||
jsonObject.add("filter", filterToJson(filter))
|
||||
return jsonObject
|
||||
}
|
||||
|
||||
fun typesToJson(types: Set<FeedType>): JsonArray {
|
||||
return JsonArray().apply { types.forEach { add(it.name.lowercase()) } }
|
||||
}
|
||||
fun typesToJson(types: Set<FeedType>): JsonArray {
|
||||
return JsonArray().apply { types.forEach { add(it.name.lowercase()) } }
|
||||
}
|
||||
|
||||
fun filterToJson(filter: JsonFilter): JsonObject {
|
||||
val jsonObject = JsonObject()
|
||||
filter.ids?.run {
|
||||
jsonObject.add("ids", JsonArray().apply { filter.ids?.forEach { add(it) } })
|
||||
fun filterToJson(filter: JsonFilter): JsonObject {
|
||||
val jsonObject = JsonObject()
|
||||
filter.ids?.run {
|
||||
jsonObject.add("ids", JsonArray().apply { filter.ids?.forEach { add(it) } })
|
||||
}
|
||||
filter.authors?.run {
|
||||
jsonObject.add("authors", JsonArray().apply { filter.authors?.forEach { add(it) } })
|
||||
}
|
||||
filter.kinds?.run {
|
||||
jsonObject.add("kinds", JsonArray().apply { filter.kinds?.forEach { add(it) } })
|
||||
}
|
||||
filter.tags?.run {
|
||||
entries.forEach { kv ->
|
||||
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
|
||||
}
|
||||
}
|
||||
filter.since?.run {
|
||||
jsonObject.addProperty("since", filter.since)
|
||||
}
|
||||
filter.until?.run {
|
||||
jsonObject.addProperty("until", filter.until)
|
||||
}
|
||||
filter.limit?.run {
|
||||
jsonObject.addProperty("limit", filter.limit)
|
||||
}
|
||||
return jsonObject
|
||||
}
|
||||
filter.authors?.run {
|
||||
jsonObject.add("authors", JsonArray().apply { filter.authors?.forEach { add(it) } })
|
||||
}
|
||||
filter.kinds?.run {
|
||||
jsonObject.add("kinds", JsonArray().apply { filter.kinds?.forEach { add(it) } })
|
||||
}
|
||||
filter.tags?.run {
|
||||
entries.forEach { kv ->
|
||||
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
|
||||
}
|
||||
}
|
||||
filter.since?.run {
|
||||
jsonObject.addProperty("since", filter.since)
|
||||
}
|
||||
filter.until?.run {
|
||||
jsonObject.addProperty("until", filter.until)
|
||||
}
|
||||
filter.limit?.run {
|
||||
jsonObject.addProperty("limit", filter.limit)
|
||||
}
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,66 +23,65 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val nip19 = Nip19().uriToRoute(intent?.data?.toString())
|
||||
val startingPage = when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(this).components {
|
||||
if (SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
val nip19 = Nip19().uriToRoute(intent?.data?.toString())
|
||||
val startingPage = when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
} //.logger(DebugLogger())
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel(LocalPreferences(applicationContext))
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, startingPage)
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(this).components {
|
||||
if (SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
} // .logger(DebugLogger())
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel(LocalPreferences(applicationContext))
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, startingPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Client.lenient = true
|
||||
}
|
||||
|
||||
Client.lenient = true
|
||||
}
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// Only starts after login
|
||||
ServiceManager.start()
|
||||
}
|
||||
// Only starts after login
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
ServiceManager.pause()
|
||||
override fun onPause() {
|
||||
ServiceManager.pause()
|
||||
|
||||
super.onPause()
|
||||
}
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
/**
|
||||
* Release memory when the UI becomes hidden or when system resources become low.
|
||||
* @param level the memory-related event that was raised.
|
||||
*/
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
println("Trim Memory $level")
|
||||
ServiceManager.cleanUp()
|
||||
}
|
||||
/**
|
||||
* Release memory when the UI becomes hidden or when system resources become low.
|
||||
* @param level the memory-related event that was raised.
|
||||
*/
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
println("Trim Memory $level")
|
||||
ServiceManager.cleanUp()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import okio.IOException
|
||||
import okio.sink
|
||||
import java.io.File
|
||||
|
||||
|
||||
object ImageSaver {
|
||||
/**
|
||||
* Saves the image to the gallery.
|
||||
@@ -27,7 +26,7 @@ object ImageSaver {
|
||||
url: String,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
onError: (Throwable) -> Any?
|
||||
) {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
@@ -56,13 +55,13 @@ object ImageSaver {
|
||||
displayName = File(url).nameWithoutExtension,
|
||||
contentType = contentType,
|
||||
contentSource = response.body.source(),
|
||||
contentResolver = context.contentResolver,
|
||||
contentResolver = context.contentResolver
|
||||
)
|
||||
} else {
|
||||
saveContentDefault(
|
||||
fileName = File(url).name,
|
||||
contentSource = response.body.source(),
|
||||
context = context,
|
||||
context = context
|
||||
)
|
||||
}
|
||||
onSuccess()
|
||||
@@ -79,7 +78,7 @@ object ImageSaver {
|
||||
displayName: String,
|
||||
contentType: String,
|
||||
contentSource: BufferedSource,
|
||||
contentResolver: ContentResolver,
|
||||
contentResolver: ContentResolver
|
||||
) {
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
|
||||
@@ -114,7 +113,7 @@ object ImageSaver {
|
||||
private fun saveContentDefault(
|
||||
fileName: String,
|
||||
contentSource: BufferedSource,
|
||||
context: Context,
|
||||
context: Context
|
||||
) {
|
||||
val subdirectory = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
|
||||
@@ -144,4 +143,4 @@ object ImageSaver {
|
||||
}
|
||||
|
||||
private const val PICTURES_SUBDIRECTORY = "Amethyst"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,66 +11,66 @@ import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
object ImageUploader {
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
contentResolver: ContentResolver,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (Throwable) -> Unit,
|
||||
) {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
contentResolver: ContentResolver,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
val requestBody: RequestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"image",
|
||||
"${UUID.randomUUID()}",
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType? =
|
||||
contentType?.toMediaType()
|
||||
val requestBody: RequestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"image",
|
||||
"${UUID.randomUUID()}",
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType? =
|
||||
contentType?.toMediaType()
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
|
||||
imageInputStream.source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.url("https://api.imgur.com/3/image")
|
||||
.header("Authorization", "Client-ID e6aea87296f3f96")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val tree = jacksonObjectMapper().readTree(body.string())
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
}
|
||||
|
||||
onSuccess(url)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
imageInputStream.source().use(sink::writeAll)
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.url("https://api.imgur.com/3/image")
|
||||
.header("Authorization", "Client-ID e6aea87296f3f96")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val tree = jacksonObjectMapper().readTree(body.string())
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
|
||||
onSuccess(url)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -42,8 +41,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
|
||||
dismissOnClickOutside = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
) {
|
||||
Surface() {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
@@ -124,7 +122,4 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
|
||||
class NewChannelViewModel: ViewModel() {
|
||||
class NewChannelViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
private var originalChannel: Channel? = null
|
||||
|
||||
@@ -15,7 +15,6 @@ class NewChannelViewModel: ViewModel() {
|
||||
val channelPicture = mutableStateOf(TextFieldValue())
|
||||
val channelDescription = mutableStateOf(TextFieldValue())
|
||||
|
||||
|
||||
fun load(account: Account, channel: Channel?) {
|
||||
this.account = account
|
||||
if (channel != null) {
|
||||
@@ -34,13 +33,14 @@ class NewChannelViewModel: ViewModel() {
|
||||
channelDescription.value.text,
|
||||
channelPicture.value.text
|
||||
)
|
||||
} else
|
||||
} else {
|
||||
account.sendChangeChannel(
|
||||
channelName.value.text,
|
||||
channelDescription.value.text,
|
||||
channelPicture.value.text,
|
||||
originalChannel!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
clear()
|
||||
@@ -51,4 +51,4 @@ class NewChannelViewModel: ViewModel() {
|
||||
channelPicture.value = TextFieldValue()
|
||||
channelDescription.value = TextFieldValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,13 +36,12 @@ import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import kotlinx.coroutines.delay
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@@ -89,7 +88,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
})
|
||||
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
isUploading = postViewModel.isUploadingImage
|
||||
) {
|
||||
postViewModel.upload(it, context)
|
||||
}
|
||||
@@ -99,8 +98,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = postViewModel.message.text.isNotBlank()
|
||||
&& !postViewModel.isUploadingImage
|
||||
isActive = postViewModel.message.text.isNotBlank() &&
|
||||
!postViewModel.isUploadingImage
|
||||
)
|
||||
}
|
||||
|
||||
@@ -172,7 +171,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
)
|
||||
)
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl)
|
||||
.matches()
|
||||
.matches()
|
||||
) {
|
||||
VideoView(myUrlPreview)
|
||||
} else {
|
||||
@@ -184,7 +183,6 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val userSuggestions = postViewModel.userSuggestions
|
||||
@@ -197,7 +195,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
) {
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }) { index, item ->
|
||||
key = { _, item -> item.pubkeyHex }
|
||||
) { index, item ->
|
||||
UserLine(item, account) {
|
||||
postViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NewPostViewModel: ViewModel() {
|
||||
class NewPostViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
private var originalNote: Note? = null
|
||||
|
||||
@@ -182,4 +182,4 @@ class NewPostViewModel: ViewModel() {
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import java.lang.Math.round
|
||||
|
||||
|
||||
@Composable
|
||||
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
|
||||
val postViewModel: NewRelayListViewModel = viewModel()
|
||||
@@ -74,8 +73,7 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
|
||||
dismissOnClickOutside = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
) {
|
||||
Surface() {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
@@ -105,26 +103,27 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
)
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
|
||||
if (index == 0)
|
||||
if (index == 0) {
|
||||
ServerConfigHeader()
|
||||
ServerConfig(item,
|
||||
}
|
||||
ServerConfig(
|
||||
item,
|
||||
onToggleDownload = { postViewModel.toggleDownload(it) },
|
||||
onToggleUpload = { postViewModel.toggleUpload(it) },
|
||||
onToggleUpload = { postViewModel.toggleUpload(it) },
|
||||
|
||||
onToggleFollows = { postViewModel.toggleFollows(it) },
|
||||
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
|
||||
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
|
||||
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
|
||||
onToggleGlobal = { postViewModel.toggleGlobal(it) },
|
||||
onToggleSearch = { postViewModel.toggleSearch(it) },
|
||||
onToggleGlobal = { postViewModel.toggleGlobal(it) },
|
||||
onToggleSearch = { postViewModel.toggleSearch(it) },
|
||||
|
||||
onDelete = { postViewModel.deleteRelay(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
@@ -161,7 +160,7 @@ fun ServerConfigHeader() {
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
@@ -171,7 +170,7 @@ fun ServerConfigHeader() {
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
@@ -181,7 +180,7 @@ fun ServerConfigHeader() {
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(5.dp))
|
||||
@@ -191,7 +190,7 @@ fun ServerConfigHeader() {
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
@@ -217,7 +216,8 @@ fun ServerConfig(
|
||||
onToggleGlobal: (RelaySetupInfo) -> Unit,
|
||||
onToggleSearch: (RelaySetupInfo) -> Unit,
|
||||
|
||||
onDelete: (RelaySetupInfo) -> Unit) {
|
||||
onDelete: (RelaySetupInfo) -> Unit
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -262,9 +262,13 @@ fun ServerConfig(
|
||||
modifier = Modifier
|
||||
.padding(end = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) {
|
||||
Color.Green
|
||||
} else {
|
||||
MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
@@ -277,9 +281,13 @@ fun ServerConfig(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) {
|
||||
Color.Green
|
||||
} else {
|
||||
MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
@@ -292,9 +300,13 @@ fun ServerConfig(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) {
|
||||
Color.Green
|
||||
} else {
|
||||
MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
@@ -307,9 +319,13 @@ fun ServerConfig(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) {
|
||||
Color.Green
|
||||
} else {
|
||||
MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -323,9 +339,13 @@ fun ServerConfig(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.feedTypes.contains(FeedType.SEARCH)) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
tint = if (item.feedTypes.contains(FeedType.SEARCH)) {
|
||||
Color.Green
|
||||
} else {
|
||||
MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -343,9 +363,13 @@ fun ServerConfig(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.read) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
tint = if (item.read) {
|
||||
Color.Green
|
||||
} else {
|
||||
MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -354,7 +378,7 @@ fun ServerConfig(
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
@@ -367,9 +391,13 @@ fun ServerConfig(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = if (item.write) Color.Green else MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
tint = if (item.write) {
|
||||
Color.Green
|
||||
} else {
|
||||
MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -378,7 +406,7 @@ fun ServerConfig(
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Icon(
|
||||
@@ -395,7 +423,7 @@ fun ServerConfig(
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Icon(
|
||||
@@ -410,7 +438,7 @@ fun ServerConfig(
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -433,7 +461,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.add_a_relay)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f),
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
placeholder = {
|
||||
@@ -487,14 +515,12 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.add), color = Color.White)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun countToHumanReadable(counter: Int) = when {
|
||||
counter >= 1000000000 -> "${round(counter/1000000000f)}G"
|
||||
counter >= 1000000 -> "${round(counter/1000000f)}M"
|
||||
counter >= 1000 -> "${round(counter/1000f)}k"
|
||||
counter >= 1000000000 -> "${round(counter / 1000000000f)}G"
|
||||
counter >= 1000000 -> "${round(counter / 1000000f)}M"
|
||||
counter >= 1000 -> "${round(counter / 1000f)}k"
|
||||
else -> "$counter"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class NewRelayListViewModel: ViewModel() {
|
||||
class NewRelayListViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
|
||||
@@ -40,11 +39,12 @@ class NewRelayListViewModel: ViewModel() {
|
||||
// TODO: Remove when search becomes more available.
|
||||
if (relayFile?.none { it.key == Constants.forcedRelayForSearch.url } == true) {
|
||||
relayFile = relayFile + Pair(
|
||||
Constants.forcedRelayForSearch.url, ContactListEvent.ReadWrite(Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write)
|
||||
Constants.forcedRelayForSearch.url,
|
||||
ContactListEvent.ReadWrite(Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write)
|
||||
)
|
||||
}
|
||||
|
||||
if (relayFile != null)
|
||||
if (relayFile != null) {
|
||||
relayFile.map {
|
||||
val liveRelay = RelayPool.getRelay(it.key)
|
||||
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
|
||||
@@ -56,7 +56,7 @@ class NewRelayListViewModel: ViewModel() {
|
||||
|
||||
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes)
|
||||
}.sortedBy { it.downloadCount }.reversed()
|
||||
else
|
||||
} else {
|
||||
account.localRelays.map {
|
||||
val liveRelay = RelayPool.getRelay(it.url)
|
||||
|
||||
@@ -67,6 +67,7 @@ class NewRelayListViewModel: ViewModel() {
|
||||
|
||||
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes)
|
||||
}.sortedBy { it.downloadCount }.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,14 +121,14 @@ class NewRelayListViewModel: ViewModel() {
|
||||
fun toggleGlobal(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy( feedTypes = newTypes ))
|
||||
it.updated(relay, relay.copy(feedTypes = newTypes))
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSearch(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH)
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy( feedTypes = newTypes ))
|
||||
it.updated(relay, relay.copy(feedTypes = newTypes))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,4 +137,4 @@ fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new
|
||||
|
||||
fun <T> togglePresenceInSet(set: Set<T>, item: T): Set<T> {
|
||||
return if (set.contains(item)) set.minus(item) else set.plus(item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
dismissOnClickOutside = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
) {
|
||||
Surface() {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
@@ -72,7 +71,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.display_name)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.displayName.value,
|
||||
onValueChange = { postViewModel.displayName.value = it },
|
||||
placeholder = {
|
||||
@@ -219,7 +218,6 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.StringWriter
|
||||
|
||||
class NewUserMetadataViewModel: ViewModel() {
|
||||
class NewUserMetadataViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
val userName = mutableStateOf("")
|
||||
@@ -80,4 +80,4 @@ class NewUserMetadataViewModel: ViewModel() {
|
||||
lnAddress.value = ""
|
||||
lnURL.value = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ fun SaveToGallery(url: String) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
||||
fun saveImage() {
|
||||
ImageSaver.saveImage(
|
||||
context = localContext,
|
||||
@@ -83,4 +82,4 @@ fun SaveToGallery(url: String) {
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.save), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import com.vitorpamplona.amethyst.R
|
||||
@Composable
|
||||
fun UploadFromGallery(
|
||||
isUploading: Boolean,
|
||||
onImageChosen: (Uri) -> Unit,
|
||||
onImageChosen: (Uri) -> Unit
|
||||
) {
|
||||
val cameraPermissionState =
|
||||
rememberPermissionState(
|
||||
@@ -40,8 +40,9 @@ fun UploadFromGallery(
|
||||
GallerySelect(
|
||||
onImageUri = { uri ->
|
||||
showGallerySelect = false
|
||||
if (uri != null)
|
||||
if (uri != null) {
|
||||
onImageChosen(uri)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
@@ -67,7 +68,7 @@ fun UploadFromGallery(
|
||||
Column {
|
||||
Button(
|
||||
onClick = { cameraPermissionState.launchPermissionRequest() },
|
||||
enabled = !isUploading,
|
||||
enabled = !isUploading
|
||||
) {
|
||||
if (!isUploading) {
|
||||
Text(stringResource(R.string.upload_image))
|
||||
@@ -79,7 +80,6 @@ fun UploadFromGallery(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun GallerySelect(
|
||||
onImageUri: (Uri?) -> Unit = { }
|
||||
@@ -99,4 +99,4 @@ fun GallerySelect(
|
||||
}
|
||||
|
||||
LaunchGallery()
|
||||
}
|
||||
}
|
||||
|
||||
+92
-91
@@ -18,127 +18,128 @@ 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)
|
||||
}
|
||||
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 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
|
||||
val startIndex = builderBefore.toString().length
|
||||
|
||||
builderBefore.append("$keyB32$restOfWord ") // accounts for the \n at the end of each paragraph
|
||||
builderBefore.append("$keyB32$restOfWord ") // accounts for the \n at the end of each paragraph
|
||||
|
||||
val endIndex = startIndex + keyB32.length
|
||||
val endIndex = startIndex + keyB32.length
|
||||
|
||||
val key = decodePublicKey(keyB32.removePrefix("@"))
|
||||
val user = LocalCache.getOrCreateUser(key.toHexKey())
|
||||
val key = decodePublicKey(keyB32.removePrefix("@"))
|
||||
val user = LocalCache.getOrCreateUser(key.toHexKey())
|
||||
|
||||
val newWord = "@${user.toBestDisplayName()}"
|
||||
val startNew = builderAfter.toString().length
|
||||
val newWord = "@${user.toBestDisplayName()}"
|
||||
val startNew = builderAfter.toString().length
|
||||
|
||||
builderAfter.append("$newWord$restOfWord ") // accounts for the \n at the end of each paragraph
|
||||
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.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")
|
||||
)
|
||||
}
|
||||
|
||||
substitutions.forEach {
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = color,
|
||||
textDecoration = TextDecoration.None
|
||||
),
|
||||
start = it.modified.start, end = it.modified.end
|
||||
)
|
||||
}
|
||||
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()
|
||||
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()
|
||||
}
|
||||
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 }
|
||||
val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end }
|
||||
|
||||
if (lastRangeThrough != null) {
|
||||
return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
|
||||
} else {
|
||||
return offset
|
||||
}
|
||||
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()
|
||||
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()
|
||||
}
|
||||
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 }
|
||||
val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end }
|
||||
|
||||
if (lastRangeThrough != null) {
|
||||
return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
|
||||
} else {
|
||||
return offset
|
||||
}
|
||||
if (lastRangeThrough != null) {
|
||||
return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
|
||||
} else {
|
||||
return offset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TransformedText(
|
||||
newText,
|
||||
numberOffsetTranslator
|
||||
newText,
|
||||
numberOffsetTranslator
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,15 +28,16 @@ fun NewChannelButton(account: Account) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToPost)
|
||||
if (wantsToPost) {
|
||||
NewChannelView({ wantsToPost = false }, account = account)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { wantsToPost = true },
|
||||
modifier = Modifier.size(55.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Add,
|
||||
@@ -45,4 +46,4 @@ fun NewChannelButton(account: Account) {
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,16 @@ fun NewNoteButton(account: Account) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToPost)
|
||||
if (wantsToPost) {
|
||||
NewPostView({ wantsToPost = false }, account = account)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { wantsToPost = true },
|
||||
modifier = Modifier.size(55.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_compose),
|
||||
@@ -43,4 +44,4 @@ fun NewNoteButton(account: Account) {
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,76 +9,73 @@ import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.LocalImageLoader
|
||||
import java.util.Base64
|
||||
|
||||
data class ResizeImage(val url: String?, val size: Dp) {
|
||||
fun proxyUrl(): String? {
|
||||
if (url == null) return null
|
||||
fun proxyUrl(): String? {
|
||||
if (url == null) return null
|
||||
|
||||
// Fixes Image size to reduce pings to servers for each size used in the app
|
||||
val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() }
|
||||
val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray())
|
||||
// Fixes Image size to reduce pings to servers for each size used in the app
|
||||
val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() }
|
||||
val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray())
|
||||
|
||||
return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
|
||||
}
|
||||
return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun AsyncImageProxy(
|
||||
model: ResizeImage,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: Painter? = null,
|
||||
error: Painter? = null,
|
||||
fallback: Painter? = error,
|
||||
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null,
|
||||
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null,
|
||||
onError: ((AsyncImagePainter.State.Error) -> Unit)? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
|
||||
model: ResizeImage,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: Painter? = null,
|
||||
error: Painter? = null,
|
||||
fallback: Painter? = error,
|
||||
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null,
|
||||
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null,
|
||||
onError: ((AsyncImagePainter.State.Error) -> Unit)? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
if (model.url == null) {
|
||||
AsyncImage(
|
||||
model = model.url,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = placeholder,
|
||||
error = error,
|
||||
fallback = fallback,
|
||||
onLoading = onLoading,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = model.proxyUrl(),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = placeholder,
|
||||
error = error,
|
||||
fallback = fallback,
|
||||
onLoading = onLoading,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
}
|
||||
if (model.url == null) {
|
||||
AsyncImage(
|
||||
model = model.url,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = placeholder,
|
||||
error = error,
|
||||
fallback = fallback,
|
||||
onLoading = onLoading,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = model.proxyUrl(),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = placeholder,
|
||||
error = error,
|
||||
fallback = fallback,
|
||||
onLoading = onLoading,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,26 +12,27 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@Composable
|
||||
fun ClickableEmail(email: String) {
|
||||
val context = LocalContext.current
|
||||
val context = LocalContext.current
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$email "),
|
||||
onClick = { runCatching { context.sendMail(email) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
)
|
||||
ClickableText(
|
||||
text = AnnotatedString("$email "),
|
||||
onClick = { runCatching { context.sendMail(email) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.sendMail(to: String, subject: String? = null) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = "vnd.android.cursor.item/email" // or "message/rfc822"
|
||||
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to))
|
||||
if (subject != null)
|
||||
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// TODO: Handle case where no email app is available
|
||||
} catch (t: Throwable) {
|
||||
// TODO: Handle potential other type of exceptions
|
||||
}
|
||||
}
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = "vnd.android.cursor.item/email" // or "message/rfc822"
|
||||
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to))
|
||||
if (subject != null) {
|
||||
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// TODO: Handle case where no email app is available
|
||||
} catch (t: Throwable) {
|
||||
// TODO: Handle potential other type of exceptions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
|
||||
@Composable
|
||||
fun ClickableNoteTag(
|
||||
baesNote: Note,
|
||||
navController: NavController
|
||||
baesNote: Note,
|
||||
navController: NavController
|
||||
) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${baesNote.idNote().toShortenHex()} "),
|
||||
onClick = { navController.navigate("Note/${baesNote.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${baesNote.idNote().toShortenHex()} "),
|
||||
onClick = { navController.navigate("Note/${baesNote.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,20 +12,20 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@Composable
|
||||
fun ClickablePhone(phone: String) {
|
||||
val context = LocalContext.current
|
||||
val context = LocalContext.current
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$phone "),
|
||||
onClick = { runCatching { context.dial(phone) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
)
|
||||
ClickableText(
|
||||
text = AnnotatedString("$phone "),
|
||||
onClick = { runCatching { context.dial(phone) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.dial(phone: String) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null))
|
||||
startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
// TODO: Handle potential exceptions
|
||||
}
|
||||
}
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null))
|
||||
startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
// TODO: Handle potential exceptions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,46 +14,46 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
|
||||
@Composable
|
||||
fun ClickableRoute(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
) {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
val userBase = LocalCache.getOrCreateUser(nip19.hex)
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
val userBase = LocalCache.getOrCreateUser(nip19.hex)
|
||||
|
||||
val userState by userBase.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val userState by userBase.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val route = "User/${nip19.hex}"
|
||||
val text = user.toBestDisplayName()
|
||||
val route = "User/${nip19.hex}"
|
||||
val text = user.toBestDisplayName()
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${text} "),
|
||||
onClick = { navController.navigate(route) },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
} else {
|
||||
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
|
||||
val noteState by noteBase.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.idDisplayNote()} "),
|
||||
onClick = { navController.navigate("Channel/${nip19.hex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
} else if (note.channel() != null) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.channel()?.toBestDisplayName()} "),
|
||||
onClick = { navController.navigate("Channel/${note.channel()?.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
ClickableText(
|
||||
text = AnnotatedString("@$text "),
|
||||
onClick = { navController.navigate(route) },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
} else {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.idDisplayNote()} "),
|
||||
onClick = { navController.navigate("Note/${nip19.hex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
|
||||
val noteState by noteBase.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.idDisplayNote()} "),
|
||||
onClick = { navController.navigate("Channel/${nip19.hex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
} else if (note.channel() != null) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.channel()?.toBestDisplayName()} "),
|
||||
onClick = { navController.navigate("Channel/${note.channel()?.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
} else {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.idDisplayNote()} "),
|
||||
onClick = { navController.navigate("Note/${nip19.hex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@Composable
|
||||
fun ClickableUrl(urlText: String, url: String) {
|
||||
val uri = LocalUriHandler.current
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$urlText "),
|
||||
onClick = { runCatching { uri.openUri(url) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
)
|
||||
}
|
||||
ClickableText(
|
||||
text = AnnotatedString("$urlText "),
|
||||
onClick = { runCatching { uri.openUri(url) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,13 +12,13 @@ import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
@Composable
|
||||
fun ClickableUserTag(
|
||||
user: User,
|
||||
navController: NavController
|
||||
user: User,
|
||||
navController: NavController
|
||||
) {
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
|
||||
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
|
||||
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
+51
-54
@@ -13,7 +13,6 @@ import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -23,68 +22,66 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ExpandableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
|
||||
val text = if (showFullText) content else content.take(350)
|
||||
val text = if (showFullText) content else content.take(350)
|
||||
|
||||
Box(contentAlignment = Alignment.BottomCenter) {
|
||||
//CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
RichTextViewer(
|
||||
text,
|
||||
canPreview,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
//}
|
||||
Box(contentAlignment = Alignment.BottomCenter) {
|
||||
// CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
RichTextViewer(
|
||||
text,
|
||||
canPreview,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
// }
|
||||
|
||||
if (content.length > 350 && !showFullText) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
backgroundColor.copy(alpha = 0f),
|
||||
backgroundColor
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
onClick = { showFullText = !showFullText },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_more), color = Color.White)
|
||||
if (content.length > 350 && !showFullText) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
backgroundColor.copy(alpha = 0f),
|
||||
backgroundColor
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
onClick = { showFullText = !showFullText },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_more), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,82 +33,80 @@ import java.text.NumberFormat
|
||||
|
||||
@Composable
|
||||
fun InvoicePreview(lnInvoice: String) {
|
||||
val amount = try {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
val amount = try {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp)
|
||||
) {
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.lightning_invoice),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
amount?.let {
|
||||
Text(
|
||||
text = "${
|
||||
NumberFormat.getInstance().format(amount)
|
||||
} ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.lightning_invoice),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
|
||||
startActivity(context, intent, null)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.pay), color = Color.White, fontSize = 20.sp)
|
||||
}
|
||||
Divider()
|
||||
|
||||
amount?.let {
|
||||
Text(
|
||||
text = "${
|
||||
NumberFormat.getInstance().format(amount)
|
||||
} ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
|
||||
startActivity(context, intent, null)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.pay), color = Color.White, fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,123 +38,127 @@ 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.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onClose: () -> Unit ) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||
) {
|
||||
fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp)
|
||||
) {
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.lightning_tips),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
var message by remember { mutableStateOf("") }
|
||||
var amount by remember { mutableStateOf(1000L) }
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.note_to_receiver)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.thank_you_so_much),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.amount_in_sats)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = amount.toString(),
|
||||
onValueChange = {
|
||||
runCatching {
|
||||
if (it.isEmpty())
|
||||
amount = 0
|
||||
else
|
||||
amount = it.toLong()
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "1000",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
val zapRequest = account.createZapRequestFor(toUserPubKeyHex)
|
||||
|
||||
LightningAddressResolver().lnAddressInvoice(lud16, amount * 1000, message, zapRequest?.toJson(),
|
||||
onSuccess = {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
|
||||
startActivity(context, intent, null)
|
||||
}
|
||||
onClose()
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
onClose()
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.lightning_tips),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
var message by remember { mutableStateOf("") }
|
||||
var amount by remember { mutableStateOf(1000L) }
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.note_to_receiver)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.thank_you_so_much),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.amount_in_sats)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = amount.toString(),
|
||||
onValueChange = {
|
||||
runCatching {
|
||||
if (it.isEmpty()) {
|
||||
amount = 0
|
||||
} else {
|
||||
amount = it.toLong()
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "1000",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
val zapRequest = account.createZapRequestFor(toUserPubKeyHex)
|
||||
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount * 1000,
|
||||
message,
|
||||
zapRequest?.toJson(),
|
||||
onSuccess = {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
|
||||
startActivity(context, intent, null)
|
||||
}
|
||||
onClose()
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.send_sats), color = Color.White, fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,28 +8,20 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.text.Paragraph
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -41,9 +33,9 @@ import com.halilibo.richtext.markdown.MarkdownParseOptions
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.Nip19
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import java.net.MalformedURLException
|
||||
@@ -61,238 +53,234 @@ val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)")
|
||||
val urlPattern: Pattern = Patterns.WEB_URL
|
||||
|
||||
fun isValidURL(url: String?): Boolean {
|
||||
return try {
|
||||
URL(url).toURI()
|
||||
true
|
||||
} catch (e: MalformedURLException) {
|
||||
false
|
||||
} catch (e: URISyntaxException) {
|
||||
false
|
||||
}
|
||||
return try {
|
||||
URL(url).toURI()
|
||||
true
|
||||
} catch (e: MalformedURLException) {
|
||||
false
|
||||
} catch (e: URISyntaxException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
|
||||
val myMarkDownStyle = RichTextStyle().resolveDefaults().copy(
|
||||
codeBlockStyle = RichTextStyle().resolveDefaults().codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
val myMarkDownStyle = RichTextStyle().resolveDefaults().copy(
|
||||
codeBlockStyle = RichTextStyle().resolveDefaults().codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor))
|
||||
),
|
||||
stringStyle = RichTextStyle().resolveDefaults().stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
),
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
|
||||
)
|
||||
)
|
||||
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor))
|
||||
),
|
||||
stringStyle = RichTextStyle().resolveDefaults().stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
),
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Column(modifier = modifier.animateContentSize()) {
|
||||
|
||||
if ( content.startsWith("# ")
|
||||
|| content.contains("##")
|
||||
|| content.contains("**")
|
||||
|| content.contains("__")
|
||||
|| content.contains("```")
|
||||
) {
|
||||
|
||||
MaterialRichText(
|
||||
style = myMarkDownStyle,
|
||||
) {
|
||||
Markdown(
|
||||
content = content,
|
||||
markdownParseOptions = MarkdownParseOptions.Default,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
content.split('\n').forEach { paragraph ->
|
||||
FlowRow() {
|
||||
val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ');
|
||||
s.forEach { word: String ->
|
||||
if (canPreview) {
|
||||
// Explicit URL
|
||||
val lnInvoice = LnInvoiceUtil.findInvoice(word)
|
||||
if (lnInvoice != null) {
|
||||
InvoicePreview(lnInvoice)
|
||||
} else if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
ZoomableImageView(word)
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
VideoView(word)
|
||||
} else {
|
||||
UrlPreview(word, word)
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
ClickablePhone(word)
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
UrlPreview("https://$word", word)
|
||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
Column(modifier = modifier.animateContentSize()) {
|
||||
if (content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("**") ||
|
||||
content.contains("__") ||
|
||||
content.contains("```")
|
||||
) {
|
||||
MaterialRichText(
|
||||
style = myMarkDownStyle
|
||||
) {
|
||||
Markdown(
|
||||
content = content,
|
||||
markdownParseOptions = MarkdownParseOptions.Default
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (isValidURL(word)) {
|
||||
ClickableUrl("$word ", word)
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
ClickablePhone(word)
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
ClickableUrl(word, "https://$word")
|
||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
content.split('\n').forEach { paragraph ->
|
||||
FlowRow() {
|
||||
val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ')
|
||||
s.forEach { word: String ->
|
||||
if (canPreview) {
|
||||
// Explicit URL
|
||||
val lnInvoice = LnInvoiceUtil.findInvoice(word)
|
||||
if (lnInvoice != null) {
|
||||
InvoicePreview(lnInvoice)
|
||||
} else if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
ZoomableImageView(word)
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
VideoView(word)
|
||||
} else {
|
||||
UrlPreview(word, word)
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
ClickablePhone(word)
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
UrlPreview("https://$word", word)
|
||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (isValidURL(word)) {
|
||||
ClickableUrl("$word ", word)
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
ClickablePhone(word)
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
ClickableUrl(word, "https://$word")
|
||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isArabic(text: String): Boolean {
|
||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||
}
|
||||
|
||||
|
||||
fun isBechLink(word: String): Boolean {
|
||||
return word.startsWith("nostr:", true)
|
||||
|| word.startsWith("npub1", true)
|
||||
|| word.startsWith("note1", true)
|
||||
|| word.startsWith("nprofile1", true)
|
||||
|| word.startsWith("nevent1", true)
|
||||
|| word.startsWith("@npub1", true)
|
||||
|| word.startsWith("@note1", true)
|
||||
|| word.startsWith("@nprofile1", true)
|
||||
|| word.startsWith("@nevent1", true)
|
||||
return word.startsWith("nostr:", true) ||
|
||||
word.startsWith("npub1", true) ||
|
||||
word.startsWith("note1", true) ||
|
||||
word.startsWith("nprofile1", true) ||
|
||||
word.startsWith("nevent1", true) ||
|
||||
word.startsWith("@npub1", true) ||
|
||||
word.startsWith("@note1", true) ||
|
||||
word.startsWith("@nprofile1", true) ||
|
||||
word.startsWith("@nevent1", true)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BechLink(word: String, navController: NavController) {
|
||||
val uri = if (word.startsWith("nostr", true)) {
|
||||
word
|
||||
} else if (word.startsWith("@")) {
|
||||
word.replaceFirst("@", "nostr:")
|
||||
} else {
|
||||
"nostr:${word}"
|
||||
}
|
||||
val uri = if (word.startsWith("nostr", true)) {
|
||||
word
|
||||
} else if (word.startsWith("@")) {
|
||||
word.replaceFirst("@", "nostr:")
|
||||
} else {
|
||||
"nostr:$word"
|
||||
}
|
||||
|
||||
val nip19Route = try {
|
||||
Nip19().uriToRoute(uri)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
val nip19Route = try {
|
||||
Nip19().uriToRoute(uri)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (nip19Route == null) {
|
||||
Text(text = "$word ")
|
||||
} else {
|
||||
ClickableRoute(nip19Route, navController)
|
||||
}
|
||||
if (nip19Route == null) {
|
||||
Text(text = "$word ")
|
||||
} else {
|
||||
ClickableRoute(nip19Route, navController)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val matcher = tagIndex.matcher(word)
|
||||
val matcher = tagIndex.matcher(word)
|
||||
|
||||
val index = try {
|
||||
matcher.find()
|
||||
matcher.group(1).toInt()
|
||||
} catch (e: Exception) {
|
||||
println("Couldn't link tag ${word}")
|
||||
null
|
||||
}
|
||||
val index = try {
|
||||
matcher.find()
|
||||
matcher.group(1).toInt()
|
||||
} catch (e: Exception) {
|
||||
println("Couldn't link tag $word")
|
||||
null
|
||||
}
|
||||
|
||||
if (index == null) {
|
||||
return Text(text = "$word ")
|
||||
}
|
||||
if (index == null) {
|
||||
return Text(text = "$word ")
|
||||
}
|
||||
|
||||
if (index >= 0 && index < tags.size) {
|
||||
if (tags[index][0] == "p") {
|
||||
val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1])
|
||||
if (baseUser != null) {
|
||||
val userState = baseUser.live().metadata.observeAsState()
|
||||
val user = userState.value?.user
|
||||
if (user != null) {
|
||||
ClickableUserTag(user, navController)
|
||||
if (index >= 0 && index < tags.size) {
|
||||
if (tags[index][0] == "p") {
|
||||
val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1])
|
||||
if (baseUser != null) {
|
||||
val userState = baseUser.live().metadata.observeAsState()
|
||||
val user = userState.value?.user
|
||||
if (user != null) {
|
||||
ClickableUserTag(user, navController)
|
||||
} else {
|
||||
Text(text = "$word ")
|
||||
}
|
||||
} else {
|
||||
// if here the tag is not a valid Nostr Hex
|
||||
Text(text = "$word ")
|
||||
}
|
||||
} else if (tags[index][0] == "e") {
|
||||
val note = LocalCache.checkGetOrCreateNote(tags[index][1])
|
||||
if (note != null) {
|
||||
if (canPreview) {
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
} else {
|
||||
ClickableNoteTag(note, navController)
|
||||
}
|
||||
} else {
|
||||
// if here the tag is not a valid Nostr Hex
|
||||
Text(text = "$word ")
|
||||
}
|
||||
} else {
|
||||
Text(text = "$word ")
|
||||
Text(text = "$word ")
|
||||
}
|
||||
} else {
|
||||
// if here the tag is not a valid Nostr Hex
|
||||
Text(text = "$word ")
|
||||
}
|
||||
} else if (tags[index][0] == "e") {
|
||||
val note = LocalCache.checkGetOrCreateNote(tags[index][1])
|
||||
if (note != null) {
|
||||
if (canPreview) {
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
} else {
|
||||
ClickableNoteTag(note, navController)
|
||||
}
|
||||
} else {
|
||||
// if here the tag is not a valid Nostr Hex
|
||||
Text(text = "$word ")
|
||||
}
|
||||
} else
|
||||
Text(text = "$word ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+198
-191
@@ -39,209 +39,216 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||
import com.vitorpamplona.amethyst.service.lang.ResultOrError
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TranslateableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val translatedTextState = remember {
|
||||
mutableStateOf(ResultOrError(content, null, null, null))
|
||||
}
|
||||
|
||||
var showOriginal by remember { mutableStateOf(false) }
|
||||
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
LaunchedEffect(accountState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
LanguageTranslatorService.autoTranslate(
|
||||
content,
|
||||
account.dontTranslateFrom,
|
||||
account.translateTo
|
||||
).addOnCompleteListener { task ->
|
||||
if (task.isSuccessful && content != task.result.result) {
|
||||
if (task.result.sourceLang != null && task.result.targetLang != null) {
|
||||
val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!)
|
||||
showOriginal = preference == task.result.sourceLang
|
||||
}
|
||||
translatedTextState.value = task.result
|
||||
} else {
|
||||
translatedTextState.value = ResultOrError(content, null, null, null)
|
||||
}
|
||||
}
|
||||
val translatedTextState = remember {
|
||||
mutableStateOf(ResultOrError(content, null, null, null))
|
||||
}
|
||||
}
|
||||
|
||||
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
|
||||
var showOriginal by remember { mutableStateOf(false) }
|
||||
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.padding(top = 5.dp)) {
|
||||
ExpandableRichTextViewer(
|
||||
toBeViewed,
|
||||
canPreview,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController,
|
||||
)
|
||||
val context = LocalContext.current
|
||||
|
||||
val target = translatedTextState.value.targetLang
|
||||
val source = translatedTextState.value.sourceLang
|
||||
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
if (source != null && target != null) {
|
||||
if (source != target) {
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 5.dp)) {
|
||||
val clickableTextStyle =
|
||||
SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
|
||||
|
||||
val annotatedTranslationString = buildAnnotatedString {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("langSettings", true.toString())
|
||||
append(stringResource(R.string.auto))
|
||||
}
|
||||
|
||||
append("-${stringResource(R.string.translated_from)} ")
|
||||
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("showOriginal", true.toString())
|
||||
append(Locale(source).displayName)
|
||||
}
|
||||
|
||||
append(" ${stringResource(R.string.to)} ")
|
||||
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("showOriginal", false.toString())
|
||||
append(Locale(target).displayName)
|
||||
}
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = annotatedTranslationString,
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
|
||||
overflow = TextOverflow.Visible,
|
||||
maxLines = 3
|
||||
) { spanOffset ->
|
||||
annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
|
||||
.firstOrNull()
|
||||
?.also { span ->
|
||||
if (span.tag == "showOriginal")
|
||||
showOriginal = span.item.toBoolean()
|
||||
else
|
||||
langSettingsPopupExpanded = !langSettingsPopupExpanded
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = langSettingsPopupExpanded,
|
||||
onDismissRequest = { langSettingsPopupExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.dontTranslateFrom(source, context)
|
||||
langSettingsPopupExpanded = false
|
||||
}) {
|
||||
if (source in account.dontTranslateFrom)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
else
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(stringResource(R.string.never_translate_from) + "${Locale(source).displayName}")
|
||||
}
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.prefer(source, target, source)
|
||||
langSettingsPopupExpanded = false
|
||||
}) {
|
||||
if (account.preferenceBetween(source, target) == source)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
else
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(
|
||||
"${stringResource(R.string.show_in)} ${Locale(source).displayName} ${
|
||||
stringResource(
|
||||
R.string.first
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.prefer(source, target, target)
|
||||
langSettingsPopupExpanded = false
|
||||
}) {
|
||||
if (account.preferenceBetween(source, target) == target)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
else
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(
|
||||
"${stringResource(R.string.show_in)} ${Locale(target).displayName} ${
|
||||
stringResource(
|
||||
R.string.first
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
|
||||
val languageList =
|
||||
ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
||||
for (i in 0 until languageList.size()) {
|
||||
languageList.get(i)?.let { lang ->
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.translateTo(lang, context)
|
||||
langSettingsPopupExpanded = false
|
||||
}) {
|
||||
if (lang.language in account.translateTo)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
else
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text("${stringResource(R.string.always_translate_to)}${lang.displayName}")
|
||||
LaunchedEffect(accountState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
LanguageTranslatorService.autoTranslate(
|
||||
content,
|
||||
account.dontTranslateFrom,
|
||||
account.translateTo
|
||||
).addOnCompleteListener { task ->
|
||||
if (task.isSuccessful && content != task.result.result) {
|
||||
if (task.result.sourceLang != null && task.result.targetLang != null) {
|
||||
val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!)
|
||||
showOriginal = preference == task.result.sourceLang
|
||||
}
|
||||
translatedTextState.value = task.result
|
||||
} else {
|
||||
translatedTextState.value = ResultOrError(content, null, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
|
||||
|
||||
Column(modifier = Modifier.padding(top = 5.dp)) {
|
||||
ExpandableRichTextViewer(
|
||||
toBeViewed,
|
||||
canPreview,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
|
||||
val target = translatedTextState.value.targetLang
|
||||
val source = translatedTextState.value.sourceLang
|
||||
|
||||
if (source != null && target != null) {
|
||||
if (source != target) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 5.dp)
|
||||
) {
|
||||
val clickableTextStyle =
|
||||
SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
|
||||
|
||||
val annotatedTranslationString = buildAnnotatedString {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("langSettings", true.toString())
|
||||
append(stringResource(R.string.auto))
|
||||
}
|
||||
|
||||
append("-${stringResource(R.string.translated_from)} ")
|
||||
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("showOriginal", true.toString())
|
||||
append(Locale(source).displayName)
|
||||
}
|
||||
|
||||
append(" ${stringResource(R.string.to)} ")
|
||||
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("showOriginal", false.toString())
|
||||
append(Locale(target).displayName)
|
||||
}
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = annotatedTranslationString,
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
|
||||
overflow = TextOverflow.Visible,
|
||||
maxLines = 3
|
||||
) { spanOffset ->
|
||||
annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
|
||||
.firstOrNull()
|
||||
?.also { span ->
|
||||
if (span.tag == "showOriginal") {
|
||||
showOriginal = span.item.toBoolean()
|
||||
} else {
|
||||
langSettingsPopupExpanded = !langSettingsPopupExpanded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = langSettingsPopupExpanded,
|
||||
onDismissRequest = { langSettingsPopupExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.dontTranslateFrom(source, context)
|
||||
langSettingsPopupExpanded = false
|
||||
}) {
|
||||
if (source in account.dontTranslateFrom) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(stringResource(R.string.never_translate_from) + "${Locale(source).displayName}")
|
||||
}
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.prefer(source, target, source)
|
||||
langSettingsPopupExpanded = false
|
||||
}) {
|
||||
if (account.preferenceBetween(source, target) == source) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(
|
||||
"${stringResource(R.string.show_in)} ${Locale(source).displayName} ${
|
||||
stringResource(
|
||||
R.string.first
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.prefer(source, target, target)
|
||||
langSettingsPopupExpanded = false
|
||||
}) {
|
||||
if (account.preferenceBetween(source, target) == target) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(
|
||||
"${stringResource(R.string.show_in)} ${Locale(target).displayName} ${
|
||||
stringResource(
|
||||
R.string.first
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
|
||||
val languageList =
|
||||
ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
||||
for (i in 0 until languageList.size()) {
|
||||
languageList.get(i)?.let { lang ->
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.translateTo(lang, context)
|
||||
langSettingsPopupExpanded = false
|
||||
}) {
|
||||
if (lang.language in account.translateTo) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text("${stringResource(R.string.always_translate_to)}${lang.displayName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,56 +16,57 @@ import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
@Composable
|
||||
fun UrlPreview(url: String, urlText: String) {
|
||||
val default = UrlCachedPreviewer.cache[url]?.let {
|
||||
if (it.url == url)
|
||||
UrlPreviewState.Loaded(it)
|
||||
else
|
||||
UrlPreviewState.Empty
|
||||
val default = UrlCachedPreviewer.cache[url]?.let {
|
||||
if (it.url == url) {
|
||||
UrlPreviewState.Loaded(it)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
} ?: UrlPreviewState.Loading
|
||||
var context = LocalContext.current
|
||||
|
||||
} ?: UrlPreviewState.Loading
|
||||
var context = LocalContext.current
|
||||
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
|
||||
|
||||
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
|
||||
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
|
||||
LaunchedEffect(url) {
|
||||
if (urlPreviewState == UrlPreviewState.Loading) {
|
||||
withContext(Dispatchers.IO) {
|
||||
UrlCachedPreviewer.previewInfo(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
if (urlInfo.allFetchComplete() && urlInfo.url == url) {
|
||||
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
urlPreviewState = UrlPreviewState.Empty
|
||||
}
|
||||
}
|
||||
|
||||
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
|
||||
LaunchedEffect(url) {
|
||||
if (urlPreviewState == UrlPreviewState.Loading) {
|
||||
withContext(Dispatchers.IO) {
|
||||
UrlCachedPreviewer.previewInfo(url, object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
if (urlInfo.allFetchComplete() && urlInfo.url == url)
|
||||
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
|
||||
else
|
||||
urlPreviewState = UrlPreviewState.Empty
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
urlPreviewState = UrlPreviewState.Error(
|
||||
context.getString(
|
||||
R.string.error_parsing_preview_for,
|
||||
url,
|
||||
throwable.message
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
urlPreviewState = UrlPreviewState.Error(
|
||||
context.getString(
|
||||
R.string.error_parsing_preview_for,
|
||||
url,
|
||||
throwable.message
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = urlPreviewState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
else -> {
|
||||
ClickableUrl(urlText, url)
|
||||
}
|
||||
Crossfade(targetState = urlPreviewState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
else -> {
|
||||
ClickableUrl(urlText, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -25,51 +25,52 @@ import java.net.URL
|
||||
|
||||
@Composable
|
||||
fun UrlPreviewCard(
|
||||
url: String,
|
||||
previewInfo: UrlInfoItem
|
||||
url: String,
|
||||
previewInfo: UrlInfoItem
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
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 {
|
||||
// correctly treating relative images
|
||||
val imageUrl = if (previewInfo.image.startsWith("/"))
|
||||
URL(URL(previewInfo.url), previewInfo.image).toString()
|
||||
else
|
||||
previewInfo.image
|
||||
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, url),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = previewInfo.title,
|
||||
style = MaterialTheme.typography.body2,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
.clickable { runCatching { uri.openUri(url) } }
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||
) {
|
||||
Column {
|
||||
// correctly treating relative images
|
||||
val imageUrl = if (previewInfo.image.startsWith("/")) {
|
||||
URL(URL(previewInfo.url), previewInfo.image).toString()
|
||||
} else {
|
||||
previewInfo.image
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, url),
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import com.baha.url.preview.UrlInfoItem
|
||||
|
||||
sealed class UrlPreviewState {
|
||||
object Loading: UrlPreviewState()
|
||||
class Loaded(val previewInfo: UrlInfoItem): UrlPreviewState()
|
||||
object Empty: UrlPreviewState()
|
||||
class Error(val errorMessage: String): UrlPreviewState()
|
||||
object Loading : UrlPreviewState()
|
||||
class Loaded(val previewInfo: UrlInfoItem) : UrlPreviewState()
|
||||
object Empty : UrlPreviewState()
|
||||
class Error(val errorMessage: String) : UrlPreviewState()
|
||||
}
|
||||
|
||||
@@ -20,35 +20,36 @@ import com.vitorpamplona.amethyst.VideoCache
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: String) {
|
||||
val context = LocalContext.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(exoPlayer) {
|
||||
exoPlayer.setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
|
||||
)
|
||||
exoPlayer.prepare()
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
factory = {
|
||||
StyledPlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
DisposableEffect(exoPlayer) {
|
||||
exoPlayer.setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
|
||||
)
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
}
|
||||
})
|
||||
}
|
||||
exoPlayer.prepare()
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
factory = {
|
||||
StyledPlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ fun ZoomableAsyncImage(imageUrl: String) {
|
||||
scaleY = scale,
|
||||
translationX = offsetX,
|
||||
translationY = offsetY
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,56 +34,56 @@ import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
||||
@Composable
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
|
||||
fun ZoomableImageView(word: String) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var dialogOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
// store the dialog open or close state
|
||||
var dialogOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = word,
|
||||
contentDescription = word,
|
||||
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))
|
||||
.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(word)) },
|
||||
)
|
||||
)
|
||||
AsyncImage(
|
||||
model = word,
|
||||
contentDescription = word,
|
||||
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))
|
||||
.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
|
||||
)
|
||||
)
|
||||
|
||||
if (dialogOpen) {
|
||||
ZoomableImageDialog(word, onDismiss = { dialogOpen = false })
|
||||
}
|
||||
if (dialogOpen) {
|
||||
ZoomableImageDialog(word, onDismiss = { dialogOpen = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onDismiss)
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onDismiss)
|
||||
|
||||
SaveToGallery(url = imageUrl)
|
||||
SaveToGallery(url = imageUrl)
|
||||
}
|
||||
|
||||
ZoomableAsyncImage(imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
ZoomableAsyncImage(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user