mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
299 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c48366d56 | ||
|
|
f1834af506 | ||
|
|
f26fa71e49 | ||
|
|
4faee68174 | ||
|
|
2326e66382 | ||
|
|
bf71365f51 | ||
|
|
f45e8d491c | ||
|
|
5a476cab32 | ||
|
|
b5b0a19df5 | ||
|
|
85d26dee08 | ||
|
|
476da07d3d | ||
|
|
3b7344e894 | ||
|
|
d307bb0e49 | ||
|
|
1bbcb97d54 | ||
|
|
3a9637ccb9 | ||
|
|
071da53a6a | ||
|
|
9d190f93bb | ||
|
|
7c65a93896 | ||
|
|
d9b96d2fd5 | ||
|
|
3333fdc237 | ||
|
|
e178ff988d | ||
|
|
df649445d0 | ||
|
|
a38346c7d3 | ||
|
|
71da587426 | ||
|
|
6ecb5ecc47 | ||
|
|
b2a19efcd0 | ||
|
|
1123586ef6 | ||
|
|
ab037e61aa | ||
|
|
4a43b57f79 | ||
|
|
f81c83326c | ||
|
|
fdd1dd1855 | ||
|
|
9f3d4db9dd | ||
|
|
c23a9f8ad6 | ||
|
|
36b0c8acb5 | ||
|
|
dc12789166 | ||
|
|
942b0fa531 | ||
|
|
8ae7f31c3d | ||
|
|
bc1bbc2f70 | ||
|
|
632c78792a | ||
|
|
e2a719164f | ||
|
|
ff94f45d04 | ||
|
|
7e1b1c7d2c | ||
|
|
f6f9d82665 | ||
|
|
3638059c23 | ||
|
|
a781e733f2 | ||
|
|
56d6376efc | ||
|
|
3ac9d3a4f7 | ||
|
|
521da9a774 | ||
|
|
18ebdaf96d | ||
|
|
19d394a383 | ||
|
|
b198b4c081 | ||
|
|
28bc46f120 | ||
|
|
7a6de4ea49 | ||
|
|
bc108875e6 | ||
|
|
aa38b41c58 | ||
|
|
a4c26b1fd8 | ||
|
|
550cfe8bb4 | ||
|
|
56664afa4e | ||
|
|
90c9deecd4 | ||
|
|
add65b094d | ||
|
|
c7a4161799 | ||
|
|
f6a16e4394 | ||
|
|
44a615020d | ||
|
|
288aafc3a2 | ||
|
|
aa97c7ee9b | ||
|
|
51a6eb4974 | ||
|
|
f8dfc06a06 | ||
|
|
2d4db9fea8 | ||
|
|
34b743146e | ||
|
|
13eb8b1abc | ||
|
|
e98a77b37f | ||
|
|
6a17a8a871 | ||
|
|
3ce726bbb7 | ||
|
|
593bf24f5b | ||
|
|
7a857711eb | ||
|
|
420e325d1f | ||
|
|
610657a9b6 | ||
|
|
ea891367e9 | ||
|
|
0c22e66e8f | ||
|
|
cd008d78a0 | ||
|
|
2c2e4a1694 | ||
|
|
d7790cd31e | ||
|
|
0bb571b52e | ||
|
|
c65a4b8fe7 | ||
|
|
abb0c73c71 | ||
|
|
224bcf97ec | ||
|
|
04c449072a | ||
|
|
b26aba92a1 | ||
|
|
5ce9df9dce | ||
|
|
7426600dc6 | ||
|
|
9d4a88d26f | ||
|
|
85aaf25561 | ||
|
|
1cd5845960 | ||
|
|
e38f6be20c | ||
|
|
c78c00acd9 | ||
|
|
11fa762028 | ||
|
|
599fddb369 | ||
|
|
76103ac057 | ||
|
|
6538cbd9da | ||
|
|
566ca0e390 | ||
|
|
c5f7ea801d | ||
|
|
469e7abd99 | ||
|
|
e540954125 | ||
|
|
f73052ed48 | ||
|
|
a9437d9001 | ||
|
|
02f6422bab | ||
|
|
da2b59af49 | ||
|
|
7acdf56e68 | ||
|
|
24be6cd90d | ||
|
|
afee0ddc53 | ||
|
|
c85b8a1d83 | ||
|
|
cfeedfa4e2 | ||
|
|
dd112d28ae | ||
|
|
fc27526113 | ||
|
|
666635811b | ||
|
|
4e43938f96 | ||
|
|
2a744205f0 | ||
|
|
7e6ca34d2a | ||
|
|
24f7991116 | ||
|
|
beb901120e | ||
|
|
0936df9851 | ||
|
|
9ceb8866ed | ||
|
|
c88b21b547 | ||
|
|
5c366d5cfc | ||
|
|
ea70d44ac7 | ||
|
|
67f10920f6 | ||
|
|
654632a585 | ||
|
|
794b05106b | ||
|
|
5812e290c9 | ||
|
|
c279c04858 | ||
|
|
01016525d3 | ||
|
|
72c6e93524 | ||
|
|
d6988ad4e1 | ||
|
|
4c1cd1c9ab | ||
|
|
9fb8d4821e | ||
|
|
8b052567c4 | ||
|
|
4a6ea550d6 | ||
|
|
5c88e7993f | ||
|
|
1bd1493bf4 | ||
|
|
404278a4e3 | ||
|
|
edca55b0b6 | ||
|
|
738187d32b | ||
|
|
d9de0d2798 | ||
|
|
b20515b1a0 | ||
|
|
e6d8291f07 | ||
|
|
18d08bf6e0 | ||
|
|
b2193f48d5 | ||
|
|
de391f03b1 | ||
|
|
c494cf8ac1 | ||
|
|
193e9a5adb | ||
|
|
4fb9c93cf0 | ||
|
|
6bd98201f8 | ||
|
|
cbc6697631 | ||
|
|
d48634ac0e | ||
|
|
5e2c8de15e | ||
|
|
926a721c53 | ||
|
|
5bc6da3bfc | ||
|
|
eae066b003 | ||
|
|
b74fa975ba | ||
|
|
7f2b8519f3 | ||
|
|
51335e06f1 | ||
|
|
0b6cd08c4a | ||
|
|
1bfe57da63 | ||
|
|
283d52ac2f | ||
|
|
d1646761d2 | ||
|
|
dc8209c90d | ||
|
|
420323bcb0 | ||
|
|
142aca40ce | ||
|
|
4fa3d60638 | ||
|
|
a9eeb04014 | ||
|
|
f09b00ff01 | ||
|
|
ceaae398c2 | ||
|
|
39f87af072 | ||
|
|
a34a3cbc83 | ||
|
|
f8afb4b783 | ||
|
|
fad4248539 | ||
|
|
13a53876d0 | ||
|
|
ea8affaebf | ||
|
|
df44e172ab | ||
|
|
8a50b3938d | ||
|
|
0245c907ff | ||
|
|
3bdc75b1be | ||
|
|
9d02361d01 | ||
|
|
6e6a13c5bf | ||
|
|
1f45a63081 | ||
|
|
11a5f4a67e | ||
|
|
52e79580bf | ||
|
|
561b19c447 | ||
|
|
9b21c3c964 | ||
|
|
baaa984d0d | ||
|
|
2b7ef79d21 | ||
|
|
22c96d2489 | ||
|
|
0c1187e4f5 | ||
|
|
7310ef175f | ||
|
|
53ba65cac2 | ||
|
|
58ed27dc75 | ||
|
|
065ba1c165 | ||
|
|
896d227fea | ||
|
|
6bac18c5df | ||
|
|
9ad62ef263 | ||
|
|
fe45e188bd | ||
|
|
334b948900 | ||
|
|
fb9ad2b457 | ||
|
|
921bb41596 | ||
|
|
ab4d01583b | ||
|
|
314531e938 | ||
|
|
991eed9bdf | ||
|
|
357981f266 | ||
|
|
119e9b7281 | ||
|
|
8ca53e9707 | ||
|
|
326e38f293 | ||
|
|
6232e2682f | ||
|
|
a2363221c6 | ||
|
|
1eef457b4e | ||
|
|
fbd88bdeab | ||
|
|
5d25bec1c9 | ||
|
|
201a6d4462 | ||
|
|
1345ad3745 | ||
|
|
b5f2a2b428 | ||
|
|
fe7b9b7930 | ||
|
|
b24d3d863d | ||
|
|
3b7252616b | ||
|
|
c76ed3bb53 | ||
|
|
cf775eebfb | ||
|
|
72018dc208 | ||
|
|
fef635ab39 | ||
|
|
7a243af45c | ||
|
|
94af0eb220 | ||
|
|
c0aea75c16 | ||
|
|
175b79b291 | ||
|
|
e5b8523bee | ||
|
|
1aecd9cf45 | ||
|
|
6600a49564 | ||
|
|
cdb65640ba | ||
|
|
0c9e76eeaf | ||
|
|
cfdbd0a9b6 | ||
|
|
09b8178a7c | ||
|
|
5ea793eb51 | ||
|
|
8cf04967c3 | ||
|
|
ef363457e8 | ||
|
|
e87394f3f7 | ||
|
|
86ebfe8564 | ||
|
|
a2b3cfb991 | ||
|
|
1b6aa621cd | ||
|
|
b7f73c6eab | ||
|
|
e35fb88ff1 | ||
|
|
6ecb3c8e1f | ||
|
|
ff20f0a266 | ||
|
|
a4cc6337f9 | ||
|
|
202b897029 | ||
|
|
79c174b92e | ||
|
|
bdd3f19b2c | ||
|
|
afcc775d1b | ||
|
|
8bbf308619 | ||
|
|
df378937fe | ||
|
|
02ab7a3f3f | ||
|
|
68ba9b3b91 | ||
|
|
f62833d1be | ||
|
|
3be246c9cc | ||
|
|
b28546b172 | ||
|
|
0fccfd7f80 | ||
|
|
3f35b57571 | ||
|
|
32b9b6c37a | ||
|
|
2342da114d | ||
|
|
ef0d77f8eb | ||
|
|
5559b69bdb | ||
|
|
eda25b4cfe | ||
|
|
9ce14e08fd | ||
|
|
b046fd91cb | ||
|
|
8c9800664f | ||
|
|
b90a57220d | ||
|
|
d16b0f58bb | ||
|
|
a538b66db3 | ||
|
|
f04631b0dd | ||
|
|
6e31cff99c | ||
|
|
1553640c18 | ||
|
|
68b8f9c82a | ||
|
|
f6cce42028 | ||
|
|
0cbddad9c0 | ||
|
|
b14154e2b5 | ||
|
|
c4250ccd35 | ||
|
|
31516964c8 | ||
|
|
4722b2a617 | ||
|
|
eca5b47ab0 | ||
|
|
d38b57025c | ||
|
|
fa7aa3cf24 | ||
|
|
d8e9b4773b | ||
|
|
f9a7b13ba1 | ||
|
|
ecbf0e404d | ||
|
|
c2f8df963a | ||
|
|
ff20960bb5 | ||
|
|
8dd1fc2077 | ||
|
|
bb2fb2b103 | ||
|
|
00a9c49915 | ||
|
|
d2872cc8bb | ||
|
|
d33a1ce14f | ||
|
|
31958215be | ||
|
|
bbbb614718 | ||
|
|
776a23c256 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,9 +7,11 @@
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/androidTestResultsUserPreferences.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
/.idea/deploymentTargetSelector.xml
|
||||
/.idea/appInsightsSettings.xml
|
||||
/.idea/ktlint-plugin.xml
|
||||
/.idea/ktfmt.xml
|
||||
/.idea/studiobot.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
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.9.22" />
|
||||
<option name="version" value="1.9.24" />
|
||||
</component>
|
||||
</project>
|
||||
35
README.md
35
README.md
@@ -43,7 +43,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] OpenTimestamps Attestations (NIP-03)
|
||||
- [x] Private Messages (NIP-04)
|
||||
- [x] DNS Address (NIP-05)
|
||||
- [ ] Mnemonic seed phrase (NIP-06)
|
||||
- [x] Mnemonic seed phrase (NIP-06)
|
||||
- [ ] WebBrowser Signer (NIP-07, Not applicable)
|
||||
- [x] Old-style mentions (NIP-08)
|
||||
- [x] Event Deletion (NIP-09)
|
||||
@@ -55,6 +55,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Events with a Subject (NIP-14)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [x] Event Treatment (NIP-16)
|
||||
- [x] Private Direct Messages (NIP-17)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] Reposts, Quotes, Generic Reposts (NIP-18)
|
||||
- [x] Bech Encoding support (NIP-19)
|
||||
@@ -66,17 +67,20 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [ ] Delegated Event Signing (NIP-26, Will not implement)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [ ] Relay-based Groups (NIP-29)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Event kind summaries (NIP-31)
|
||||
- [ ] Labeling (NIP-32)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
- [x] Git Stuff (NIP-34/Draft)
|
||||
- [x] Git Stuff (NIP-34)
|
||||
- [ ] Torrents (NIP-35)
|
||||
- [x] Sensitive Content (NIP-36)
|
||||
- [x] Note Edits (NIP-37/Draft)
|
||||
- [x] Edits (NIP-37/Draft)
|
||||
- [x] User Status Event (NIP-38)
|
||||
- [x] External Identities (NIP-39)
|
||||
- [x] Expiration Support (NIP-40)
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44)
|
||||
- [ ] Event Counts (NIP-45, Will not implement)
|
||||
- [ ] Nostr Connect (NIP-46)
|
||||
- [x] Wallet Connect API (NIP-47)
|
||||
@@ -86,31 +90,32 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] Lists (NIP-51)
|
||||
- [ ] Calendar Events (NIP-52)
|
||||
- [x] Live Activities & Live Chats (NIP-53)
|
||||
- [x] Wiki (NIP-54)
|
||||
- [x] Inline Metadata (NIP-55 - Draft)
|
||||
- [x] Reporting (NIP-56)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (NIP-57)
|
||||
- [x] Private Zaps
|
||||
- [x] Zap Splits (NIP-57)
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Gift Wraps & Seals (NIP-59)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [x] Badges (NIP-58)
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Video Events (NIP-71)
|
||||
- [x] Moderated Communities (NIP-72)
|
||||
- [ ] Zap Goals (NIP-75)
|
||||
- [ ] Arbitrary Custom App Data (NIP-78)
|
||||
- [x] Highlights (NIP-84)
|
||||
- [x] Notify Request (NIP-88/Draft)
|
||||
- [x] Recommended Application Handlers (NIP-89)
|
||||
- [ ] Data Vending Machine (NIP-90)
|
||||
- [x] Data Vending Machine (NIP-90)
|
||||
- [x] Inline Metadata (NIP-92)
|
||||
- [x] Verifiable file URLs (NIP-94)
|
||||
- [x] Binary Blobs (NIP-95)
|
||||
- [x] HTTP File Storage Integration (NIP-96 Draft)
|
||||
- [x] Binary Blobs (NIP-95/Draft)
|
||||
- [x] HTTP File Storage Integration (NIP-96)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Classifieds (NIP-99)
|
||||
- [x] Private Messages and Small Groups (NIP-24/Draft)
|
||||
- [x] Versioned Encrypted Payloads (NIP-44/Draft)
|
||||
- [x] Audio Tracks (zapstr.live) (kind:31337)
|
||||
- [x] Push Notifications (Google and Unified Push)
|
||||
- [x] In-Device Automatic Translations
|
||||
@@ -120,9 +125,15 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
- [x] De-googled F-Droid flavor
|
||||
- [x] Multiple Accounts
|
||||
- [x] Markdown Support
|
||||
- [x] FHIR Payloads (kind:82)
|
||||
- [ ] Decentralized Wiki (kind:30818)
|
||||
- [ ] Embed events
|
||||
- [x] Medical Data (NIP-xx/Draft)
|
||||
- [x] Embed events (NIP-xx/Draft)
|
||||
- [x] Draft Events (NIP-xx/Draft)
|
||||
- [ ] Event Sets (NIP-xx/Draft)
|
||||
- [ ] Topical Notes (NIP-xx/Draft)
|
||||
- [ ] Relationship Status (NIP-xx/Draft)
|
||||
- [ ] Signed Filters (NIP-xx/Draft)
|
||||
- [ ] Key Migration (NIP-xx/Draft)
|
||||
- [ ] Time-based Sync (NIP-xx/Draft)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] Workspaces
|
||||
|
||||
@@ -12,9 +12,9 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 365
|
||||
versionName "0.86.2"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"a704a11334ed4fe6fc6ee6f8856f6f005da33644770616f1437f8b2b488b52b1\""
|
||||
versionCode 371
|
||||
versionName "0.87.2"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"863124b6592359494396c22b917f9d251ad12ac31b09ea29e0265e3c088ce9f8\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -143,7 +143,7 @@ android {
|
||||
|
||||
composeOptions {
|
||||
// Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
|
||||
kotlinCompilerExtensionVersion "1.5.8"
|
||||
kotlinCompilerExtensionVersion "1.5.14"
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
@@ -151,7 +151,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ object PushDistributorHandler : PushDistributorActions {
|
||||
}
|
||||
|
||||
override fun getSavedDistributor(): String {
|
||||
return unifiedPush.getDistributor(appContext)
|
||||
return unifiedPush.getSavedDistributor(appContext) ?: ""
|
||||
}
|
||||
|
||||
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
|
||||
|
||||
@@ -47,9 +47,11 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
|
||||
import com.halilibo.richtext.commonmark.MarkdownParseOptions
|
||||
import com.halilibo.richtext.markdown.BasicMarkdown
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material3.Material3RichText
|
||||
import com.halilibo.richtext.ui.material3.RichText
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
|
||||
@@ -101,12 +103,18 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
|
||||
onDismissRequest = { distributorPresent = true },
|
||||
title = { Text(stringResource(R.string.push_server_install_app)) },
|
||||
text = {
|
||||
Material3RichText(
|
||||
val content = stringResource(R.string.push_server_install_app_description)
|
||||
|
||||
val astNode =
|
||||
remember {
|
||||
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content)
|
||||
}
|
||||
|
||||
RichText(
|
||||
style = RichTextStyle().resolveDefaults(),
|
||||
renderer = null,
|
||||
) {
|
||||
Markdown(
|
||||
content = stringResource(R.string.push_server_install_app_description),
|
||||
)
|
||||
BasicMarkdown(astNode)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
@@ -77,6 +77,7 @@ private object PrefKeys {
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val RELAYS = "relays"
|
||||
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||
const val LOCAL_RELAY_SERVERS = "localRelayServers"
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
@@ -91,7 +92,7 @@ private object PrefKeys {
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog"
|
||||
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
|
||||
@@ -284,6 +285,7 @@ object LocalPreferences {
|
||||
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
|
||||
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
|
||||
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, account.localRelayServers)
|
||||
putString(
|
||||
PrefKeys.LANGUAGE_PREFS,
|
||||
Event.mapper.writeValueAsString(account.languagePreferences),
|
||||
@@ -318,7 +320,7 @@ object LocalPreferences {
|
||||
Event.mapper.writeValueAsString(account.backupContactList),
|
||||
)
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, account.hideNIP17WarningDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
|
||||
@@ -410,6 +412,7 @@ object LocalPreferences {
|
||||
?: setOf<RelaySetupInfo>()
|
||||
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
val defaultHomeFollowList =
|
||||
getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
|
||||
@@ -526,7 +529,7 @@ object LocalPreferences {
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
|
||||
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
@@ -577,6 +580,7 @@ object LocalPreferences {
|
||||
keyPair = keyPair,
|
||||
signer = signer,
|
||||
localRelays = localRelays,
|
||||
localRelayServers = localRelayServers,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
languagePreferences = languagePreferences,
|
||||
translateTo = translateTo,
|
||||
@@ -591,7 +595,7 @@ object LocalPreferences {
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP24WarningDialog = hideNIP24WarningDialog,
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
backupContactList = latestContactList,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
|
||||
@@ -58,6 +58,7 @@ import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
@@ -104,7 +105,7 @@ class ServiceManager {
|
||||
}
|
||||
|
||||
if (myAccount != null) {
|
||||
val relaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
|
||||
val relaySet = myAccount.connectToRelays.value
|
||||
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
|
||||
Client.reconnect(relaySet)
|
||||
|
||||
@@ -112,10 +113,10 @@ class ServiceManager {
|
||||
collectorJob = null
|
||||
collectorJob =
|
||||
scope.launch {
|
||||
myAccount.userProfile().flow().relays.stateFlow.collect {
|
||||
myAccount.connectToRelaysFlow.collectLatest {
|
||||
delay(500)
|
||||
if (isStarted) {
|
||||
val newRelaySet = myAccount.activeRelays() ?: myAccount.convertLocalRelays()
|
||||
Client.reconnect(newRelaySet, onlyIfChanged = true)
|
||||
Client.reconnect(it, onlyIfChanged = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
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
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
@@ -47,11 +49,13 @@ import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.Contact
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
@@ -78,18 +82,21 @@ import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.NIP24Factory
|
||||
import com.vitorpamplona.quartz.events.NIP17Factory
|
||||
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.Response
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
@@ -115,7 +122,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flattenMerge
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
@@ -148,7 +154,7 @@ val DefaultReactions =
|
||||
"\uD83D\uDE31",
|
||||
)
|
||||
|
||||
val DefaultZapAmounts = listOf(500L, 1000L, 5000L)
|
||||
val DefaultZapAmounts = listOf(100L, 500L, 1000L)
|
||||
|
||||
fun getLanguagesSpokenByUser(): Set<String> {
|
||||
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
||||
@@ -171,6 +177,7 @@ class Account(
|
||||
val keyPair: KeyPair,
|
||||
val signer: NostrSigner = NostrSignerInternal(keyPair),
|
||||
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
|
||||
var localRelayServers: Set<String> = setOf(),
|
||||
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
@@ -185,7 +192,7 @@ class Account(
|
||||
var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var hideNIP24WarningDialog: Boolean = false,
|
||||
var hideNIP17WarningDialog: Boolean = false,
|
||||
var backupContactList: ContactListEvent? = null,
|
||||
var proxy: Proxy? = null,
|
||||
var proxyPort: Int = 9050,
|
||||
@@ -222,6 +229,125 @@ class Account(
|
||||
|
||||
class ListNameNotePair(val listName: String, val event: GeneralListEvent?)
|
||||
|
||||
val connectToRelaysFlow =
|
||||
combineTransform(
|
||||
getNIP65RelayListFlow(),
|
||||
getDMRelayListFlow(),
|
||||
getSearchRelayListFlow(),
|
||||
getPrivateOutboxRelayListFlow(),
|
||||
userProfile().flow().relays.stateFlow,
|
||||
) { nip65RelayList, dmRelayList, searchRelayList, privateOutBox, userProfile ->
|
||||
val baseRelaySet = activeRelays() ?: convertLocalRelays()
|
||||
val newDMRelaySet = (dmRelayList.note.event as? ChatMessageRelayListEvent)?.relays()?.toSet() ?: emptySet()
|
||||
val searchRelaySet = (searchRelayList.note.event as? SearchRelayListEvent)?.relays()?.toSet() ?: Constants.defaultSearchRelaySet
|
||||
val nip65RelaySet = (nip65RelayList.note.event as? AdvertisedRelayListEvent)?.relays()
|
||||
val privateOutboxRelaySet = (privateOutBox.note.event as? PrivateOutboxRelayListEvent)?.relays() ?: emptySet()
|
||||
|
||||
// ------
|
||||
// DMs
|
||||
// ------
|
||||
|
||||
var mappedRelaySet =
|
||||
baseRelaySet.map {
|
||||
if (newDMRelaySet.contains(it.url)) {
|
||||
Relay(it.url, true, true, it.activeTypes + FeedType.PRIVATE_DMS)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
newDMRelaySet.forEach { newUrl ->
|
||||
if (mappedRelaySet.none { it.url == newUrl }) {
|
||||
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, true, setOf(FeedType.PRIVATE_DMS))
|
||||
}
|
||||
}
|
||||
|
||||
// ------
|
||||
// SEARCH
|
||||
// ------
|
||||
|
||||
mappedRelaySet =
|
||||
mappedRelaySet.map {
|
||||
if (searchRelaySet.contains(it.url)) {
|
||||
Relay(it.url, true, it.write || false, it.activeTypes + FeedType.SEARCH)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
searchRelaySet.forEach { newUrl ->
|
||||
if (mappedRelaySet.none { it.url == newUrl }) {
|
||||
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, false, setOf(FeedType.SEARCH))
|
||||
}
|
||||
}
|
||||
|
||||
// --------------
|
||||
// PRIVATE OUTBOX
|
||||
// --------------
|
||||
|
||||
mappedRelaySet =
|
||||
mappedRelaySet.map {
|
||||
if (privateOutboxRelaySet.contains(it.url)) {
|
||||
Relay(it.url, true, true, it.activeTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
privateOutboxRelaySet.forEach { newUrl ->
|
||||
if (mappedRelaySet.none { it.url == newUrl }) {
|
||||
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, true, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
}
|
||||
}
|
||||
|
||||
// --------------
|
||||
// PRIVATE OUTBOX
|
||||
// --------------
|
||||
|
||||
mappedRelaySet =
|
||||
mappedRelaySet.map {
|
||||
if (localRelayServers.contains(it.url)) {
|
||||
Relay(it.url, true, true, it.activeTypes + setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
localRelayServers.forEach { newUrl ->
|
||||
if (mappedRelaySet.none { it.url == newUrl }) {
|
||||
mappedRelaySet = mappedRelaySet + Relay(newUrl, true, true, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL, FeedType.PRIVATE_DMS))
|
||||
}
|
||||
}
|
||||
|
||||
// --------------
|
||||
// NIP-65 Public Inbox/Outbox
|
||||
// --------------
|
||||
|
||||
mappedRelaySet =
|
||||
mappedRelaySet.map { relay ->
|
||||
val nip65setup = nip65RelaySet?.firstOrNull { relay.url == it.relayUrl }
|
||||
if (nip65setup != null) {
|
||||
val write = nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || nip65setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.READ
|
||||
|
||||
Relay(relay.url, true, relay.write || write, relay.activeTypes + setOf(FeedType.FOLLOWS, FeedType.GLOBAL, FeedType.PUBLIC_CHATS))
|
||||
} else {
|
||||
relay
|
||||
}
|
||||
}
|
||||
|
||||
nip65RelaySet?.forEach { newNip65Setup ->
|
||||
if (mappedRelaySet.none { it.url == newNip65Setup.relayUrl }) {
|
||||
val write = newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || newNip65Setup.type == AdvertisedRelayListEvent.AdvertisedRelayType.READ
|
||||
|
||||
mappedRelaySet = mappedRelaySet + Relay(newNip65Setup.relayUrl, true, write, setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS))
|
||||
}
|
||||
}
|
||||
|
||||
emit(mappedRelaySet.toTypedArray())
|
||||
}
|
||||
|
||||
val connectToRelays = connectToRelaysFlow.stateIn(scope, SharingStarted.Eagerly, activeRelays() ?: convertLocalRelays())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveKind3FollowsFlow: Flow<LiveFollowLists> =
|
||||
userProfile().flow().follows.stateFlow.transformLatest {
|
||||
@@ -484,7 +610,7 @@ class Account(
|
||||
return keyPair.privKey != null || signer is NostrSignerExternal
|
||||
}
|
||||
|
||||
fun sendNewRelayList(relays: Map<String, ContactListEvent.ReadWrite>) {
|
||||
fun sendKind3RelayList(relays: Map<String, ContactListEvent.ReadWrite>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val contactList = userProfile().latestContactList
|
||||
@@ -593,7 +719,7 @@ class Account(
|
||||
val emojiUrl = EmojiUrl.decode(reaction)
|
||||
if (emojiUrl != null) {
|
||||
note.event?.let {
|
||||
NIP24Factory().createReactionWithinGroup(
|
||||
NIP17Factory().createReactionWithinGroup(
|
||||
emojiUrl = emojiUrl,
|
||||
originalNote = it,
|
||||
to = users,
|
||||
@@ -608,7 +734,7 @@ class Account(
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
NIP24Factory().createReactionWithinGroup(
|
||||
NIP17Factory().createReactionWithinGroup(
|
||||
content = reaction,
|
||||
originalNote = it,
|
||||
to = users,
|
||||
@@ -655,8 +781,10 @@ class Account(
|
||||
note.event?.let { event ->
|
||||
LnZapRequestEvent.create(
|
||||
event,
|
||||
userProfile().latestContactList?.relays()?.keys?.ifEmpty { null }
|
||||
?: localRelays.map { it.url }.toSet(),
|
||||
relays =
|
||||
getNIP65RelayList()?.readRelays()?.toSet()
|
||||
?: userProfile().latestContactList?.relays()?.keys?.ifEmpty { null }
|
||||
?: localRelays.map { it.url }.toSet(),
|
||||
signer,
|
||||
pollOption,
|
||||
message,
|
||||
@@ -709,6 +837,7 @@ class Account(
|
||||
fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
onSent: () -> Unit,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@@ -730,6 +859,8 @@ class Account(
|
||||
LocalCache.consume(event, zappedNote) { it.response(signer) { onResponse(it) } }
|
||||
|
||||
Client.send(event, nip47.relayUri, wcListener.feedTypes) { wcListener.destroy() }
|
||||
|
||||
onSent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,7 +981,22 @@ class Account(
|
||||
fun broadcast(note: Note) {
|
||||
note.event?.let {
|
||||
if (it is WrappedEvent && it.host != null) {
|
||||
it.host?.let { hostEvent -> Client.send(hostEvent) }
|
||||
it.host?.let {
|
||||
Client.sendFilterAndStopOnFirstResponse(
|
||||
filters =
|
||||
listOf(
|
||||
TypedFilter(
|
||||
setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL),
|
||||
JsonFilter(
|
||||
ids = listOf(it.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
onResponse = {
|
||||
Client.send(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Client.send(it)
|
||||
}
|
||||
@@ -1306,7 +1452,12 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
@@ -1370,7 +1521,12 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
@@ -1396,15 +1552,15 @@ class Account(
|
||||
|
||||
fun deleteDraft(draftTag: String) {
|
||||
val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag)
|
||||
LocalCache.getAddressableNoteIfExists(key)?.let {
|
||||
val noteEvent = it.event
|
||||
LocalCache.getAddressableNoteIfExists(key)?.let { note ->
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is DraftEvent) {
|
||||
noteEvent.createDeletedEvent(signer) {
|
||||
Client.send(it)
|
||||
Client.sendPrivately(it, relayList = note.relays.map { it.url })
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
delete(it)
|
||||
delete(note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1454,7 +1610,12 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
@@ -1547,7 +1708,12 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
@@ -1601,7 +1767,12 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
Client.send(draftEvent)
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
@@ -1648,7 +1819,12 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
Client.send(draftEvent)
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
@@ -1722,7 +1898,12 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
|
||||
Client.send(draftEvent)
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
@@ -1733,7 +1914,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNIP24PrivateMessage(
|
||||
fun sendNIP17PrivateMessage(
|
||||
message: String,
|
||||
toUsers: List<HexKey>,
|
||||
subject: String? = null,
|
||||
@@ -1751,7 +1932,7 @@ class Account(
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
NIP24Factory().createMsgNIP24(
|
||||
NIP17Factory().createMsgNIP17(
|
||||
msg = message,
|
||||
to = toUsers,
|
||||
subject = subject,
|
||||
@@ -1770,7 +1951,12 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it.msg, emptyList(), signer) { draftEvent ->
|
||||
Client.send(draftEvent)
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
}
|
||||
@@ -1780,7 +1966,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun broadcastPrivately(signedEvents: NIP24Factory.Result) {
|
||||
fun broadcastPrivately(signedEvents: NIP17Factory.Result) {
|
||||
val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) }
|
||||
|
||||
mine.forEach { giftWrap ->
|
||||
@@ -1798,13 +1984,28 @@ class Account(
|
||||
val id = mine.firstOrNull()?.id
|
||||
val mineNote = if (id == null) null else LocalCache.getNoteIfExists(id)
|
||||
|
||||
signedEvents.wraps.forEach {
|
||||
signedEvents.wraps.forEach { wrap ->
|
||||
// Creates an alias
|
||||
if (mineNote != null && it.recipientPubKey() != keyPair.pubKey.toHexKey()) {
|
||||
LocalCache.getOrAddAliasNote(it.id, mineNote)
|
||||
if (mineNote != null && wrap.recipientPubKey() != keyPair.pubKey.toHexKey()) {
|
||||
LocalCache.getOrAddAliasNote(wrap.id, mineNote)
|
||||
}
|
||||
|
||||
Client.send(it)
|
||||
val receiver = wrap.recipientPubKey()
|
||||
if (receiver != null) {
|
||||
val relayList =
|
||||
(
|
||||
LocalCache.getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(receiver))
|
||||
?.event as? ChatMessageRelayListEvent
|
||||
)?.relays()?.ifEmpty { null }
|
||||
|
||||
if (relayList != null) {
|
||||
Client.sendPrivately(signedEvent = wrap, relayList = relayList)
|
||||
} else {
|
||||
Client.send(wrap)
|
||||
}
|
||||
} else {
|
||||
Client.send(wrap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2054,17 +2255,6 @@ class Account(
|
||||
return LocalCache.getOrCreateAddressableNote(aTag)
|
||||
}
|
||||
|
||||
fun getFileServersNote(): AddressableNote {
|
||||
val aTag =
|
||||
ATag(
|
||||
FileServersEvent.KIND,
|
||||
userProfile().pubkeyHex,
|
||||
"",
|
||||
null,
|
||||
)
|
||||
return LocalCache.getOrCreateAddressableNote(aTag)
|
||||
}
|
||||
|
||||
fun getBlockList(): PeopleListEvent? {
|
||||
return getBlockListNote().event as? PeopleListEvent
|
||||
}
|
||||
@@ -2073,10 +2263,6 @@ class Account(
|
||||
return getMuteListNote().event as? MuteListEvent
|
||||
}
|
||||
|
||||
fun getFileServersList(): FileServersEvent? {
|
||||
return getFileServersNote().event as? FileServersEvent
|
||||
}
|
||||
|
||||
fun hideWord(word: String) {
|
||||
val muteList = getMuteList()
|
||||
|
||||
@@ -2272,6 +2458,17 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun requestDVMContentDiscovery(
|
||||
dvmPublicKey: String,
|
||||
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
|
||||
) {
|
||||
NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey, signer) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
onReady(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun unwrap(
|
||||
event: GiftWrapEvent,
|
||||
onReady: (Event) -> Unit,
|
||||
@@ -2315,6 +2512,10 @@ class Account(
|
||||
event.plainContent(signer, onReady)
|
||||
} else if (event is LnZapRequestEvent) {
|
||||
decryptZapContentAuthor(note) { onReady(it.content) }
|
||||
} else if (event is DraftEvent) {
|
||||
event.cachedDraft(signer) {
|
||||
onReady(it.content)
|
||||
}
|
||||
} else {
|
||||
event?.content()?.let { onReady(it) }
|
||||
}
|
||||
@@ -2336,6 +2537,12 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLocalRelayServers(servers: Set<String>) {
|
||||
localRelayServers = servers
|
||||
liveLanguages.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun addDontTranslateFrom(languageCode: String) {
|
||||
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
|
||||
liveLanguages.invalidateData()
|
||||
@@ -2378,7 +2585,7 @@ class Account(
|
||||
|
||||
// Takes a User's relay list and adds the types of feeds they are active for.
|
||||
fun activeRelays(): Array<Relay>? {
|
||||
var usersRelayList =
|
||||
val usersRelayList =
|
||||
userProfile().latestContactList?.relays()?.map {
|
||||
val localFeedTypes =
|
||||
localRelays.firstOrNull { localRelay -> localRelay.url == it.key }?.feedTypes
|
||||
@@ -2389,26 +2596,7 @@ class Account(
|
||||
?: FeedType.values().toSet()
|
||||
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
|
||||
}
|
||||
?: return null
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
// TODO: Remove when search becomes more available.
|
||||
val searchRelays =
|
||||
usersRelayList.filter { it.url.removeSuffix("/") in Constants.forcedRelaysForSearchSet }
|
||||
val hasSearchRelay = usersRelayList.any { it.activeTypes.contains(FeedType.SEARCH) }
|
||||
if (!hasSearchRelay && searchRelays.isEmpty()) {
|
||||
usersRelayList =
|
||||
usersRelayList +
|
||||
Constants.forcedRelayForSearch.map {
|
||||
Relay(
|
||||
it.url,
|
||||
it.read,
|
||||
it.write,
|
||||
it.feedTypes,
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
return usersRelayList.toTypedArray()
|
||||
}
|
||||
@@ -2418,14 +2606,14 @@ class Account(
|
||||
}
|
||||
|
||||
fun activeGlobalRelays(): Array<String> {
|
||||
return (activeRelays() ?: convertLocalRelays())
|
||||
return connectToRelays.value
|
||||
.filter { it.activeTypes.contains(FeedType.GLOBAL) }
|
||||
.map { it.url }
|
||||
.toTypedArray()
|
||||
}
|
||||
|
||||
fun activeWriteRelays(): List<Relay> {
|
||||
return (activeRelays() ?: convertLocalRelays()).filter { it.write }
|
||||
return connectToRelays.value.filter { it.write }
|
||||
}
|
||||
|
||||
fun isAllHidden(users: Set<HexKey>): Boolean {
|
||||
@@ -2511,10 +2699,10 @@ class Account(
|
||||
.toSet()
|
||||
}
|
||||
|
||||
fun saveRelayList(value: List<RelaySetupInfo>) {
|
||||
fun saveKind3RelayList(value: List<RelaySetupInfo>) {
|
||||
try {
|
||||
localRelays = value.toSet()
|
||||
return sendNewRelayList(
|
||||
return sendKind3RelayList(
|
||||
value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) },
|
||||
)
|
||||
} finally {
|
||||
@@ -2522,13 +2710,205 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun getDMRelayListNote(): AddressableNote {
|
||||
return LocalCache.getOrCreateAddressableNote(
|
||||
ChatMessageRelayListEvent.createAddressATag(signer.pubKey),
|
||||
)
|
||||
}
|
||||
|
||||
fun getDMRelayListFlow(): StateFlow<NoteState> {
|
||||
return getDMRelayListNote().flow().metadata.stateFlow
|
||||
}
|
||||
|
||||
fun getDMRelayList(): ChatMessageRelayListEvent? {
|
||||
return getDMRelayListNote().event as? ChatMessageRelayListEvent
|
||||
}
|
||||
|
||||
fun saveDMRelayList(dmRelays: List<String>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val relayListForDMs = getDMRelayList()
|
||||
if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) {
|
||||
ChatMessageRelayListEvent.updateRelayList(
|
||||
earlierVersion = relayListForDMs,
|
||||
relays = dmRelays,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
} else {
|
||||
ChatMessageRelayListEvent.createFromScratch(
|
||||
relays = dmRelays,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPrivateOutboxRelayListNote(): AddressableNote {
|
||||
return LocalCache.getOrCreateAddressableNote(
|
||||
PrivateOutboxRelayListEvent.createAddressATag(signer.pubKey),
|
||||
)
|
||||
}
|
||||
|
||||
fun getPrivateOutboxRelayListFlow(): StateFlow<NoteState> {
|
||||
return getPrivateOutboxRelayListNote().flow().metadata.stateFlow
|
||||
}
|
||||
|
||||
fun getPrivateOutboxRelayList(): PrivateOutboxRelayListEvent? {
|
||||
return getPrivateOutboxRelayListNote().event as? PrivateOutboxRelayListEvent
|
||||
}
|
||||
|
||||
fun savePrivateOutboxRelayList(relays: List<String>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val relayListForPrivateOutbox = getPrivateOutboxRelayList()
|
||||
|
||||
if (relayListForPrivateOutbox != null && !relayListForPrivateOutbox.cachedPrivateTags().isNullOrEmpty()) {
|
||||
PrivateOutboxRelayListEvent.updateRelayList(
|
||||
earlierVersion = relayListForPrivateOutbox,
|
||||
relays = relays,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
} else {
|
||||
PrivateOutboxRelayListEvent.createFromScratch(
|
||||
relays = relays,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSearchRelayListNote(): AddressableNote {
|
||||
return LocalCache.getOrCreateAddressableNote(
|
||||
SearchRelayListEvent.createAddressATag(signer.pubKey),
|
||||
)
|
||||
}
|
||||
|
||||
fun getSearchRelayListFlow(): StateFlow<NoteState> {
|
||||
return getSearchRelayListNote().flow().metadata.stateFlow
|
||||
}
|
||||
|
||||
fun getSearchRelayList(): SearchRelayListEvent? {
|
||||
return getSearchRelayListNote().event as? SearchRelayListEvent
|
||||
}
|
||||
|
||||
fun saveSearchRelayList(searchRelays: List<String>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val relayListForSearch = getSearchRelayList()
|
||||
|
||||
if (relayListForSearch != null && relayListForSearch.tags.isNotEmpty()) {
|
||||
SearchRelayListEvent.updateRelayList(
|
||||
earlierVersion = relayListForSearch,
|
||||
relays = searchRelays,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
} else {
|
||||
SearchRelayListEvent.createFromScratch(
|
||||
relays = searchRelays,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getNIP65RelayListNote(): AddressableNote {
|
||||
return LocalCache.getOrCreateAddressableNote(
|
||||
AdvertisedRelayListEvent.createAddressATag(signer.pubKey),
|
||||
)
|
||||
}
|
||||
|
||||
fun getNIP65RelayListFlow(): StateFlow<NoteState> {
|
||||
return getNIP65RelayListNote().flow().metadata.stateFlow
|
||||
}
|
||||
|
||||
fun getNIP65RelayList(): AdvertisedRelayListEvent? {
|
||||
return getNIP65RelayListNote().event as? AdvertisedRelayListEvent
|
||||
}
|
||||
|
||||
fun sendNip65RelayList(relays: List<AdvertisedRelayListEvent.AdvertisedRelayInfo>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val nip65RelayList = getNIP65RelayList()
|
||||
|
||||
if (nip65RelayList != null) {
|
||||
AdvertisedRelayListEvent.updateRelayList(
|
||||
earlierVersion = nip65RelayList,
|
||||
relays = relays,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
} else {
|
||||
AdvertisedRelayListEvent.createFromScratch(
|
||||
relays = relays,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileServersList(): FileServersEvent? {
|
||||
return getFileServersNote().event as? FileServersEvent
|
||||
}
|
||||
|
||||
fun getFileServersListFlow(): StateFlow<NoteState> {
|
||||
return getFileServersNote().flow().metadata.stateFlow
|
||||
}
|
||||
|
||||
fun getFileServersNote(): AddressableNote {
|
||||
return LocalCache.getOrCreateAddressableNote(FileServersEvent.createAddressATag(userProfile().pubkeyHex))
|
||||
}
|
||||
|
||||
fun sendFileServersList(servers: List<String>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val serverList = getFileServersList()
|
||||
|
||||
if (serverList != null && serverList.tags.isNotEmpty()) {
|
||||
FileServersEvent.updateRelayList(
|
||||
earlierVersion = serverList,
|
||||
relays = servers,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
} else {
|
||||
FileServersEvent.createFromScratch(
|
||||
relays = servers,
|
||||
signer = signer,
|
||||
) {
|
||||
Client.send(it)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideDeleteRequestDialog() {
|
||||
hideDeleteRequestDialog = true
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun setHideNIP24WarningDialog() {
|
||||
hideNIP24WarningDialog = true
|
||||
fun setHideNIP17WarningDialog() {
|
||||
hideNIP17WarningDialog = true
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.data.DeletionIndex
|
||||
import com.vitorpamplona.amethyst.commons.data.LargeCache
|
||||
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
|
||||
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
@@ -56,6 +58,7 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
@@ -88,18 +91,25 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90UserDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90UserDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.events.NNSEvent
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.RecommendRelayEvent
|
||||
import com.vitorpamplona.quartz.events.RelaySetEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
@@ -113,10 +123,12 @@ import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
@@ -136,6 +148,76 @@ object LocalCache {
|
||||
|
||||
val deletionIndex = DeletionIndex()
|
||||
|
||||
private val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>>(10)
|
||||
private val observablesByKindAndAuthor = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>>(10)
|
||||
|
||||
fun <T : Event> observeETag(
|
||||
kind: Int,
|
||||
eventId: HexKey,
|
||||
scope: CoroutineScope,
|
||||
): LatestByKindWithETag<T> {
|
||||
var eTagList = observablesByKindAndETag.get(kind)
|
||||
|
||||
if (eTagList == null) {
|
||||
eTagList = ConcurrentHashMap<HexKey, LatestByKindWithETag<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>
|
||||
observablesByKindAndETag.put(kind, eTagList)
|
||||
}
|
||||
|
||||
val value = eTagList.get(eventId)
|
||||
|
||||
return if (value != null) {
|
||||
value
|
||||
} else {
|
||||
val newObject = LatestByKindWithETag<T>(kind, eventId) as LatestByKindWithETag<Event>
|
||||
val obj = eTagList.putIfAbsent(eventId, newObject) ?: newObject
|
||||
if (obj == newObject) {
|
||||
// initialize
|
||||
scope.launch(Dispatchers.IO) {
|
||||
obj.init()
|
||||
}
|
||||
}
|
||||
obj
|
||||
} as LatestByKindWithETag<T>
|
||||
}
|
||||
|
||||
fun <T : Event> observeAuthor(
|
||||
kind: Int,
|
||||
pubkey: HexKey,
|
||||
scope: CoroutineScope,
|
||||
): LatestByKindAndAuthor<T> {
|
||||
var authorObsList = observablesByKindAndAuthor.get(kind)
|
||||
|
||||
if (authorObsList == null) {
|
||||
authorObsList = ConcurrentHashMap<HexKey, LatestByKindAndAuthor<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>
|
||||
observablesByKindAndAuthor.put(kind, authorObsList)
|
||||
}
|
||||
|
||||
val value = authorObsList.get(pubkey)
|
||||
|
||||
return if (value != null) {
|
||||
value
|
||||
} else {
|
||||
val newObject = LatestByKindAndAuthor<T>(kind, pubkey) as LatestByKindAndAuthor<Event>
|
||||
val obj = authorObsList.putIfAbsent(pubkey, newObject) ?: newObject
|
||||
if (obj == newObject) {
|
||||
// initialize
|
||||
scope.launch(Dispatchers.IO) {
|
||||
obj.init()
|
||||
}
|
||||
}
|
||||
obj
|
||||
} as LatestByKindAndAuthor<T>
|
||||
}
|
||||
|
||||
fun updateObservables(event: Event) {
|
||||
val observablesOfKind = observablesByKindAndETag[event.kind()] ?: return
|
||||
event.forEachTaggedEvent {
|
||||
observablesOfKind[it]?.updateIfMatches(event)
|
||||
}
|
||||
|
||||
observablesByKindAndAuthor[event.kind()]?.get(event.pubKey)?.updateIfMatches(event)
|
||||
}
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
// checkNotInMainThread()
|
||||
|
||||
@@ -171,6 +253,22 @@ object LocalCache {
|
||||
return channels.get(key)
|
||||
}
|
||||
|
||||
fun getNoteIfExists(event: Event): Note? {
|
||||
return if (event is AddressableEvent) {
|
||||
getAddressableNoteIfExists(event.addressTag())
|
||||
} else {
|
||||
getNoteIfExists(event.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun getOrCreateNote(event: Event): Note {
|
||||
return if (event is AddressableEvent) {
|
||||
getOrCreateAddressableNote(event.address())
|
||||
} else {
|
||||
getOrCreateNote(event.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun checkGetOrCreateNote(key: String): Note? {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -312,6 +410,8 @@ object LocalCache {
|
||||
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty()) {
|
||||
user.updateContactList(event)
|
||||
// Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
|
||||
|
||||
updateObservables(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +467,146 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: NIP90ContentDiscoveryResponseEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: NIP90ContentDiscoveryRequestEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: NIP90StatusEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: NIP90UserDiscoveryResponseEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: NIP90UserDiscoveryRequestEvent,
|
||||
relay: Relay? = null,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()}
|
||||
// ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach { it.addReply(note) }
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: GitPatchEvent,
|
||||
relay: Relay? = null,
|
||||
@@ -683,6 +923,27 @@ object LocalCache {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: ChatMessageRelayListEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: PrivateOutboxRelayListEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: SearchRelayListEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
consumeBaseReplaceable(event, relay)
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: CommunityDefinitionEvent,
|
||||
relay: Relay?,
|
||||
@@ -1591,7 +1852,7 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
fun consume(
|
||||
event: SealedGossipEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
@@ -1606,7 +1867,7 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
note.loadEvent(event.copyNoContent(), author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
@@ -1626,7 +1887,7 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
note.loadEvent(event.copyNoContent(), author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
@@ -1843,6 +2104,26 @@ object LocalCache {
|
||||
notes.forEach { _, it -> it.clearLive() }
|
||||
addressables.forEach { _, it -> it.clearLive() }
|
||||
users.forEach { _, it -> it.clearLive() }
|
||||
|
||||
notes.forEach { _, it -> it.clearFlow() }
|
||||
addressables.forEach { _, it -> it.clearFlow() }
|
||||
users.forEach { _, it -> it.clearFlow() }
|
||||
|
||||
observablesByKindAndETag.forEach { _, list ->
|
||||
list.forEach { key, value ->
|
||||
if (value.canDelete()) {
|
||||
list.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
observablesByKindAndAuthor.forEach { _, list ->
|
||||
list.forEach { key, value ->
|
||||
if (value.canDelete()) {
|
||||
list.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneOldAndHiddenMessages(account: Account) {
|
||||
@@ -2070,6 +2351,7 @@ object LocalCache {
|
||||
val live: LocalCacheLiveData = LocalCacheLiveData()
|
||||
|
||||
private fun refreshObservers(newNote: Note) {
|
||||
updateObservables(newNote.event as Event)
|
||||
live.invalidateData(newNote)
|
||||
}
|
||||
|
||||
@@ -2098,7 +2380,7 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
fun consume(
|
||||
event: DraftEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
@@ -2240,6 +2522,7 @@ object LocalCache {
|
||||
is ChannelMetadataEvent -> consume(event)
|
||||
is ChannelMuteUserEvent -> consume(event)
|
||||
is ChatMessageEvent -> consume(event, relay)
|
||||
is ChatMessageRelayListEvent -> consume(event, relay)
|
||||
is ClassifiedsEvent -> consume(event, relay)
|
||||
is CommunityDefinitionEvent -> consume(event, relay)
|
||||
is CommunityListEvent -> consume(event, relay)
|
||||
@@ -2252,7 +2535,10 @@ object LocalCache {
|
||||
is DraftEvent -> consume(event, relay)
|
||||
is EmojiPackEvent -> consume(event, relay)
|
||||
is EmojiPackSelectionEvent -> consume(event, relay)
|
||||
is SealedGossipEvent -> consume(event, relay)
|
||||
is GenericRepostEvent -> {
|
||||
event.containedPost()?.let { verifyAndConsume(it, relay) }
|
||||
consume(event)
|
||||
}
|
||||
is FhirResourceEvent -> consume(event, relay)
|
||||
is FileHeaderEvent -> consume(event, relay)
|
||||
is FileServersEvent -> consume(event, relay)
|
||||
@@ -2274,6 +2560,11 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
is LnZapRequestEvent -> consume(event)
|
||||
is NIP90StatusEvent -> consume(event, relay)
|
||||
is NIP90ContentDiscoveryResponseEvent -> consume(event, relay)
|
||||
is NIP90ContentDiscoveryRequestEvent -> consume(event, relay)
|
||||
is NIP90UserDiscoveryResponseEvent -> consume(event, relay)
|
||||
is NIP90UserDiscoveryRequestEvent -> consume(event, relay)
|
||||
is LnZapPaymentRequestEvent -> consume(event)
|
||||
is LnZapPaymentResponseEvent -> consume(event)
|
||||
is LongTextNoteEvent -> consume(event, relay)
|
||||
@@ -2282,6 +2573,7 @@ object LocalCache {
|
||||
is NNSEvent -> comsume(event, relay)
|
||||
is OtsEvent -> consume(event, relay)
|
||||
is PrivateDmEvent -> consume(event, relay)
|
||||
is PrivateOutboxRelayListEvent -> consume(event, relay)
|
||||
is PinListEvent -> consume(event, relay)
|
||||
is PeopleListEvent -> consume(event, relay)
|
||||
is PollNoteEvent -> consume(event, relay)
|
||||
@@ -2293,10 +2585,8 @@ object LocalCache {
|
||||
event.containedPost()?.let { verifyAndConsume(it, relay) }
|
||||
consume(event)
|
||||
}
|
||||
is GenericRepostEvent -> {
|
||||
event.containedPost()?.let { verifyAndConsume(it, relay) }
|
||||
consume(event)
|
||||
}
|
||||
is SealedGossipEvent -> consume(event, relay)
|
||||
is SearchRelayListEvent -> consume(event, relay)
|
||||
is StatusEvent -> consume(event, relay)
|
||||
is TextNoteEvent -> consume(event, relay)
|
||||
is TextNoteModificationEvent -> consume(event, relay)
|
||||
@@ -2314,7 +2604,14 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun hasConsumed(notificationEvent: Event): Boolean {
|
||||
return notes.containsKey(notificationEvent.id)
|
||||
return if (notificationEvent is AddressableEvent) {
|
||||
val note = addressables.get(notificationEvent.addressTag())
|
||||
val noteEvent = note?.event
|
||||
noteEvent != null && notificationEvent.createdAt <= noteEvent.createdAt()
|
||||
} else {
|
||||
val note = notes.get(notificationEvent.id)
|
||||
note?.event != null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.amethyst.ui.actions.relays.updated
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.combineWith
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
@@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import com.vitorpamplona.quartz.encoders.toNote
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
@@ -71,9 +72,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@Stable
|
||||
@@ -154,7 +152,7 @@ open class Note(val idHex: String) {
|
||||
Nip19Bech32.createNEvent(
|
||||
host.id,
|
||||
host.pubKey,
|
||||
host.kind(),
|
||||
host.kind,
|
||||
relays.firstOrNull()?.url,
|
||||
)
|
||||
} else {
|
||||
@@ -210,96 +208,6 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
return Instant.ofEpochSecond(timestamp)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
|
||||
}
|
||||
|
||||
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
|
||||
|
||||
/**
|
||||
* This method caches signatures during each execution to avoid recalculation in longer threads
|
||||
*/
|
||||
fun replyLevelSignature(
|
||||
eventsToConsider: Set<HexKey>,
|
||||
cachedSignatures: MutableMap<Note, LevelSignature>,
|
||||
account: User,
|
||||
accountFollowingSet: Set<String>,
|
||||
now: Long,
|
||||
): LevelSignature {
|
||||
val replyTo = replyTo
|
||||
if (
|
||||
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
|
||||
) {
|
||||
return LevelSignature(
|
||||
signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";",
|
||||
createdAt = createdAt(),
|
||||
author = author,
|
||||
)
|
||||
}
|
||||
|
||||
val parent =
|
||||
(
|
||||
replyTo
|
||||
.filter {
|
||||
it.idHex in eventsToConsider
|
||||
} // This forces the signature to be based on a branch, avoiding two roots
|
||||
.map {
|
||||
cachedSignatures[it]
|
||||
?: it
|
||||
.replyLevelSignature(
|
||||
eventsToConsider,
|
||||
cachedSignatures,
|
||||
account,
|
||||
accountFollowingSet,
|
||||
now,
|
||||
)
|
||||
.apply { cachedSignatures.put(it, this) }
|
||||
}
|
||||
.maxByOrNull { it.signature.length }
|
||||
)
|
||||
|
||||
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
|
||||
|
||||
val threadOrder =
|
||||
if (parent?.author == author && createdAt() != null) {
|
||||
// author of the thread first, in **ascending** order
|
||||
"9" +
|
||||
formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) +
|
||||
idHex.substring(0, 8)
|
||||
} else if (author?.pubkeyHex == account.pubkeyHex) {
|
||||
"8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies
|
||||
} else if (author?.pubkeyHex in accountFollowingSet) {
|
||||
"7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies.
|
||||
} else {
|
||||
"0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else.
|
||||
}
|
||||
|
||||
val mySignature =
|
||||
LevelSignature(
|
||||
signature = parentSignature + "/" + threadOrder + ";",
|
||||
createdAt = createdAt(),
|
||||
author = author,
|
||||
)
|
||||
|
||||
cachedSignatures[this] = mySignature
|
||||
return mySignature
|
||||
}
|
||||
|
||||
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
|
||||
val replyTo = replyTo
|
||||
if (
|
||||
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return replyTo.maxOf {
|
||||
cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) }
|
||||
} + 1
|
||||
}
|
||||
|
||||
fun addReply(note: Note) {
|
||||
if (note !in replies) {
|
||||
replies = replies + note
|
||||
@@ -1120,8 +1028,7 @@ object RelayBriefInfoCache {
|
||||
@Immutable
|
||||
data class RelayBriefInfo(
|
||||
val url: String,
|
||||
val displayUrl: String =
|
||||
url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
|
||||
val displayUrl: String = RelayUrlFormatter.displayUrl(url).intern(),
|
||||
val favIcon: String = "https://$displayUrl/favicon.ico".intern(),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
|
||||
|
||||
object ThreadLevelCalculator {
|
||||
val levelFormatter = DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")
|
||||
|
||||
private fun formattedDateTime(timestamp: Long): String {
|
||||
return Instant.ofEpochSecond(timestamp)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.format(levelFormatter)
|
||||
}
|
||||
|
||||
/**
|
||||
* This method caches signatures during each execution to avoid recalculation in longer threads
|
||||
*/
|
||||
fun replyLevelSignature(
|
||||
note: Note,
|
||||
eventsToConsider: Set<HexKey>,
|
||||
cachedSignatures: MutableMap<Note, LevelSignature>,
|
||||
account: User,
|
||||
accountFollowingSet: Set<String>,
|
||||
now: Long,
|
||||
): LevelSignature {
|
||||
val replyTo = note.replyTo
|
||||
if (
|
||||
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
|
||||
) {
|
||||
return LevelSignature(
|
||||
signature = "/" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) + ";",
|
||||
createdAt = note.createdAt(),
|
||||
author = note.author,
|
||||
)
|
||||
}
|
||||
|
||||
val parent =
|
||||
(
|
||||
replyTo
|
||||
.filter {
|
||||
it.idHex in eventsToConsider
|
||||
} // This forces the signature to be based on a branch, avoiding two roots
|
||||
.map {
|
||||
cachedSignatures[it]
|
||||
?: replyLevelSignature(
|
||||
it,
|
||||
eventsToConsider,
|
||||
cachedSignatures,
|
||||
account,
|
||||
accountFollowingSet,
|
||||
now,
|
||||
).apply { cachedSignatures.put(it, this) }
|
||||
}
|
||||
.maxByOrNull { it.signature.length }
|
||||
)
|
||||
|
||||
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
|
||||
|
||||
val threadOrder =
|
||||
if (parent?.author == note.author && note.createdAt() != null) {
|
||||
// author of the thread first, in **ascending** order
|
||||
"9" +
|
||||
formattedDateTime((parent?.createdAt ?: 0) + (now - (note.createdAt() ?: 0))) +
|
||||
note.idHex.substring(0, 8)
|
||||
} else if (note.author?.pubkeyHex == account.pubkeyHex) {
|
||||
"8" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my replies
|
||||
} else if (note.author?.pubkeyHex in accountFollowingSet) {
|
||||
"7" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // my follows replies.
|
||||
} else {
|
||||
"0" + formattedDateTime(note.createdAt() ?: 0) + note.idHex.substring(0, 8) // everyone else.
|
||||
}
|
||||
|
||||
val mySignature =
|
||||
LevelSignature(
|
||||
signature = parentSignature + "/" + threadOrder + ";",
|
||||
createdAt = note.createdAt(),
|
||||
author = note.author,
|
||||
)
|
||||
|
||||
cachedSignatures[note] = mySignature
|
||||
return mySignature
|
||||
}
|
||||
|
||||
fun replyLevel(
|
||||
note: Note,
|
||||
cachedLevels: MutableMap<Note, Int> = mutableMapOf(),
|
||||
): Int {
|
||||
val replyTo = note.replyTo
|
||||
if (
|
||||
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return replyTo.maxOf {
|
||||
cachedLevels[it] ?: replyLevel(it, cachedLevels).apply { cachedLevels.put(it, this) }
|
||||
} + 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
object CreatedAtComparator : Comparator<Note> {
|
||||
override fun compare(
|
||||
first: Note?,
|
||||
second: Note?,
|
||||
): Int {
|
||||
val firstEvent = first?.event
|
||||
val secondEvent = second?.event
|
||||
|
||||
return if (firstEvent == null && secondEvent == null) {
|
||||
0
|
||||
} else if (firstEvent == null) {
|
||||
1
|
||||
} else if (secondEvent == null) {
|
||||
-1
|
||||
} else {
|
||||
firstEvent.createdAt().compareTo(secondEvent.createdAt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object CreatedAtComparatorAddresses : Comparator<AddressableNote> {
|
||||
override fun compare(
|
||||
first: AddressableNote?,
|
||||
second: AddressableNote?,
|
||||
): Int {
|
||||
val firstEvent = first?.event
|
||||
val secondEvent = second?.event
|
||||
|
||||
return if (firstEvent == null && secondEvent == null) {
|
||||
0
|
||||
} else if (firstEvent == null) {
|
||||
1
|
||||
} else if (secondEvent == null) {
|
||||
-1
|
||||
} else {
|
||||
firstEvent.createdAt().compareTo(secondEvent.createdAt())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class LatestByKindAndAuthor<T : Event>(private val kind: Int, private val pubkey: String) {
|
||||
private val _latest = MutableStateFlow<T?>(null)
|
||||
val latest = _latest.asStateFlow()
|
||||
|
||||
fun matches(event: T) = event.kind == kind && event.pubKey == pubkey
|
||||
|
||||
fun updateIfMatches(event: T) {
|
||||
if (matches(event)) {
|
||||
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
|
||||
_latest.tryEmit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(): Boolean {
|
||||
return _latest.subscriptionCount.value == 0
|
||||
}
|
||||
|
||||
suspend fun init() {
|
||||
val latestNote =
|
||||
if ((kind in 10000..19999) || (kind in 30000..39999)) {
|
||||
LocalCache.addressables.maxOrNullOf(
|
||||
filter = { idHex: String, note: AddressableNote ->
|
||||
note.event?.let {
|
||||
it.kind() == kind && it.pubKey() == pubkey
|
||||
} == true
|
||||
},
|
||||
comparator = CreatedAtComparatorAddresses,
|
||||
)?.event as? T
|
||||
} else {
|
||||
LocalCache.notes.maxOrNullOf(
|
||||
filter = { idHex: String, note: Note ->
|
||||
note.event?.let {
|
||||
it.kind() == kind && it.pubKey() == pubkey
|
||||
} == true
|
||||
},
|
||||
comparator = CreatedAtComparator,
|
||||
)?.event as? T
|
||||
}
|
||||
|
||||
_latest.tryEmit(latestNote)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class LatestByKindWithETag<T : Event>(private val kind: Int, private val eTag: String) {
|
||||
private val _latest = MutableStateFlow<T?>(null)
|
||||
val latest = _latest.asStateFlow()
|
||||
|
||||
fun matches(event: T) = event.kind == kind && event.isTaggedEvent(eTag)
|
||||
|
||||
fun updateIfMatches(event: T) {
|
||||
if (matches(event)) {
|
||||
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
|
||||
_latest.tryEmit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(): Boolean {
|
||||
return _latest.subscriptionCount.value == 0
|
||||
}
|
||||
|
||||
suspend fun init() {
|
||||
val latestNote =
|
||||
LocalCache.notes.maxOrNullOf(
|
||||
filter = { idHex: String, note: Note ->
|
||||
note.event?.let {
|
||||
it.kind() == kind && it.isTaggedEvent(eTag)
|
||||
} == true
|
||||
},
|
||||
comparator = CreatedAtComparator,
|
||||
)?.event as? T
|
||||
|
||||
_latest.tryEmit(latestNote)
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,9 @@ import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import com.vitorpamplona.amethyst.commons.preview.BlurHashDecoder
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Stable
|
||||
class BlurHashFetcher(
|
||||
@@ -43,23 +43,9 @@ class BlurHashFetcher(
|
||||
override suspend fun fetch(): FetchResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val encodedHash = data.toString().removePrefix("bluehash:")
|
||||
val hash = URLDecoder.decode(encodedHash, "utf-8")
|
||||
val hash = URLDecoder.decode(data.toString().removePrefix("bluehash:"), "utf-8")
|
||||
|
||||
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
|
||||
|
||||
val preferredWidth = 100
|
||||
|
||||
val bitmap =
|
||||
BlurHashDecoder.decode(
|
||||
hash,
|
||||
preferredWidth,
|
||||
(preferredWidth * (1 / aspectRatio)).roundToInt(),
|
||||
)
|
||||
|
||||
if (bitmap == null) {
|
||||
throw Exception("Unable to convert Bluehash $hash")
|
||||
}
|
||||
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: throw Exception("Unable to convert Bluehash $data")
|
||||
|
||||
return DrawableResult(
|
||||
drawable = bitmap.toDrawable(options.context.resources),
|
||||
|
||||
@@ -39,6 +39,7 @@ object HttpClientManager {
|
||||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
|
||||
private var defaultHttpClient: OkHttpClient? = null
|
||||
private var defaultHttpClientWithoutProxy: OkHttpClient? = null
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by
|
||||
@@ -58,6 +59,10 @@ object HttpClientManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultProxy(): Proxy? {
|
||||
return this.internalProxy
|
||||
}
|
||||
|
||||
fun setDefaultTimeout(timeout: Duration) {
|
||||
Log.d("HttpClient", "Changing timeout to: $timeout")
|
||||
if (this.defaultTimeout.seconds != timeout.seconds) {
|
||||
@@ -72,7 +77,7 @@ object HttpClientManager {
|
||||
proxy: Proxy?,
|
||||
timeout: Duration,
|
||||
): OkHttpClient {
|
||||
val seconds = if (proxy != null) timeout.seconds * 2 else timeout.seconds
|
||||
val seconds = if (proxy != null) timeout.seconds * 3 else timeout.seconds
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
@@ -98,11 +103,18 @@ object HttpClientManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
fun getHttpClient(useProxy: Boolean = true): OkHttpClient {
|
||||
return if (useProxy) {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
}
|
||||
defaultHttpClient!!
|
||||
} else {
|
||||
if (this.defaultHttpClientWithoutProxy == null) {
|
||||
this.defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
|
||||
}
|
||||
defaultHttpClientWithoutProxy!!
|
||||
}
|
||||
return defaultHttpClient!!
|
||||
}
|
||||
|
||||
fun initProxy(
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Call
|
||||
@@ -42,7 +43,7 @@ object Nip11CachedRetriever {
|
||||
val retriever = Nip11Retriever()
|
||||
|
||||
fun getFromCache(dirtyUrl: String): Nip11RelayInformation? {
|
||||
val result = relayInformationDocumentCache.get(retriever.cleanUrl(dirtyUrl)) ?: return null
|
||||
val result = relayInformationDocumentCache.get(RelayUrlFormatter.getHttpsUrl(dirtyUrl)) ?: return null
|
||||
if (result is RetrieveResultSuccess) return result.data
|
||||
return null
|
||||
}
|
||||
@@ -52,7 +53,7 @@ object Nip11CachedRetriever {
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
val url = retriever.cleanUrl(dirtyUrl)
|
||||
val url = RelayUrlFormatter.getHttpsUrl(dirtyUrl)
|
||||
val doc = relayInformationDocumentCache.get(url)
|
||||
|
||||
if (doc != null) {
|
||||
@@ -62,35 +63,33 @@ object Nip11CachedRetriever {
|
||||
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
|
||||
onError(dirtyUrl, doc.error, null)
|
||||
} else {
|
||||
Nip11Retriever()
|
||||
.loadRelayInfo(
|
||||
url = url,
|
||||
dirtyUrl = dirtyUrl,
|
||||
onInfo = {
|
||||
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
|
||||
onInfo(it)
|
||||
},
|
||||
onError = { dirtyUrl, code, errorMsg ->
|
||||
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
|
||||
onError(url, code, errorMsg)
|
||||
},
|
||||
)
|
||||
retriever.loadRelayInfo(
|
||||
url = url,
|
||||
dirtyUrl = dirtyUrl,
|
||||
onInfo = {
|
||||
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
|
||||
onInfo(it)
|
||||
},
|
||||
onError = { dirtyUrl, code, errorMsg ->
|
||||
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
|
||||
onError(url, code, errorMsg)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Nip11Retriever()
|
||||
.loadRelayInfo(
|
||||
url = url,
|
||||
dirtyUrl = dirtyUrl,
|
||||
onInfo = {
|
||||
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
|
||||
onInfo(it)
|
||||
},
|
||||
onError = { dirtyUrl, code, errorMsg ->
|
||||
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
|
||||
onError(url, code, errorMsg)
|
||||
},
|
||||
)
|
||||
retriever.loadRelayInfo(
|
||||
url = url,
|
||||
dirtyUrl = dirtyUrl,
|
||||
onInfo = {
|
||||
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
|
||||
onInfo(it)
|
||||
},
|
||||
onError = { dirtyUrl, code, errorMsg ->
|
||||
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
|
||||
onError(url, code, errorMsg)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,14 +102,6 @@ class Nip11Retriever {
|
||||
FAIL_WITH_HTTP_STATUS,
|
||||
}
|
||||
|
||||
fun cleanUrl(dirtyUrl: String): String {
|
||||
return if (dirtyUrl.contains("://")) {
|
||||
dirtyUrl.replace("wss://", "https://").replace("ws://", "http://")
|
||||
} else {
|
||||
"https://$dirtyUrl"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
url: String,
|
||||
dirtyUrl: String,
|
||||
@@ -121,8 +112,9 @@ class Nip11Retriever {
|
||||
try {
|
||||
val request: Request =
|
||||
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
|
||||
val isLocalHost = dirtyUrl.startsWith("ws://127.0.0.1") || dirtyUrl.startsWith("ws://localhost")
|
||||
|
||||
HttpClientManager.getHttpClient()
|
||||
HttpClientManager.getHttpClient(useProxy = !isLocalHost)
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
|
||||
@@ -39,11 +39,13 @@ import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.FileServersEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||
@@ -56,10 +58,12 @@ import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -101,9 +105,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(AdvertisedRelayListEvent.KIND, StatusEvent.KIND),
|
||||
kinds = listOf(StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, SearchRelayListEvent.KIND, PrivateOutboxRelayListEvent.KIND),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 5,
|
||||
limit = 10,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -119,6 +123,10 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
MetadataEvent.KIND,
|
||||
ContactListEvent.KIND,
|
||||
AdvertisedRelayListEvent.KIND,
|
||||
ChatMessageRelayListEvent.KIND,
|
||||
PrivateOutboxRelayListEvent.KIND,
|
||||
SearchRelayListEvent.KIND,
|
||||
FileServersEvent.KIND,
|
||||
MuteListEvent.KIND,
|
||||
PeopleListEvent.KIND,
|
||||
),
|
||||
@@ -173,8 +181,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
?.followList
|
||||
?.get(account.defaultNotificationFollowList.value)
|
||||
?.relayList
|
||||
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
@@ -206,7 +213,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
?.followList
|
||||
?.get(account.defaultNotificationFollowList.value)
|
||||
?.relayList
|
||||
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
|
||||
return TypedFilter(
|
||||
@@ -237,6 +244,14 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
JsonFilter(
|
||||
kinds = listOf(GiftWrapEvent.KIND),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get("&&((GIFTWRAPS_EOSE))&&")
|
||||
?.relayList
|
||||
?.mapValues {
|
||||
EOSETime(it.value.time - TimeUtils.twoDays())
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -249,6 +264,13 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
"&&((GIFTWRAPS_EOSE))&&",
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
} else {
|
||||
hasLoadedTheBasics[account.userProfile()] = true
|
||||
|
||||
@@ -264,46 +286,83 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
if (LocalCache.justVerify(event)) {
|
||||
when (event) {
|
||||
is PrivateOutboxRelayListEvent -> {
|
||||
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent == null || event.createdAt > noteEvent.createdAt()) {
|
||||
event.privateTags(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is DraftEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
|
||||
if (!event.isDeleted()) {
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note != null && relay.brief in note.relays) return
|
||||
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent != null) {
|
||||
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
|
||||
LocalCache.consume(event, relay)
|
||||
}
|
||||
} else {
|
||||
// decrypts
|
||||
event.cachedDraft(account.signer) {}
|
||||
|
||||
// decrypts
|
||||
event.cachedDraft(account.signer) {}
|
||||
|
||||
LocalCache.justConsume(event, relay)
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is GiftWrapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note != null && relay.brief in note.relays) return
|
||||
|
||||
event.cachedGift(account.signer) { this.consume(it, relay) }
|
||||
val noteEvent = note?.event as? GiftWrapEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
LocalCache.justConsume(noteEvent, relay)
|
||||
noteEvent.cachedGift(account.signer) {
|
||||
this.consume(it, relay)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.cachedGift(account.signer) { this.consume(it, relay) }
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
is SealedGossipEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note != null && relay.brief in note.relays) return
|
||||
|
||||
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
|
||||
val noteEvent = note?.event as? SealedGossipEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
// adds the relay to seal and inner chat
|
||||
LocalCache.consume(noteEvent, relay)
|
||||
noteEvent.cachedGossip(account.signer) {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note != null && relay.brief in note.relays) return
|
||||
|
||||
event.zapRequest?.let {
|
||||
if (it.isPrivateZap()) {
|
||||
it.decryptPrivateZap(account.signer) {}
|
||||
if (note?.event == null) {
|
||||
event.zapRequest?.let {
|
||||
if (it.isPrivateZap()) {
|
||||
it.decryptPrivateZap(account.signer) {}
|
||||
}
|
||||
}
|
||||
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,9 +43,9 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
|
||||
data class Counter(var counter: Int)
|
||||
data class Counter(val subscriptionId: String, val eventKind: Int, var counter: Int)
|
||||
|
||||
private var eventCounter = mapOf<String, Counter>()
|
||||
private var eventCounter = mapOf<Int, Counter>()
|
||||
var changingFilters = AtomicBoolean()
|
||||
|
||||
private var active: Boolean = false
|
||||
@@ -54,11 +54,18 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
eventCounter.forEach {
|
||||
Log.d(
|
||||
"STATE DUMP ${this.javaClass.simpleName}",
|
||||
"Received Events ${it.key}: ${it.value.counter}",
|
||||
"Received Events $debugName ${it.value.subscriptionId} ${it.value.eventKind}: ${it.value.counter}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun hashCodeFields(
|
||||
str1: String,
|
||||
str2: Int,
|
||||
): Int {
|
||||
return 31 * str1.hashCode() + str2.hashCode()
|
||||
}
|
||||
|
||||
private val clientListener =
|
||||
object : Client.Listener() {
|
||||
override fun onEvent(
|
||||
@@ -68,12 +75,12 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
afterEOSE: Boolean,
|
||||
) {
|
||||
if (subscriptions.containsKey(subscriptionId)) {
|
||||
val key = "$debugName $subscriptionId ${event.kind}"
|
||||
val keyValue = eventCounter.get(key)
|
||||
val key = hashCodeFields(subscriptionId, event.kind)
|
||||
val keyValue = eventCounter[key]
|
||||
if (keyValue != null) {
|
||||
keyValue.counter++
|
||||
} else {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
eventCounter = eventCounter + Pair(key, Counter(subscriptionId, event.kind, 1))
|
||||
}
|
||||
|
||||
// Log.d(this@NostrDataSource.javaClass.simpleName, "Relay ${relay.url}: ${event.kind}")
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
@@ -131,6 +132,25 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createNIP89Filter(kTags: List<String>): List<TypedFilter> {
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(AppDefinitionEvent.KIND),
|
||||
limit = 300,
|
||||
tags = mapOf("k" to kTags),
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamFilter(): List<TypedFilter> {
|
||||
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
|
||||
|
||||
@@ -404,6 +424,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
override fun updateChannelFilters() {
|
||||
discoveryFeedChannel.typedFilters =
|
||||
createLiveStreamFilter()
|
||||
.plus(createNIP89Filter(listOf("5300")))
|
||||
.plus(createPublicChatFilter())
|
||||
.plus(createMarketplaceFilter())
|
||||
.plus(
|
||||
@@ -417,6 +438,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
createPublicChatsGeohashesFilter(),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
|
||||
private var geohashToWatch: String? = null
|
||||
@@ -61,6 +62,7 @@ object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
|
||||
HighlightEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
limit = 200,
|
||||
),
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
private var hashtagToWatch: String? = null
|
||||
@@ -64,6 +65,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
HighlightEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
limit = 200,
|
||||
),
|
||||
|
||||
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -77,7 +78,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS),
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
@@ -94,6 +95,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
PinListEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
LiveActivitiesEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
authors = followSet,
|
||||
limit = 400,
|
||||
@@ -124,6 +126,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
tags =
|
||||
mapOf(
|
||||
@@ -160,6 +163,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
tags =
|
||||
mapOf(
|
||||
@@ -196,6 +200,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
CommunityPostApprovalEvent.KIND,
|
||||
),
|
||||
tags =
|
||||
|
||||
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
@@ -175,6 +176,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
LiveActivitiesEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
NNSEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
search = mySearchString,
|
||||
limit = 100,
|
||||
|
||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -63,7 +63,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
|
||||
// downloads linked events to this event.
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.KIND),
|
||||
@@ -86,7 +86,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
return directEventsToLoad.map {
|
||||
it.address().let { aTag ->
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
|
||||
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
@@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GitReplyEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
@@ -60,7 +62,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return groupByEOSEPresence(addressesToWatch).map {
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
@@ -82,7 +84,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
@@ -110,7 +112,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
it.address()?.let { aTag ->
|
||||
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
@@ -120,7 +122,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
} else {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
@@ -142,7 +144,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return groupByEOSEPresence(eventsToWatch).map {
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
@@ -165,12 +167,14 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
DeletionEvent.KIND,
|
||||
NIP90ContentDiscoveryResponseEvent.KIND,
|
||||
NIP90StatusEvent.KIND,
|
||||
),
|
||||
tags = mapOf("e" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
@@ -190,9 +194,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return groupByEOSEPresence(eventsToWatch).map {
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.KIND),
|
||||
tags = mapOf("q" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
// Max amount of "replies" to download on a specific event.
|
||||
@@ -221,7 +226,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
// downloads linked events to this event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
ids = interestedEvents.toList(),
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.EVENT_FINDER_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
@@ -41,7 +41,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.KIND),
|
||||
@@ -64,7 +64,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
val minEOSEs = findMinimumEOSEsForUsers(group)
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.KIND, StatusEvent.KIND),
|
||||
@@ -73,7 +73,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
),
|
||||
),
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = EVENT_FINDER_TYPES,
|
||||
filter =
|
||||
JsonFilter(
|
||||
kinds = listOf(ReportEvent.KIND),
|
||||
|
||||
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
@@ -79,6 +80,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
PinListEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200,
|
||||
|
||||
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.collectSuccessfulSigningOperations
|
||||
import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
|
||||
@@ -35,7 +37,6 @@ import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.round
|
||||
|
||||
@@ -59,17 +60,35 @@ class ZapPaymentHandler(val account: Account) {
|
||||
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val zapSplitSetup = note.event?.zapSplitSetup()
|
||||
|
||||
val noteEvent = note.event
|
||||
val zapSplitSetup = noteEvent?.zapSplitSetup()
|
||||
|
||||
val zapsToSend =
|
||||
if (!zapSplitSetup.isNullOrEmpty()) {
|
||||
zapSplitSetup
|
||||
} else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) {
|
||||
noteEvent.hosts().map { ZapSplitSetup(it, null, weight = 1.0, false) }
|
||||
} else if (noteEvent is AppDefinitionEvent) {
|
||||
val appLud16 = noteEvent.appMetaData()?.lnAddress()
|
||||
if (appLud16 != null) {
|
||||
listOf(ZapSplitSetup(appLud16, null, weight = 1.0, true))
|
||||
} else {
|
||||
val lud16 = note.author?.info?.lnAddress()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
onError(
|
||||
context.getString(R.string.missing_lud16),
|
||||
context.getString(
|
||||
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
),
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
|
||||
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
|
||||
}
|
||||
} else {
|
||||
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
|
||||
val lud16 = note.author?.info?.lnAddress()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
onError(
|
||||
@@ -84,101 +103,226 @@ class ZapPaymentHandler(val account: Account) {
|
||||
listOf(ZapSplitSetup(lud16, null, weight = 1.0, true))
|
||||
}
|
||||
|
||||
val totalWeight = zapsToSend.sumOf { it.weight }
|
||||
|
||||
val invoicesToPayOnIntent = mutableListOf<Payable>()
|
||||
|
||||
zapsToSend.forEachIndexed { index, value ->
|
||||
val outerProgressMin = index / zapsToSend.size.toFloat()
|
||||
val outerProgressMax = (index + 1) / zapsToSend.size.toFloat()
|
||||
|
||||
val zapValue = round((amountMilliSats * value.weight / totalWeight) / 1000f).toLong() * 1000
|
||||
|
||||
if (value.isLnAddress) {
|
||||
innerZap(
|
||||
lud16 = value.lnAddressOrPubKeyHex,
|
||||
note = note,
|
||||
amount = zapValue,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
context = context,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
|
||||
},
|
||||
zapType = zapType,
|
||||
onPayInvoiceThroughIntent = {
|
||||
invoicesToPayOnIntent.add(
|
||||
Payable(
|
||||
info = value,
|
||||
user = null,
|
||||
amountMilliSats = zapValue,
|
||||
invoice = it,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
onProgress(0.02f)
|
||||
signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs ->
|
||||
if (splitZapRequestPairs.isEmpty()) {
|
||||
onProgress(0.00f)
|
||||
return@signAllZapRequests
|
||||
} else {
|
||||
val user = LocalCache.getUserIfExists(value.lnAddressOrPubKeyHex)
|
||||
val lud16 = user?.info?.lnAddress()
|
||||
onProgress(0.05f)
|
||||
}
|
||||
|
||||
if (lud16 != null) {
|
||||
innerZap(
|
||||
lud16 = lud16,
|
||||
note = note,
|
||||
amount = zapValue,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
context = context,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
onProgress((it * (outerProgressMax - outerProgressMin)) + outerProgressMin)
|
||||
},
|
||||
zapType = zapType,
|
||||
overrideUser = user,
|
||||
onPayInvoiceThroughIntent = {
|
||||
invoicesToPayOnIntent.add(
|
||||
Payable(
|
||||
info = value,
|
||||
user = user,
|
||||
amountMilliSats = zapValue,
|
||||
invoice = it,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, onError, onProgress = {
|
||||
onProgress(it * 0.7f + 0.05f) // keeps within range.
|
||||
}, context) {
|
||||
if (it.isEmpty()) {
|
||||
onProgress(0.00f)
|
||||
return@assembleAllInvoices
|
||||
} else {
|
||||
onError(
|
||||
context.getString(
|
||||
R.string.missing_lud16,
|
||||
),
|
||||
context.getString(
|
||||
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
user?.toBestDisplayName() ?: value.lnAddressOrPubKeyHex,
|
||||
),
|
||||
onProgress(0.75f)
|
||||
}
|
||||
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
payViaNWC(it.values.map { it.invoice }, note, onError, onProgress = {
|
||||
onProgress(it * 0.25f + 0.75f) // keeps within range.
|
||||
}, context) {
|
||||
// onProgress(1f)
|
||||
}
|
||||
} else {
|
||||
onPayViaIntent(
|
||||
it.map {
|
||||
Payable(
|
||||
info = it.key.first,
|
||||
user = it.key.second.user,
|
||||
amountMilliSats = it.value.zapValue,
|
||||
invoice = it.value.invoice,
|
||||
)
|
||||
}.toImmutableList(),
|
||||
)
|
||||
|
||||
onProgress(0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (invoicesToPayOnIntent.isNotEmpty()) {
|
||||
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
|
||||
onProgress(1f)
|
||||
} else {
|
||||
launch(Dispatchers.IO) {
|
||||
// Awaits for the event to come back to LocalCache.
|
||||
var count = 0
|
||||
while (invoicesToPayOnIntent.size < zapsToSend.size || count < 4) {
|
||||
count++
|
||||
Thread.sleep(5000)
|
||||
}
|
||||
if (invoicesToPayOnIntent.isNotEmpty()) {
|
||||
onPayViaIntent(invoicesToPayOnIntent.toImmutableList())
|
||||
onProgress(1f)
|
||||
private fun calculateZapValue(
|
||||
amountMilliSats: Long,
|
||||
weight: Double,
|
||||
totalWeight: Double,
|
||||
): Long {
|
||||
val shareValue = amountMilliSats * (weight / totalWeight)
|
||||
val roundedZapValue = round(shareValue / 1000f).toLong() * 1000
|
||||
return roundedZapValue
|
||||
}
|
||||
|
||||
class SignAllZapRequestsReturn(
|
||||
val zapRequestJson: String,
|
||||
val user: User? = null,
|
||||
)
|
||||
|
||||
suspend fun signAllZapRequests(
|
||||
note: Note,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zapsToSend: List<ZapSplitSetup>,
|
||||
onAllDone: suspend (MutableMap<ZapSplitSetup, SignAllZapRequestsReturn>) -> Unit,
|
||||
) {
|
||||
collectSuccessfulSigningOperations<ZapSplitSetup, SignAllZapRequestsReturn>(
|
||||
operationsInput = zapsToSend,
|
||||
runRequestFor = { next: ZapSplitSetup, onReady ->
|
||||
if (next.isLnAddress) {
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType) { zapRequestJson ->
|
||||
if (zapRequestJson != null) {
|
||||
onReady(SignAllZapRequestsReturn(zapRequestJson))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onProgress(1f)
|
||||
val user = LocalCache.getUserIfExists(next.lnAddressOrPubKeyHex)
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user) { zapRequestJson ->
|
||||
if (zapRequestJson != null) {
|
||||
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onReady = onAllDone,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun assembleAllInvoices(
|
||||
invoices: List<Pair<ZapSplitSetup, SignAllZapRequestsReturn>>,
|
||||
totalAmountMilliSats: Long,
|
||||
message: String,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onAllDone: suspend (MutableMap<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>) -> Unit,
|
||||
) {
|
||||
var progressAllPayments = 0.00f
|
||||
val totalWeight = invoices.sumOf { it.first.weight }
|
||||
|
||||
collectSuccessfulSigningOperations<Pair<ZapSplitSetup, SignAllZapRequestsReturn>, AssembleInvoiceReturn>(
|
||||
operationsInput = invoices,
|
||||
runRequestFor = { splitZapRequestPair: Pair<ZapSplitSetup, SignAllZapRequestsReturn>, onReady ->
|
||||
assembleInvoice(
|
||||
splitSetup = splitZapRequestPair.first,
|
||||
nostrZapRequest = splitZapRequestPair.second.zapRequestJson,
|
||||
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.first.weight, totalWeight),
|
||||
message = message,
|
||||
onError = onError,
|
||||
onProgressStep = { percentStepForThisPayment ->
|
||||
progressAllPayments += percentStepForThisPayment / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
},
|
||||
context = context,
|
||||
onReady = onReady,
|
||||
)
|
||||
},
|
||||
onReady = onAllDone,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun payViaNWC(
|
||||
invoices: List<String>,
|
||||
note: Note,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onAllDone: suspend (MutableMap<String, Boolean>) -> Unit,
|
||||
) {
|
||||
var progressAllPayments = 0.00f
|
||||
|
||||
collectSuccessfulSigningOperations<String, Boolean>(
|
||||
operationsInput = invoices,
|
||||
runRequestFor = { invoice: String, onReady ->
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = invoice,
|
||||
zappedNote = note,
|
||||
onSent = {
|
||||
progressAllPayments += 0.5f / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
onReady(true)
|
||||
},
|
||||
onResponse = { response ->
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
progressAllPayments += 0.5f / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
onError(
|
||||
context.getString(R.string.error_dialog_pay_invoice_error),
|
||||
context.getString(
|
||||
R.string.wallet_connect_pay_invoice_error_error,
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString() ?: "Error parsing error message",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
progressAllPayments += 0.5f / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onReady = onAllDone,
|
||||
)
|
||||
}
|
||||
|
||||
class AssembleInvoiceReturn(
|
||||
val zapValue: Long,
|
||||
val invoice: String,
|
||||
)
|
||||
|
||||
private fun assembleInvoice(
|
||||
splitSetup: ZapSplitSetup,
|
||||
nostrZapRequest: String,
|
||||
zapValue: Long,
|
||||
message: String,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgressStep: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
onReady: (AssembleInvoiceReturn) -> Unit,
|
||||
) {
|
||||
var progressThisPayment = 0.00f
|
||||
|
||||
var user: User? = null
|
||||
val lud16 =
|
||||
if (splitSetup.isLnAddress) {
|
||||
splitSetup.lnAddressOrPubKeyHex
|
||||
} else {
|
||||
user = LocalCache.getUserIfExists(splitSetup.lnAddressOrPubKeyHex)
|
||||
user?.info?.lnAddress()
|
||||
}
|
||||
|
||||
if (lud16 != null) {
|
||||
LightningAddressResolver()
|
||||
.lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = zapValue,
|
||||
message = message,
|
||||
nostrRequest = nostrZapRequest,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
val step = it - progressThisPayment
|
||||
progressThisPayment = it
|
||||
onProgressStep(step)
|
||||
},
|
||||
context = context,
|
||||
onSuccess = {
|
||||
onProgressStep(1 - progressThisPayment)
|
||||
onReady(AssembleInvoiceReturn(zapValue, it))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
onError(
|
||||
context.getString(
|
||||
R.string.missing_lud16,
|
||||
),
|
||||
context.getString(
|
||||
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
user?.toBestDisplayName() ?: splitSetup.lnAddressOrPubKeyHex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,63 +342,4 @@ class ZapPaymentHandler(val account: Account) {
|
||||
onReady(null)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun innerZap(
|
||||
lud16: String,
|
||||
note: Note,
|
||||
amount: Long,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayInvoiceThroughIntent: (String) -> Unit,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
overrideUser: User? = null,
|
||||
) {
|
||||
onProgress(0.05f)
|
||||
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson ->
|
||||
onProgress(0.10f)
|
||||
|
||||
LightningAddressResolver()
|
||||
.lnAddressInvoice(
|
||||
lud16,
|
||||
amount,
|
||||
message,
|
||||
zapRequestJson,
|
||||
onSuccess = {
|
||||
onProgress(0.7f)
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = it,
|
||||
note,
|
||||
onResponse = { response ->
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_dialog_pay_invoice_error),
|
||||
context.getString(
|
||||
R.string.wallet_connect_pay_invoice_error_error,
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString() ?: "Error parsing error message",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
onProgress(1f)
|
||||
}
|
||||
},
|
||||
)
|
||||
onProgress(0.8f)
|
||||
} else {
|
||||
onPayInvoiceThroughIntent(it)
|
||||
onProgress(0f)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
onProgress = onProgress,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Lud06
|
||||
import com.vitorpamplona.quartz.encoders.toLnUrl
|
||||
import okhttp3.Request
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
@@ -151,20 +150,6 @@ class LightningAddressResolver() {
|
||||
}
|
||||
}
|
||||
|
||||
fun lnAddressToLnUrl(
|
||||
lnaddress: String,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = { onSuccess(it.toByteArray().toLnUrl()) },
|
||||
onError = onError,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(
|
||||
lnaddress: String,
|
||||
milliSats: Long,
|
||||
@@ -190,7 +175,8 @@ class LightningAddressResolver() {
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup,
|
||||
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
null
|
||||
@@ -202,7 +188,8 @@ class LightningAddressResolver() {
|
||||
onError(
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration,
|
||||
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -227,7 +214,8 @@ class LightningAddressResolver() {
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string
|
||||
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup,
|
||||
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
null
|
||||
@@ -268,7 +256,8 @@ class LightningAddressResolver() {
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
|
||||
lnaddress,
|
||||
reason,
|
||||
),
|
||||
)
|
||||
@@ -279,7 +268,8 @@ class LightningAddressResolver() {
|
||||
context.getString(R.string.error_unable_to_fetch_invoice),
|
||||
context.getString(
|
||||
R.string
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json,
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
|
||||
lnaddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -45,6 +46,7 @@ import java.math.BigDecimal
|
||||
|
||||
class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
suspend fun consume(event: GiftWrapEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Notification Arrived")
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
if (!notificationManager().areNotificationsEnabled()) return
|
||||
|
||||
@@ -64,14 +66,23 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
account: Account,
|
||||
) {
|
||||
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
|
||||
if (!LocalCache.hasConsumed(notificationEvent) && LocalCache.justVerify(notificationEvent)) {
|
||||
val consumed = LocalCache.hasConsumed(notificationEvent)
|
||||
val verified = LocalCache.justVerify(notificationEvent)
|
||||
Log.d("EventNotificationConsumer", "New Notification Arrived for ${account.userProfile().toBestDisplayName()} consumed= $consumed && verified= $verified")
|
||||
if (!consumed && verified) {
|
||||
Log.d("EventNotificationConsumer", "New Notification was verified")
|
||||
unwrapAndConsume(notificationEvent, account) { innerEvent ->
|
||||
if (!LocalCache.hasConsumed(innerEvent)) {
|
||||
|
||||
Log.d("EventNotificationConsumer", "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
|
||||
if (!consumed) {
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Nip-04 DM to Notify")
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Zap to Notify")
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
Log.d("EventNotificationConsumer", "New ChatMessage to Notify")
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +97,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
onReady: (Event) -> Unit,
|
||||
) {
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
if (LocalCache.hasConsumed(event)) return
|
||||
|
||||
when (event) {
|
||||
is GiftWrapEvent -> {
|
||||
@@ -93,9 +105,11 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
}
|
||||
is SealedGossipEvent -> {
|
||||
event.cachedGossip(account.signer) {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
onReady(it)
|
||||
if (!LocalCache.hasConsumed(it)) {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
onReady(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
|
||||
@@ -27,13 +27,13 @@ import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
object PlaybackClientController {
|
||||
var executorService = Executors.newCachedThreadPool()
|
||||
val cache = LruCache<Int, SessionToken>(1)
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun prepareController(
|
||||
controllerID: String,
|
||||
videoUri: String,
|
||||
@@ -67,7 +67,7 @@ object PlaybackClientController {
|
||||
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
|
||||
}
|
||||
},
|
||||
MoreExecutors.directExecutor(),
|
||||
executorService,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@@ -71,16 +72,16 @@ class WssOrHttpFactory(httpClient: OkHttpClient) : MediaSource.Factory {
|
||||
}
|
||||
}
|
||||
|
||||
@UnstableApi // Extend MediaSessionService
|
||||
class PlaybackService : MediaSessionService() {
|
||||
private var videoViewedPositionCache = VideoViewedPositionCache()
|
||||
|
||||
private var managerAllInOne: MultiPlayerPlaybackManager? = null
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun newAllInOneDataSource(): MediaSource.Factory {
|
||||
// This might be needed for live kit.
|
||||
// return WssOrHttpFactory(HttpClientManager.getHttpClient())
|
||||
return DefaultMediaSourceFactory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
|
||||
return DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(HttpClientManager.getHttpClient()))
|
||||
}
|
||||
|
||||
fun lazyDS(): MultiPlayerPlaybackManager {
|
||||
|
||||
@@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.events.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
@@ -45,7 +44,7 @@ object Client : RelayPool.Listener {
|
||||
relays: Array<Relay>?,
|
||||
onlyIfChanged: Boolean = false,
|
||||
) {
|
||||
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays")
|
||||
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.read + " " + it.write + " " + it.activeTypes.joinToString(",") { it.name } }}")
|
||||
checkNotInMainThread()
|
||||
|
||||
if (onlyIfChanged) {
|
||||
@@ -101,6 +100,34 @@ object Client : RelayPool.Listener {
|
||||
RelayPool.sendFilter(subscriptionId, filters)
|
||||
}
|
||||
|
||||
fun sendFilterAndStopOnFirstResponse(
|
||||
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||
filters: List<TypedFilter> = listOf(),
|
||||
onResponse: (Event) -> Unit,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
subscribe(
|
||||
object : Listener() {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
subId: String,
|
||||
relay: Relay,
|
||||
afterEOSE: Boolean,
|
||||
) {
|
||||
if (subId == subscriptionId) {
|
||||
onResponse(event)
|
||||
unsubscribe(this)
|
||||
close(subscriptionId)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
subscriptions = subscriptions + Pair(subscriptionId, filters)
|
||||
RelayPool.sendFilter(subscriptionId, filters)
|
||||
}
|
||||
|
||||
fun sendFilterOnlyIfDisconnected(
|
||||
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||
filters: List<TypedFilter> = listOf(),
|
||||
@@ -125,47 +152,21 @@ object Client : RelayPool.Listener {
|
||||
} else if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
|
||||
|
||||
if (useConnectedRelayIfPresent.isNotEmpty()) {
|
||||
useConnectedRelayIfPresent.forEach { it.send(signedEvent) }
|
||||
} else {
|
||||
/** temporary connection */
|
||||
newSporadicRelay(
|
||||
relay,
|
||||
feedTypes,
|
||||
onConnected = { myRelay -> myRelay.send(signedEvent) },
|
||||
onDone = onDone,
|
||||
)
|
||||
RelayPool.getOrCreateRelay(relay, feedTypes, onDone) {
|
||||
it.send(signedEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun newSporadicRelay(
|
||||
url: String,
|
||||
feedTypes: Set<FeedType>?,
|
||||
onConnected: (Relay) -> Unit,
|
||||
onDone: (() -> Unit)?,
|
||||
fun sendPrivately(
|
||||
signedEvent: EventInterface,
|
||||
relayList: List<String>,
|
||||
) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
RelayPool.addRelay(relay)
|
||||
checkNotInMainThread()
|
||||
|
||||
relay.connectAndRun {
|
||||
allSubscriptions().forEach {
|
||||
relay.sendFilter(it.key, it.value)
|
||||
}
|
||||
|
||||
onConnected(relay)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(60000) // waits for a reply
|
||||
relay.disconnect()
|
||||
RelayPool.removeRelay(relay)
|
||||
|
||||
if (onDone != null) {
|
||||
onDone()
|
||||
}
|
||||
relayList.forEach { relayUrl ->
|
||||
RelayPool.getOrCreateRelay(relayUrl, setOf(FeedType.PRIVATE_DMS), { }) {
|
||||
it.sendOverride(signedEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ object Constants {
|
||||
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
|
||||
RelaySetupInfo("wss://nostr.fmt.wiz.biz", read = true, write = false, feedTypes = activeTypesChats),
|
||||
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
|
||||
// Global
|
||||
@@ -53,11 +52,5 @@ object Constants {
|
||||
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
)
|
||||
|
||||
val forcedRelayForSearch =
|
||||
arrayOf(
|
||||
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch),
|
||||
)
|
||||
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
|
||||
val defaultSearchRelaySet = setOf("wss://relay.nostr.band", "wss://nostr.wine", "wss://relay.noswhere.com")
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ enum class FeedType {
|
||||
val COMMON_FEED_TYPES =
|
||||
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
|
||||
val EVENT_FINDER_TYPES =
|
||||
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.GLOBAL)
|
||||
|
||||
class Relay(
|
||||
val url: String,
|
||||
val read: Boolean = true,
|
||||
@@ -63,7 +66,12 @@ class Relay(
|
||||
const val RECONNECTING_IN_SECONDS = 60 * 3
|
||||
}
|
||||
|
||||
private val httpClient = HttpClientManager.getHttpClient()
|
||||
private val httpClient =
|
||||
if (url.startsWith("ws://127.0.0.1") || url.startsWith("ws://localhost")) {
|
||||
HttpClientManager.getHttpClient(false)
|
||||
} else {
|
||||
HttpClientManager.getHttpClient()
|
||||
}
|
||||
|
||||
private var listeners = setOf<Listener>()
|
||||
private var socket: WebSocket? = null
|
||||
@@ -82,6 +90,7 @@ class Relay(
|
||||
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
|
||||
|
||||
val authResponse = mutableMapOf<HexKey, Boolean>()
|
||||
val sendWhenReady = mutableListOf<EventInterface>()
|
||||
|
||||
fun register(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
@@ -107,7 +116,7 @@ class Relay(
|
||||
private var connectingBlock = AtomicBoolean()
|
||||
|
||||
fun connectAndRun(onConnected: (Relay) -> Unit) {
|
||||
Log.d("Relay", "Relay.connect $url hasProxy: ${this.httpClient.proxy != null}")
|
||||
Log.d("Relay", "Relay.connect $url connecting: ${connectingBlock.get()} hasProxy: ${this.httpClient.proxy != null}")
|
||||
// BRB is crashing OkHttp Deflater object :(
|
||||
if (url.contains("brb.io")) return
|
||||
|
||||
@@ -116,13 +125,16 @@ class Relay(
|
||||
return
|
||||
}
|
||||
|
||||
checkNotInMainThread()
|
||||
|
||||
if (socket != null) return
|
||||
|
||||
lastConnectTentative = TimeUtils.now()
|
||||
|
||||
try {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (socket != null) {
|
||||
connectingBlock.set(false)
|
||||
return
|
||||
}
|
||||
|
||||
lastConnectTentative = TimeUtils.now()
|
||||
|
||||
val request =
|
||||
Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
@@ -159,6 +171,13 @@ class Relay(
|
||||
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
|
||||
onConnected(this@Relay)
|
||||
|
||||
synchronized(sendWhenReady) {
|
||||
sendWhenReady.forEach {
|
||||
send(it)
|
||||
}
|
||||
sendWhenReady.clear()
|
||||
}
|
||||
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) }
|
||||
}
|
||||
|
||||
@@ -264,6 +283,7 @@ class Relay(
|
||||
val event = Event.fromJson(msgArray.get(2))
|
||||
|
||||
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
|
||||
|
||||
listeners.forEach {
|
||||
it.onEvent(
|
||||
this@Relay,
|
||||
@@ -432,6 +452,38 @@ class Relay(
|
||||
}
|
||||
}
|
||||
|
||||
// This function sends the event regardless of the relay being write or not.
|
||||
fun sendOverride(signedEvent: EventInterface) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (signedEvent is RelayAuthEvent) {
|
||||
authResponse.put(signedEvent.id, false)
|
||||
// specific protocol for this event.
|
||||
val event = """["AUTH",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
} else {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
if (isConnected()) {
|
||||
if (isReady) {
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
} else {
|
||||
// sends all filters after connection is successful.
|
||||
connectAndRun {
|
||||
checkNotInMainThread()
|
||||
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
|
||||
// Sends everything.
|
||||
renewFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -448,6 +500,10 @@ class Relay(
|
||||
if (isReady) {
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
} else {
|
||||
synchronized(sendWhenReady) {
|
||||
sendWhenReady.add(signedEvent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// sends all filters after connection is successful.
|
||||
|
||||
@@ -24,10 +24,15 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
|
||||
@@ -58,6 +63,57 @@ object RelayPool : Relay.Listener {
|
||||
return relays.filter { it.url == url }
|
||||
}
|
||||
|
||||
fun getOrCreateRelay(
|
||||
url: String,
|
||||
feedTypes: Set<FeedType>? = null,
|
||||
onDone: (() -> Unit)? = null,
|
||||
whenConnected: (Relay) -> Unit,
|
||||
) {
|
||||
synchronized(this) {
|
||||
val matching = getRelays(url)
|
||||
if (matching.isNotEmpty()) {
|
||||
matching.forEach { whenConnected(it) }
|
||||
} else {
|
||||
/** temporary connection */
|
||||
newSporadicRelay(
|
||||
url,
|
||||
feedTypes,
|
||||
onConnected = whenConnected,
|
||||
onDone = onDone,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun newSporadicRelay(
|
||||
url: String,
|
||||
feedTypes: Set<FeedType>?,
|
||||
onConnected: (Relay) -> Unit,
|
||||
onDone: (() -> Unit)?,
|
||||
) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
addRelay(relay)
|
||||
|
||||
relay.connectAndRun {
|
||||
Client.allSubscriptions().forEach {
|
||||
relay.sendFilter(it.key, it.value)
|
||||
}
|
||||
|
||||
onConnected(relay)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(60000) // waits for a reply
|
||||
relay.disconnect()
|
||||
removeRelay(relay)
|
||||
|
||||
if (onDone != null) {
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRelays(relayList: List<Relay>) {
|
||||
if (!relayList.isNullOrEmpty()) {
|
||||
relayList.forEach { addRelay(it) }
|
||||
@@ -94,13 +150,17 @@ object RelayPool : Relay.Listener {
|
||||
list: List<Relay>,
|
||||
signedEvent: EventInterface,
|
||||
) {
|
||||
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) } }
|
||||
list.forEach { relay -> relays.filter { it.url == relay.url }.forEach { it.sendOverride(signedEvent) } }
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
relays.forEach { it.send(signedEvent) }
|
||||
}
|
||||
|
||||
fun sendOverride(signedEvent: EventInterface) {
|
||||
relays.forEach { it.sendOverride(signedEvent) }
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String) {
|
||||
relays.forEach { it.close(subscriptionId) }
|
||||
}
|
||||
|
||||
70
app/src/main/java/com/vitorpamplona/amethyst/ui/AppScreen.kt
Normal file
70
app/src/main/java/com/vitorpamplona/amethyst/ui/AppScreen.kt
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@Composable
|
||||
fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
|
||||
val displayFeatures = calculateDisplayFeatures(act)
|
||||
val windowSizeClass = calculateWindowSizeClass(act)
|
||||
|
||||
LaunchedEffect(key1 = sharedPreferencesViewModel) {
|
||||
sharedPreferencesViewModel.init()
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
LaunchedEffect(act.isOnMobileDataState) {
|
||||
sharedPreferencesViewModel.updateConnectivityStatusState(act.isOnMobileDataState)
|
||||
}
|
||||
|
||||
return sharedPreferencesViewModel
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppScreen(
|
||||
sharedPreferencesViewModel: SharedPreferencesViewModel,
|
||||
serviceManager: ServiceManager,
|
||||
) {
|
||||
AmethystTheme(sharedPreferencesViewModel) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel()
|
||||
accountStateViewModel.serviceManager = serviceManager
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountStateViewModel.tryLoginExistingAccountAsync()
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
|
||||
}
|
||||
}
|
||||
@@ -33,16 +33,7 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -53,10 +44,6 @@ import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING
|
||||
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.debugState
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
@@ -76,14 +63,13 @@ import java.util.Timer
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val isOnMobileDataState = mutableStateOf(false)
|
||||
val isOnMobileDataState = mutableStateOf(false)
|
||||
private val isOnWifiDataState = mutableStateOf(false)
|
||||
|
||||
// Service Manager is only active when the activity is active.
|
||||
val serviceManager = ServiceManager()
|
||||
private var shouldPauseService = true
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -91,36 +77,8 @@ class MainActivity : AppCompatActivity() {
|
||||
Log.d("Lifetime Event", "MainActivity.onCreate")
|
||||
|
||||
setContent {
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
|
||||
val displayFeatures = calculateDisplayFeatures(this)
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
|
||||
LaunchedEffect(key1 = sharedPreferencesViewModel) {
|
||||
sharedPreferencesViewModel.init()
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
LaunchedEffect(isOnMobileDataState) {
|
||||
sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState)
|
||||
}
|
||||
|
||||
AmethystTheme(sharedPreferencesViewModel) {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel()
|
||||
accountStateViewModel.serviceManager = serviceManager
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountStateViewModel.tryLoginExistingAccountAsync()
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
|
||||
}
|
||||
}
|
||||
val sharedPreferencesViewModel = prepareSharedViewModel(act = this)
|
||||
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel, serviceManager = serviceManager)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import com.vitorpamplona.amethyst.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import java.net.HttpURLConnection
|
||||
@@ -36,7 +37,12 @@ class ImageDownloader {
|
||||
try {
|
||||
HttpURLConnection.setFollowRedirects(true)
|
||||
var url = URL(imageUrl)
|
||||
var huc = url.openConnection() as HttpURLConnection
|
||||
var huc =
|
||||
if (HttpClientManager.getDefaultProxy() != null) {
|
||||
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
huc.instanceFollowRedirects = true
|
||||
var responseCode = huc.responseCode
|
||||
|
||||
@@ -45,7 +51,12 @@ class ImageDownloader {
|
||||
|
||||
// open the new connnection again
|
||||
url = URL(newUrl)
|
||||
huc = url.openConnection() as HttpURLConnection
|
||||
huc =
|
||||
if (HttpClientManager.getDefaultProxy() != null) {
|
||||
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
responseCode = huc.responseCode
|
||||
}
|
||||
|
||||
|
||||
@@ -146,9 +146,9 @@ class NewMessageTagger(
|
||||
|
||||
fun getNostrAddress(
|
||||
bechAddress: String,
|
||||
restOfTheWord: String,
|
||||
restOfTheWord: String?,
|
||||
): String {
|
||||
return if (restOfTheWord.isEmpty()) {
|
||||
return if (restOfTheWord.isNullOrEmpty()) {
|
||||
"nostr:$bechAddress"
|
||||
} else {
|
||||
if (Bech32.ALPHABET.contains(restOfTheWord.get(0), true)) {
|
||||
@@ -159,7 +159,7 @@ class NewMessageTagger(
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String)
|
||||
@Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String?)
|
||||
|
||||
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
var key = mightBeAKey
|
||||
@@ -181,7 +181,7 @@ class NewMessageTagger(
|
||||
val pubkey =
|
||||
Nip19Bech32.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
return DirtyKeyInfo(pubkey, restOfWord.ifEmpty { null })
|
||||
} else if (key.startsWith("npub1", true)) {
|
||||
if (key.length < 63) {
|
||||
return null
|
||||
@@ -192,7 +192,7 @@ class NewMessageTagger(
|
||||
|
||||
val pubkey = Nip19Bech32.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkey, restOfWord)
|
||||
return DirtyKeyInfo(pubkey, restOfWord.ifEmpty { null })
|
||||
} else if (key.startsWith("note1", true)) {
|
||||
if (key.length < 63) {
|
||||
return null
|
||||
@@ -203,7 +203,7 @@ class NewMessageTagger(
|
||||
|
||||
val noteId = Nip19Bech32.uriToRoute(keyB32) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteId, restOfWord)
|
||||
return DirtyKeyInfo(noteId, restOfWord.ifEmpty { null })
|
||||
} else if (key.startsWith("nprofile", true)) {
|
||||
val pubkeyRelay = Nip19Bech32.uriToRoute(key) ?: return null
|
||||
|
||||
|
||||
@@ -92,10 +92,10 @@ open class NewPostViewModel() : ViewModel() {
|
||||
|
||||
var accountViewModel: AccountViewModel? = null
|
||||
var account: Account? = null
|
||||
var requiresNIP24: Boolean = false
|
||||
var requiresNIP17: Boolean = false
|
||||
|
||||
var originalNote: Note? = null
|
||||
var forkedFromNote: Note? = null
|
||||
var originalNote: Note? by mutableStateOf<Note?>(null)
|
||||
var forkedFromNote: Note? by mutableStateOf<Note?>(null)
|
||||
|
||||
var pTags by mutableStateOf<List<User>?>(null)
|
||||
var eTags by mutableStateOf<List<Note>?>(null)
|
||||
@@ -169,8 +169,8 @@ open class NewPostViewModel() : ViewModel() {
|
||||
var wantsZapraiser by mutableStateOf(false)
|
||||
var zapRaiserAmount by mutableStateOf<Long?>(null)
|
||||
|
||||
// NIP24 Wrapped DMs / Group messages
|
||||
var nip24 by mutableStateOf(false)
|
||||
// NIP17 Wrapped DMs / Group messages
|
||||
var nip17 by mutableStateOf(false)
|
||||
|
||||
val draftTextChanges = Channel<String>(Channel.CONFLATED)
|
||||
|
||||
@@ -425,8 +425,8 @@ open class NewPostViewModel() : ViewModel() {
|
||||
TextFieldValue(draftEvent.content())
|
||||
}
|
||||
|
||||
requiresNIP24 = draftEvent is ChatMessageEvent
|
||||
nip24 = draftEvent is ChatMessageEvent
|
||||
requiresNIP17 = draftEvent is ChatMessageEvent
|
||||
nip17 = draftEvent is ChatMessageEvent
|
||||
|
||||
if (draftEvent is ChatMessageEvent) {
|
||||
toUsers =
|
||||
@@ -557,7 +557,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
.toSet()
|
||||
.toList()
|
||||
|
||||
account?.sendNIP24PrivateMessage(
|
||||
account?.sendNIP17PrivateMessage(
|
||||
message = tagger.message,
|
||||
toUsers = receivers,
|
||||
subject = subject.text.ifBlank { null },
|
||||
@@ -571,8 +571,8 @@ open class NewPostViewModel() : ViewModel() {
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (!dmUsers.isNullOrEmpty()) {
|
||||
if (nip24 || dmUsers.size > 1) {
|
||||
account?.sendNIP24PrivateMessage(
|
||||
if (nip17 || dmUsers.size > 1) {
|
||||
account?.sendNIP17PrivateMessage(
|
||||
message = tagger.message,
|
||||
toUsers = dmUsers.map { it.pubkeyHex },
|
||||
subject = subject.text.ifBlank { null },
|
||||
@@ -843,11 +843,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
open fun findUrlInMessage(): String? {
|
||||
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
|
||||
paragraph.split(' ').firstOrNull { word: String ->
|
||||
RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word)
|
||||
}
|
||||
}
|
||||
return RichTextParser().parseValidUrls(message.text).firstOrNull()
|
||||
}
|
||||
|
||||
open fun removeFromReplyList(userToRemove: User) {
|
||||
@@ -1041,7 +1037,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
||||
isUploadingImage = false
|
||||
nip94attachments = nip94attachments + event
|
||||
nip94attachments = nip94attachments.filter { it.url() != event.url() } + event
|
||||
|
||||
message = message.insertUrlAtCursor(imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
@@ -1131,10 +1127,10 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
fun toggleNIP04And24() {
|
||||
if (requiresNIP24) {
|
||||
nip24 = true
|
||||
if (requiresNIP17) {
|
||||
nip17 = true
|
||||
} else {
|
||||
nip24 = !nip24
|
||||
nip17 = !nip17
|
||||
}
|
||||
if (message.text.isNotBlank()) {
|
||||
saveDraft()
|
||||
|
||||
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.relays.RelayInformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
|
||||
@@ -78,7 +79,7 @@ fun RelaySelectionDialog(
|
||||
|
||||
var relays by remember {
|
||||
mutableStateOf(
|
||||
accountViewModel.account.activeWriteRelays().map {
|
||||
accountViewModel.account.connectToRelays.value.map {
|
||||
RelayList(
|
||||
relay = it,
|
||||
relayInfo = RelayBriefInfoCache.RelayBriefInfo(it.url),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddDMRelayListDialog(
|
||||
onClose: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val postViewModel: DMRelayListViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Text(stringResource(R.string.dm_relays_title))
|
||||
|
||||
SaveButton(
|
||||
onPost = {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(
|
||||
onPress = {
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
16.dp,
|
||||
pad.calculateTopPadding(),
|
||||
16.dp,
|
||||
pad.calculateBottomPadding(),
|
||||
),
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
Explanation()
|
||||
|
||||
DMRelayList(postViewModel, accountViewModel, onClose, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Explanation() {
|
||||
Card(modifier = MaterialTheme.colorScheme.imageModifier) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.dm_relays_not_found_editing),
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.dm_relays_not_found_examples),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddSearchRelayListDialog(
|
||||
onClose: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val postViewModel: SearchRelayListViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Text(stringResource(R.string.search_relays_title))
|
||||
|
||||
SaveButton(
|
||||
onPost = {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(
|
||||
onPress = {
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
16.dp,
|
||||
pad.calculateTopPadding(),
|
||||
16.dp,
|
||||
pad.calculateBottomPadding(),
|
||||
),
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
Explanation()
|
||||
|
||||
SearchRelayList(postViewModel, accountViewModel, onClose, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Explanation() {
|
||||
Card(modifier = MaterialTheme.colorScheme.imageModifier) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.search_relays_not_found_editing),
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.search_relays_not_found_examples),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AllRelayListView(
|
||||
onClose: () -> Unit,
|
||||
relayToAdd: String = "",
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val kind3ViewModel: Kind3RelayListViewModel = viewModel()
|
||||
val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
val dmViewModel: DMRelayListViewModel = viewModel()
|
||||
val dmFeedState by dmViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
val nip65ViewModel: Nip65RelayListViewModel = viewModel()
|
||||
val homeFeedState by nip65ViewModel.homeRelays.collectAsStateWithLifecycle()
|
||||
val notifFeedState by nip65ViewModel.notificationRelays.collectAsStateWithLifecycle()
|
||||
|
||||
val privateOutboxViewModel: PrivateOutboxRelayListViewModel = viewModel()
|
||||
val privateOutboxFeedState by privateOutboxViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
val searchViewModel: SearchRelayListViewModel = viewModel()
|
||||
val searchFeedState by searchViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
val localViewModel: LocalRelayListViewModel = viewModel()
|
||||
val localFeedState by localViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kind3ViewModel.load(accountViewModel.account)
|
||||
dmViewModel.load(accountViewModel.account)
|
||||
nip65ViewModel.load(accountViewModel.account)
|
||||
searchViewModel.load(accountViewModel.account)
|
||||
localViewModel.load(accountViewModel.account)
|
||||
privateOutboxViewModel.load(accountViewModel.account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = MinHorzSpacer)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.relay_settings),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
|
||||
SaveButton(
|
||||
onPost = {
|
||||
kind3ViewModel.create()
|
||||
dmViewModel.create()
|
||||
nip65ViewModel.create()
|
||||
searchViewModel.create()
|
||||
localViewModel.create()
|
||||
privateOutboxViewModel.create()
|
||||
onClose()
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
Row {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(
|
||||
onPress = {
|
||||
kind3ViewModel.clear()
|
||||
dmViewModel.clear()
|
||||
nip65ViewModel.clear()
|
||||
searchViewModel.clear()
|
||||
localViewModel.clear()
|
||||
privateOutboxViewModel.clear()
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
16.dp,
|
||||
pad.calculateTopPadding(),
|
||||
16.dp,
|
||||
pad.calculateBottomPadding(),
|
||||
),
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
LazyColumn(contentPadding = FeedPadding) {
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringResource(R.string.public_home_section),
|
||||
stringResource(R.string.public_home_section_explainer),
|
||||
Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, onClose, nav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringResource(R.string.public_notif_section),
|
||||
stringResource(R.string.public_notif_section_explainer),
|
||||
)
|
||||
}
|
||||
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, onClose, nav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringResource(R.string.private_inbox_section),
|
||||
stringResource(R.string.private_inbox_section_explainer),
|
||||
)
|
||||
}
|
||||
renderDMItems(dmFeedState, dmViewModel, accountViewModel, onClose, nav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringResource(R.string.private_outbox_section),
|
||||
stringResource(R.string.private_outbox_section_explainer),
|
||||
)
|
||||
}
|
||||
renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, onClose, nav)
|
||||
|
||||
item {
|
||||
SettingsCategoryWithButton(
|
||||
stringResource(R.string.search_section),
|
||||
stringResource(R.string.search_section_explainer),
|
||||
action = {
|
||||
ResetSearchRelays(searchViewModel)
|
||||
},
|
||||
)
|
||||
}
|
||||
renderSearchItems(searchFeedState, searchViewModel, accountViewModel, onClose, nav)
|
||||
|
||||
item {
|
||||
SettingsCategory(
|
||||
stringResource(R.string.local_section),
|
||||
stringResource(R.string.local_section_explainer),
|
||||
)
|
||||
}
|
||||
renderLocalItems(localFeedState, localViewModel, accountViewModel, onClose, nav)
|
||||
|
||||
item {
|
||||
SettingsCategoryWithButton(
|
||||
stringResource(R.string.kind_3_section),
|
||||
stringResource(R.string.kind_3_section_description),
|
||||
action = {
|
||||
ResetKind3Relays(kind3ViewModel)
|
||||
},
|
||||
)
|
||||
}
|
||||
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, onClose, nav, relayToAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ResetKind3Relays(postViewModel: Kind3RelayListViewModel) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
postViewModel.deleteAll()
|
||||
Constants.defaultRelays.forEach { postViewModel.addRelay(it) }
|
||||
postViewModel.loadRelayDocuments()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.default_relays))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ResetSearchRelays(postViewModel: SearchRelayListViewModel) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
postViewModel.deleteAll()
|
||||
Constants.defaultSearchRelaySet.forEach { postViewModel.addRelay(BasicRelaySetupInfo(it)) }
|
||||
postViewModel.loadRelayDocuments()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.default_relays))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsCategory(
|
||||
title: String,
|
||||
description: String? = null,
|
||||
modifier: Modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
|
||||
) {
|
||||
Column(modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
if (description != null) {
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsCategoryWithButton(
|
||||
title: String,
|
||||
description: String? = null,
|
||||
action: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
|
||||
) {
|
||||
Row(modifier, horizontalArrangement = RowColSpacing) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
action()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
|
||||
@Immutable
|
||||
data class BasicRelaySetupInfo(
|
||||
val url: String,
|
||||
val errorCount: Int = 0,
|
||||
val downloadCountInBytes: Int = 0,
|
||||
val uploadCountInBytes: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val paidRelay: Boolean = false,
|
||||
) {
|
||||
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
|
||||
|
||||
@Composable
|
||||
fun BasicRelaySetupInfoClickableRow(
|
||||
item: BasicRelaySetupInfo,
|
||||
loadProfilePicture: Boolean,
|
||||
onDelete: (BasicRelaySetupInfo) -> Unit,
|
||||
onClick: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
) {
|
||||
Column(Modifier.clickable(onClick = onClick)) {
|
||||
val iconUrlFromRelayInfoDoc =
|
||||
remember(item) {
|
||||
Nip11CachedRetriever.getFromCache(item.url)?.icon
|
||||
}
|
||||
|
||||
RenderRelayIcon(
|
||||
item.briefInfo.displayUrl,
|
||||
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
|
||||
loadProfilePicture,
|
||||
MaterialTheme.colorScheme.largeRelayIconModifier,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = HalfHorzPadding)
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
RelayNameAndRemoveButton(
|
||||
item,
|
||||
onClick,
|
||||
onDelete,
|
||||
ReactionRowHeightChat.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = ReactionRowHeightChat.fillMaxWidth(),
|
||||
) {
|
||||
RelayStatusRow(
|
||||
item = item,
|
||||
modifier = HalfStartPadding.weight(1f),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun BasicRelaySetupInfoDialog(
|
||||
item: BasicRelaySetupInfo,
|
||||
onDelete: (BasicRelaySetupInfo) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
relayInfo?.let {
|
||||
RelayInformationDialog(
|
||||
onClose = { relayInfo = null },
|
||||
relayInfo = it.relayInfo,
|
||||
relayBriefInfo = it.relayBriefInfo,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
val automaticallyShowProfilePicture =
|
||||
remember {
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
BasicRelaySetupInfoClickableRow(
|
||||
item = item,
|
||||
loadProfilePicture = automaticallyShowProfilePicture,
|
||||
onDelete = onDelete,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
accountViewModel.retrieveRelayDocument(
|
||||
item.url,
|
||||
onInfo = {
|
||||
relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
val msg =
|
||||
when (errorCode) {
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL ->
|
||||
context.getString(
|
||||
R.string.relay_information_document_error_assemble_url,
|
||||
url,
|
||||
exceptionMessage,
|
||||
)
|
||||
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
|
||||
context.getString(
|
||||
R.string.relay_information_document_error_assemble_url,
|
||||
url,
|
||||
exceptionMessage,
|
||||
)
|
||||
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
|
||||
context.getString(
|
||||
R.string.relay_information_document_error_assemble_url,
|
||||
url,
|
||||
exceptionMessage,
|
||||
)
|
||||
|
||||
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
|
||||
context.getString(
|
||||
R.string.relay_information_document_error_assemble_url,
|
||||
url,
|
||||
exceptionMessage,
|
||||
)
|
||||
}
|
||||
|
||||
accountViewModel.toast(
|
||||
context.getString(R.string.unable_to_download_relay_document),
|
||||
msg,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
abstract class BasicRelaySetupInfoModel : ViewModel() {
|
||||
lateinit var account: Account
|
||||
|
||||
private val _relays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList())
|
||||
val relays = _relays.asStateFlow()
|
||||
|
||||
var hasModified = false
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
clear()
|
||||
loadRelayDocuments()
|
||||
}
|
||||
|
||||
abstract fun getRelayList(): List<String>?
|
||||
|
||||
abstract fun saveRelayList(urlList: List<String>)
|
||||
|
||||
fun create() {
|
||||
if (hasModified) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
saveRelayList(_relays.value.map { it.url })
|
||||
clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRelayDocuments() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_relays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
onInfo = {
|
||||
togglePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
var hasModified = false
|
||||
_relays.update {
|
||||
val relayList = getRelayList() ?: emptyList()
|
||||
|
||||
relayList.map { relayUrl ->
|
||||
val liveRelay = RelayPool.getRelay(relayUrl)
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
BasicRelaySetupInfo(
|
||||
relayUrl,
|
||||
errorCounter,
|
||||
eventDownloadCounter,
|
||||
eventUploadCounter,
|
||||
spamCounter,
|
||||
)
|
||||
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: BasicRelaySetupInfo) {
|
||||
if (relays.value.any { it.url == relay.url }) return
|
||||
|
||||
_relays.update { it.plus(relay) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun deleteRelay(relay: BasicRelaySetupInfo) {
|
||||
_relays.update { it.minus(relay) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun deleteAll() {
|
||||
_relays.update { relays -> emptyList() }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun togglePaidRelay(
|
||||
relay: BasicRelaySetupInfo,
|
||||
paid: Boolean,
|
||||
) {
|
||||
_relays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
fun countToHumanReadableBytes(counter: Int) =
|
||||
when {
|
||||
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)} GB"
|
||||
counter >= 1000000 -> "${Math.round(counter / 1000000f)} MB"
|
||||
counter >= 1000 -> "${Math.round(counter / 1000f)} KB"
|
||||
else -> "$counter"
|
||||
}
|
||||
|
||||
fun countToHumanReadable(
|
||||
counter: Int,
|
||||
str: String,
|
||||
) = when {
|
||||
counter >= 1000000000 -> "${Math.round(counter / 1000000000f)}G $str"
|
||||
counter >= 1000000 -> "${Math.round(counter / 1000000f)}M $str"
|
||||
counter >= 1000 -> "${Math.round(counter / 1000f)}K $str"
|
||||
else -> "$counter $str"
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
|
||||
@Composable
|
||||
fun DMRelayList(
|
||||
postViewModel: DMRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
renderDMItems(feedState, postViewModel, accountViewModel, onClose, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyListScope.renderDMItems(
|
||||
feedState: List<BasicRelaySetupInfo>,
|
||||
postViewModel: DMRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "DM" + item.url }) { index, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteRelay(item) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RelayUrlEditField { postViewModel.addRelay(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
class DMRelayListViewModel : BasicRelaySetupInfoModel() {
|
||||
override fun getRelayList(): List<String>? {
|
||||
return account.getDMRelayList()?.relays()
|
||||
}
|
||||
|
||||
override fun saveRelayList(urlList: List<String>) {
|
||||
account.saveDMRelayList(urlList)
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,12 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -32,6 +31,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
@@ -40,23 +40,17 @@ import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Paid
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SyncProblem
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -72,214 +66,85 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants.defaultRelays
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.WarningColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.Math.round
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewRelayListView(
|
||||
onClose: () -> Unit,
|
||||
fun Kind3RelayListView(
|
||||
feedState: List<RelaySetupInfo>,
|
||||
postViewModel: Kind3RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
relayToAdd: String = "",
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
relayToAdd: String,
|
||||
) {
|
||||
val postViewModel: NewRelayListViewModel = viewModel()
|
||||
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
postViewModel.deleteAll()
|
||||
defaultRelays.forEach { postViewModel.addRelay(it) }
|
||||
postViewModel.loadRelayDocuments()
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.default_relays))
|
||||
}
|
||||
|
||||
SaveButton(
|
||||
onPost = {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(
|
||||
onPress = {
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
},
|
||||
)
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
16.dp,
|
||||
pad.calculateTopPadding(),
|
||||
16.dp,
|
||||
pad.calculateBottomPadding(),
|
||||
),
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
|
||||
ServerConfig(
|
||||
item,
|
||||
onToggleDownload = { postViewModel.toggleDownload(it) },
|
||||
onToggleUpload = { postViewModel.toggleUpload(it) },
|
||||
onToggleFollows = { postViewModel.toggleFollows(it) },
|
||||
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
|
||||
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
|
||||
onToggleGlobal = { postViewModel.toggleGlobal(it) },
|
||||
onToggleSearch = { postViewModel.toggleSearch(it) },
|
||||
onDelete = { postViewModel.deleteRelay(it) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
EditableServerConfig(relayToAdd) { postViewModel.addRelay(it) }
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(contentPadding = FeedPadding) {
|
||||
renderKind3Items(feedState, postViewModel, accountViewModel, onClose, nav, relayToAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerConfigHeader() {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.relay_address),
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.weight(1.4f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Modifier.size(30.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.bytes),
|
||||
maxLines = 1,
|
||||
fontSize = Font14SP,
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(5.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.bytes),
|
||||
maxLines = 1,
|
||||
fontSize = Font14SP,
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(5.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.errors),
|
||||
maxLines = 1,
|
||||
fontSize = Font14SP,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(5.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.spam),
|
||||
maxLines = 1,
|
||||
fontSize = Font14SP,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
}
|
||||
}
|
||||
fun LazyListScope.renderKind3Items(
|
||||
feedState: List<RelaySetupInfo>,
|
||||
postViewModel: Kind3RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
relayToAdd: String,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "kind3" + item.url }) { index, item ->
|
||||
LoadRelayInfo(
|
||||
item,
|
||||
onToggleDownload = { postViewModel.toggleDownload(it) },
|
||||
onToggleUpload = { postViewModel.toggleUpload(it) },
|
||||
onToggleFollows = { postViewModel.toggleFollows(it) },
|
||||
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
|
||||
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
|
||||
onToggleGlobal = { postViewModel.toggleGlobal(it) },
|
||||
onToggleSearch = { postViewModel.toggleSearch(it) },
|
||||
onDelete = { postViewModel.deleteRelay(it) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
item {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Kind3RelayEditBox(relayToAdd) { postViewModel.addRelay(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ServerConfigPreview() {
|
||||
ServerConfigClickableLine(
|
||||
ClickableRelayItem(
|
||||
loadProfilePicture = true,
|
||||
item =
|
||||
RelaySetupInfo(
|
||||
@@ -306,7 +171,7 @@ fun ServerConfigPreview() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerConfig(
|
||||
fun LoadRelayInfo(
|
||||
item: RelaySetupInfo,
|
||||
onToggleDownload: (RelaySetupInfo) -> Unit,
|
||||
onToggleUpload: (RelaySetupInfo) -> Unit,
|
||||
@@ -337,7 +202,7 @@ fun ServerConfig(
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
ServerConfigClickableLine(
|
||||
ClickableRelayItem(
|
||||
item = item,
|
||||
loadProfilePicture = automaticallyShowProfilePicture,
|
||||
onToggleDownload = onToggleDownload,
|
||||
@@ -351,7 +216,9 @@ fun ServerConfig(
|
||||
onClick = {
|
||||
accountViewModel.retrieveRelayDocument(
|
||||
item.url,
|
||||
onInfo = { relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) },
|
||||
onInfo = {
|
||||
relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage ->
|
||||
val msg =
|
||||
when (errorCode) {
|
||||
@@ -361,18 +228,21 @@ fun ServerConfig(
|
||||
url,
|
||||
exceptionMessage,
|
||||
)
|
||||
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
|
||||
context.getString(
|
||||
R.string.relay_information_document_error_assemble_url,
|
||||
url,
|
||||
exceptionMessage,
|
||||
)
|
||||
|
||||
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
|
||||
context.getString(
|
||||
R.string.relay_information_document_error_assemble_url,
|
||||
url,
|
||||
exceptionMessage,
|
||||
)
|
||||
|
||||
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
|
||||
context.getString(
|
||||
R.string.relay_information_document_error_assemble_url,
|
||||
@@ -392,7 +262,7 @@ fun ServerConfig(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerConfigClickableLine(
|
||||
fun ClickableRelayItem(
|
||||
item: RelaySetupInfo,
|
||||
loadProfilePicture: Boolean,
|
||||
onToggleDownload: (RelaySetupInfo) -> Unit,
|
||||
@@ -433,13 +303,12 @@ fun ServerConfigClickableLine(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = ReactionRowHeightChat.fillMaxWidth(),
|
||||
) {
|
||||
RenderActiveToggles(
|
||||
ActiveToggles(
|
||||
item = item,
|
||||
onToggleFollows = onToggleFollows,
|
||||
onTogglePrivateDMs = onTogglePrivateDMs,
|
||||
onTogglePublicChats = onTogglePublicChats,
|
||||
onToggleGlobal = onToggleGlobal,
|
||||
onToggleSearch = onToggleSearch,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -447,7 +316,7 @@ fun ServerConfigClickableLine(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = ReactionRowHeightChat.fillMaxWidth(),
|
||||
) {
|
||||
RenderStatusRow(
|
||||
StatusRow(
|
||||
item = item,
|
||||
onToggleDownload = onToggleDownload,
|
||||
onToggleUpload = onToggleUpload,
|
||||
@@ -463,7 +332,7 @@ fun ServerConfigClickableLine(
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun RenderStatusRow(
|
||||
private fun StatusRow(
|
||||
item: RelaySetupInfo,
|
||||
onToggleDownload: (RelaySetupInfo) -> Unit,
|
||||
onToggleUpload: (RelaySetupInfo) -> Unit,
|
||||
@@ -615,13 +484,12 @@ private fun RenderStatusRow(
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun RenderActiveToggles(
|
||||
private fun ActiveToggles(
|
||||
item: RelaySetupInfo,
|
||||
onToggleFollows: (RelaySetupInfo) -> Unit,
|
||||
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
|
||||
onTogglePublicChats: (RelaySetupInfo) -> Unit,
|
||||
onToggleGlobal: (RelaySetupInfo) -> Unit,
|
||||
onToggleSearch: (RelaySetupInfo) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
@@ -767,40 +635,6 @@ private fun RenderActiveToggles(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Size30Modifier,
|
||||
onClick = { onToggleSearch(item) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
stringResource(R.string.search_feed),
|
||||
modifier =
|
||||
Modifier.padding(horizontal = 5.dp)
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = { onToggleSearch(item) },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.search_feed),
|
||||
Toast.LENGTH_SHORT,
|
||||
)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
),
|
||||
tint =
|
||||
if (item.feedTypes.contains(FeedType.SEARCH)) {
|
||||
MaterialTheme.colorScheme.allGoodColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(
|
||||
alpha = 0.32f,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -844,7 +678,7 @@ private fun FirstLine(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditableServerConfig(
|
||||
fun Kind3RelayEditBox(
|
||||
relayToAdd: String,
|
||||
onNewRelay: (RelaySetupInfo) -> Unit,
|
||||
) {
|
||||
@@ -899,18 +733,15 @@ fun EditableServerConfig(
|
||||
Button(
|
||||
onClick = {
|
||||
if (url.isNotBlank() && url != "/") {
|
||||
var addedWSS =
|
||||
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
|
||||
if (url.endsWith(".onion") || url.endsWith(".onion/")) {
|
||||
"ws://$url"
|
||||
} else {
|
||||
"wss://$url"
|
||||
}
|
||||
} else {
|
||||
url
|
||||
}
|
||||
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
|
||||
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
|
||||
val addedWSS = RelayUrlFormatter.normalize(url)
|
||||
onNewRelay(
|
||||
RelaySetupInfo(
|
||||
addedWSS,
|
||||
read,
|
||||
write,
|
||||
feedTypes = FeedType.values().toSet(),
|
||||
),
|
||||
)
|
||||
url = ""
|
||||
write = true
|
||||
read = true
|
||||
@@ -931,21 +762,3 @@ fun EditableServerConfig(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun countToHumanReadableBytes(counter: Int) =
|
||||
when {
|
||||
counter >= 1000000000 -> "${round(counter / 1000000000f)} GB"
|
||||
counter >= 1000000 -> "${round(counter / 1000000f)} MB"
|
||||
counter >= 1000 -> "${round(counter / 1000f)} KB"
|
||||
else -> "$counter"
|
||||
}
|
||||
|
||||
fun countToHumanReadable(
|
||||
counter: Int,
|
||||
str: String,
|
||||
) = when {
|
||||
counter >= 1000000000 -> "${round(counter / 1000000000f)}G $str"
|
||||
counter >= 1000000 -> "${round(counter / 1000000f)}M $str"
|
||||
counter >= 1000 -> "${round(counter / 1000f)}K $str"
|
||||
else -> "$counter $str"
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -36,12 +35,14 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NewRelayListViewModel : ViewModel() {
|
||||
class Kind3RelayListViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
|
||||
val relays = _relays.asStateFlow()
|
||||
|
||||
var hasModified = false
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
clear()
|
||||
@@ -49,9 +50,9 @@ class NewRelayListViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun create() {
|
||||
relays.let {
|
||||
if (hasModified) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.saveRelayList(it.value)
|
||||
account.saveKind3RelayList(relays.value)
|
||||
clear()
|
||||
}
|
||||
}
|
||||
@@ -72,35 +73,11 @@ class NewRelayListViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
hasModified = false
|
||||
_relays.update {
|
||||
var relayFile = account.userProfile().latestContactList?.relays()
|
||||
|
||||
if (relayFile != null) {
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
// TODO: Remove when search becomes more available.
|
||||
|
||||
val needsSearchRelay =
|
||||
relayFile.none { it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet } &&
|
||||
relayFile.none {
|
||||
account.localRelays
|
||||
.filter { localRelay -> localRelay.url == it.key }
|
||||
.firstOrNull()
|
||||
?.feedTypes
|
||||
?.contains(FeedType.SEARCH)
|
||||
?: false
|
||||
}
|
||||
|
||||
if (needsSearchRelay) {
|
||||
relayFile =
|
||||
relayFile +
|
||||
Constants.forcedRelayForSearch.map {
|
||||
Pair(
|
||||
it.url,
|
||||
ContactListEvent.ReadWrite(it.read, it.write),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
relayFile
|
||||
.map {
|
||||
val liveRelay = RelayPool.getRelay(it.key)
|
||||
@@ -166,47 +143,58 @@ class NewRelayListViewModel : ViewModel() {
|
||||
if (relays.value.any { it.url == relay.url }) return
|
||||
|
||||
_relays.update { it.plus(relay) }
|
||||
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun deleteRelay(relay: RelaySetupInfo) {
|
||||
_relays.update { it.minus(relay) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun deleteAll() {
|
||||
_relays.update { relays -> emptyList() }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleDownload(relay: RelaySetupInfo) {
|
||||
_relays.update { it.updated(relay, relay.copy(read = !relay.read)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleUpload(relay: RelaySetupInfo) {
|
||||
_relays.update { it.updated(relay, relay.copy(write = !relay.write)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleFollows(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
|
||||
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleMessages(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
|
||||
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun togglePublicChats(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
|
||||
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleGlobal(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
|
||||
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleSearch(relay: RelaySetupInfo) {
|
||||
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH)
|
||||
_relays.update { it.updated(relay, relay.copy(feedTypes = newTypes)) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun togglePaidRelay(
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
|
||||
@Composable
|
||||
fun LocalRelayList(
|
||||
postViewModel: LocalRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
renderLocalItems(feedState, postViewModel, accountViewModel, onClose, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyListScope.renderLocalItems(
|
||||
feedState: List<BasicRelaySetupInfo>,
|
||||
postViewModel: LocalRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "Local" + item.url }) { index, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteRelay(item) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RelayUrlEditField { postViewModel.addRelay(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
class LocalRelayListViewModel : BasicRelaySetupInfoModel() {
|
||||
override fun getRelayList(): List<String>? {
|
||||
return account.localRelayServers.toList()
|
||||
}
|
||||
|
||||
override fun saveRelayList(urlList: List<String>) {
|
||||
account.updateLocalRelayServers(urlList.toSet())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
|
||||
@Composable
|
||||
fun Nip65RelayList(
|
||||
postViewModel: Nip65RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val homeFeedState by postViewModel.homeRelays.collectAsStateWithLifecycle()
|
||||
val notifFeedState by postViewModel.notificationRelays.collectAsStateWithLifecycle()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
renderNip65HomeItems(homeFeedState, postViewModel, accountViewModel, onClose, nav)
|
||||
renderNip65NotifItems(notifFeedState, postViewModel, accountViewModel, onClose, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyListScope.renderNip65HomeItems(
|
||||
feedState: List<BasicRelaySetupInfo>,
|
||||
postViewModel: Nip65RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.url }) { index, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteHomeRelay(item) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RelayUrlEditField { postViewModel.addHomeRelay(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyListScope.renderNip65NotifItems(
|
||||
feedState: List<BasicRelaySetupInfo>,
|
||||
postViewModel: Nip65RelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.url }) { index, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteNotifRelay(item) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RelayUrlEditField { postViewModel.addNotifRelay(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class Nip65RelayListViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
private val _homeRelays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList())
|
||||
val homeRelays = _homeRelays.asStateFlow()
|
||||
|
||||
private val _notificationRelays = MutableStateFlow<List<BasicRelaySetupInfo>>(emptyList())
|
||||
val notificationRelays = _notificationRelays.asStateFlow()
|
||||
|
||||
var hasModified = false
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
clear()
|
||||
loadRelayDocuments()
|
||||
}
|
||||
|
||||
fun create() {
|
||||
if (hasModified) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val writes = _homeRelays.value.map { it.url }.toSet()
|
||||
val reads = _notificationRelays.value.map { it.url }.toSet()
|
||||
|
||||
val urls = writes.union(reads)
|
||||
|
||||
account.sendNip65RelayList(
|
||||
urls.map {
|
||||
val type =
|
||||
if (writes.contains(it) && reads.contains(it)) {
|
||||
AdvertisedRelayListEvent.AdvertisedRelayType.BOTH
|
||||
} else if (writes.contains(it)) {
|
||||
AdvertisedRelayListEvent.AdvertisedRelayType.WRITE
|
||||
} else {
|
||||
AdvertisedRelayListEvent.AdvertisedRelayType.READ
|
||||
}
|
||||
AdvertisedRelayListEvent.AdvertisedRelayInfo(it, type)
|
||||
},
|
||||
)
|
||||
clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRelayDocuments() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_homeRelays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
onInfo = {
|
||||
toggleHomePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage -> },
|
||||
)
|
||||
}
|
||||
|
||||
_notificationRelays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
onInfo = {
|
||||
toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
onError = { url, errorCode, exceptionMessage -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
hasModified = false
|
||||
_homeRelays.update {
|
||||
val relayList = account.getNIP65RelayList()?.writeRelays() ?: emptyList()
|
||||
|
||||
relayList.map { relayUrl ->
|
||||
val liveRelay = RelayPool.getRelay(relayUrl)
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
BasicRelaySetupInfo(
|
||||
relayUrl,
|
||||
errorCounter,
|
||||
eventDownloadCounter,
|
||||
eventUploadCounter,
|
||||
spamCounter,
|
||||
)
|
||||
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
|
||||
}
|
||||
|
||||
_notificationRelays.update {
|
||||
val relayList = account.getNIP65RelayList()?.readRelays() ?: emptyList()
|
||||
|
||||
relayList.map { relayUrl ->
|
||||
val liveRelay = RelayPool.getRelay(relayUrl)
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
BasicRelaySetupInfo(
|
||||
relayUrl,
|
||||
errorCounter,
|
||||
eventDownloadCounter,
|
||||
eventUploadCounter,
|
||||
spamCounter,
|
||||
)
|
||||
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
fun addHomeRelay(relay: BasicRelaySetupInfo) {
|
||||
if (_homeRelays.value.any { it.url == relay.url }) return
|
||||
|
||||
_homeRelays.update { it.plus(relay) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun deleteHomeRelay(relay: BasicRelaySetupInfo) {
|
||||
_homeRelays.update { it.minus(relay) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun deleteHomeAll() {
|
||||
_homeRelays.update { relays -> emptyList() }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleHomePaidRelay(
|
||||
relay: BasicRelaySetupInfo,
|
||||
paid: Boolean,
|
||||
) {
|
||||
_homeRelays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
|
||||
}
|
||||
|
||||
fun addNotifRelay(relay: BasicRelaySetupInfo) {
|
||||
if (_notificationRelays.value.any { it.url == relay.url }) return
|
||||
|
||||
_notificationRelays.update { it.plus(relay) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun deleteNotifRelay(relay: BasicRelaySetupInfo) {
|
||||
_notificationRelays.update { it.minus(relay) }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun deleteNotifAll() {
|
||||
_notificationRelays.update { relays -> emptyList() }
|
||||
hasModified = true
|
||||
}
|
||||
|
||||
fun toggleNotifPaidRelay(
|
||||
relay: BasicRelaySetupInfo,
|
||||
paid: Boolean,
|
||||
) {
|
||||
_notificationRelays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
|
||||
@Composable
|
||||
fun PrivateOutboxRelayList(
|
||||
postViewModel: PrivateOutboxRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
renderPrivateOutboxItems(feedState, postViewModel, accountViewModel, onClose, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyListScope.renderPrivateOutboxItems(
|
||||
feedState: List<BasicRelaySetupInfo>,
|
||||
postViewModel: PrivateOutboxRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "Outbox" + item.url }) { index, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteRelay(item) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RelayUrlEditField { postViewModel.addRelay(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() {
|
||||
override fun getRelayList(): List<String>? {
|
||||
return account.getPrivateOutboxRelayList()?.relays()
|
||||
}
|
||||
|
||||
override fun saveRelayList(urlList: List<String>) {
|
||||
account.savePrivateOutboxRelayList(urlList)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -48,6 +48,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.Paid
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.theme.WarningColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
|
||||
@Composable
|
||||
fun RelayNameAndRemoveButton(
|
||||
item: BasicRelaySetupInfo,
|
||||
onClick: () -> Unit,
|
||||
onDelete: (BasicRelaySetupInfo) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
|
||||
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = item.briefInfo.displayUrl,
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (item.paidRelay) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Paid,
|
||||
null,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 5.dp, top = 1.dp)
|
||||
.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.allGoodColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = { onDelete(item) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Cancel,
|
||||
contentDescription = stringResource(id = R.string.remove),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 10.dp)
|
||||
.size(15.dp),
|
||||
tint = WarningColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.SyncProblem
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.warningColor
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun RelayStatusRow(
|
||||
item: BasicRelaySetupInfo,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = stringResource(R.string.read_from_relay),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = {
|
||||
accountViewModel.toast(
|
||||
R.string.read_from_relay,
|
||||
R.string.read_from_relay_description,
|
||||
)
|
||||
},
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.allGoodColor,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadableBytes(item.downloadCountInBytes),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.Upload,
|
||||
stringResource(R.string.write_to_relay),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = {
|
||||
accountViewModel.toast(
|
||||
R.string.write_to_relay,
|
||||
R.string.write_to_relay_description,
|
||||
)
|
||||
},
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.allGoodColor,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadableBytes(item.uploadCountInBytes),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.SyncProblem,
|
||||
stringResource(R.string.errors),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = {
|
||||
accountViewModel.toast(
|
||||
R.string.errors,
|
||||
R.string.errors_description,
|
||||
)
|
||||
},
|
||||
),
|
||||
tint =
|
||||
if (item.errorCount > 0) {
|
||||
MaterialTheme.colorScheme.warningColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.allGoodColor
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadable(item.errorCount, "errors"),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.DeleteSweep,
|
||||
stringResource(R.string.spam),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(15.dp)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = {
|
||||
accountViewModel.toast(
|
||||
R.string.spam,
|
||||
R.string.spam_description,
|
||||
)
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.spam),
|
||||
Toast.LENGTH_SHORT,
|
||||
)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
),
|
||||
tint =
|
||||
if (item.spamCount > 0) {
|
||||
MaterialTheme.colorScheme.warningColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.allGoodColor
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countToHumanReadable(item.spamCount, "spam"),
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
|
||||
@Composable
|
||||
fun RelayUrlEditField(onNewRelay: (BasicRelaySetupInfo) -> Unit) {
|
||||
var url by remember { mutableStateOf("") }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.add_a_relay)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "server.com",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (url.isNotBlank() && url != "/") {
|
||||
val addedWSS = RelayUrlFormatter.normalize(url)
|
||||
onNewRelay(BasicRelaySetupInfo(addedWSS))
|
||||
url = ""
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor =
|
||||
if (url.isNotBlank()) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.placeholderText
|
||||
},
|
||||
),
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.add), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
|
||||
@Composable
|
||||
fun SearchRelayList(
|
||||
postViewModel: SearchRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
renderSearchItems(feedState, postViewModel, accountViewModel, onClose, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyListScope.renderSearchItems(
|
||||
feedState: List<BasicRelaySetupInfo>,
|
||||
postViewModel: SearchRelayListViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> "Search" + item.url }) { index, item ->
|
||||
BasicRelaySetupInfoDialog(
|
||||
item,
|
||||
onDelete = { postViewModel.deleteRelay(item) },
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
onClose()
|
||||
nav(it)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RelayUrlEditField { postViewModel.addRelay(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
class SearchRelayListViewModel : BasicRelaySetupInfoModel() {
|
||||
override fun getRelayList(): List<String>? {
|
||||
return account.getSearchRelayList()?.relays()
|
||||
}
|
||||
|
||||
override fun saveRelayList(urlList: List<String>) {
|
||||
account.saveSearchRelayList(urlList)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ fun LoadOrCreateNote(
|
||||
@Composable
|
||||
private fun LoadAndDisplayEvent(
|
||||
event: Event,
|
||||
additionalChars: String,
|
||||
additionalChars: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -141,7 +141,7 @@ private fun LoadAndDisplayEvent(
|
||||
private fun DisplayEvent(
|
||||
hex: HexKey,
|
||||
kind: Int?,
|
||||
additionalChars: String,
|
||||
additionalChars: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -164,7 +164,7 @@ private fun DisplayNoteLink(
|
||||
it: Note,
|
||||
hex: HexKey,
|
||||
kind: Int?,
|
||||
addedCharts: String,
|
||||
addedCharts: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -218,7 +218,7 @@ private fun DisplayNoteLink(
|
||||
@Composable
|
||||
private fun DisplayAddress(
|
||||
nip19: Nip19Bech32.NAddress,
|
||||
additionalChars: String,
|
||||
additionalChars: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -245,16 +245,22 @@ private fun DisplayAddress(
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
remember { "@${nip19.atag}$additionalChars" },
|
||||
)
|
||||
if (additionalChars != null) {
|
||||
Text(
|
||||
remember { "@${nip19.atag}$additionalChars" },
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
remember { "@${nip19.atag}" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUser(
|
||||
public fun DisplayUser(
|
||||
userHex: HexKey,
|
||||
additionalChars: String,
|
||||
additionalChars: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
@@ -274,30 +280,34 @@ private fun DisplayUser(
|
||||
userBase?.let { RenderUserAsClickableText(it, additionalChars, nav) }
|
||||
|
||||
if (userBase == null) {
|
||||
Text(
|
||||
remember { "@${userHex}$additionalChars" },
|
||||
)
|
||||
if (additionalChars != null) {
|
||||
Text(
|
||||
remember { "@${userHex}$additionalChars" },
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
remember { "@$userHex" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderUserAsClickableText(
|
||||
baseUser: User,
|
||||
additionalChars: String,
|
||||
additionalChars: String?,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val userState by baseUser.live().userMetadataInfo.observeAsState()
|
||||
|
||||
userState?.bestName()?.let {
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = it,
|
||||
suffix = additionalChars.ifBlank { null },
|
||||
maxLines = 1,
|
||||
route = "User/${baseUser.pubkeyHex}",
|
||||
nav = nav,
|
||||
tags = userState?.tags ?: EmptyTagList,
|
||||
)
|
||||
}
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = userState?.bestName() ?: ("@" + baseUser.pubkeyDisplayHex()),
|
||||
suffix = additionalChars?.ifBlank { null },
|
||||
maxLines = 1,
|
||||
route = "User/${baseUser.pubkeyHex}",
|
||||
nav = nav,
|
||||
tags = userState?.tags ?: EmptyTagList,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -79,7 +79,7 @@ fun ClickableWithdrawal(withdrawalString: String) {
|
||||
|
||||
ClickableText(
|
||||
text = withdraw,
|
||||
onClick = { payViaIntent(withdrawalString, context) { showErrorMessageDialog = it } },
|
||||
onClick = { payViaIntent(withdrawalString, context, { }) { showErrorMessageDialog = it } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -42,13 +41,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.commons.richtext.ExpandableTextCutOffCalculator
|
||||
import com.vitorpamplona.amethyst.ui.note.getGradient
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
@@ -68,15 +67,16 @@ fun ExpandableRichTextViewer(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
var showFullText by remember {
|
||||
val cached = ShowFullTextCache.cache[id]
|
||||
if (cached == null) {
|
||||
ShowFullTextCache.cache.put(id, false)
|
||||
mutableStateOf(false)
|
||||
} else {
|
||||
mutableStateOf(cached)
|
||||
var showFullText by
|
||||
remember {
|
||||
val cached = ShowFullTextCache.cache[id]
|
||||
if (cached == null) {
|
||||
ShowFullTextCache.cache.put(id, false)
|
||||
mutableStateOf(false)
|
||||
} else {
|
||||
mutableStateOf(cached)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val whereToCut = remember(content) { ExpandableTextCutOffCalculator.indexToCutOff(content) }
|
||||
|
||||
@@ -124,7 +124,7 @@ fun ExpandableRichTextViewer(
|
||||
@Composable
|
||||
fun ShowMoreButton(onClick: () -> Unit) {
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
modifier = StdTopPadding,
|
||||
onClick = onClick,
|
||||
shape = ButtonBorder,
|
||||
colors =
|
||||
|
||||
@@ -169,7 +169,7 @@ fun InvoicePreview(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = { payViaIntent(lnInvoice, context) { showErrorMessageDialog = it } },
|
||||
onClick = { payViaIntent(lnInvoice, context, { }) { showErrorMessageDialog = it } },
|
||||
shape = QuoteBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
|
||||
@@ -61,25 +61,7 @@ fun LoadUrlPreview(
|
||||
) { state ->
|
||||
when (state) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
if (state.previewInfo.mimeType.type == "image") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlImage(url),
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else if (state.previewInfo.mimeType.type == "video") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlVideo(url),
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
RenderLoaded(state, url, accountViewModel)
|
||||
}
|
||||
else -> {
|
||||
ClickableUrl(urlText, url)
|
||||
@@ -88,3 +70,30 @@ fun LoadUrlPreview(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderLoaded(
|
||||
state: UrlPreviewState.Loaded,
|
||||
url: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (state.previewInfo.mimeType.type == "image") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlImage(url),
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else if (state.previewInfo.mimeType.type == "video") {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(
|
||||
content = MediaUrlVideo(url),
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import android.util.Patterns
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
class MarkdownParser {
|
||||
private fun getDisplayNameAndNIP19FromTag(
|
||||
tag: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
): Pair<String, String>? {
|
||||
val matcher = RichTextParser.tagIndex.matcher(tag)
|
||||
val (index, suffix) =
|
||||
try {
|
||||
matcher.find()
|
||||
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("Tag Parser", "Couldn't link tag $tag", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (index != null && index >= 0 && index < tags.lists.size) {
|
||||
val tag = tags.lists[index]
|
||||
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
LocalCache.checkGetOrCreateNote(tag[1])?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun getDisplayNameFromNip19(nip19: Nip19Bech32.Entity): Pair<String, String>? {
|
||||
return when (nip19) {
|
||||
is Nip19Bech32.NSec -> null
|
||||
is Nip19Bech32.NPub -> {
|
||||
LocalCache.getUserIfExists(nip19.hex)?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NProfile -> {
|
||||
LocalCache.getUserIfExists(nip19.hex)?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.Note -> {
|
||||
LocalCache.getNoteIfExists(nip19.hex)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NEvent -> {
|
||||
LocalCache.getNoteIfExists(nip19.hex)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NEmbed -> {
|
||||
if (LocalCache.getNoteIfExists(nip19.event.id) == null) {
|
||||
LocalCache.verifyAndConsume(nip19.event, null)
|
||||
}
|
||||
|
||||
LocalCache.getNoteIfExists(nip19.event.id)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
is Nip19Bech32.NRelay -> null
|
||||
is Nip19Bech32.NAddress -> {
|
||||
LocalCache.getAddressableNoteIfExists(nip19.atag)?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun returnNIP19References(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
): List<Nip19Bech32.Entity> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val listOfReferences = mutableListOf<Nip19Bech32.Entity>()
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (RichTextParser.startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19Bech32.uriToRoute(word)
|
||||
parsedNip19?.let { listOfReferences.add(it.entity) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags?.lists?.forEach {
|
||||
if (it[0] == "p" && it.size > 1) {
|
||||
listOfReferences.add(Nip19Bech32.NProfile(it[1], listOfNotNull(it.getOrNull(2))))
|
||||
} else if (it[0] == "e" && it.size > 1) {
|
||||
listOfReferences.add(Nip19Bech32.NEvent(it[1], listOfNotNull(it.getOrNull(2)), null, null))
|
||||
} else if (it[0] == "a" && it.size > 1) {
|
||||
ATag.parseAtag(it[1], it.getOrNull(2))?.let { atag ->
|
||||
listOfReferences.add(Nip19Bech32.NAddress(it[1], listOfNotNull(atag.relay), atag.pubKeyHex, atag.kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return listOfReferences
|
||||
}
|
||||
|
||||
suspend fun returnMarkdownWithSpecialContent(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
): String {
|
||||
var returnContent = ""
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (RichTextParser.isValidURL(word)) {
|
||||
if (RichTextParser.isImageUrl(word)) {
|
||||
returnContent += " "
|
||||
} else {
|
||||
returnContent += "[$word]($word) "
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
returnContent += "[$word](mailto:$word) "
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
returnContent += "[$word](tel:$word) "
|
||||
} else if (RichTextParser.startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19Bech32.uriToRoute(word)
|
||||
returnContent +=
|
||||
if (parsedNip19?.entity !== null) {
|
||||
val pair = getDisplayNameFromNip19(parsedNip19.entity)
|
||||
if (pair != null) {
|
||||
val (displayName, nip19) = pair
|
||||
"[$displayName](nostr:$nip19) "
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else if (word.startsWith("#")) {
|
||||
if (RichTextParser.tagIndex.matcher(word).matches() && tags != null) {
|
||||
val pair = getDisplayNameAndNIP19FromTag(word, tags)
|
||||
if (pair != null) {
|
||||
returnContent += "[${pair.first}](nostr:${pair.second}) "
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else if (RichTextParser.hashTagsPattern.matcher(word).matches()) {
|
||||
val hashtagMatcher = RichTextParser.hashTagsPattern.matcher(word)
|
||||
|
||||
val (myTag, mySuffix) =
|
||||
try {
|
||||
hashtagMatcher.find()
|
||||
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (myTag != null) {
|
||||
returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix "
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
}
|
||||
returnContent += "\n"
|
||||
}
|
||||
return returnContent
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ class MediaCompressor {
|
||||
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(),
|
||||
configureWith =
|
||||
Configuration(
|
||||
quality = VideoQuality.LOW,
|
||||
quality = VideoQuality.MEDIUM,
|
||||
// => required name
|
||||
videoNames = listOf(UUID.randomUUID().toString()),
|
||||
),
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -29,12 +30,12 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -51,9 +52,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFontFamilyResolver
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -65,9 +63,6 @@ import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.markdown.MarkdownParseOptions
|
||||
import com.halilibo.richtext.ui.material3.Material3RichText
|
||||
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
|
||||
@@ -79,10 +74,8 @@ import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Segment
|
||||
@@ -93,33 +86,29 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
||||
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun isMarkdown(content: String): Boolean {
|
||||
return content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("__") ||
|
||||
content.contains("**") ||
|
||||
content.contains("```") ||
|
||||
content.contains("](")
|
||||
}
|
||||
@@ -137,13 +126,36 @@ fun RichTextViewer(
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (remember(content) { isMarkdown(content) }) {
|
||||
RenderContentAsMarkdown(content, tags, accountViewModel, nav)
|
||||
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
} else {
|
||||
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderStrangeNamePreview() {
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
RenderRegular(
|
||||
"If you want to stream or download the music from nostr:npub1sctag667a7np6p6ety2up94pnwwxhd2ep8n8afr2gtr47cwd4ewsvdmmjm can you here",
|
||||
EmptyTagList,
|
||||
) { word, state ->
|
||||
when (word) {
|
||||
is BechSegment -> {
|
||||
Text(
|
||||
"FreeFrom Official \uD80C\uDD66",
|
||||
modifier = Modifier.border(1.dp, Color.Red),
|
||||
)
|
||||
}
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderRegularPreview() {
|
||||
@@ -346,17 +358,6 @@ fun RenderRegular(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
// UrlPreviews and Images have a 5dp spacing down. This also adds the space to Text.
|
||||
val lastElement = state.paragraphs.lastOrNull()?.words?.lastOrNull()
|
||||
if (lastElement !is ImageSegment &&
|
||||
lastElement !is LinkSegment &&
|
||||
lastElement !is InvoiceSegment &&
|
||||
lastElement !is CashuSegment
|
||||
) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,186 +463,6 @@ fun RenderCustomEmoji(
|
||||
)
|
||||
}
|
||||
|
||||
val markdownParseOptions =
|
||||
MarkdownParseOptions(
|
||||
autolink = true,
|
||||
isImage = { url -> RichTextParser.isImageOrVideoUrl(url) },
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun RenderContentAsMarkdown(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val onClick =
|
||||
remember {
|
||||
{ link: String ->
|
||||
val route = uriToRoute(link)
|
||||
if (route != null) {
|
||||
nav(route)
|
||||
} else {
|
||||
runCatching { uri.openUri(link) }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
ProvideTextStyle(MarkdownTextStyle) {
|
||||
Material3RichText(style = MaterialTheme.colorScheme.markdownStyle) {
|
||||
RefreshableContent(content, tags, accountViewModel) {
|
||||
Markdown(
|
||||
content = it,
|
||||
markdownParseOptions = markdownParseOptions,
|
||||
onLinkClicked = onClick,
|
||||
onMediaCompose = { title, destination ->
|
||||
ZoomableContentView(
|
||||
content =
|
||||
remember(destination, tags) {
|
||||
RichTextParser().parseMediaUrl(
|
||||
destination,
|
||||
tags ?: EmptyTagList,
|
||||
title.ifEmpty { null } ?: content,
|
||||
) ?: MediaUrlImage(url = destination, description = title.ifEmpty { null } ?: content)
|
||||
},
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RefreshableContent(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
accountViewModel: AccountViewModel,
|
||||
onCompose: @Composable (String) -> Unit,
|
||||
) {
|
||||
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(content) }
|
||||
|
||||
ObserverAllNIP19References(content, tags, accountViewModel) {
|
||||
accountViewModel.returnMarkdownWithSpecialContent(content, tags) { newMarkdownWithSpecialContent ->
|
||||
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
|
||||
markdownWithSpecialContent = newMarkdownWithSpecialContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markdownWithSpecialContent?.let { onCompose(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserverAllNIP19References(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
var nip19References by remember(content) { mutableStateOf<List<Nip19Bech32.Entity>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(key1 = content) {
|
||||
accountViewModel.returnNIP19References(content, tags) {
|
||||
nip19References = it
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
nip19References.forEach { ObserveNIP19(it, accountViewModel, onRefresh) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveNIP19(
|
||||
entity: Nip19Bech32.Entity,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
when (entity) {
|
||||
is Nip19Bech32.NPub -> ObserveNIP19User(entity.hex, accountViewModel, onRefresh)
|
||||
is Nip19Bech32.NProfile -> ObserveNIP19User(entity.hex, accountViewModel, onRefresh)
|
||||
|
||||
is Nip19Bech32.Note -> ObserveNIP19Event(entity.hex, accountViewModel, onRefresh)
|
||||
is Nip19Bech32.NEvent -> ObserveNIP19Event(entity.hex, accountViewModel, onRefresh)
|
||||
is Nip19Bech32.NEmbed -> ObserveNIP19Event(entity.event.id, accountViewModel, onRefresh)
|
||||
|
||||
is Nip19Bech32.NAddress -> ObserveNIP19Event(entity.atag, accountViewModel, onRefresh)
|
||||
|
||||
is Nip19Bech32.NSec -> {}
|
||||
is Nip19Bech32.NRelay -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveNIP19Event(
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
var baseNote by remember(hex) { mutableStateOf<Note?>(accountViewModel.getNoteIfExists(hex)) }
|
||||
|
||||
if (baseNote == null) {
|
||||
LaunchedEffect(key1 = hex) {
|
||||
accountViewModel.checkGetOrCreateNote(hex) { note ->
|
||||
launch(Dispatchers.Main) { baseNote = note }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseNote?.let { note -> ObserveNote(note, onRefresh) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveNote(
|
||||
note: Note,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val loadedNoteId by note.live().metadata.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = loadedNoteId) {
|
||||
if (loadedNoteId != null) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveNIP19User(
|
||||
hex: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
var baseUser by remember(hex) { mutableStateOf<User?>(accountViewModel.getUserIfExists(hex)) }
|
||||
|
||||
if (baseUser == null) {
|
||||
LaunchedEffect(key1 = hex) {
|
||||
accountViewModel.checkGetOrCreateUser(hex)?.let { user ->
|
||||
launch(Dispatchers.Main) { baseUser = user }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseUser?.let { user -> ObserveUser(user, onRefresh) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveUser(
|
||||
user: User,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val loadedUserMetaId by user.live().metadata.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = loadedUserMetaId) {
|
||||
if (loadedUserMetaId != null) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BechLink(
|
||||
word: String,
|
||||
@@ -683,7 +504,7 @@ fun BechLink(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayFullNote(
|
||||
fun DisplayFullNote(
|
||||
note: Note,
|
||||
extraChars: String?,
|
||||
quotesLeft: Int,
|
||||
@@ -752,13 +573,7 @@ fun HashTag(
|
||||
|
||||
@Composable
|
||||
private fun InlineIcon(hashtagIcon: HashtagIcon) =
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = Font17SP,
|
||||
height = Font17SP,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
),
|
||||
) {
|
||||
InlineTextContent(inlinePlaceholder) {
|
||||
Icon(
|
||||
imageVector = hashtagIcon.icon,
|
||||
contentDescription = hashtagIcon.description,
|
||||
|
||||
@@ -80,8 +80,6 @@ class Split<T>() {
|
||||
} else {
|
||||
splitItem.percentage = percentage
|
||||
|
||||
println("Update ${items[index].key} to $percentage")
|
||||
|
||||
val othersMustShare = 1.0f - splitItem.percentage
|
||||
|
||||
val othersHave =
|
||||
@@ -89,8 +87,6 @@ class Split<T>() {
|
||||
|
||||
if (abs(othersHave - othersMustShare) < 0.01) return // nothing to do
|
||||
|
||||
println("Others Must Share $othersMustShare but have $othersHave")
|
||||
|
||||
bottomUpAdjustment(othersMustShare, othersHave, index)
|
||||
}
|
||||
}
|
||||
@@ -109,14 +105,10 @@ class Split<T>() {
|
||||
val oldValue = items[i].percentage
|
||||
items[i].percentage -= needToRemove
|
||||
needToRemove = 0f
|
||||
println(
|
||||
"- Updating ${items[i].key} from $oldValue to ${items[i].percentage - needToRemove}. $needToRemove left",
|
||||
)
|
||||
} else {
|
||||
val oldValue = items[i].percentage
|
||||
needToRemove -= items[i].percentage
|
||||
items[i].percentage = 0f
|
||||
println("- Updating ${items[i].key} from $oldValue to ${0}. $needToRemove left")
|
||||
}
|
||||
|
||||
if (needToRemove < 0.01) {
|
||||
|
||||
@@ -115,6 +115,7 @@ import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.abs
|
||||
|
||||
public val DEFAULT_MUTED_SETTING = mutableStateOf(true)
|
||||
@@ -394,6 +395,8 @@ fun GetVideoController(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val onlyOnePreparing = AtomicBoolean()
|
||||
|
||||
val controller =
|
||||
remember(videoUri) {
|
||||
mutableStateOf(
|
||||
@@ -421,31 +424,36 @@ fun GetVideoController(
|
||||
// If it is not null, the user might have come back from a playing video, like clicking on
|
||||
// the notification of the video player.
|
||||
if (controller.value == null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("PlaybackService", "Preparing Video $videoUri ")
|
||||
PlaybackClientController.prepareController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
context,
|
||||
) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
if (!it.isPlaying) {
|
||||
if (keepPlayingMutex?.isPlaying == true) {
|
||||
// There is a video playing, start this one on mute.
|
||||
it.volume = 0f
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
it.volume = if (defaultToStart) 0f else 1f
|
||||
// If there is a connection, don't wait.
|
||||
if (!onlyOnePreparing.getAndSet(true)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("PlaybackService", "Preparing Video $videoUri ")
|
||||
PlaybackClientController.prepareController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
context,
|
||||
) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
if (!it.isPlaying) {
|
||||
if (keepPlayingMutex?.isPlaying == true) {
|
||||
// There is a video playing, start this one on mute.
|
||||
it.volume = 0f
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
it.volume = if (defaultToStart) 0f else 1f
|
||||
}
|
||||
}
|
||||
|
||||
it.setMediaItem(mediaItem.value)
|
||||
it.prepare()
|
||||
|
||||
controller.value = it
|
||||
|
||||
onlyOnePreparing.getAndSet(false)
|
||||
}
|
||||
|
||||
it.setMediaItem(mediaItem.value)
|
||||
it.prepare()
|
||||
|
||||
controller.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,6 +462,8 @@ fun GetVideoController(
|
||||
controller.value?.let {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
|
||||
Log.d("PlaybackService", "Preparing Existing Video $videoUri ")
|
||||
|
||||
if (it.isPlaying) {
|
||||
// There is a video playing, start this one on mute.
|
||||
it.volume = 0f
|
||||
@@ -497,32 +507,35 @@ fun GetVideoController(
|
||||
// if the controller is null, restarts the controller with a new one
|
||||
// if the controller is not null, just continue playing what the controller was playing
|
||||
if (controller.value == null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("PlaybackService", "Preparing Video from Resume $videoUri ")
|
||||
PlaybackClientController.prepareController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
context,
|
||||
) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
// checks again to make sure no other thread has created a controller.
|
||||
if (!it.isPlaying) {
|
||||
if (keepPlayingMutex?.isPlaying == true) {
|
||||
// There is a video playing, start this one on mute.
|
||||
it.volume = 0f
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
it.volume = if (defaultToStart) 0f else 1f
|
||||
if (!onlyOnePreparing.getAndSet(true)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("PlaybackService", "Preparing Video from Resume $videoUri ")
|
||||
PlaybackClientController.prepareController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
context,
|
||||
) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
// checks again to make sure no other thread has created a controller.
|
||||
if (!it.isPlaying) {
|
||||
if (keepPlayingMutex?.isPlaying == true) {
|
||||
// There is a video playing, start this one on mute.
|
||||
it.volume = 0f
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
it.volume = if (defaultToStart) 0f else 1f
|
||||
}
|
||||
}
|
||||
|
||||
it.setMediaItem(mediaItem.value)
|
||||
it.prepare()
|
||||
|
||||
controller.value = it
|
||||
onlyOnePreparing.getAndSet(false)
|
||||
}
|
||||
|
||||
it.setMediaItem(mediaItem.value)
|
||||
it.prepare()
|
||||
|
||||
controller.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
|
||||
@@ -542,7 +543,10 @@ fun ShowHash(
|
||||
|
||||
if (content.hash != null) {
|
||||
LaunchedEffect(key1 = content.url) {
|
||||
val newVerifiedHash = verifyHash(content)
|
||||
val newVerifiedHash =
|
||||
withContext(Dispatchers.IO) {
|
||||
verifyHash(content)
|
||||
}
|
||||
if (newVerifiedHash != verifiedHash) {
|
||||
verifiedHash = newVerifiedHash
|
||||
}
|
||||
@@ -661,14 +665,16 @@ fun DisplayBlurHash(
|
||||
if (blurhash == null) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val model =
|
||||
remember {
|
||||
BlurHashRequester.imageRequest(
|
||||
context,
|
||||
blurhash,
|
||||
)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model =
|
||||
remember {
|
||||
BlurHashRequester.imageRequest(
|
||||
context,
|
||||
blurhash,
|
||||
)
|
||||
},
|
||||
model = model,
|
||||
contentDescription = description,
|
||||
contentScale = contentScale,
|
||||
modifier = modifier,
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components.markdown
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.halilibo.richtext.ui.MediaRenderer
|
||||
import com.halilibo.richtext.ui.string.InlineContent
|
||||
import com.halilibo.richtext.ui.string.RichTextString
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.HashtagIcon
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.ui.components.DisplayFullNote
|
||||
import com.vitorpamplona.amethyst.ui.components.DisplayUser
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size17Modifier
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class MarkdownMediaRenderer(
|
||||
val startOfText: String,
|
||||
val tags: ImmutableListOfLists<String>?,
|
||||
val canPreview: Boolean,
|
||||
val quotesLeft: Int,
|
||||
val backgroundColor: MutableState<Color>,
|
||||
val accountViewModel: AccountViewModel,
|
||||
val nav: (String) -> Unit,
|
||||
) : MediaRenderer {
|
||||
val parser = RichTextParser()
|
||||
|
||||
override fun shouldRenderLinkPreview(
|
||||
title: String?,
|
||||
uri: String,
|
||||
): Boolean {
|
||||
return if (canPreview && uri.startsWith("http")) {
|
||||
if (title.isNullOrBlank() || title == uri) {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun renderImage(
|
||||
title: String?,
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
if (canPreview) {
|
||||
val content =
|
||||
parser.parseMediaUrl(
|
||||
fullUrl = uri,
|
||||
eventTags = tags ?: EmptyTagList,
|
||||
description = title?.ifEmpty { null } ?: startOfText,
|
||||
) ?: MediaUrlImage(url = uri, description = title?.ifEmpty { null } ?: startOfText)
|
||||
|
||||
renderInlineFullWidth(richTextStringBuilder) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
override fun renderLinkPreview(
|
||||
title: String?,
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
val content = parser.parseMediaUrl(uri, eventTags = tags ?: EmptyTagList, startOfText)
|
||||
|
||||
if (canPreview) {
|
||||
if (content != null) {
|
||||
renderInlineFullWidth(richTextStringBuilder) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (!accountViewModel.settings.showUrlPreview.value) {
|
||||
renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder)
|
||||
} else {
|
||||
renderInlineFullWidth(richTextStringBuilder) {
|
||||
LoadUrlPreview(uri, title ?: uri, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
renderAsCompleteLink(title ?: uri, uri, richTextStringBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
override fun renderNostrUri(
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
// This should be fast, so it is ok.
|
||||
val loadedLink =
|
||||
accountViewModel.bechLinkCache.cached(uri)
|
||||
?: runBlocking {
|
||||
accountViewModel.bechLinkCache.update(uri)
|
||||
}
|
||||
|
||||
val baseNote = loadedLink?.baseNote
|
||||
|
||||
if (canPreview && quotesLeft > 0 && baseNote != null) {
|
||||
renderInlineFullWidth(richTextStringBuilder) {
|
||||
Row {
|
||||
DisplayFullNote(
|
||||
note = baseNote,
|
||||
extraChars = loadedLink.nip19.additionalChars?.ifBlank { null },
|
||||
quotesLeft = quotesLeft,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (loadedLink?.nip19 != null) {
|
||||
when (val entity = loadedLink.nip19.entity) {
|
||||
is Nip19Bech32.NPub -> renderObservableUser(entity.hex, richTextStringBuilder)
|
||||
is Nip19Bech32.NProfile -> renderObservableUser(entity.hex, richTextStringBuilder)
|
||||
is Nip19Bech32.Note -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NEvent -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NEmbed -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NAddress -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NRelay -> renderShortNostrURI(uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NSec -> renderShortNostrURI(uri, richTextStringBuilder)
|
||||
else -> renderShortNostrURI(uri, richTextStringBuilder)
|
||||
}
|
||||
} else {
|
||||
renderShortNostrURI(uri, richTextStringBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
override fun renderHashtag(
|
||||
tag: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
val tagWithoutHash = tag.removePrefix("#")
|
||||
renderAsCompleteLink(tag, "nostr:Hashtag?id=$tagWithoutHash}", richTextStringBuilder)
|
||||
|
||||
val hashtagIcon: HashtagIcon? = checkForHashtagWithIcon(tagWithoutHash)
|
||||
if (hashtagIcon != null) {
|
||||
renderInline(richTextStringBuilder) {
|
||||
Box(Size17Modifier) {
|
||||
Icon(
|
||||
imageVector = hashtagIcon.icon,
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = Color.Unspecified,
|
||||
modifier = hashtagIcon.modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun renderObservableUser(
|
||||
userHex: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
renderInline(richTextStringBuilder) {
|
||||
DisplayUser(userHex, null, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
fun renderObservableShortNoteUri(
|
||||
loadedLink: LoadedBechLink,
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
loadedLink.baseNote?.let { renderNoteObserver(it, richTextStringBuilder) }
|
||||
renderShortNostrURI(uri, richTextStringBuilder)
|
||||
}
|
||||
|
||||
private fun renderNoteObserver(
|
||||
baseNote: Note,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
renderInvisible(richTextStringBuilder) {
|
||||
// Preloads note if not loaded yet.
|
||||
baseNote.live().metadata.observeAsState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderShortNostrURI(
|
||||
uri: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
val nip19 = "@" + uri.removePrefix("nostr:")
|
||||
|
||||
renderAsCompleteLink(
|
||||
title =
|
||||
if (nip19.length > 16) {
|
||||
nip19.replaceRange(8, nip19.length - 8, ":")
|
||||
} else {
|
||||
nip19
|
||||
},
|
||||
destination = uri,
|
||||
richTextStringBuilder = richTextStringBuilder,
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderInvisible(
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
innerComposable: @Composable () -> Unit,
|
||||
) {
|
||||
richTextStringBuilder.appendInlineContent(
|
||||
content =
|
||||
InlineContent(
|
||||
initialSize = {
|
||||
IntSize(0.dp.roundToPx(), 0.dp.roundToPx())
|
||||
},
|
||||
) {
|
||||
innerComposable()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderInline(
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
innerComposable: @Composable () -> Unit,
|
||||
) {
|
||||
richTextStringBuilder.appendInlineContent(
|
||||
content =
|
||||
InlineContent(
|
||||
initialSize = {
|
||||
IntSize(Font17SP.roundToPx(), Font17SP.roundToPx())
|
||||
},
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
) {
|
||||
innerComposable()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderInlineFullWidth(
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
innerComposable: @Composable () -> Unit,
|
||||
) {
|
||||
richTextStringBuilder.appendInlineContentFullWidth(
|
||||
content =
|
||||
InlineContent(
|
||||
initialSize = {
|
||||
IntSize(Font17SP.roundToPx(), Font17SP.roundToPx())
|
||||
},
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
) {
|
||||
innerComposable()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderAsCompleteLink(
|
||||
title: String,
|
||||
destination: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
richTextStringBuilder.pushFormat(
|
||||
RichTextString.Format.Link(destination = destination),
|
||||
)
|
||||
richTextStringBuilder.append(title)
|
||||
richTextStringBuilder.pop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components.markdown
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
|
||||
import com.halilibo.richtext.commonmark.MarkdownParseOptions
|
||||
import com.halilibo.richtext.markdown.BasicMarkdown
|
||||
import com.halilibo.richtext.ui.material3.RichText
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
||||
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
@Composable
|
||||
fun RenderContentAsMarkdown(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val onClick =
|
||||
remember {
|
||||
{ link: String ->
|
||||
val route = uriToRoute(link)
|
||||
if (route != null) {
|
||||
nav(route)
|
||||
} else {
|
||||
runCatching { uri.openUri(link) }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
ProvideTextStyle(MarkdownTextStyle) {
|
||||
val astNode =
|
||||
remember(content) {
|
||||
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content)
|
||||
}
|
||||
|
||||
val renderer =
|
||||
remember(content) {
|
||||
MarkdownMediaRenderer(
|
||||
content.take(100),
|
||||
tags,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
RichText(
|
||||
style = MaterialTheme.colorScheme.markdownStyle,
|
||||
linkClickHandler = onClick,
|
||||
renderer = renderer,
|
||||
) {
|
||||
BasicMarkdown(astNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.amethyst.ui.actions.relays.updated
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
|
||||
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import com.vitorpamplona.amethyst.ui.actions.relays.updated
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
@@ -46,12 +46,12 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
|
||||
|
||||
val privateMessages =
|
||||
newChatrooms.mapNotNull { it ->
|
||||
it.value.roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).lastOrNull {
|
||||
it.value.roomMessages.sortedWith(DefaultFeedOrder).firstOrNull {
|
||||
it.event != null
|
||||
}
|
||||
}
|
||||
|
||||
return privateMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
return privateMessages.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
|
||||
override fun updateListWith(
|
||||
|
||||
@@ -21,5 +21,23 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
|
||||
val DefaultFeedOrder = compareBy<Note>({ it.createdAt() }, { it.idHex }).reversed()
|
||||
val DefaultFeedOrder: Comparator<Note> =
|
||||
compareBy<Note>(
|
||||
{
|
||||
val noteEvent = it.event
|
||||
if (noteEvent == null) {
|
||||
null
|
||||
} else {
|
||||
if (noteEvent is Event) {
|
||||
noteEvent.createdAt
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
it.idHex
|
||||
},
|
||||
).reversed()
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverNIP89FeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
val lastAnnounced = 90 * 24 * 60 * 60 // 90 Days ago
|
||||
// TODO better than announced would be last active, as this requires the DVM provider to regularly update the NIP89 announcement
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + followList()
|
||||
}
|
||||
|
||||
open fun followList(): String {
|
||||
return account.defaultDiscoveryFollowList.value
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val filterParams = buildFilterParams(account)
|
||||
|
||||
val notes =
|
||||
LocalCache.addressables.filterIntoSet { _, it ->
|
||||
acceptDVM(it)
|
||||
}
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams {
|
||||
return FilterByListParams.create(
|
||||
account.userProfile().pubkeyHex,
|
||||
account.defaultDiscoveryFollowList.value,
|
||||
account.liveDiscoveryFollowLists.value,
|
||||
account.flowHiddenUsers.value,
|
||||
)
|
||||
}
|
||||
|
||||
fun acceptDVM(note: Note): Boolean {
|
||||
val noteEvent = note.event
|
||||
return if (noteEvent is AppDefinitionEvent) {
|
||||
acceptDVM(noteEvent)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptDVM(noteEvent: AppDefinitionEvent): Boolean {
|
||||
val filterParams = buildFilterParams(account)
|
||||
return noteEvent.appMetaData()?.subscription != true &&
|
||||
filterParams.match(noteEvent) &&
|
||||
noteEvent.includeKind("5300") &&
|
||||
noteEvent.createdAt > TimeUtils.now() - lastAnnounced // && params.match(noteEvent)
|
||||
}
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
return collection.filterTo(HashSet()) {
|
||||
acceptDVM(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
@@ -59,6 +60,7 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
|
||||
return (
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is WikiNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is PrivateDmEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
@@ -59,6 +60,7 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
|
||||
return (
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is WikiNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is PrivateDmEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
@@ -85,7 +86,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
}
|
||||
}
|
||||
|
||||
fun acceptableEvent(
|
||||
private fun acceptableEvent(
|
||||
it: Note,
|
||||
globalRelays: Set<String>,
|
||||
filterParams: FilterByListParams,
|
||||
@@ -98,13 +99,12 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
noteEvent is RepostEvent ||
|
||||
noteEvent is GenericRepostEvent ||
|
||||
noteEvent is LongTextNoteEvent ||
|
||||
noteEvent is WikiNoteEvent ||
|
||||
noteEvent is PollNoteEvent ||
|
||||
noteEvent is HighlightEvent ||
|
||||
noteEvent is AudioTrackEvent ||
|
||||
noteEvent is AudioHeaderEvent
|
||||
) &&
|
||||
filterParams.match(noteEvent, isGlobalRelay) &&
|
||||
it.isNewThread()
|
||||
) && filterParams.match(noteEvent, isGlobalRelay) && it.isNewThread()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
|
||||
open class NIP90ContentDiscoveryResponseFilter(
|
||||
val account: Account,
|
||||
val dvmkey: String,
|
||||
val request: String,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
var latestNote: Note? = null
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + request
|
||||
}
|
||||
|
||||
open fun followList(): String {
|
||||
return account.defaultDiscoveryFollowList.value
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
fun acceptableEvent(note: Note): Boolean {
|
||||
val noteEvent = note.event
|
||||
return noteEvent is NIP90ContentDiscoveryResponseEvent && noteEvent.isTaggedEvent(request)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
|
||||
latestNote =
|
||||
LocalCache.notes.maxOrNullOf(
|
||||
filter = { idHex: String, note: Note ->
|
||||
acceptableEvent(note)
|
||||
},
|
||||
comparator = CreatedAtComparator,
|
||||
)
|
||||
|
||||
val noteEvent = latestNote?.event as? NIP90ContentDiscoveryResponseEvent ?: return listOf()
|
||||
|
||||
return noteEvent.innerTags().mapNotNull {
|
||||
LocalCache.checkGetOrCreateNote(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams {
|
||||
return FilterByListParams.create(
|
||||
account.userProfile().pubkeyHex,
|
||||
account.defaultDiscoveryFollowList.value,
|
||||
account.liveDiscoveryFollowLists.value,
|
||||
account.flowHiddenUsers.value,
|
||||
)
|
||||
}
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
// val params = buildFilterParams(account)
|
||||
|
||||
val maxNote = collection.filter { acceptableEvent(it) }.maxByOrNull { it.createdAt() ?: 0 } ?: return emptySet()
|
||||
|
||||
if ((maxNote.createdAt() ?: 0) > (latestNote?.createdAt() ?: 0)) {
|
||||
latestNote = maxNote
|
||||
}
|
||||
|
||||
val noteEvent = latestNote?.event as? NIP90ContentDiscoveryResponseEvent ?: return setOf()
|
||||
|
||||
return noteEvent.innerTags().mapNotNull {
|
||||
LocalCache.checkGetOrCreateNote(it)
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.toList() // collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.events.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
@@ -111,6 +114,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
it.event !is LnZapRequestEvent &&
|
||||
it.event !is BadgeDefinitionEvent &&
|
||||
it.event !is BadgeProfilesEvent &&
|
||||
it.event !is NIP90ContentDiscoveryResponseEvent && it.event !is NIP90StatusEvent && it.event !is NIP90ContentDiscoveryRequestEvent &&
|
||||
it.event !is GiftWrapEvent &&
|
||||
(it.event is LnZapEvent || notifAuthor != loggedInUserHex) &&
|
||||
(filterParams.isGlobal || filterParams.followLists?.users?.contains(notifAuthor) == true) &&
|
||||
|
||||
@@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LevelSignature
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.amethyst.model.ThreadLevelCalculator
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Immutable
|
||||
@@ -33,7 +35,7 @@ class ThreadFeedFilter(val account: Account, val noteId: String) : FeedFilter<No
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val cachedSignatures: MutableMap<Note, Note.LevelSignature> = mutableMapOf()
|
||||
val cachedSignatures: MutableMap<Note, LevelSignature> = mutableMapOf()
|
||||
val followingKeySet = account.liveKind3Follows.value.users
|
||||
val eventsToWatch = ThreadAssembler().findThreadFor(noteId)
|
||||
val eventsInHex = eventsToWatch.map { it.idHex }.toSet()
|
||||
@@ -42,15 +44,14 @@ class ThreadFeedFilter(val account: Account, val noteId: String) : FeedFilter<No
|
||||
// Currently orders by date of each event, descending, at each level of the reply stack
|
||||
val order =
|
||||
compareByDescending<Note> {
|
||||
it
|
||||
.replyLevelSignature(
|
||||
eventsInHex,
|
||||
cachedSignatures,
|
||||
account.userProfile(),
|
||||
followingKeySet,
|
||||
now,
|
||||
)
|
||||
.signature
|
||||
ThreadLevelCalculator.replyLevelSignature(
|
||||
it,
|
||||
eventsInHex,
|
||||
cachedSignatures,
|
||||
account.userProfile(),
|
||||
followingKeySet,
|
||||
now,
|
||||
).signature
|
||||
}
|
||||
|
||||
return eventsToWatch.sortedWith(order)
|
||||
|
||||
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
class UserProfileNewThreadFeedFilter(val user: User, val account: Account) :
|
||||
AdditiveFeedFilter<Note>() {
|
||||
@@ -72,6 +73,7 @@ class UserProfileNewThreadFeedFilter(val user: User, val account: Account) :
|
||||
it.event is RepostEvent ||
|
||||
it.event is GenericRepostEvent ||
|
||||
it.event is LongTextNoteEvent ||
|
||||
it.event is WikiNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is HighlightEvent ||
|
||||
it.event is AudioTrackEvent ||
|
||||
|
||||
@@ -82,7 +82,7 @@ fun ChannelNamePreview() {
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row {
|
||||
Text("This is a message from this person", Modifier.weight(1f))
|
||||
NewItemsBubble()
|
||||
}
|
||||
@@ -96,6 +96,8 @@ fun ChannelNamePreview() {
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,16 +23,15 @@ package com.vitorpamplona.amethyst.ui.navigation
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
@@ -46,16 +45,13 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size0dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.bottomIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
val bottomNavigationItems =
|
||||
@@ -135,7 +131,7 @@ private fun RenderBottomMenu(
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
NavigationBar(tonalElevation = Size0dp) {
|
||||
NavigationBar(tonalElevation = Size0dp, containerColor = Color.Transparent) {
|
||||
bottomNavigationItems.forEach { item ->
|
||||
HasNewItemsIcon(item, accountViewModel, navEntryState, nav)
|
||||
}
|
||||
@@ -190,7 +186,7 @@ private fun NotifiableIcon(
|
||||
fun AddNotifIconIfNeeded(
|
||||
route: Route,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val flow = accountViewModel.notificationDots.hasNewItems[route] ?: return
|
||||
val hasNewItems by flow.collectAsStateWithLifecycle()
|
||||
@@ -201,18 +197,8 @@ fun AddNotifIconIfNeeded(
|
||||
|
||||
@Composable
|
||||
private fun NotificationDotIcon(modifier: Modifier) {
|
||||
Box(modifier.size(Size10dp)) {
|
||||
Box(
|
||||
modifier = MaterialTheme.colorScheme.bottomIconModifier,
|
||||
contentAlignment = Alignment.TopEnd,
|
||||
) {
|
||||
Text(
|
||||
"",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = Font12SP,
|
||||
// modifier = Modifier.wrapContentHeight().align(Alignment.TopEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
val color = MaterialTheme.colorScheme.primary
|
||||
Canvas(modifier = Size10Modifier.then(modifier), onDraw = {
|
||||
drawCircle(color = color)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverNIP89FeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||
@@ -67,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NIP90ContentDiscoveryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
||||
@@ -86,6 +88,7 @@ fun AppNavigation(
|
||||
newFeedViewModel: NostrChatroomListNewFeedViewModel,
|
||||
videoFeedViewModel: NostrVideoFeedViewModel,
|
||||
discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel,
|
||||
discoverNip89FeedViewModel: NostrDiscoverNIP89FeedViewModel,
|
||||
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
|
||||
@@ -173,6 +176,7 @@ fun AppNavigation(
|
||||
route.arguments,
|
||||
content = {
|
||||
DiscoverScreen(
|
||||
discoveryContentNIP89FeedViewModel = discoverNip89FeedViewModel,
|
||||
discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel,
|
||||
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
|
||||
@@ -215,8 +219,25 @@ fun AppNavigation(
|
||||
|
||||
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) })
|
||||
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) })
|
||||
|
||||
composable(Route.Drafts.route, content = { DraftListScreen(accountViewModel, nav) })
|
||||
|
||||
Route.ContentDiscovery.let { route ->
|
||||
composable(
|
||||
route.route,
|
||||
route.arguments,
|
||||
content = {
|
||||
it.arguments?.getString("id")?.let { id ->
|
||||
NIP90ContentDiscoveryScreen(
|
||||
appDefinitionEventId = id,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Route.Profile.let { route ->
|
||||
composable(
|
||||
route.route,
|
||||
|
||||
@@ -34,8 +34,10 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
@@ -60,11 +62,13 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
@@ -73,6 +77,7 @@ import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import coil.Coil
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
@@ -95,9 +100,9 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.note.AmethystIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
@@ -107,12 +112,12 @@ import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DislayGeoTagHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.GeoHashActionOptions
|
||||
@@ -124,6 +129,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.LongRoomHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.RoomNameOnlyDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShortChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.observeAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
@@ -139,12 +145,15 @@ import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -187,6 +196,7 @@ private fun RenderTopRouteBar(
|
||||
Route.Settings.base -> TopBarWithBackButton(stringResource(id = R.string.application_preferences), navPopBack)
|
||||
Route.Bookmarks.base -> TopBarWithBackButton(stringResource(id = R.string.bookmarks), navPopBack)
|
||||
Route.Drafts.base -> TopBarWithBackButton(stringResource(id = R.string.drafts), navPopBack)
|
||||
|
||||
else -> {
|
||||
if (id != null) {
|
||||
when (currentRoute) {
|
||||
@@ -197,6 +207,7 @@ private fun RenderTopRouteBar(
|
||||
Route.Hashtag.base -> HashTagTopBar(id, accountViewModel, navPopBack)
|
||||
Route.Geohash.base -> GeoHashTopBar(id, accountViewModel, navPopBack)
|
||||
Route.Note.base -> ThreadTopBar(id, accountViewModel, navPopBack)
|
||||
Route.ContentDiscovery.base -> DvmTopBar(id, accountViewModel, nav, navPopBack)
|
||||
else -> MainTopBar(drawerState, accountViewModel, nav)
|
||||
}
|
||||
} else {
|
||||
@@ -292,6 +303,43 @@ private fun RoomTopBar(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DvmTopBar(
|
||||
appDefinitionId: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
navPopBack: () -> Unit,
|
||||
) {
|
||||
FlexibleTopBarWithBackButton(
|
||||
title = {
|
||||
LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote ->
|
||||
if (appDefinitionNote != null) {
|
||||
val card = observeAppDefinition(appDefinitionNote)
|
||||
|
||||
card.cover?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(Size34dp).clip(shape = CircleShape),
|
||||
)
|
||||
} ?: run { NoteAuthorPicture(baseNote = appDefinitionNote, size = Size34dp, accountViewModel = accountViewModel) }
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Text(
|
||||
text = card.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
popBack = navPopBack,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomByAuthorTopBar(
|
||||
id: String,
|
||||
@@ -621,111 +669,93 @@ class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
ResourceName(R.string.follow_list_mute_list),
|
||||
CodeNameType.HARDCODED,
|
||||
)
|
||||
val defaultLists = persistentListOf(kind3Follow, globalFollow, muteListFollow)
|
||||
|
||||
private var _kind3GlobalPeopleRoutes =
|
||||
MutableStateFlow<ImmutableList<CodeName>>(emptyList<CodeName>().toPersistentList())
|
||||
val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.asStateFlow()
|
||||
|
||||
private var _kind3GlobalPeople =
|
||||
MutableStateFlow<ImmutableList<CodeName>>(emptyList<CodeName>().toPersistentList())
|
||||
val kind3GlobalPeople = _kind3GlobalPeople.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch(Dispatchers.Default) { refreshFollows() }
|
||||
}
|
||||
|
||||
private suspend fun refreshFollows() {
|
||||
checkNotInMainThread()
|
||||
|
||||
val newFollowLists =
|
||||
LocalCache.addressables
|
||||
.mapNotNull { _, addressableNote ->
|
||||
val event = (addressableNote.event as? PeopleListEvent)
|
||||
// Has to have an list
|
||||
if (
|
||||
event != null &&
|
||||
event.pubKey == account.userProfile().pubkeyHex &&
|
||||
(event.tags.size > 1 || event.content.length > 50)
|
||||
) {
|
||||
CodeName(event.address().toTag(), PeopleListName(addressableNote), CodeNameType.PEOPLE_LIST)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val livePeopleListsFlow: Flow<List<CodeName>> =
|
||||
LocalCache.live.newEventBundles.transformLatest { newNotes ->
|
||||
val hasNewList =
|
||||
newNotes.any {
|
||||
it.event?.pubKey() == account.userProfile().pubkeyHex &&
|
||||
(
|
||||
it.event is PeopleListEvent ||
|
||||
it.event is MuteListEvent ||
|
||||
it.event is ContactListEvent
|
||||
)
|
||||
}
|
||||
.sortedBy { it.name.name() }
|
||||
|
||||
val communities =
|
||||
account.userProfile().cachedFollowingCommunitiesSet().mapNotNull {
|
||||
LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote ->
|
||||
CodeName(
|
||||
"Community/${communityNote.idHex}",
|
||||
CommunityName(communityNote),
|
||||
CodeNameType.ROUTE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val hashtags =
|
||||
account.userProfile().cachedFollowingTagSet().map {
|
||||
CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE)
|
||||
}
|
||||
|
||||
val geotags =
|
||||
account.userProfile().cachedFollowingGeohashSet().map {
|
||||
CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE)
|
||||
}
|
||||
|
||||
val routeList = (communities + hashtags + geotags).sortedBy { it.name.name() }
|
||||
|
||||
val kind3GlobalPeopleRouteList =
|
||||
listOf(listOf(kind3Follow, globalFollow), newFollowLists, routeList, listOf(muteListFollow))
|
||||
.flatten()
|
||||
.toImmutableList()
|
||||
|
||||
if (!equalImmutableLists(_kind3GlobalPeopleRoutes.value, kind3GlobalPeopleRouteList)) {
|
||||
_kind3GlobalPeopleRoutes.emit(kind3GlobalPeopleRouteList)
|
||||
}
|
||||
|
||||
val kind3GlobalPeopleList =
|
||||
listOf(listOf(kind3Follow, globalFollow), newFollowLists, listOf(muteListFollow))
|
||||
.flatten()
|
||||
.toImmutableList()
|
||||
|
||||
if (!equalImmutableLists(_kind3GlobalPeople.value, kind3GlobalPeopleList)) {
|
||||
_kind3GlobalPeople.emit(kind3GlobalPeopleList)
|
||||
}
|
||||
}
|
||||
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
Log.d("Init", "App Top Bar")
|
||||
refresh()
|
||||
collectorJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
checkNotInMainThread()
|
||||
if (
|
||||
newNotes.any {
|
||||
it.event?.pubKey() == account.userProfile().pubkeyHex &&
|
||||
(
|
||||
it.event is PeopleListEvent ||
|
||||
it.event is MuteListEvent ||
|
||||
it.event is ContactListEvent
|
||||
)
|
||||
if (hasNewList) {
|
||||
val newFollowLists =
|
||||
LocalCache.addressables
|
||||
.mapNotNull { _, addressableNote ->
|
||||
val event = (addressableNote.event as? PeopleListEvent)
|
||||
// Has to have an list
|
||||
if (
|
||||
event != null &&
|
||||
event.pubKey == account.userProfile().pubkeyHex &&
|
||||
(event.tags.size > 1 || event.content.length > 50)
|
||||
) {
|
||||
CodeName(event.address().toTag(), PeopleListName(addressableNote), CodeNameType.PEOPLE_LIST)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
) {
|
||||
refresh()
|
||||
.sortedBy { it.name.name() }
|
||||
|
||||
emit(newFollowLists)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveKind3FollowsFlow: Flow<List<CodeName>> =
|
||||
account.userProfile().flow().follows.stateFlow.transformLatest {
|
||||
|
||||
val communities =
|
||||
it.user.cachedFollowingCommunitiesSet().mapNotNull {
|
||||
LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote ->
|
||||
CodeName(
|
||||
"Community/${communityNote.idHex}",
|
||||
CommunityName(communityNote),
|
||||
CodeNameType.ROUTE,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
collectorJob?.cancel()
|
||||
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
|
||||
super.onCleared()
|
||||
}
|
||||
val hashtags =
|
||||
it.user.cachedFollowingTagSet().map {
|
||||
CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE)
|
||||
}
|
||||
|
||||
val geotags =
|
||||
it.user.cachedFollowingGeohashSet().map {
|
||||
CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE)
|
||||
}
|
||||
|
||||
emit(
|
||||
(communities + hashtags + geotags).sortedBy { it.name.name() },
|
||||
)
|
||||
}
|
||||
|
||||
private val _kind3GlobalPeopleRoutes =
|
||||
combineTransform(livePeopleListsFlow, liveKind3FollowsFlow) { myLivePeopleListsFlow, myLiveKind3FollowsFlow ->
|
||||
emit(
|
||||
listOf(listOf(kind3Follow, globalFollow), myLivePeopleListsFlow, myLiveKind3FollowsFlow, listOf(muteListFollow))
|
||||
.flatten()
|
||||
.toImmutableList(),
|
||||
)
|
||||
}
|
||||
val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists)
|
||||
|
||||
private val _kind3GlobalPeople =
|
||||
combineTransform(livePeopleListsFlow, liveKind3FollowsFlow) { myLivePeopleListsFlow, myLiveKind3FollowsFlow ->
|
||||
emit(
|
||||
listOf(listOf(kind3Follow, globalFollow), myLivePeopleListsFlow, listOf(muteListFollow))
|
||||
.flatten()
|
||||
.toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
val kind3GlobalPeople = _kind3GlobalPeople.stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists)
|
||||
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <FollowListViewModel : ViewModel> create(modelClass: Class<FollowListViewModel>): FollowListViewModel {
|
||||
@@ -960,6 +990,8 @@ fun debugState(context: Context) {
|
||||
"Notes: " +
|
||||
LocalCache.notes.filter { _, it -> it.liveSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.notes.filter { _, it -> it.flowSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.notes.filter { _, it -> it.event != null }.size +
|
||||
" / " +
|
||||
LocalCache.notes.size(),
|
||||
@@ -969,6 +1001,8 @@ fun debugState(context: Context) {
|
||||
"Addressables: " +
|
||||
LocalCache.addressables.filter { _, it -> it.liveSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.addressables.filter { _, it -> it.flowSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.addressables.filter { _, it -> it.event != null }.size +
|
||||
" / " +
|
||||
LocalCache.addressables.size(),
|
||||
@@ -978,6 +1012,8 @@ fun debugState(context: Context) {
|
||||
"Users: " +
|
||||
LocalCache.users.filter { _, it -> it.liveSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
|
||||
" / " +
|
||||
LocalCache.users.size(),
|
||||
@@ -990,12 +1026,22 @@ fun debugState(context: Context) {
|
||||
" MB",
|
||||
)
|
||||
|
||||
LocalCache.notes
|
||||
.countByGroup { _, it -> it.event?.kind() }
|
||||
.forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value} elements ") }
|
||||
LocalCache.addressables
|
||||
.countByGroup { _, it -> it.event?.kind() }
|
||||
.forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value} elements ") }
|
||||
val qttNotes = LocalCache.notes.countByGroup { _, it -> it.event?.kind() }
|
||||
val qttAddressables = LocalCache.addressables.countByGroup { _, it -> it.event?.kind() }
|
||||
|
||||
val bytesNotes =
|
||||
LocalCache.notes
|
||||
.sumByGroup(groupMap = { _, it -> it.event?.kind() }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
|
||||
val bytesAddressables =
|
||||
LocalCache.addressables
|
||||
.sumByGroup(groupMap = { _, it -> it.event?.kind() }, sumOf = { _, it -> it.event?.countMemory() ?: 0L })
|
||||
|
||||
qttNotes.forEach { kind, qtt ->
|
||||
Log.d("STATE DUMP", "Kind $kind:\t$qtt elements\t${bytesNotes.get(kind)?.div((1024 * 1024))}MB ")
|
||||
}
|
||||
qttAddressables.forEach { kind, qtt ->
|
||||
Log.d("STATE DUMP", "Kind $kind:\t$qtt elements\t${bytesAddressables.get(kind)?.div((1024 * 1024))}MB ")
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
@@ -88,7 +88,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.actions.relays.AllRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableText
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
@@ -239,7 +239,6 @@ fun ProfileContentTemplate(
|
||||
.height(100.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.border(3.dp, MaterialTheme.colorScheme.background, CircleShape)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.clickable(onClick = onClick),
|
||||
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
|
||||
)
|
||||
@@ -566,7 +565,7 @@ fun ListContent(
|
||||
}
|
||||
|
||||
if (wantsToEditRelays) {
|
||||
NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
|
||||
AllRelayListView({ wantsToEditRelays = false }, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
if (backupDialogOpen) {
|
||||
AccountBackupDialog(accountViewModel, onClose = { backupDialogOpen = false })
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
@@ -75,6 +76,8 @@ fun routeFor(
|
||||
} else {
|
||||
return "Note/${URLEncoder.encode(noteEvent.id(), "utf-8")}"
|
||||
}
|
||||
} else if (noteEvent is AppDefinitionEvent) {
|
||||
return "ContentDiscovery/${noteEvent.id}"
|
||||
} else if (noteEvent is IsInPublicChatChannel) {
|
||||
noteEvent.channel()?.let {
|
||||
return "Channel/$it"
|
||||
|
||||
@@ -45,6 +45,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size23dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -148,6 +150,14 @@ sealed class Route(
|
||||
contentDescriptor = R.string.route_home,
|
||||
)
|
||||
|
||||
object ContentDiscovery :
|
||||
Route(
|
||||
icon = R.drawable.ic_bookmarks,
|
||||
contentDescriptor = R.string.discover_content,
|
||||
route = "ContentDiscovery/{id}",
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList(),
|
||||
)
|
||||
|
||||
object Drafts :
|
||||
Route(
|
||||
route = "Drafts",
|
||||
@@ -312,6 +322,14 @@ object HomeLatestItem : LatestItem() {
|
||||
|
||||
return (newestItem?.createdAt() ?: 0) > lastTime
|
||||
}
|
||||
|
||||
override fun filterMore(
|
||||
newItems: Set<Note>,
|
||||
account: Account,
|
||||
): Set<Note> {
|
||||
// removes reposts from the dot notifications.
|
||||
return newItems.filter { it.event !is GenericRepostEvent && it.event !is RepostEvent }.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
object NotificationLatestItem : LatestItem() {
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.BottomStart
|
||||
import androidx.compose.ui.Alignment.Companion.Center
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
@@ -79,16 +80,19 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.observeAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
@@ -213,6 +217,9 @@ fun InnerChannelCardWithReactions(
|
||||
is ClassifiedsEvent -> {
|
||||
InnerCardBox(baseNote, accountViewModel, nav)
|
||||
}
|
||||
is AppDefinitionEvent -> {
|
||||
InnerCardRow(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +275,9 @@ private fun RenderNoteRow(
|
||||
is ChannelCreateEvent -> {
|
||||
RenderChannelThumb(baseNote, accountViewModel, nav)
|
||||
}
|
||||
is AppDefinitionEvent -> {
|
||||
RenderContentDVMThumb(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +343,9 @@ fun InnerRenderClassifiedsThumb(
|
||||
note: Note,
|
||||
) {
|
||||
Box(
|
||||
Modifier.fillMaxWidth().aspectRatio(1f),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
contentAlignment = BottomStart,
|
||||
) {
|
||||
card.image?.let {
|
||||
@@ -346,7 +358,10 @@ fun InnerRenderClassifiedsThumb(
|
||||
} ?: run { DisplayAuthorBanner(note) }
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().background(Color.Black.copy(0.6f)).padding(Size5dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Black.copy(0.6f))
|
||||
.padding(Size5dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
card.title?.let {
|
||||
@@ -442,14 +457,20 @@ fun RenderLiveActivityThumb(
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = TopEnd,
|
||||
modifier = Modifier.aspectRatio(ratio = 16f / 9f).fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.aspectRatio(ratio = 16f / 9f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
card.cover?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().clip(QuoteBorder),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder),
|
||||
)
|
||||
} ?: run { DisplayAuthorBanner(baseNote) }
|
||||
|
||||
@@ -485,7 +506,9 @@ fun RenderLiveActivityThumb(
|
||||
|
||||
LoadParticipants(card.participants, baseNote, accountViewModel) { participantUsers ->
|
||||
Box(
|
||||
Modifier.padding(10.dp).align(BottomStart),
|
||||
Modifier
|
||||
.padding(10.dp)
|
||||
.align(BottomStart),
|
||||
) {
|
||||
if (participantUsers.isNotEmpty()) {
|
||||
Gallery(participantUsers, accountViewModel)
|
||||
@@ -516,6 +539,13 @@ data class CommunityCard(
|
||||
val moderators: ImmutableList<Participant>,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class DVMCard(
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val cover: String?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RenderCommunitiesThumb(
|
||||
baseNote: Note,
|
||||
@@ -556,7 +586,10 @@ fun RenderCommunitiesThumb(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().clip(QuoteBorder),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder),
|
||||
)
|
||||
}
|
||||
} ?: run { DisplayAuthorBanner(baseNote) }
|
||||
@@ -571,12 +604,17 @@ fun RenderCommunitiesThumb(
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LikeReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
||||
accountViewModel = accountViewModel,
|
||||
nav,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = RowColSpacing,
|
||||
) {
|
||||
LikeReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
||||
accountViewModel = accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(
|
||||
baseNote = baseNote,
|
||||
@@ -715,6 +753,78 @@ private fun LoadParticipants(
|
||||
inner(participantUsers)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderContentDVMThumb(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val card = observeAppDefinition(appDefinitionNote = baseNote)
|
||||
|
||||
LeftPictureLayout(
|
||||
onImage = {
|
||||
card.cover?.let {
|
||||
Box(contentAlignment = BottomStart) {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder),
|
||||
)
|
||||
}
|
||||
} ?: run { DisplayAuthorBanner(baseNote) }
|
||||
},
|
||||
onTitleRow = {
|
||||
Text(
|
||||
text = card.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = RowColSpacing,
|
||||
) {
|
||||
LikeReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
||||
accountViewModel = accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
onDescription = {
|
||||
card.description?.let {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Row {
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onBottomRow = {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderChannelThumb(
|
||||
baseNote: Note,
|
||||
@@ -783,7 +893,10 @@ fun RenderChannelThumb(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().clip(QuoteBorder),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder),
|
||||
)
|
||||
} ?: run { DisplayAuthorBanner(baseNote) }
|
||||
},
|
||||
@@ -795,14 +908,18 @@ fun RenderChannelThumb(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LikeReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
||||
accountViewModel = accountViewModel,
|
||||
nav,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = RowColSpacing,
|
||||
) {
|
||||
LikeReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
||||
accountViewModel = accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(
|
||||
baseNote = baseNote,
|
||||
@@ -854,6 +971,11 @@ fun Gallery(
|
||||
@Composable
|
||||
fun DisplayAuthorBanner(note: Note) {
|
||||
WatchAuthor(note) {
|
||||
BannerImage(it, Modifier.fillMaxSize().clip(QuoteBorder))
|
||||
BannerImage(
|
||||
it,
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user