mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
197 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e956f51f19 | ||
|
|
165678f118 | ||
|
|
d32d2da280 | ||
|
|
d6a6a52821 | ||
|
|
dfb697975f | ||
|
|
c0c0fd9e6d | ||
|
|
5bdcdc6f4a | ||
|
|
102ab63673 | ||
|
|
b34c1f98d5 | ||
|
|
74b05e3d6a | ||
|
|
972bce426a | ||
|
|
3de57d6ae2 | ||
|
|
5695a82c20 | ||
|
|
e171120836 | ||
|
|
abdfe531f7 | ||
|
|
ca90866877 | ||
|
|
450fa2b70d | ||
|
|
93c9ffeb02 | ||
|
|
2e5e546e7d | ||
|
|
00e7add001 | ||
|
|
e28cd2212e | ||
|
|
8f38353271 | ||
|
|
a41ee653d8 | ||
|
|
839be06f62 | ||
|
|
bbef06de59 | ||
|
|
515159f18c | ||
|
|
e7b7ebbbdf | ||
|
|
5965d9a3bb | ||
|
|
31959721b1 | ||
|
|
8e16daaaa3 | ||
|
|
e9fd47a536 | ||
|
|
6191ef9a6b | ||
|
|
05aa12ebac | ||
|
|
42428c5e0f | ||
|
|
1a3685c5ab | ||
|
|
fff892e1c8 | ||
|
|
99c30816c6 | ||
|
|
92c61f317b | ||
|
|
b0e50e0c00 | ||
|
|
f065dcfc5a | ||
|
|
91f34000f5 | ||
|
|
93e2af3c1f | ||
|
|
6072e5977e | ||
|
|
8575ea018d | ||
|
|
ed919ae531 | ||
|
|
3eb832c4e0 | ||
|
|
a57ca2a79d | ||
|
|
07a681c002 | ||
|
|
cb561d432c | ||
|
|
d942c12627 | ||
|
|
a466f33b4e | ||
|
|
3744f90e87 | ||
|
|
1cc0f4bf80 | ||
|
|
dec409c209 | ||
|
|
c664397b11 | ||
|
|
bc1b71e9bf | ||
|
|
a42606af0d | ||
|
|
b301e1d79b | ||
|
|
5ab94e8a1c | ||
|
|
7ad982ec71 | ||
|
|
030064e112 | ||
|
|
d9a392d2c1 | ||
|
|
97cd1c5e30 | ||
|
|
24a6d8a155 | ||
|
|
ea185147b3 | ||
|
|
f2db2b88c2 | ||
|
|
6ea0972151 | ||
|
|
093705f3b6 | ||
|
|
1972297755 | ||
|
|
ac00694690 | ||
|
|
605861dc32 | ||
|
|
43d508ba33 | ||
|
|
e692647d58 | ||
|
|
64f98e4b92 | ||
|
|
c58c20cc54 | ||
|
|
89ddb7f4b7 | ||
|
|
cb92a51191 | ||
|
|
213b172c32 | ||
|
|
cfdd470626 | ||
|
|
a5a3c62edd | ||
|
|
c9bcfa1c17 | ||
|
|
02ad85a740 | ||
|
|
446273de80 | ||
|
|
b513877552 | ||
|
|
013eedd892 | ||
|
|
d2cbb27756 | ||
|
|
1a477ea1c8 | ||
|
|
83df639aff | ||
|
|
f298cc6f13 | ||
|
|
300a70ce18 | ||
|
|
0f6bf1cc59 | ||
|
|
6bc1b6b28a | ||
|
|
1c381f64f9 | ||
|
|
5e3703e849 | ||
|
|
acbd41ce61 | ||
|
|
056d00b73b | ||
|
|
72fc21cdc6 | ||
|
|
3e983cdf1c | ||
|
|
d81af9a194 | ||
|
|
7f6b44cd0b | ||
|
|
88bad1e308 | ||
|
|
aa0a4d1aac | ||
|
|
b0a61cce42 | ||
|
|
b3be4567e2 | ||
|
|
c5a586ec56 | ||
|
|
ece3664e74 | ||
|
|
2297c797dc | ||
|
|
4517ca9fdd | ||
|
|
1146b8ad54 | ||
|
|
545643d165 | ||
|
|
359e22ae42 | ||
|
|
69be28ec98 | ||
|
|
a8ffbb922d | ||
|
|
bd4a450336 | ||
|
|
39818268ab | ||
|
|
d26b83a851 | ||
|
|
8dc9c2ed2f | ||
|
|
860f3bd096 | ||
|
|
a3adadb251 | ||
|
|
755d89dc79 | ||
|
|
fa4463f1d7 | ||
|
|
56a68b9100 | ||
|
|
fe317964b8 | ||
|
|
07ccf75d44 | ||
|
|
ff927d4830 | ||
|
|
6461c3e498 | ||
|
|
2ed5eae029 | ||
|
|
1693455d0d | ||
|
|
3bcda33fa6 | ||
|
|
9f394900a7 | ||
|
|
4f2c78e73a | ||
|
|
6a3600fd73 | ||
|
|
b40c6f1361 | ||
|
|
5ce19474ae | ||
|
|
e9bdc045ba | ||
|
|
aed7f46ca0 | ||
|
|
310a93ef29 | ||
|
|
1ccaa1a028 | ||
|
|
493888d121 | ||
|
|
e5aba62fdd | ||
|
|
b643cedf8f | ||
|
|
2c0bfe2118 | ||
|
|
f1ecaf1ee5 | ||
|
|
4841d4004e | ||
|
|
75d56214d2 | ||
|
|
f0ba68e467 | ||
|
|
5a375b94d6 | ||
|
|
dcbd4148e2 | ||
|
|
0015100dea | ||
|
|
8898e5d10b | ||
|
|
15621f16a2 | ||
|
|
a29d9ad433 | ||
|
|
d355f22938 | ||
|
|
2082c6cb28 | ||
|
|
4b49cc1aab | ||
|
|
538e61b38e | ||
|
|
93f8847544 | ||
|
|
a1da4affeb | ||
|
|
0bf94f316f | ||
|
|
a3fc22a04a | ||
|
|
6437a2106a | ||
|
|
0a8c79dacd | ||
|
|
22dbaaf95a | ||
|
|
278091e6ef | ||
|
|
d99711d3e6 | ||
|
|
48f8cd7d95 | ||
|
|
3b816559e4 | ||
|
|
e96a50b454 | ||
|
|
93282b71ae | ||
|
|
8223476cb3 | ||
|
|
460187c4a4 | ||
|
|
5da49c920b | ||
|
|
df5b5b90af | ||
|
|
e5b937ed7e | ||
|
|
b4b90cfd19 | ||
|
|
99d302802a | ||
|
|
ec2fc2f7e3 | ||
|
|
26634ccb04 | ||
|
|
7766886587 | ||
|
|
b219b3631c | ||
|
|
a0797f7104 | ||
|
|
330f3503a6 | ||
|
|
7da87af0fb | ||
|
|
2eb0096630 | ||
|
|
459e579bcb | ||
|
|
3c069d574e | ||
|
|
639569cd86 | ||
|
|
e41df98920 | ||
|
|
323e71c7cb | ||
|
|
cbe9462f28 | ||
|
|
95febc1c8e | ||
|
|
5d8ffd1e2c | ||
|
|
f2616855c2 | ||
|
|
5040350be5 | ||
|
|
d6c8651f0d | ||
|
|
c5176b8c49 | ||
|
|
d2accf7622 |
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.8.10" />
|
||||
<option name="version" value="1.8.21" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -60,12 +60,12 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
|
||||
- [x] Push Notifications (Zaps and Messages)
|
||||
- [x] Generic Tags (NIP-12)
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Sensitive Content (NIP-36)
|
||||
- [ ] Relay Pages (NIP-11)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Events with a Subject (NIP-14)
|
||||
|
||||
@@ -13,17 +13,19 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 170
|
||||
versionName "0.50.2"
|
||||
versionCode 197
|
||||
versionName "0.55.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW" )
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Make sure all of JSON parsers work when activating these.
|
||||
//minifyEnabled true
|
||||
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
resValue "string", "app_name", "@string/app_name_release"
|
||||
@@ -71,7 +73,7 @@ android {
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion "1.4.3"
|
||||
kotlinCompilerExtensionVersion "1.4.7"
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
@@ -82,12 +84,11 @@ android {
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.activity:activity-compose:1.7.1'
|
||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
@@ -128,17 +129,20 @@ dependencies {
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.0'
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
|
||||
// link preview
|
||||
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||
implementation 'org.jsoup:jsoup:1.16.1'
|
||||
//implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
|
||||
// important for proxy / tor
|
||||
implementation 'com.google.android.exoplayer:extension-okhttp:2.18.7'
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||
@@ -150,8 +154,6 @@ dependencies {
|
||||
// create blurhash
|
||||
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
|
||||
|
||||
// Rendering clickable text
|
||||
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
||||
// Permission to upload pictures:
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
|
||||
@@ -183,8 +185,11 @@ dependencies {
|
||||
implementation "com.patrykandpatrick.vico:views:${vico_version}"
|
||||
implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}"
|
||||
|
||||
// immutable collections to avoid recomposition
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
|
||||
|
||||
// Automatic memory leak detection
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'io.mockk:mockk:1.13.5'
|
||||
|
||||
@@ -3,18 +3,18 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
fun TranslatableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
tags: ImmutableList<List<String>>,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
nav: (String) -> Unit
|
||||
) = ExpandableRichTextViewer(
|
||||
content,
|
||||
canPreview,
|
||||
@@ -22,5 +22,5 @@ fun TranslatableRichTextViewer(
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
nav
|
||||
)
|
||||
|
||||
@@ -30,11 +30,13 @@
|
||||
android:largeHeap="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
tools:targetApi="33">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -30,6 +31,7 @@ import java.util.Locale
|
||||
private const val DEBUG_PLAINTEXT_PREFERENCES = false
|
||||
private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
|
||||
|
||||
@Immutable
|
||||
data class AccountInfo(
|
||||
val npub: String,
|
||||
val hasPrivKey: Boolean = false
|
||||
@@ -58,6 +60,7 @@ private object PrefKeys {
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
|
||||
@@ -214,6 +217,12 @@ object LocalPreferences {
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
|
||||
|
||||
if (account.showSensitiveContent == null) {
|
||||
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
|
||||
} else {
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
|
||||
}
|
||||
}.apply()
|
||||
}
|
||||
|
||||
@@ -251,9 +260,9 @@ object LocalPreferences {
|
||||
) ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val defaultFileServer = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"),
|
||||
getString(PrefKeys.DEFAULT_FILE_SERVER, "NOSTR_BUILD"),
|
||||
object : TypeToken<ServersAvailable>() {}.type
|
||||
) ?: ServersAvailable.IMGUR
|
||||
) ?: ServersAvailable.NOSTR_BUILD
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
@@ -292,6 +301,12 @@ object LocalPreferences {
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
val showSensitiveContent = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
|
||||
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val a = Account(
|
||||
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
followingChannels,
|
||||
@@ -311,7 +326,8 @@ object LocalPreferences {
|
||||
hideBlockAlertDialog,
|
||||
latestContactList,
|
||||
proxy,
|
||||
proxyPort
|
||||
proxyPort,
|
||||
showSensitiveContent
|
||||
)
|
||||
|
||||
return a
|
||||
|
||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object NotificationCache {
|
||||
// TODO: This must be account-based
|
||||
val lastReadByRoute = mutableMapOf<String, Long>()
|
||||
|
||||
fun markAsRead(route: String, timestampInSecs: Long) {
|
||||
@@ -38,14 +39,14 @@ object NotificationCache {
|
||||
|
||||
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
@@ -15,19 +22,37 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
|
||||
object ServiceManager {
|
||||
private var account: Account? = null
|
||||
|
||||
fun start(account: Account) {
|
||||
fun start(account: Account, context: Context) {
|
||||
this.account = account
|
||||
start()
|
||||
start(context)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
fun start(context: Context) {
|
||||
val myAccount = account
|
||||
|
||||
// Resets Proxy Use
|
||||
HttpClient.start(account)
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(context).components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
} // .logger(DebugLogger())
|
||||
.okHttpClient { HttpClient.getHttpClient() }
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(context.applicationContext)
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
@@ -46,9 +71,6 @@ object ServiceManager {
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleChannelDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
} else {
|
||||
// if not logged in yet, start a basic service wit default relays
|
||||
Client.connect(Constants.convertDefaultRelays())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
object VideoCache {
|
||||
|
||||
@@ -31,7 +32,14 @@ object VideoCache {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
} else {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
@@ -43,6 +45,7 @@ val GLOBAL_FOLLOWS = " Global "
|
||||
val KIND3_FOLLOWS = " All Follows "
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@Stable
|
||||
class Account(
|
||||
val loggedIn: Persona,
|
||||
var followingChannels: Set<String> = DefaultChannels,
|
||||
@@ -53,7 +56,7 @@ class Account(
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
|
||||
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
|
||||
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
|
||||
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD,
|
||||
var defaultHomeFollowList: String = KIND3_FOLLOWS,
|
||||
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
|
||||
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
|
||||
@@ -62,7 +65,8 @@ class Account(
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var backupContactList: ContactListEvent? = null,
|
||||
var proxy: Proxy?,
|
||||
var proxyPort: Int
|
||||
var proxyPort: Int,
|
||||
var showSensitiveContent: Boolean? = null
|
||||
) {
|
||||
var transientHiddenUsers: Set<String> = setOf()
|
||||
|
||||
@@ -217,7 +221,12 @@ class Account(
|
||||
zapPaymentRequest?.let { nip47 ->
|
||||
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!)
|
||||
|
||||
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
|
||||
val wcListener = NostrLnZapPaymentResponseDataSource(
|
||||
fromServiceHex = nip47.pubKeyHex,
|
||||
toUserHex = event.pubKey,
|
||||
replyingToHex = event.id,
|
||||
authSigningKey = nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!
|
||||
)
|
||||
wcListener.start()
|
||||
|
||||
LocalCache.consume(event, zappedNote) {
|
||||
@@ -472,7 +481,14 @@ class Account(
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
}
|
||||
|
||||
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null, zapReceiver: String? = null) {
|
||||
fun sendPost(
|
||||
message: String,
|
||||
replyTo: List<Note>?,
|
||||
mentions: List<User>?,
|
||||
tags: List<String>? = null,
|
||||
zapReceiver: String? = null,
|
||||
wantsToMarkAsSensitive: Boolean
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
|
||||
@@ -486,6 +502,7 @@ class Account(
|
||||
addresses = addresses,
|
||||
extraTags = tags,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
@@ -502,7 +519,8 @@ class Account(
|
||||
valueMinimum: Int?,
|
||||
consensusThreshold: Int?,
|
||||
closedAt: Int?,
|
||||
zapReceiver: String? = null
|
||||
zapReceiver: String? = null,
|
||||
wantsToMarkAsSensitive: Boolean
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -521,14 +539,15 @@ class Account(
|
||||
valueMinimum = valueMinimum,
|
||||
consensusThreshold = consensusThreshold,
|
||||
closedAt = closedAt,
|
||||
zapReceiver = zapReceiver
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive
|
||||
)
|
||||
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null) {
|
||||
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
@@ -541,26 +560,27 @@ class Account(
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
}
|
||||
|
||||
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null) {
|
||||
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
|
||||
if (!isWriteable()) return
|
||||
val user = LocalCache.users[toUser] ?: return
|
||||
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
val signedEvent = PrivateDmEvent.create(
|
||||
recipientPubKey = user.pubkey(),
|
||||
publishedRecipientPubKey = user.pubkey(),
|
||||
recipientPubKey = toUser.pubkey(),
|
||||
publishedRecipientPubKey = toUser.pubkey(),
|
||||
msg = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
privateKey = loggedIn.privKey!!,
|
||||
advertiseNip18 = false
|
||||
)
|
||||
@@ -593,18 +613,33 @@ class Account(
|
||||
|
||||
val bookmarks = userProfile().latestBookmarkList
|
||||
|
||||
val event = BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents() ?: emptyList(),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
val event = if (note is AddressableNote) {
|
||||
BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents() ?: emptyList(),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.plus(note.address) ?: listOf(note.address),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents() ?: emptyList(),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
@@ -615,18 +650,33 @@ class Account(
|
||||
|
||||
val bookmarks = userProfile().latestBookmarkList
|
||||
|
||||
val event = BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
val event = if (note is AddressableNote) {
|
||||
BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents() ?: emptyList(),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses()?.plus(note.address) ?: listOf(note.address),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
@@ -637,18 +687,33 @@ class Account(
|
||||
|
||||
val bookmarks = userProfile().latestBookmarkList
|
||||
|
||||
val event = BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents() ?: emptyList(),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
val event = if (note is AddressableNote) {
|
||||
BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents() ?: emptyList(),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.minus(note.address) ?: listOf(),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents() ?: emptyList(),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
@@ -665,18 +730,33 @@ class Account(
|
||||
|
||||
val bookmarks = userProfile().latestBookmarkList
|
||||
|
||||
val event = BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents()?.minus(note.idHex),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
val event = if (note is AddressableNote) {
|
||||
BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents() ?: emptyList(),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses()?.minus(note.address),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents()?.minus(note.idHex),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
@@ -828,15 +908,7 @@ class Account(
|
||||
fun decryptContent(note: Note): String? {
|
||||
val event = note.event
|
||||
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
|
||||
var pubkeyToUse = event.pubKey
|
||||
|
||||
val recepientPK = event.verifiedRecipientPubKey()
|
||||
|
||||
if (note.author == userProfile() && recepientPK != null) {
|
||||
pubkeyToUse = recepientPK
|
||||
}
|
||||
|
||||
event.plainContent(loggedIn.privKey!!, pubkeyToUse.hexToByteArray())
|
||||
event.plainContent(loggedIn.privKey!!, event.talkingWith(userProfile().pubkeyHex).hexToByteArray())
|
||||
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
|
||||
decryptZapContentAuthor(note)?.content()
|
||||
} else {
|
||||
@@ -931,10 +1003,10 @@ class Account(
|
||||
}
|
||||
|
||||
private fun updateContactListTo(newContactList: ContactListEvent?) {
|
||||
if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return
|
||||
if (newContactList == null || newContactList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupContactList?.id != newContactList?.id) {
|
||||
if (backupContactList?.id != newContactList.id) {
|
||||
backupContactList = newContactList
|
||||
saveable.invalidateData()
|
||||
}
|
||||
@@ -1046,6 +1118,12 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun updateShowSensitiveContent(show: Boolean?) {
|
||||
showSensitiveContent = show
|
||||
saveable.invalidateData()
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun registerObservers() {
|
||||
// Observes relays to restart connections
|
||||
userProfile().live().relays.observeForever {
|
||||
@@ -1056,7 +1134,9 @@ class Account(
|
||||
|
||||
// saves contact list for the next time.
|
||||
userProfile().live().follows.observeForever {
|
||||
updateContactListTo(userProfile().latestContactList)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
updateContactListTo(userProfile().latestContactList)
|
||||
}
|
||||
}
|
||||
|
||||
// imports transient blocks due to spam.
|
||||
@@ -1067,6 +1147,7 @@ class Account(
|
||||
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
|
||||
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
|
||||
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
|
||||
live.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1086,14 +1167,14 @@ class Account(
|
||||
|
||||
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Default) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Default)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
@@ -1101,4 +1182,5 @@ class AccountLiveData(private val account: Account) : LiveData<AccountState>(Acc
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class AccountState(val account: Account)
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.vitorpamplona.amethyst.model
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,6 +18,8 @@ class AntiSpamFilter {
|
||||
|
||||
@Synchronized
|
||||
fun isSpam(event: Event, relay: Relay?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
val idHex = event.id
|
||||
|
||||
// if short message, ok
|
||||
@@ -28,6 +32,7 @@ class AntiSpamFilter {
|
||||
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
|
||||
|
||||
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
|
||||
Log.w("Potential SPAM Message for sharing", "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}")
|
||||
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
|
||||
|
||||
// Log down offenders
|
||||
@@ -55,14 +60,14 @@ class AntiSpamFilter {
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
@@ -9,6 +10,7 @@ import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Stable
|
||||
class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
@@ -33,6 +35,10 @@ class Channel(val idHex: String) {
|
||||
notes.remove(note.idHex)
|
||||
}
|
||||
|
||||
fun removeNote(noteHex: String) {
|
||||
notes.remove(noteHex)
|
||||
}
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
@@ -74,14 +80,14 @@ class Channel(val idHex: String) {
|
||||
|
||||
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
@@ -13,7 +12,6 @@ import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import nostr.postr.toNpub
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
@@ -23,12 +21,6 @@ import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object LocalCache {
|
||||
val metadataParser by lazy {
|
||||
jacksonObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.readerFor(UserMetadata::class.java)
|
||||
}
|
||||
|
||||
val antiSpam = AntiSpamFilter()
|
||||
|
||||
val users = ConcurrentHashMap<HexKey, User>(5000)
|
||||
@@ -40,6 +32,8 @@ object LocalCache {
|
||||
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (isValidHexNpub(key)) {
|
||||
return getOrCreateUser(key)
|
||||
}
|
||||
@@ -48,6 +42,8 @@ object LocalCache {
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateUser(key: HexKey): User {
|
||||
// checkNotInMainThread()
|
||||
|
||||
return users[key] ?: run {
|
||||
val answer = User(key)
|
||||
users.put(key, answer)
|
||||
@@ -56,6 +52,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun checkGetOrCreateNote(key: String): Note? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (ATag.isATag(key)) {
|
||||
return checkGetOrCreateAddressableNote(key)
|
||||
}
|
||||
@@ -67,6 +65,8 @@ object LocalCache {
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateNote(idHex: String): Note {
|
||||
checkNotInMainThread()
|
||||
|
||||
return notes[idHex] ?: run {
|
||||
val answer = Note(idHex)
|
||||
notes.put(idHex, answer)
|
||||
@@ -75,6 +75,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun checkGetOrCreateChannel(key: String): Channel? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (isValidHexNpub(key)) {
|
||||
return getOrCreateChannel(key)
|
||||
}
|
||||
@@ -93,6 +95,8 @@ object LocalCache {
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateChannel(key: String): Channel {
|
||||
checkNotInMainThread()
|
||||
|
||||
return channels[key] ?: run {
|
||||
val answer = Channel(key)
|
||||
channels.put(key, answer)
|
||||
@@ -116,6 +120,8 @@ object LocalCache {
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
|
||||
checkNotInMainThread()
|
||||
|
||||
// we can't use naddr here because naddr might include relay info and
|
||||
// the preferred relay should not be part of the index.
|
||||
return addressables[key.toTag()] ?: run {
|
||||
@@ -130,18 +136,10 @@ object LocalCache {
|
||||
// new event
|
||||
val oldUser = getOrCreateUser(event.pubKey)
|
||||
if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) {
|
||||
val newUser = try {
|
||||
metadataParser.readValue(
|
||||
ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)),
|
||||
UserMetadata::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error ${e.localizedMessage} ${event.content}")
|
||||
return
|
||||
val newUserMetadata = event.contactMetaData()
|
||||
if (newUserMetadata != null) {
|
||||
oldUser.updateUserInfo(newUserMetadata, event)
|
||||
}
|
||||
|
||||
oldUser.updateUserInfo(newUser, event)
|
||||
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
|
||||
} else {
|
||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
@@ -285,12 +283,13 @@ object LocalCache {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: AudioTrackEvent) {
|
||||
@@ -298,11 +297,13 @@ object LocalCache {
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) {
|
||||
@@ -357,6 +358,32 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: AppDefinitionEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: AppRecommendationEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(event: RecommendRelayEvent) {
|
||||
// // Log.d("RR", event.toJson())
|
||||
@@ -429,8 +456,10 @@ object LocalCache {
|
||||
masterNote.removeReport(deleteNote)
|
||||
}
|
||||
|
||||
val channel = deleteNote.channel()
|
||||
channel?.removeNote(deleteNote)
|
||||
deleteNote.channelHex()?.let {
|
||||
val channel = checkGetOrCreateChannel(it)
|
||||
channel?.removeNote(deleteNote)
|
||||
}
|
||||
|
||||
if (deleteNote.event is PrivateDmEvent) {
|
||||
val author = deleteNote.author
|
||||
@@ -505,15 +534,6 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.content == "!" || // nostr_console hide.
|
||||
event.content == "\u26A0\uFE0F" // Warning sign
|
||||
) {
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
@@ -541,9 +561,15 @@ object LocalCache {
|
||||
mentions.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
}
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
} else {
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
|
||||
mentions.forEach {
|
||||
// doesn't add to reports, but triggers recounts
|
||||
it.liveSet?.reports?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
@@ -553,17 +579,20 @@ object LocalCache {
|
||||
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
|
||||
val oldChannel = getOrCreateChannel(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event == null) {
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
if (event.createdAt <= oldChannel.updatedMetadataAt) {
|
||||
return // older data, does nothing
|
||||
}
|
||||
if (oldChannel.creator == null || oldChannel.creator == author) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,16 +607,18 @@ object LocalCache {
|
||||
if (event.createdAt > oldChannel.updatedMetadataAt) {
|
||||
if (oldChannel.creator == null || oldChannel.creator == author) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
} else {
|
||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
}
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
if (note.event == null) {
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: ChannelMessageEvent, relay: Relay?) {
|
||||
@@ -834,6 +865,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun findUsersStartingWith(username: String): List<User> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val key = decodePublicKeyAsHexOrNull(username)
|
||||
|
||||
if (key != null && users[key] != null) {
|
||||
@@ -848,6 +881,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun findNotesStartingWith(text: String): List<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val key = try {
|
||||
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
@@ -873,6 +908,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun findChannelsStartingWith(text: String): List<Channel> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val key = try {
|
||||
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
@@ -901,6 +938,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun pruneOldAndHiddenMessages(account: Account) {
|
||||
checkNotInMainThread()
|
||||
|
||||
channels.forEach { it ->
|
||||
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
|
||||
|
||||
@@ -919,6 +958,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun pruneHiddenMessages(account: Account) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val toBeRemoved = account.hiddenUsers.map {
|
||||
(users[it]?.notes ?: emptySet())
|
||||
}.flatten()
|
||||
@@ -946,6 +987,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun pruneContactLists(userAccount: Account) {
|
||||
checkNotInMainThread()
|
||||
|
||||
var removingContactList = 0
|
||||
users.values.forEach {
|
||||
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
|
||||
@@ -965,10 +1008,14 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun consume(event: Event, relay: Relay?) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (!event.hasValidSignature()) return
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is AppDefinitionEvent -> consume(event)
|
||||
is AppRecommendationEvent -> consume(event)
|
||||
is AudioTrackEvent -> consume(event)
|
||||
is BadgeAwardEvent -> consume(event)
|
||||
is BadgeDefinitionEvent -> consume(event)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
@@ -20,6 +22,7 @@ import java.util.regex.Pattern
|
||||
|
||||
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
|
||||
|
||||
@Stable
|
||||
class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun toNEvent() = address.toNAddr()
|
||||
@@ -28,6 +31,7 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
|
||||
}
|
||||
|
||||
@Stable
|
||||
open class Note(val idHex: String) {
|
||||
// These fields are only available after the Text Note event is received.
|
||||
// They are immutable after that.
|
||||
@@ -69,10 +73,6 @@ open class Note(val idHex: String) {
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
}
|
||||
|
||||
fun channel(): Channel? {
|
||||
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
open fun address(): ATag? = null
|
||||
|
||||
open fun createdAt() = event?.createdAt()
|
||||
@@ -228,7 +228,7 @@ open class Note(val idHex: String) {
|
||||
fun isZappedBy(user: User, account: Account): Boolean {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.any {
|
||||
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
|
||||
it.key.author?.pubkeyHex == user.pubkeyHex || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
|
||||
} || zapPayments.any {
|
||||
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
|
||||
val response = if (zapResponseEvent != null) {
|
||||
@@ -241,11 +241,11 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): Boolean {
|
||||
return reactions.any { it.author === user }
|
||||
return reactions.any { it.author?.pubkeyHex == user.pubkeyHex }
|
||||
}
|
||||
|
||||
fun isBoostedBy(user: User): Boolean {
|
||||
return boosts.any { it.author === user }
|
||||
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): Set<Note> {
|
||||
@@ -410,14 +410,14 @@ class NoteLiveSet(u: Note) {
|
||||
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
// if (hasObservers()) {
|
||||
postValue(NoteState(note))
|
||||
// }
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
// if (hasObservers()) {
|
||||
postValue(NoteState(note))
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
@@ -439,4 +439,5 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class NoteState(val note: Note)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.baha.url.preview.BahaUrlPreview
|
||||
import com.baha.url.preview.IUrlPreviewCallback
|
||||
import com.baha.url.preview.UrlInfoItem
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
|
||||
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
|
||||
object UrlCachedPreviewer {
|
||||
var cache = mapOf<String, UrlInfoItem>()
|
||||
@@ -15,6 +12,8 @@ object UrlCachedPreviewer {
|
||||
private set
|
||||
|
||||
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
|
||||
checkNotInMainThread()
|
||||
|
||||
cache[url]?.let {
|
||||
callback?.onComplete(it)
|
||||
return
|
||||
@@ -25,22 +24,19 @@ object UrlCachedPreviewer {
|
||||
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)
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
failures = failures + Pair(url, throwable)
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
BahaUrlPreview(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache = cache + Pair(url, urlInfo)
|
||||
callback?.onComplete(urlInfo)
|
||||
}
|
||||
).fetchUrlPreview()
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
failures = failures + Pair(url, throwable)
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
}
|
||||
).fetchUrlPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
@@ -20,6 +23,7 @@ import java.util.regex.Pattern
|
||||
|
||||
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
|
||||
|
||||
@Stable
|
||||
class User(val pubkeyHex: String) {
|
||||
var info: UserMetadata? = null
|
||||
|
||||
@@ -182,6 +186,8 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
@Synchronized
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
checkNotInMainThread()
|
||||
|
||||
return privateChatrooms[user] ?: run {
|
||||
val privateChatroom = Chatroom(setOf<Note>())
|
||||
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
|
||||
@@ -191,6 +197,8 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
@Synchronized
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
|
||||
@@ -200,6 +208,8 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
@Synchronized
|
||||
fun removeMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
|
||||
@@ -246,15 +256,11 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean {
|
||||
return latestContactList?.unverifiedFollowKeySet()?.toSet()?.let {
|
||||
return user.pubkeyHex in it
|
||||
} ?: false
|
||||
return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
|
||||
}
|
||||
|
||||
fun isFollowingHashtag(tag: String): Boolean {
|
||||
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
|
||||
return tag in it
|
||||
} ?: false
|
||||
return latestContactList?.isTaggedHash(tag) ?: false
|
||||
}
|
||||
|
||||
fun isFollowingHashtagCached(tag: String): Boolean {
|
||||
@@ -269,11 +275,17 @@ class User(val pubkeyHex: String) {
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingCached(userHex: String): Boolean {
|
||||
return latestContactList?.verifiedFollowKeySet?.let {
|
||||
return userHex in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun transientFollowCount(): Int? {
|
||||
return latestContactList?.unverifiedFollowKeySet()?.size
|
||||
}
|
||||
|
||||
fun transientFollowerCount(): Int {
|
||||
suspend fun transientFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
@@ -289,14 +301,14 @@ class User(val pubkeyHex: String) {
|
||||
return latestContactList?.verifiedFollowKeySet?.size
|
||||
}
|
||||
|
||||
fun cachedFollowerCount(): Int {
|
||||
suspend fun cachedFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun hasSentMessagesTo(user: User?): Boolean {
|
||||
val messagesToUser = privateChatrooms[user] ?: return false
|
||||
|
||||
return messagesToUser.roomMessages.any { this === it.author }
|
||||
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
|
||||
}
|
||||
|
||||
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
|
||||
@@ -358,6 +370,7 @@ data class RelayInfo(
|
||||
|
||||
data class Chatroom(var roomMessages: Set<Note>)
|
||||
|
||||
@Stable
|
||||
class UserMetadata {
|
||||
var name: String? = null
|
||||
var username: String? = null
|
||||
@@ -384,6 +397,10 @@ class UserMetadata {
|
||||
var updatedMetadataAt: Long = 0
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
|
||||
fun anyName(): String? {
|
||||
return display_name ?: displayName ?: name ?: username
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
|
||||
.any { it.startsWith(prefix, true) }
|
||||
@@ -396,14 +413,14 @@ class UserMetadata {
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
@@ -417,4 +434,5 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class UserState(val user: User)
|
||||
|
||||
@@ -20,6 +20,8 @@ class BlurHashFetcher(
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val encodedHash = data.toString().removePrefix("bluehash:")
|
||||
val hash = URLDecoder.decode(encodedHash, "utf-8")
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.os.Looper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
|
||||
fun checkNotInMainThread() {
|
||||
if (BuildConfig.DEBUG && isMainThread()) throw OnMainThreadException("It should not be in the MainThread")
|
||||
}
|
||||
|
||||
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()
|
||||
|
||||
class OnMainThreadException(str: String) : RuntimeException(str)
|
||||
@@ -75,14 +75,17 @@ class Nip05Verifier() {
|
||||
fetchNip05Json(
|
||||
nip05,
|
||||
onSuccess = {
|
||||
// NIP05 usernames are case insensitive, but JSON properties are not
|
||||
// converts the json to lowercase and then tries to access the username via a
|
||||
// lowercase version of the username.
|
||||
val nip05url = try {
|
||||
mapper.readTree(it)
|
||||
mapper.readTree(it.lowercase())
|
||||
} 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].lowercase()
|
||||
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
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.JsonFilter
|
||||
@@ -12,9 +11,9 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
var account: Account? = null
|
||||
var channel: Channel? = null
|
||||
|
||||
fun loadMessagesBetween(account: Account, channelId: String) {
|
||||
fun loadMessagesBetween(account: Account, channel: Channel) {
|
||||
this.account = account
|
||||
channel = LocalCache.getOrCreateChannel(channelId)
|
||||
this.channel = channel
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
@@ -12,9 +11,9 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var withUser: User? = null
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, userId: String) {
|
||||
fun loadMessagesBetween(accountIn: Account, user: User) {
|
||||
account = accountIn
|
||||
withUser = LocalCache.users[userId]
|
||||
withUser = user
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
|
||||
@@ -103,16 +103,16 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateFilters() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFilters() {
|
||||
@@ -123,6 +123,8 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
fun resetFiltersSuspend() {
|
||||
checkNotInMainThread()
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
class NostrLnZapPaymentResponseDataSource(
|
||||
private var fromServiceHex: String,
|
||||
private var toUserHex: String,
|
||||
private var replyingToHex: String
|
||||
private val fromServiceHex: String,
|
||||
private val toUserHex: String,
|
||||
private val replyingToHex: String,
|
||||
private val authSigningKey: ByteArray
|
||||
) : NostrDataSource("LnZapPaymentResponseFeed") {
|
||||
|
||||
val feedTypes = setOf(FeedType.WALLET_CONNECT)
|
||||
@@ -36,4 +40,14 @@ class NostrLnZapPaymentResponseDataSource(
|
||||
|
||||
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
|
||||
}
|
||||
|
||||
override fun auth(relay: Relay, challenge: String) {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
val event = RelayAuthEvent.create(relay.url, challenge, authSigningKey)
|
||||
Client.send(
|
||||
event,
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
|
||||
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
|
||||
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
|
||||
PeopleListEvent.kind, BookmarkListEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -121,7 +122,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
||||
PrivateDmEvent.kind,
|
||||
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
|
||||
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
|
||||
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
|
||||
PeopleListEvent.kind, BookmarkListEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
|
||||
@@ -81,9 +81,9 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, AppRecommendationEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.nip19.Tlv
|
||||
@@ -9,6 +10,7 @@ import nostr.postr.Bech32
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toByteArray
|
||||
|
||||
@Immutable
|
||||
data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val relay: String?) {
|
||||
fun toTag() = "$kind:$pubKeyHex:$dTag"
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.UserMetadata
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
@Immutable
|
||||
class AppDefinitionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun appMetaData() = try {
|
||||
MetadataEvent.metadataParser.readValue(
|
||||
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
|
||||
UserMetadata::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
|
||||
null
|
||||
}
|
||||
|
||||
fun supportedKinds() = tags.filter { it.size > 1 && it[0] == "k" }.mapNotNull {
|
||||
runCatching { it[1].toInt() }.getOrNull()
|
||||
}
|
||||
|
||||
fun publishedAt() = tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 31990
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
@Immutable
|
||||
class AppRecommendationEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
fun recommendations() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
ATag.parse(it[1], it.getOrNull(2))
|
||||
}
|
||||
|
||||
fun forKind() = runCatching { dTag().toInt() }.getOrNull()
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
companion object {
|
||||
const val kind = 31989
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class AudioTrackEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
@Immutable
|
||||
class BadgeAwardEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
@Immutable
|
||||
class BadgeDefinitionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
@Immutable
|
||||
class BadgeProfilesEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.tagSearch
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex
|
||||
|
||||
@Immutable
|
||||
open class BaseTextNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
@@ -19,6 +21,7 @@ open class BaseTextNoteEvent(
|
||||
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
private var citedUsersCache: Set<HexKey>? = null
|
||||
private var citedNotesCache: Set<HexKey>? = null
|
||||
|
||||
fun citedUsers(): Set<HexKey> {
|
||||
citedUsersCache?.let { return it }
|
||||
@@ -61,8 +64,10 @@ open class BaseTextNoteEvent(
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun findCitations(): Set<String> {
|
||||
var citations = mutableSetOf<String>()
|
||||
fun findCitations(): Set<HexKey> {
|
||||
citedNotesCache?.let { return it }
|
||||
|
||||
val citations = mutableSetOf<HexKey>()
|
||||
// Removes citations from replies:
|
||||
val matcher = tagSearch.matcher(content)
|
||||
while (matcher.find()) {
|
||||
@@ -102,6 +107,7 @@ open class BaseTextNoteEvent(
|
||||
}
|
||||
}
|
||||
|
||||
citedNotesCache = citations
|
||||
return citations
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class BookmarkListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ChannelCreateEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ChannelHideMessageEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ChannelMessageEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
@@ -14,13 +16,27 @@ class ChannelMessageEvent(
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(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 channel() = tags.firstOrNull {
|
||||
it.size > 3 && it[0] == "e" && it[3] == "root"
|
||||
}?.get(1) ?: tags.firstOrNull {
|
||||
it.size > 1 && it[0] == "e"
|
||||
}?.get(1)
|
||||
|
||||
override fun replyTos() = tags.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
companion object {
|
||||
const val kind = 42
|
||||
|
||||
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
|
||||
fun create(
|
||||
message: String,
|
||||
channel: String,
|
||||
replyTos: List<String>? = null,
|
||||
mentions: List<String>? = null,
|
||||
zapReceiver: String?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
markAsSensitive: Boolean
|
||||
): ChannelMessageEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf(
|
||||
@@ -35,6 +51,9 @@ class ChannelMessageEvent(
|
||||
zapReceiver?.let {
|
||||
tags.add(listOf("zap", it))
|
||||
}
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ChannelMetadataEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
@@ -14,7 +16,7 @@ class ChannelMetadataEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
fun channelInfo() =
|
||||
try {
|
||||
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ChannelMuteUserEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
data class Contact(val pubKeyHex: String, val relayUri: String?)
|
||||
|
||||
class ContactListEvent(
|
||||
@@ -21,14 +23,12 @@ class ContactListEvent(
|
||||
// This function is only used by the user logged in
|
||||
// But it is used all the time.
|
||||
val verifiedFollowKeySet: Set<HexKey> by lazy {
|
||||
tags.filter { it[0] == "p" }.mapNotNull {
|
||||
it.getOrNull(1)?.let { unverifiedHex: String ->
|
||||
try {
|
||||
decodePublicKey(unverifiedHex).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
|
||||
null
|
||||
}
|
||||
tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
|
||||
try {
|
||||
decodePublicKey(it[1]).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class DeletionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.*
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
@@ -14,6 +15,7 @@ import java.math.BigDecimal
|
||||
import java.security.MessageDigest
|
||||
import java.util.*
|
||||
|
||||
@Immutable
|
||||
open class Event(
|
||||
val id: HexKey,
|
||||
@SerializedName("pubkey") val pubKey: HexKey,
|
||||
@@ -46,6 +48,12 @@ open class Event(
|
||||
|
||||
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
|
||||
|
||||
override fun isSensitive() = tags.any {
|
||||
(it.size > 0 && it[0].equals("content-warning", true)) ||
|
||||
(it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) ||
|
||||
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
|
||||
}
|
||||
|
||||
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
@@ -220,6 +228,8 @@ open class Event(
|
||||
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
|
||||
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -285,6 +295,7 @@ open class Event(
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
interface AddressableEvent {
|
||||
fun dTag(): String
|
||||
fun address(): ATag
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Immutable
|
||||
interface EventInterface {
|
||||
fun id(): HexKey
|
||||
|
||||
@@ -35,4 +37,5 @@ interface EventInterface {
|
||||
fun getPoWRank(): Int
|
||||
|
||||
fun zapAddress(): String?
|
||||
fun isSensitive(): Boolean
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class FileHeaderEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Base64
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class FileStorageEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class FileStorageHeaderEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import nostr.postr.Utils
|
||||
|
||||
@Immutable
|
||||
abstract class GeneralListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class HighlightEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
|
||||
@Immutable
|
||||
class LnZapEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Immutable
|
||||
interface LnZapEventInterface : EventInterface {
|
||||
|
||||
fun zappedPost(): List<String>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
@@ -12,6 +13,7 @@ import nostr.postr.Utils
|
||||
import java.lang.reflect.Type
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class LnZapPaymentRequestEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
@@ -10,6 +11,7 @@ import com.vitorpamplona.amethyst.model.HexKey
|
||||
import nostr.postr.Utils
|
||||
import java.lang.reflect.Type
|
||||
|
||||
@Immutable
|
||||
class LnZapPaymentResponseEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.Utils
|
||||
@@ -11,6 +12,7 @@ import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@Immutable
|
||||
class LnZapRequestEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class LongTextNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.google.gson.Gson
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.UserMetadata
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.Date
|
||||
|
||||
data class ContactMetaData(
|
||||
val name: String,
|
||||
val picture: String,
|
||||
val about: String,
|
||||
val nip05: String?
|
||||
)
|
||||
|
||||
abstract class IdentityClaim(
|
||||
var identity: String,
|
||||
var proof: String
|
||||
@@ -143,9 +140,13 @@ class MetadataEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun contactMetaData() = try {
|
||||
gson.fromJson(content, ContactMetaData::class.java)
|
||||
metadataParser.readValue(
|
||||
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
|
||||
UserMetadata::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("MetadataEvent", "Can't parse $content", e)
|
||||
e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
|
||||
null
|
||||
}
|
||||
|
||||
@@ -162,8 +163,10 @@ class MetadataEvent(
|
||||
const val kind = 0
|
||||
val gson = Gson()
|
||||
|
||||
fun create(contactMetaData: ContactMetaData, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
|
||||
return create(gson.toJson(contactMetaData), identities, privateKey, createdAt = createdAt)
|
||||
val metadataParser by lazy {
|
||||
jacksonObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.readerFor(UserMetadata::class.java)
|
||||
}
|
||||
|
||||
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class MuteListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class PeopleListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class PinListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
@@ -11,6 +12,7 @@ const val VALUE_MINIMUM = "value_minimum"
|
||||
const val CONSENSUS_THRESHOLD = "consensus_threshold"
|
||||
const val CLOSED_AT = "closed_at"
|
||||
|
||||
@Immutable
|
||||
class PollNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
@@ -49,7 +51,8 @@ class PollNoteEvent(
|
||||
valueMinimum: Int?,
|
||||
consensusThreshold: Int?,
|
||||
closedAt: Int?,
|
||||
zapReceiver: String?
|
||||
zapReceiver: String?,
|
||||
markAsSensitive: Boolean
|
||||
): PollNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
@@ -73,6 +76,9 @@ class PollNoteEvent(
|
||||
if (zapReceiver != null) {
|
||||
tags.add(listOf("zap", zapReceiver))
|
||||
}
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import fr.acinq.secp256k1.Hex
|
||||
@@ -8,6 +9,7 @@ import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class PrivateDmEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
@@ -27,6 +29,10 @@ class PrivateDmEvent(
|
||||
|
||||
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
|
||||
|
||||
fun talkingWith(oneSideHex: String): HexKey {
|
||||
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
|
||||
}
|
||||
|
||||
/**
|
||||
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
|
||||
*
|
||||
@@ -37,7 +43,7 @@ class PrivateDmEvent(
|
||||
|
||||
fun with(pubkeyHex: String): Boolean {
|
||||
return pubkeyHex == pubKey ||
|
||||
tags.firstOrNull { it.size > 1 && it[0] == "p" }?.getOrNull(1) == pubkeyHex
|
||||
tags.any { it.size > 1 && it[0] == "p" && it[1] == pubkeyHex }
|
||||
}
|
||||
|
||||
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
@@ -71,7 +77,8 @@ class PrivateDmEvent(
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000,
|
||||
publishedRecipientPubKey: ByteArray? = null,
|
||||
advertiseNip18: Boolean = true
|
||||
advertiseNip18: Boolean = true,
|
||||
markAsSensitive: Boolean
|
||||
): PrivateDmEvent {
|
||||
val content = Utils.encrypt(
|
||||
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
|
||||
@@ -92,6 +99,9 @@ class PrivateDmEvent(
|
||||
zapReceiver?.let {
|
||||
tags.add(listOf("zap", it))
|
||||
}
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class ReactionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.net.URI
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class RecommendRelayEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class RelayAuthEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
|
||||
|
||||
// NIP 56 event.
|
||||
@Immutable
|
||||
class ReportEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class RepostEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class TextNoteEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
@@ -27,6 +29,7 @@ class TextNoteEvent(
|
||||
addresses: List<ATag>?,
|
||||
extraTags: List<String>?,
|
||||
zapReceiver: String?,
|
||||
markAsSensitive: Boolean,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): TextNoteEvent {
|
||||
@@ -43,6 +46,7 @@ class TextNoteEvent(
|
||||
}
|
||||
findHashtags(msg).forEach {
|
||||
tags.add(listOf("t", it))
|
||||
tags.add(listOf("t", it.lowercase()))
|
||||
}
|
||||
extraTags?.forEach {
|
||||
tags.add(listOf("t", it))
|
||||
@@ -53,6 +57,9 @@ class TextNoteEvent(
|
||||
findURLs(msg).forEach {
|
||||
tags.add(listOf("r", it))
|
||||
}
|
||||
if (markAsSensitive) {
|
||||
tags.add(listOf("content-warning", ""))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
|
||||
@@ -2,17 +2,21 @@ package com.vitorpamplona.amethyst.service.model.zaps
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEventInterface
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
|
||||
|
||||
object UserZaps {
|
||||
fun forProfileFeed(zaps: Map<Note, Note?>?): List<Pair<Note, Note>> {
|
||||
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
|
||||
if (zaps == null) return emptyList()
|
||||
|
||||
return (
|
||||
zaps
|
||||
.filter { it.value != null }
|
||||
.toList()
|
||||
.sortedBy { (it.second?.event as? LnZapEventInterface)?.amount() }
|
||||
.mapNotNull { entry ->
|
||||
entry.value?.let {
|
||||
ZapReqResponse(entry.key, it)
|
||||
}
|
||||
}
|
||||
.sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() }
|
||||
.reversed()
|
||||
) as List<Pair<Note, Note>>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Bech32
|
||||
@@ -15,6 +16,7 @@ object Nip19 {
|
||||
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
@Immutable
|
||||
data class Return(
|
||||
val type: Type,
|
||||
val hex: String,
|
||||
|
||||
@@ -13,15 +13,22 @@ import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
fun consume(event: Event) {
|
||||
// adds to database
|
||||
LocalCache.consume(event, null)
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
// adds to database
|
||||
LocalCache.consume(event, null)
|
||||
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event)
|
||||
is LnZapEvent -> notify(event)
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event)
|
||||
is LnZapEvent -> notify(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +61,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
|
||||
private fun notify(event: LnZapEvent) {
|
||||
val noteZapEvent = LocalCache.notes[event.id] ?: return
|
||||
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.app.NotificationCompat
|
||||
import coil.ImageLoader
|
||||
import coil.executeBlocking
|
||||
@@ -133,13 +134,23 @@ object NotificationUtils {
|
||||
channelId: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
val notId = id.hashCode()
|
||||
|
||||
// dont notify twice
|
||||
val notifications: Array<StatusBarNotification> = getActiveNotifications()
|
||||
for (notification in notifications) {
|
||||
if (notification.id == notId) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val contentIntent = Intent(applicationContext, MainActivity::class.java).apply {
|
||||
data = Uri.parse(uri)
|
||||
}
|
||||
|
||||
val contentPendingIntent = PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
id.hashCode(),
|
||||
notId,
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
@@ -173,7 +184,7 @@ object NotificationUtils {
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
|
||||
notify(id.hashCode(), builder.build())
|
||||
notify(notId, builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
|
||||
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
||||
|
||||
fun fetchUrlPreview(timeOut: Int = 30000) {
|
||||
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
scope.launch(exceptionHandler) {
|
||||
fetch(timeOut)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetch(timeOut: Int = 30000) {
|
||||
lateinit var urlInfoItem: UrlInfoItem
|
||||
if (checkIsImageUrl()) {
|
||||
urlInfoItem = UrlInfoItem(url = url, image = url)
|
||||
} else {
|
||||
val document = getDocument(url, timeOut)
|
||||
urlInfoItem = parseHtml(url, document)
|
||||
}
|
||||
callback?.onComplete(urlInfoItem)
|
||||
}
|
||||
|
||||
private fun checkIsImageUrl(): Boolean {
|
||||
val uri = Uri.parse(url)
|
||||
var isImage = false
|
||||
for (imageExtension in imageExtensionArray) {
|
||||
if (uri.path != null && uri.path!!.toLowerCase(Locale.getDefault()).endsWith(imageExtension)) {
|
||||
isImage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return isImage
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
scope.cancel()
|
||||
callback = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
interface IUrlPreviewCallback {
|
||||
fun onComplete(urlInfo: UrlInfoItem)
|
||||
fun onFailed(throwable: Throwable)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import java.net.URL
|
||||
|
||||
@Immutable
|
||||
data class UrlInfoItem(
|
||||
val url: String = "",
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val image: String = ""
|
||||
) {
|
||||
val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull()
|
||||
val imageUrlFullPath =
|
||||
if (image.startsWith("/")) {
|
||||
URL(verifiedUrl, image).toString()
|
||||
} else {
|
||||
image
|
||||
}
|
||||
|
||||
fun allFetchComplete(): Boolean {
|
||||
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
private const val ELEMENT_TAG_META = "meta"
|
||||
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
|
||||
private const val ATTRIBUTE_VALUE_NAME = "name"
|
||||
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
|
||||
|
||||
/* for <meta property="og:" to get title */
|
||||
private val META_OG_TITLE = arrayOf("og:title", "\"og:title\"", "'og:title'")
|
||||
|
||||
/* for <meta property="og:" to get description */
|
||||
private val META_OG_DESCRIPTION =
|
||||
arrayOf("og:description", "\"og:description\"", "'og:description'")
|
||||
|
||||
/* for <meta property="og:" to get image */
|
||||
private val META_OG_IMAGE = arrayOf("og:image", "\"og:image\"", "'og:image'")
|
||||
|
||||
/*for <meta name=... to get title */
|
||||
private val META_NAME_TITLE = arrayOf(
|
||||
"twitter:title",
|
||||
"\"twitter:title\"",
|
||||
"'twitter:title'",
|
||||
"title",
|
||||
"\"title\"",
|
||||
"'title'"
|
||||
)
|
||||
|
||||
/*for <meta name=... to get description */
|
||||
private val META_NAME_DESCRIPTION = arrayOf(
|
||||
"twitter:description",
|
||||
"\"twitter:description\"",
|
||||
"'twitter:description'",
|
||||
"description",
|
||||
"\"description\"",
|
||||
"'description'"
|
||||
)
|
||||
|
||||
/*for <meta name=... to get image */
|
||||
private val META_NAME_IMAGE = arrayOf(
|
||||
"twitter:image",
|
||||
"\"twitter:image\"",
|
||||
"'twitter:image'"
|
||||
)
|
||||
|
||||
/*for <meta itemprop=... to get title */
|
||||
private val META_ITEMPROP_TITLE = arrayOf("name", "\"name\"", "'name'")
|
||||
|
||||
/*for <meta itemprop=... to get description */
|
||||
private val META_ITEMPROP_DESCRIPTION = arrayOf("description", "\"description\"", "'description'")
|
||||
|
||||
/*for <meta itemprop=... to get image */
|
||||
private val META_ITEMPROP_IMAGE = arrayOf("image", "\"image\"", "'image'")
|
||||
|
||||
private const val CONTENT = "content"
|
||||
|
||||
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
|
||||
withContext(Dispatchers.IO) {
|
||||
return@withContext Jsoup.connect(url)
|
||||
.proxy(HttpClient.getProxy())
|
||||
.timeout(timeOut)
|
||||
.get()
|
||||
}
|
||||
|
||||
suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
|
||||
|
||||
var title: String = ""
|
||||
var description: String = ""
|
||||
var image: String = ""
|
||||
|
||||
metaTags.forEach {
|
||||
val propertyTag = it.attr(ATTRIBUTE_VALUE_PROPERTY)
|
||||
when (propertyTag) {
|
||||
in META_OG_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
|
||||
in META_OG_DESCRIPTION -> if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
|
||||
in META_NAME_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
|
||||
in META_NAME_DESCRIPTION -> if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
|
||||
in META_ITEMPROP_TITLE -> if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
in META_ITEMPROP_DESCRIPTION -> if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_ITEMPROP_IMAGE -> if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
|
||||
return@withContext UrlInfoItem(url, title, description, image)
|
||||
}
|
||||
}
|
||||
return@withContext UrlInfoItem(url, title, description, image)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -32,6 +33,8 @@ object Client : RelayPool.Listener {
|
||||
|
||||
@Synchronized
|
||||
fun connect(relays: Array<Relay>) {
|
||||
checkNotInMainThread()
|
||||
|
||||
RelayPool.register(this)
|
||||
RelayPool.unloadRelays()
|
||||
RelayPool.loadRelays(relays.toList())
|
||||
@@ -54,6 +57,8 @@ object Client : RelayPool.Listener {
|
||||
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||
filters: List<TypedFilter> = listOf()
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
subscriptions = subscriptions + Pair(subscriptionId, filters)
|
||||
RelayPool.sendFilter(subscriptionId)
|
||||
}
|
||||
@@ -62,15 +67,19 @@ object Client : RelayPool.Listener {
|
||||
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||
filters: List<TypedFilter> = listOf()
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
subscriptions = subscriptions + Pair(subscriptionId, filters)
|
||||
RelayPool.sendFilterOnlyIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
val useConnectedRelayIfPresent = relays.filter { it.url == relay }
|
||||
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
|
||||
|
||||
if (useConnectedRelayIfPresent.isNotEmpty()) {
|
||||
useConnectedRelayIfPresent.forEach {
|
||||
@@ -91,7 +100,7 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
|
||||
private fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
|
||||
RelayPool.addRelay(relay)
|
||||
|
||||
@@ -103,7 +112,7 @@ object Client : RelayPool.Listener {
|
||||
onConnected(relay)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(10000) // waits for a reply
|
||||
delay(60000) // waits for a reply
|
||||
relay.disconnect()
|
||||
RelayPool.removeRelay(relay)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonElement
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
@@ -30,6 +31,7 @@ class Relay(
|
||||
) {
|
||||
val seconds = if (proxy != null) 20L else 10L
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.readTimeout(duration)
|
||||
@@ -68,6 +70,7 @@ class Relay(
|
||||
|
||||
@Synchronized
|
||||
fun requestAndWatch() {
|
||||
checkNotInMainThread()
|
||||
requestAndWatch {
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
@@ -78,6 +81,7 @@ class Relay(
|
||||
|
||||
@Synchronized
|
||||
fun requestAndWatch(onConnected: (Relay) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
if (socket != null) return
|
||||
|
||||
try {
|
||||
|
||||
@@ -30,6 +30,10 @@ object RelayPool : Relay.Listener {
|
||||
return relays.firstOrNull() { it.url == url }
|
||||
}
|
||||
|
||||
fun getRelays(url: String): List<Relay> {
|
||||
return relays.filter { it.url == url }
|
||||
}
|
||||
|
||||
fun loadRelays(relayList: List<Relay>) {
|
||||
if (!relayList.isNullOrEmpty()) {
|
||||
relayList.forEach { addRelay(it) }
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -12,14 +11,8 @@ import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
@@ -27,7 +20,7 @@ import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.components.muted
|
||||
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
@@ -46,8 +39,75 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
val uri = intent?.data?.toString()
|
||||
|
||||
val startingPage = if (uri.equals("nostr:Notifications", true)) {
|
||||
Route.Notification.route.replace("{scrollToTop}", "true")
|
||||
val startingPage = uriToRoute(uri)
|
||||
|
||||
LocalPreferences.migrateSingleUserPrefs()
|
||||
|
||||
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(this@MainActivity)
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, startingPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Client.lenient = true
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// starts muted every time
|
||||
DefaultMutedSetting.value = true
|
||||
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.start(this@MainActivity)
|
||||
}
|
||||
|
||||
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
ServiceManager.pause()
|
||||
|
||||
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")
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
ServiceManager.cleanUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
|
||||
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
return super.createIntent(context, input).apply {
|
||||
// Force only images and videos to be selectable
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uriToRoute(uri: String?): String? {
|
||||
return if (uri.equals("nostr:Notifications", true)) {
|
||||
Route.Notification.route.replace("{scrollToTop}", "true")
|
||||
} else {
|
||||
if (uri?.startsWith("nostr:Hashtag?id=") == true) {
|
||||
Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id="))
|
||||
} else {
|
||||
val nip19 = Nip19.uriToRoute(uri)
|
||||
when (nip19?.type) {
|
||||
@@ -75,78 +135,5 @@ class MainActivity : FragmentActivity() {
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(this.applicationContext)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
LocalPreferences.migrateSingleUserPrefs()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, startingPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Client.lenient = true
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// starts muted every time
|
||||
muted.value = true
|
||||
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
ServiceManager.pause()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
|
||||
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
return super.createIntent(context, input).apply {
|
||||
// Force only images and videos to be selectable
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ object ImageUploader {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
val myServer = when (server) {
|
||||
ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
|
||||
ImgurServer()
|
||||
}
|
||||
// ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
|
||||
// ImgurServer()
|
||||
// }
|
||||
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
|
||||
NostrImgServer()
|
||||
}
|
||||
@@ -53,7 +53,7 @@ object ImageUploader {
|
||||
NostrFilesDevServer()
|
||||
}
|
||||
else -> {
|
||||
ImgurServer()
|
||||
NostrBuildServer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,15 +51,14 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.note.ChannelName
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -73,9 +72,9 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) {
|
||||
fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val searchBarViewModel: SearchBarViewModel = viewModel()
|
||||
searchBarViewModel.account = account
|
||||
searchBarViewModel.account = accountViewModel.account
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
@@ -116,7 +115,7 @@ fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController:
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
RenderSeach(searchBarViewModel, account, navController)
|
||||
RenderSeach(searchBarViewModel, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,8 +125,8 @@ fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController:
|
||||
@Composable
|
||||
private fun RenderSeach(
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
account: Account,
|
||||
navController: NavController
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
@@ -146,7 +145,7 @@ private fun RenderSeach(
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
if (searchBarViewModel.isSearching()) {
|
||||
searchBarViewModel.invalidateData()
|
||||
@@ -269,13 +268,13 @@ private fun RenderSeach(
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(
|
||||
searchBarViewModel.searchResults.value,
|
||||
searchBarViewModel.searchResultsUsers.value,
|
||||
key = { _, item -> "u" + item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserComposeForChat(
|
||||
item,
|
||||
account = account,
|
||||
navController = navController
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
@@ -295,7 +294,7 @@ private fun RenderSeach(
|
||||
channelLastTime = null,
|
||||
channelLastContent = item.info.about,
|
||||
false,
|
||||
onClick = { navController.navigate("Channel/${item.idHex}") }
|
||||
onClick = { nav("Channel/${item.idHex}") }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -306,13 +305,13 @@ private fun RenderSeach(
|
||||
@Composable
|
||||
fun UserComposeForChat(
|
||||
baseUser: User,
|
||||
account: Account,
|
||||
navController: NavController
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clickable(
|
||||
onClick = { navController.navigate("Room/${baseUser.pubkeyHex}") }
|
||||
onClick = { nav("Room/${baseUser.pubkeyHex}") }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
@@ -324,7 +323,7 @@ fun UserComposeForChat(
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
|
||||
UserPicture(baseUser, nav, accountViewModel, 55.dp)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -24,13 +25,15 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {
|
||||
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: Channel? = null) {
|
||||
val postViewModel: NewChannelViewModel = viewModel()
|
||||
postViewModel.load(account, channel)
|
||||
postViewModel.load(accountViewModel.account, channel)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
@@ -53,10 +56,14 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
|
||||
onClose()
|
||||
})
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
postViewModel.channelName.value.text.isNotBlank()
|
||||
)
|
||||
@@ -100,7 +107,9 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.description)) },
|
||||
modifier = Modifier.fillMaxWidth().height(100.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp),
|
||||
value = postViewModel.channelDescription.value,
|
||||
onValueChange = { postViewModel.channelDescription.value = it },
|
||||
placeholder = {
|
||||
|
||||
@@ -38,9 +38,10 @@ open class NewMediaModel : ViewModel() {
|
||||
this.mediaType = contentType
|
||||
this.selectedServer = defaultServer()
|
||||
|
||||
if (selectedServer == ServersAvailable.IMGUR) {
|
||||
selectedServer = ServersAvailable.IMGUR_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRIMG) {
|
||||
// if (selectedServer == ServersAvailable.IMGUR) {
|
||||
// selectedServer = ServersAvailable.IMGUR_NIP_94
|
||||
// } else
|
||||
if (selectedServer == ServersAvailable.NOSTRIMG) {
|
||||
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
|
||||
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -28,7 +27,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -39,7 +37,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val context = LocalContext.current
|
||||
@@ -111,17 +109,15 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
|
||||
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
|
||||
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
// selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val fileServers = listOf(
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
@@ -153,7 +149,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = postViewModel.galleryUri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.galleryUri?.let {
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.parseDirtyWordForKey
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
|
||||
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
|
||||
class NewMessageTagger(var channelHex: String?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
|
||||
|
||||
fun addUserToMentions(user: User) {
|
||||
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
|
||||
@@ -20,12 +19,12 @@ class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var rep
|
||||
|
||||
fun tagIndex(user: User): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
|
||||
return (if (channelHex != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
|
||||
}
|
||||
|
||||
fun tagIndex(note: Note): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
||||
return (if (channelHex != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
||||
}
|
||||
|
||||
fun run() {
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
|
||||
val pollViewModel: NewPostViewModel = viewModel()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
pollViewModel.load(account, baseReplyTo, quote)
|
||||
delay(100)
|
||||
|
||||
pollViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.imePadding()
|
||||
.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
pollViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
PollButton(
|
||||
onPost = {
|
||||
pollViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = pollViewModel.message.text.isNotBlank() &&
|
||||
pollViewModel.pollOptions.values.all { it.isNotEmpty() } &&
|
||||
pollViewModel.isValidRecipients.value &&
|
||||
pollViewModel.isValidvalueMaximum.value &&
|
||||
pollViewModel.isValidvalueMinimum.value &&
|
||||
pollViewModel.isValidConsensusThreshold.value &&
|
||||
pollViewModel.isValidClosedAt.value
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
if (pollViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
|
||||
ReplyInformation(pollViewModel.replyTos, pollViewModel.mentions, account, "✖ ") {
|
||||
pollViewModel.removeFromReplyList(it)
|
||||
}
|
||||
}
|
||||
|
||||
Text(stringResource(R.string.poll_heading_required))
|
||||
// NewPollRecipientsField(pollViewModel, account)
|
||||
NewPollPrimaryDescription(pollViewModel)
|
||||
pollViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||
NewPollOption(pollViewModel, index)
|
||||
}
|
||||
Button(
|
||||
onClick = { pollViewModel.pollOptions[pollViewModel.pollOptions.size] = "" },
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
) {
|
||||
Image(
|
||||
painterResource(id = android.R.drawable.ic_input_add),
|
||||
contentDescription = "Add poll option button",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Text(stringResource(R.string.poll_heading_optional))
|
||||
NewPollVoteValueRange(pollViewModel)
|
||||
NewPollConsensusThreshold(pollViewModel)
|
||||
NewPollClosing(pollViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
val userSuggestions = pollViewModel.userSuggestions
|
||||
if (userSuggestions.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp
|
||||
),
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp)
|
||||
) {
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserLine(item, account) {
|
||||
pollViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
/*UploadFromGallery(
|
||||
isUploading = pollViewModel.isUploadingImage
|
||||
) {
|
||||
pollViewModel.upload(it, context)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PollButton(modifier: Modifier = Modifier, onPost: () -> Unit = {}, isActive: Boolean) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onPost()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.post_poll), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
/*@Preview
|
||||
@Composable
|
||||
fun NewPollViewPreview() {
|
||||
NewPollView(onClose = {}, account = Account(loggedIn = Persona()))
|
||||
}*/
|
||||
@@ -22,8 +22,11 @@ import androidx.compose.material.icons.filled.ArrowForwardIos
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -57,10 +60,8 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
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.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
@@ -69,13 +70,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
|
||||
val context = LocalContext.current
|
||||
@@ -84,7 +89,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val scroolState = rememberScrollState()
|
||||
val scrollState = rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -139,8 +144,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
@@ -154,7 +161,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scroolState)
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Notifying(postViewModel.mentions) {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
@@ -286,13 +293,13 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
} else {
|
||||
UrlPreview(myUrlPreview, myUrlPreview)
|
||||
}
|
||||
} else if (isBechLink(myUrlPreview)) {
|
||||
} else if (startsWithNIP19Scheme(myUrlPreview)) {
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
MaterialTheme.colors.background,
|
||||
accountViewModel,
|
||||
navController
|
||||
nav
|
||||
)
|
||||
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
|
||||
UrlPreview("https://$myUrlPreview", myUrlPreview)
|
||||
@@ -314,7 +321,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserLine(item, account) {
|
||||
UserLine(item, accountViewModel) {
|
||||
postViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
}
|
||||
@@ -336,10 +343,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
|
||||
if (postViewModel.canUsePoll) {
|
||||
val hashtag = stringResource(R.string.poll_hashtag)
|
||||
// These should be hashtag recommendations the user selects in the future.
|
||||
// val hashtag = stringResource(R.string.poll_hashtag)
|
||||
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
|
||||
AddPollButton(postViewModel.wantsPoll) {
|
||||
postViewModel.wantsPoll = !postViewModel.wantsPoll
|
||||
postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +357,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
MarkAsSensitive(postViewModel) {
|
||||
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
|
||||
}
|
||||
|
||||
ForwardZapTo(postViewModel) {
|
||||
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
|
||||
}
|
||||
@@ -374,11 +386,13 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
|
||||
|
||||
mentions.forEachIndexed { idx, user ->
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
val innerUser = innerUserState?.user
|
||||
|
||||
innerUser?.let { myUser ->
|
||||
innerUserState?.user?.let { myUser ->
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
|
||||
val tags = remember(innerUserState) {
|
||||
myUser.info?.latestMetadata?.tags?.toImmutableList()
|
||||
}
|
||||
|
||||
Button(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
@@ -389,8 +403,8 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
|
||||
}
|
||||
) {
|
||||
CreateTextWithEmoji(
|
||||
text = "✖ ${myUser.toBestDisplayName()}",
|
||||
tags = myUser.info?.latestMetadata?.tags,
|
||||
text = remember(innerUserState) { "✖ ${myUser.toBestDisplayName()}" },
|
||||
tags = tags,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@@ -538,6 +552,60 @@ private fun ForwardZapTo(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkAsSensitive(
|
||||
postViewModel: NewPostViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.height(20.dp)
|
||||
.width(23.dp)
|
||||
) {
|
||||
if (!postViewModel.wantsToMarkAsSensitive) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Visibility,
|
||||
contentDescription = stringResource(R.string.content_warning),
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.align(Alignment.BottomStart),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Warning,
|
||||
contentDescription = stringResource(R.string.content_warning),
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.VisibilityOff,
|
||||
contentDescription = stringResource(id = R.string.content_warning),
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.align(Alignment.BottomStart),
|
||||
tint = Color.Red
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Warning,
|
||||
contentDescription = stringResource(id = R.string.content_warning),
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = Color.Yellow
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CloseButton(onCancel: () -> Unit) {
|
||||
Button(
|
||||
@@ -641,11 +709,12 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
|
||||
}
|
||||
|
||||
enum class ServersAvailable {
|
||||
IMGUR,
|
||||
// IMGUR,
|
||||
NOSTR_BUILD,
|
||||
NOSTRIMG,
|
||||
NOSTRFILES_DEV,
|
||||
IMGUR_NIP_94,
|
||||
|
||||
// IMGUR_NIP_94,
|
||||
NOSTRIMG_NIP_94,
|
||||
NOSTR_BUILD_NIP_94,
|
||||
NOSTRFILES_DEV_NIP_94,
|
||||
@@ -662,17 +731,16 @@ fun ImageVideoDescription(
|
||||
) {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isImage = mediaType.startsWith("image")
|
||||
val isVideo = mediaType.startsWith("video")
|
||||
|
||||
val fileServers = listOf(
|
||||
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
|
||||
// Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
@@ -765,7 +833,7 @@ fun ImageVideoDescription(
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = uri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
@@ -23,7 +24,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
open class NewPostViewModel : ViewModel() {
|
||||
open class NewPostViewModel() : ViewModel() {
|
||||
var account: Account? = null
|
||||
var originalNote: Note? = null
|
||||
|
||||
@@ -67,10 +68,18 @@ open class NewPostViewModel : ViewModel() {
|
||||
var forwardZapTo by mutableStateOf<User?>(null)
|
||||
var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
// NSFW, Sensitive
|
||||
var wantsToMarkAsSensitive by mutableStateOf(false)
|
||||
|
||||
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
|
||||
if (replyNote.event is BaseTextNoteEvent) {
|
||||
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
|
||||
} else {
|
||||
this.replyTos = listOf(replyNote)
|
||||
}
|
||||
|
||||
replyNote.author?.let { replyUser ->
|
||||
val currentMentions = (replyNote.event as? TextNoteEvent)
|
||||
?.mentions()
|
||||
@@ -93,10 +102,11 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
canAddInvoice = account.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
||||
contentToAddUrl = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
forwardZapTo = null
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
@@ -104,7 +114,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text)
|
||||
val tagger = NewMessageTagger(originalNote?.channelHex(), mentions, replyTos, message.text)
|
||||
tagger.run()
|
||||
|
||||
val zapReceiver = if (wantsForwardZapTo) {
|
||||
@@ -118,13 +128,13 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
if (wantsPoll) {
|
||||
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver)
|
||||
} else if (originalNote?.channel() != null) {
|
||||
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver)
|
||||
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive)
|
||||
} else if (originalNote?.channelHex() != null) {
|
||||
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
|
||||
} else if (originalNote?.event is PrivateDmEvent) {
|
||||
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver)
|
||||
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
|
||||
} else {
|
||||
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver)
|
||||
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver, wantsToMarkAsSensitive)
|
||||
}
|
||||
|
||||
cancel()
|
||||
@@ -183,6 +193,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
wantsInvoice = false
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
forwardZapTo = null
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
@@ -215,7 +226,11 @@ open class NewPostViewModel : ViewModel() {
|
||||
userSuggestionsMainMessage = true
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
|
||||
.reversed()
|
||||
}
|
||||
} else {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
userSuggestions = emptyList()
|
||||
@@ -231,7 +246,15 @@ open class NewPostViewModel : ViewModel() {
|
||||
userSuggestionsMainMessage = false
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ account?.isFollowing(it) },
|
||||
{ it.toBestDisplayName() }
|
||||
)
|
||||
).reversed()
|
||||
}
|
||||
} else {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
userSuggestions = emptyList()
|
||||
|
||||
@@ -49,18 +49,18 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import java.lang.Math.round
|
||||
|
||||
@Composable
|
||||
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
|
||||
fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, relayToAdd: String = "") {
|
||||
val postViewModel: NewRelayListViewModel = viewModel()
|
||||
val feedState by postViewModel.relays.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account)
|
||||
postViewModel.load(accountViewModel.account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
|
||||
@@ -15,14 +15,13 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ChannelFabColumn(account: Account, navController: NavController) {
|
||||
fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
var isOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -36,11 +35,11 @@ fun ChannelFabColumn(account: Account, navController: NavController) {
|
||||
}
|
||||
|
||||
if (wantsToCreateChannel) {
|
||||
NewChannelView({ wantsToCreateChannel = false }, account = account)
|
||||
NewChannelView({ wantsToCreateChannel = false }, accountViewModel = accountViewModel)
|
||||
}
|
||||
|
||||
if (wantsToJoinChannelOrUser) {
|
||||
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, account = account, navController = navController)
|
||||
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
Column() {
|
||||
|
||||
@@ -19,17 +19,17 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewChannelButton(account: Account) {
|
||||
fun NewChannelButton(accountViewModel: AccountViewModel) {
|
||||
var wantsToPost by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToPost) {
|
||||
NewChannelView({ wantsToPost = false }, account = account)
|
||||
NewChannelView({ wantsToPost = false }, accountViewModel = accountViewModel)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
|
||||
@@ -16,20 +16,18 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewNoteButton(account: Account, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun NewNoteButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
var wantsToPost by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToPost) {
|
||||
NewPostView({ wantsToPost = false }, account = account, accountViewModel = accountViewModel, navController = navController)
|
||||
NewPostView({ wantsToPost = false }, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
@@ -14,6 +15,7 @@ import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import java.util.Base64
|
||||
|
||||
@Immutable
|
||||
data class ResizeImage(val url: String?, val size: Dp) {
|
||||
fun proxyUrl(): String? {
|
||||
if (url == null) return null
|
||||
|
||||
@@ -16,15 +16,16 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
*/
|
||||
class BundledUpdate(
|
||||
val delay: Long,
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
val onUpdate: () -> Unit
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
) {
|
||||
private var onlyOneInBlock = AtomicBoolean()
|
||||
private var invalidatesAgain = false
|
||||
|
||||
fun invalidate() {
|
||||
fun invalidate(ignoreIfDoing: Boolean = false, onUpdate: suspend () -> Unit) {
|
||||
if (onlyOneInBlock.getAndSet(true)) {
|
||||
invalidatesAgain = true
|
||||
if (!ignoreIfDoing) {
|
||||
invalidatesAgain = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,17 @@ import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
|
||||
@Composable
|
||||
fun ClickableNoteTag(
|
||||
baseNote: Note,
|
||||
navController: NavController
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
|
||||
onClick = { navController.navigate("Note/${baseNote.idHex}") },
|
||||
onClick = { nav("Note/${baseNote.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -37,7 +39,6 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -46,25 +47,32 @@ import com.vitorpamplona.amethyst.service.NIP30Parser
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ClickableRoute(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
DisplayUser(nip19, navController)
|
||||
DisplayUser(nip19, nav)
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
DisplayAddress(nip19, navController)
|
||||
DisplayAddress(nip19, nav)
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
DisplayNote(nip19, navController)
|
||||
DisplayNote(nip19, nav)
|
||||
} else if (nip19.type == Nip19.Type.EVENT) {
|
||||
DisplayEvent(nip19, navController)
|
||||
DisplayEvent(nip19, nav)
|
||||
} else {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
remember {
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -72,55 +80,70 @@ fun ClickableRoute(
|
||||
@Composable
|
||||
private fun DisplayEvent(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var noteBase by remember { mutableStateOf<Note?>(null) }
|
||||
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
|
||||
if (noteBase == null) {
|
||||
launch(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val channel = note.channel()
|
||||
val note = remember(noteState) { noteState?.note } ?: return
|
||||
val channelHex = remember(noteState) { note.channelHex() }
|
||||
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
|
||||
val addedCharts = remember { "${nip19.additionalChars} " }
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${nip19.hex}",
|
||||
navController = navController
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Channel/${nip19.hex}" },
|
||||
nav = nav
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Room/${note.author?.pubkeyHex}",
|
||||
navController = navController
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(
|
||||
clickablePart = channel.toBestDisplayName(),
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${note.channel()?.idHex}",
|
||||
navController = navController
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
|
||||
nav = nav
|
||||
)
|
||||
} else if (channelHex != null) {
|
||||
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channelDisplayName by remember(channelState) {
|
||||
derivedStateOf {
|
||||
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
|
||||
}
|
||||
}
|
||||
|
||||
CreateClickableText(
|
||||
clickablePart = channelDisplayName,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Event/${nip19.hex}",
|
||||
navController = navController
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Event/${nip19.hex}" },
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
remember {
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -128,12 +151,12 @@ private fun DisplayEvent(
|
||||
@Composable
|
||||
private fun DisplayNote(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var noteBase by remember { mutableStateOf<Note?>(null) }
|
||||
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
|
||||
}
|
||||
}
|
||||
@@ -141,42 +164,55 @@ private fun DisplayNote(
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = remember(noteState) { noteState?.note } ?: return
|
||||
val channel = note.channel()
|
||||
val channelHex = remember(noteState) { note.channelHex() }
|
||||
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
|
||||
val addedCharts = remember { "${nip19.additionalChars} " }
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${nip19.hex}",
|
||||
navController = navController
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Channel/${nip19.hex}" },
|
||||
nav = nav
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Room/${note.author?.pubkeyHex}",
|
||||
navController = navController
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(
|
||||
clickablePart = channel.toBestDisplayName(),
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${note.channel()?.idHex}",
|
||||
navController = navController
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
|
||||
nav = nav
|
||||
)
|
||||
} else if (channelHex != null) {
|
||||
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channelDisplayName by remember(channelState) {
|
||||
derivedStateOf {
|
||||
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
|
||||
}
|
||||
}
|
||||
|
||||
CreateClickableText(
|
||||
clickablePart = channelDisplayName,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Note/${nip19.hex}",
|
||||
navController = navController
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Note/${nip19.hex}" },
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
remember {
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -184,31 +220,36 @@ private fun DisplayNote(
|
||||
@Composable
|
||||
private fun DisplayAddress(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var noteBase by remember { mutableStateOf<Note?>(null) }
|
||||
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
val route = remember(noteState) { "Note/${nip19.hex}" }
|
||||
val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" }
|
||||
val addedCharts = remember { "${nip19.additionalChars} " }
|
||||
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Note/${nip19.hex}",
|
||||
navController = navController
|
||||
clickablePart = displayName,
|
||||
suffix = addedCharts,
|
||||
route = route,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
remember {
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -216,36 +257,41 @@ private fun DisplayAddress(
|
||||
@Composable
|
||||
private fun DisplayUser(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var userBase by remember { mutableStateOf<User?>(null) }
|
||||
var userBase by remember(nip19) { mutableStateOf<User?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
|
||||
}
|
||||
}
|
||||
|
||||
userBase?.let {
|
||||
val userState by it.live().metadata.observeAsState()
|
||||
val route = remember { "User/${it.pubkeyHex}" }
|
||||
val route = remember(userState) { "User/${it.pubkeyHex}" }
|
||||
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
|
||||
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
|
||||
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() }
|
||||
val addedCharts = remember {
|
||||
"${nip19.additionalChars} "
|
||||
}
|
||||
|
||||
if (userDisplayName != null) {
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = userDisplayName,
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
suffix = addedCharts,
|
||||
tags = userTags,
|
||||
route = route,
|
||||
navController = navController
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (userBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
remember {
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -257,7 +303,7 @@ fun CreateClickableText(
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
route: String,
|
||||
navController: NavController
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
ClickableText(
|
||||
text = buildAnnotatedString {
|
||||
@@ -272,14 +318,14 @@ fun CreateClickableText(
|
||||
append(suffix)
|
||||
}
|
||||
},
|
||||
onClick = { navController.navigate(route) }
|
||||
onClick = { nav(route) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
tags: List<List<String>>?,
|
||||
tags: ImmutableList<List<String>>?,
|
||||
color: Color = Color.Unspecified,
|
||||
textAlign: TextAlign? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
@@ -288,32 +334,29 @@ fun CreateTextWithEmoji(
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val emojis = remember {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
var emojiList by remember(text) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
|
||||
|
||||
LaunchedEffect(key1 = text) {
|
||||
launch(Dispatchers.Default) {
|
||||
val emojis =
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
|
||||
if (emojis.isNotEmpty()) {
|
||||
val newEmojiList = assembleAnnotatedList(text, emojis)
|
||||
if (newEmojiList.isNotEmpty()) {
|
||||
emojiList = newEmojiList.toImmutableList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CreateTextWithEmoji(text, emojis, color, textAlign, fontWeight, fontSize, maxLines, overflow, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
emojis: Map<String, String>,
|
||||
color: Color = Color.Unspecified,
|
||||
textAlign: TextAlign? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val textColor = color.takeOrElse {
|
||||
LocalTextStyle.current.color.takeOrElse {
|
||||
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
|
||||
}
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
if (emojiList.isEmpty()) {
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
@@ -325,10 +368,6 @@ fun CreateTextWithEmoji(
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(text, emojis)
|
||||
}
|
||||
|
||||
val style = LocalTextStyle.current.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
@@ -338,33 +377,97 @@ fun CreateTextWithEmoji(
|
||||
)
|
||||
).toSpanStyle()
|
||||
|
||||
InLineIconRenderer(myList, style, maxLines, overflow, modifier)
|
||||
InLineIconRenderer(emojiList, style, maxLines, overflow, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
emojis: ImmutableMap<String, String>,
|
||||
color: Color = Color.Unspecified,
|
||||
textAlign: TextAlign? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var emojiList by remember(text) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
|
||||
|
||||
LaunchedEffect(key1 = text) {
|
||||
if (emojis.isNotEmpty()) {
|
||||
launch(Dispatchers.Default) {
|
||||
val newEmojiList = assembleAnnotatedList(text, emojis)
|
||||
if (newEmojiList.isNotEmpty()) {
|
||||
emojiList = newEmojiList.toImmutableList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val textColor = color.takeOrElse {
|
||||
LocalTextStyle.current.color.takeOrElse {
|
||||
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
|
||||
}
|
||||
}
|
||||
|
||||
if (emojiList.isEmpty()) {
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
val style = LocalTextStyle.current.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize
|
||||
)
|
||||
).toSpanStyle()
|
||||
|
||||
InLineIconRenderer(emojiList, style, maxLines, overflow, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateClickableTextWithEmoji(
|
||||
clickablePart: String,
|
||||
tags: List<List<String>>?,
|
||||
tags: ImmutableList<List<String>>?,
|
||||
style: TextStyle,
|
||||
onClick: (Int) -> Unit
|
||||
) {
|
||||
val emojis = remember(tags) {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
var emojiList by remember(clickablePart) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
|
||||
|
||||
LaunchedEffect(key1 = clickablePart) {
|
||||
launch(Dispatchers.Default) {
|
||||
val emojis =
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
|
||||
if (emojis.isNotEmpty()) {
|
||||
val newEmojiList = assembleAnnotatedList(clickablePart, emojis)
|
||||
if (newEmojiList.isNotEmpty()) {
|
||||
emojiList = newEmojiList.toImmutableList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
if (emojiList.isEmpty()) {
|
||||
ClickableText(
|
||||
AnnotatedString(clickablePart),
|
||||
style = style,
|
||||
onClick = onClick
|
||||
)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(clickablePart, emojis)
|
||||
}
|
||||
|
||||
ClickableInLineIconRenderer(myList, style.toSpanStyle()) {
|
||||
ClickableInLineIconRenderer(emojiList, style.toSpanStyle()) {
|
||||
onClick(it)
|
||||
}
|
||||
}
|
||||
@@ -374,36 +477,44 @@ fun CreateClickableTextWithEmoji(
|
||||
fun CreateClickableTextWithEmoji(
|
||||
clickablePart: String,
|
||||
suffix: String,
|
||||
tags: List<List<String>>?,
|
||||
tags: ImmutableList<List<String>>?,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
route: String,
|
||||
navController: NavController
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val emojis = remember(tags) {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
var emojiLists by remember(clickablePart) {
|
||||
mutableStateOf<Pair<ImmutableList<Renderable>, ImmutableList<Renderable>>?>(null)
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
CreateClickableText(clickablePart, suffix, overrideColor, fontWeight, route, navController)
|
||||
LaunchedEffect(key1 = clickablePart) {
|
||||
launch(Dispatchers.Default) {
|
||||
val emojis =
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
|
||||
if (emojis.isNotEmpty()) {
|
||||
val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis)
|
||||
val newEmojiList2 = assembleAnnotatedList(suffix, emojis)
|
||||
|
||||
if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) {
|
||||
emojiLists = Pair(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emojiLists == null) {
|
||||
CreateClickableText(clickablePart, suffix, overrideColor, fontWeight, route, nav)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(clickablePart, emojis)
|
||||
ClickableInLineIconRenderer(emojiLists!!.first, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
|
||||
nav(route)
|
||||
}
|
||||
|
||||
ClickableInLineIconRenderer(myList, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
|
||||
navController.navigate(route)
|
||||
}
|
||||
|
||||
val myList2 = remember {
|
||||
assembleAnnotatedList(suffix, emojis)
|
||||
}
|
||||
|
||||
InLineIconRenderer(myList2, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
|
||||
InLineIconRenderer(emojiLists!!.second, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
|
||||
}
|
||||
}
|
||||
|
||||
fun assembleAnnotatedList(text: String, emojis: Map<String, String>): List<Renderable> {
|
||||
suspend fun assembleAnnotatedList(text: String, emojis: Map<String, String>): ImmutableList<Renderable> {
|
||||
return NIP30Parser().buildArray(text).map {
|
||||
val url = emojis[it]
|
||||
if (url != null) {
|
||||
@@ -411,15 +522,20 @@ fun assembleAnnotatedList(text: String, emojis: Map<String, String>): List<Rende
|
||||
} else {
|
||||
TextType(it)
|
||||
}
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
open class Renderable()
|
||||
|
||||
@Immutable
|
||||
class TextType(val text: String) : Renderable()
|
||||
|
||||
@Immutable
|
||||
class ImageUrlType(val url: String) : Renderable()
|
||||
|
||||
@Composable
|
||||
fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
|
||||
fun ClickableInLineIconRenderer(wordsInOrder: ImmutableList<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
|
||||
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
|
||||
if (value is ImageUrlType) {
|
||||
Pair(
|
||||
@@ -434,7 +550,9 @@ fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle
|
||||
AsyncImage(
|
||||
model = value.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().padding(1.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(1.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -478,7 +596,7 @@ fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle
|
||||
|
||||
@Composable
|
||||
fun InLineIconRenderer(
|
||||
wordsInOrder: List<Renderable>,
|
||||
wordsInOrder: ImmutableList<Renderable>,
|
||||
style: SpanStyle,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
@@ -490,15 +608,17 @@ fun InLineIconRenderer(
|
||||
"inlineContent$idx",
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
width = 20.sp,
|
||||
height = 20.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = value.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().padding(1.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 1.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@@ -11,10 +12,16 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
fun ClickableUrl(urlText: String, url: String) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
|
||||
val doubleCheckedUrl = remember(url) {
|
||||
if (url.contains("://")) url else "https://$url"
|
||||
}
|
||||
|
||||
val text = remember(urlText) {
|
||||
AnnotatedString(urlText)
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(urlText),
|
||||
text = text,
|
||||
onClick = { runCatching { uri.openUri(doubleCheckedUrl) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
|
||||
@@ -6,19 +6,28 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
@Composable
|
||||
fun ClickableUserTag(
|
||||
user: User,
|
||||
navController: NavController
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val route = remember {
|
||||
"User/${user.pubkeyHex}"
|
||||
}
|
||||
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
|
||||
val userName = remember(innerUserState) {
|
||||
AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}")
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}"),
|
||||
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
|
||||
text = userName,
|
||||
onClick = { nav(route) },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@ import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MayBeWithdrawal(lnurlWord: String) {
|
||||
var lnWithdrawal by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = lnurlWord) {
|
||||
withContext(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord)
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,19 @@ fun MayBeWithdrawal(lnurlWord: String) {
|
||||
fun ClickableWithdrawal(withdrawalString: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val uri = remember(withdrawalString) {
|
||||
Uri.parse("lightning:$withdrawalString")
|
||||
}
|
||||
|
||||
val withdraw = remember(withdrawalString) {
|
||||
AnnotatedString("$withdrawalString ")
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$withdrawalString "),
|
||||
text = withdraw,
|
||||
onClick = {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri)
|
||||
ContextCompat.startActivity(context, intent, null)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -25,9 +25,9 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
const val SHORT_TEXT_LENGTH = 350
|
||||
|
||||
@@ -35,11 +35,11 @@ const val SHORT_TEXT_LENGTH = 350
|
||||
fun ExpandableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
modifier: Modifier,
|
||||
tags: ImmutableList<List<String>>,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -69,7 +69,7 @@ fun ExpandableRichTextViewer(
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
nav
|
||||
)
|
||||
|
||||
if (content.length > whereToCut && !showFullText) {
|
||||
@@ -88,18 +88,26 @@ fun ExpandableRichTextViewer(
|
||||
)
|
||||
)
|
||||
) {
|
||||
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)
|
||||
ShowMoreButton() {
|
||||
showFullText = !showFullText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShowMoreButton(onClick: () -> Unit) {
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
onClick = onClick,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user