mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9587026abc | ||
|
|
6ee19506ad | ||
|
|
09fb10220b | ||
|
|
b2be345979 | ||
|
|
6ed251f7b9 | ||
|
|
391475e75e | ||
|
|
8e6d2164d7 | ||
|
|
45f1d79410 | ||
|
|
bc24434f1c | ||
|
|
2429e695ce | ||
|
|
53ae87aa9d | ||
|
|
35bf16b63e | ||
|
|
035c570902 | ||
|
|
e50ae0fb1e | ||
|
|
abe1082121 | ||
|
|
4f92878146 | ||
|
|
14f8bf1245 | ||
|
|
7cac95726b | ||
|
|
388a02b60c | ||
|
|
1c33dde81a | ||
|
|
dd3f90aee9 | ||
|
|
d94d8776eb | ||
|
|
c83256bac2 | ||
|
|
293fd39a68 | ||
|
|
cd11503b1e | ||
|
|
9b2fbcbf56 | ||
|
|
5cd83bbaa4 | ||
|
|
91dfda3480 | ||
|
|
b1b2825dd6 | ||
|
|
48d90a8252 | ||
|
|
11af50e0ad | ||
|
|
69099728b2 | ||
|
|
190b99d55a | ||
|
|
95adb53640 | ||
|
|
25a4b68958 | ||
|
|
e91c793f73 | ||
|
|
5b477c64cf | ||
|
|
11ca2c14be | ||
|
|
1b39bf1d52 | ||
|
|
a4e3625f26 | ||
|
|
e75b5d173b | ||
|
|
027cc660e2 | ||
|
|
49850e773f | ||
|
|
44d100f60a | ||
|
|
f110436853 | ||
|
|
aebbe0974a | ||
|
|
a79ceb527f | ||
|
|
29bb0d2682 | ||
|
|
794af6eb2b | ||
|
|
3e83762761 | ||
|
|
c6e1dc9d79 | ||
|
|
6354b32205 | ||
|
|
6618644ba3 | ||
|
|
329d133291 | ||
|
|
3fec18b9a8 | ||
|
|
de395f5629 | ||
|
|
55a1626159 | ||
|
|
581f13eea8 | ||
|
|
b960a4692a | ||
|
|
4256e3f3cf | ||
|
|
f291bb126a | ||
|
|
e5c8e6dd7f | ||
|
|
bbeb2a66ee | ||
|
|
7ec5357653 | ||
|
|
96a2645070 | ||
|
|
bc8b73e458 | ||
|
|
da96180a10 | ||
|
|
4dbd833c0d | ||
|
|
d5f27c7994 | ||
|
|
20425a7678 | ||
|
|
26798e2b57 | ||
|
|
9cb7f9cc21 | ||
|
|
094adcd6f6 | ||
|
|
3dbd79d879 | ||
|
|
126d58e2d1 | ||
|
|
87d0d2e768 | ||
|
|
50470771b0 | ||
|
|
acd0a13abf | ||
|
|
bb6bd3f0ae | ||
|
|
4d1e9cb8d5 | ||
|
|
abeccb23a2 | ||
|
|
d57c03a01a | ||
|
|
be1ad9cdb8 | ||
|
|
3a850ba26c | ||
|
|
96c4092138 | ||
|
|
a4bf093cb3 | ||
|
|
c5edcab051 | ||
|
|
a7f60082f0 | ||
|
|
91f5dffc6d | ||
|
|
8e4ee7a618 | ||
|
|
332bfcd88a | ||
|
|
1872a5879e | ||
|
|
a293976501 | ||
|
|
a1095b4c17 | ||
|
|
95d678f62f | ||
|
|
b625ac6d2d | ||
|
|
f848ad20d8 | ||
|
|
198e049873 | ||
|
|
f2d007f9ba | ||
|
|
4b9c3f239c | ||
|
|
3ac04cdc63 | ||
|
|
f9d231063d | ||
|
|
4bca74888d | ||
|
|
17ff27bac9 | ||
|
|
3913384fcf | ||
|
|
b02b8c4ed9 | ||
|
|
ed31b12e3f | ||
|
|
3b63264927 | ||
|
|
640c47e29f | ||
|
|
3dd1b949eb | ||
|
|
13bd492e32 | ||
|
|
ef8b9be0e8 | ||
|
|
bc491b154b | ||
|
|
a7259b21ee | ||
|
|
f76fc23840 | ||
|
|
43914d655c | ||
|
|
40ce80786e | ||
|
|
17f071b0f4 | ||
|
|
4d8583eed9 | ||
|
|
72bc7e0ed5 | ||
|
|
f997586e4b | ||
|
|
1d208edd33 | ||
|
|
7871a7672b | ||
|
|
86fd92e505 | ||
|
|
1f34a79676 | ||
|
|
5bdc684f21 | ||
|
|
944fbcd000 | ||
|
|
da53001e65 | ||
|
|
6c8021f219 | ||
|
|
a6205a29c4 | ||
|
|
c86daa6950 | ||
|
|
08d5f2ebb5 | ||
|
|
478c4cc356 | ||
|
|
292c6de08a | ||
|
|
ed9d942ce0 | ||
|
|
012e97494e | ||
|
|
006a2229fe | ||
|
|
8c69105fdb | ||
|
|
638bdb7b2d | ||
|
|
2004caa53c |
@@ -745,8 +745,8 @@ android {
|
||||
applicationId = "com.vitorpamplona.amethyst"
|
||||
minSdk = 26 // Android 8.0 (Oreo)
|
||||
targetSdk = 36 // Android 15
|
||||
versionCode = 435
|
||||
versionName = "1.07.0"
|
||||
versionCode = 442
|
||||
versionName = "1.08.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
|
||||
|
||||
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
|
||||
|
||||
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.07.0` (Maven Central)
|
||||
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.08.0` (Maven Central)
|
||||
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
|
||||
**License**: MIT
|
||||
|
||||
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
quartz = "1.07.0"
|
||||
quartz = "1.08.0"
|
||||
|
||||
[libraries]
|
||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||
@@ -41,7 +41,7 @@ kotlin {
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.07.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.08.0")
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Current version
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.07.0
|
||||
com.vitorpamplona.quartz:quartz:1.08.0
|
||||
```
|
||||
|
||||
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
|
||||
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
quartz = "1.07.0"
|
||||
quartz = "1.08.0"
|
||||
|
||||
[libraries]
|
||||
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
|
||||
@@ -55,7 +55,7 @@ kotlin {
|
||||
```kotlin
|
||||
// build.gradle.kts (app module)
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.07.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.08.0")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -70,7 +70,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.07.0")
|
||||
implementation("com.vitorpamplona.quartz:quartz:1.08.0")
|
||||
// JNA needed for libsodium (NIP-44) on JVM
|
||||
implementation("net.java.dev.jna:jna:5.18.1")
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp
|
||||
## Current artifact
|
||||
|
||||
```
|
||||
com.vitorpamplona.quartz:quartz:1.07.0
|
||||
com.vitorpamplona.quartz:quartz:1.08.0
|
||||
```
|
||||
|
||||
See `.claude/skills/quartz-integration/SKILL.md` for full integration guide.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -163,3 +163,6 @@ TASKS.md
|
||||
desktopApp/src/jvmMain/appResources/linux/
|
||||
desktopApp/src/jvmMain/appResources/macos/
|
||||
desktopApp/src/jvmMain/appResources/windows/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -1,3 +1,55 @@
|
||||
<a id="v1.08.0"></a>
|
||||
# [Release v1.08.0: Arti](https://github.com/vitorpamplona/amethyst/releases/tag/v1.08.0) - 2026-04-01
|
||||
|
||||
- Migrates C Tor to Arti Tor (hopefully no more random crashes)
|
||||
- Fixes highlight of users when composing and tagging
|
||||
|
||||
<a id="v1.07.5"></a>
|
||||
# [Release v1.07.5: Image upload fix](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.5) - 2026-03-31
|
||||
|
||||
- Fixes Image uploads crashing the app
|
||||
|
||||
<a id="v1.07.4"></a>
|
||||
# [Release v1.07.4: NWC fix](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.4) - 2026-03-31
|
||||
|
||||
- Fixes Nostr wallet connect receiving the secret.
|
||||
|
||||
<a id="v1.07.3"></a>
|
||||
# [Release v1.07.3: GIF Keyboard](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.3) - 2026-03-31
|
||||
|
||||
- Migrates Shorts UI out of a paged design
|
||||
- New edge-to-edge feed for Pictures only
|
||||
- New edge-to-edge feed for Shorts only
|
||||
- New edge-to-edge feed for long Videos only
|
||||
- Migrates Badges to kind 10008
|
||||
- Migrates Bookmarks to kind 10003
|
||||
- Fixes AOSP keyboard auto-correction bug
|
||||
- Fixes Poll's top nav filter
|
||||
- Fixes loading of Bookmark sets
|
||||
- Fixes sorting stability by including event ids
|
||||
- Fixes cursor position in quote post field
|
||||
- Fixes rendering of nostr: uris when composing notes
|
||||
- Adds Basic support for reaction notifications
|
||||
- Refactors NIP-58 Badges on Quartz
|
||||
- Refactors LocalCache methods
|
||||
- Improvements to the Chess engine (debug only)
|
||||
- Add error handling to Tor service initialization and control
|
||||
|
||||
<a id="v1.07.2"></a>
|
||||
# [Release v1.07.2: GIF Keyboard](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.2) - 2026-03-30
|
||||
|
||||
- Adds GIF uploads support from Keyboard
|
||||
- Migrates text fields and @ modifiers to the new Jetpack Compose states
|
||||
- Fixes Bug that wasn't openning Amber to sign
|
||||
- Fixes Bug on rejections using old Ambers
|
||||
- Replaces hex input with user search dialog in relay management (allow/ban user)
|
||||
|
||||
<a id="v1.07.1"></a>
|
||||
# [Release v1.07.1: Fixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.1) - 2026-03-30
|
||||
|
||||
- New translations
|
||||
- Fixes BLE's module need for synchronized events.
|
||||
|
||||
<a id="v1.07.0"></a>
|
||||
# [Release v1.07.0: Vanish, Relay Management, Pin Notes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.07.0) - 2026-03-30
|
||||
|
||||
|
||||
@@ -54,9 +54,9 @@ android {
|
||||
applicationId = "com.vitorpamplona.amethyst"
|
||||
minSdk = libs.versions.android.minSdk.get().toInteger()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInteger()
|
||||
versionCode = 436
|
||||
versionName = generateVersionName("1.07.0")
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"12cd4bce977ed53502cf121ecba89a190ab02685333c8f230bac35b04f920eeb\""
|
||||
versionCode = 442
|
||||
versionName = generateVersionName("1.08.0")
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"be99e8c8d4df0f54b44eb6c96976ccb38baeea0192436a1c6fc8bc5e930da6b0\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -372,9 +372,6 @@ dependencies {
|
||||
// Kotlin serialization for the times where we need the Json tree and performance is not that important.
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation libs.tor.android
|
||||
implementation libs.jtorctl
|
||||
|
||||
testImplementation libs.junit
|
||||
testImplementation libs.mockk
|
||||
testImplementation libs.kotlinx.coroutines.test
|
||||
|
||||
@@ -97,6 +97,10 @@ private object PrefKeys {
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
|
||||
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
|
||||
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
|
||||
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
|
||||
const val DEFAULT_LONGS_FOLLOW_LIST = "defaultLongsFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_USER_METADATA = "latestUserMetadata"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
@@ -331,6 +335,11 @@ object LocalPreferences {
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNotificationFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultDiscoveryFollowList.value))
|
||||
|
||||
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLongsFollowList.value))
|
||||
|
||||
putOrRemove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, settings.zapPaymentRequest.value?.denormalize())
|
||||
|
||||
putOrRemove(PrefKeys.LATEST_CONTACT_LIST, settings.backupContactList)
|
||||
@@ -470,6 +479,12 @@ object LocalPreferences {
|
||||
val defaultStoriesFollowListStr = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null)
|
||||
val defaultNotificationFollowListStr = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null)
|
||||
val defaultDiscoveryFollowListStr = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null)
|
||||
|
||||
val defaultPollsFollowListStr = getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null)
|
||||
val defaultPicturesFollowListStr = getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null)
|
||||
val defaultShortsFollowListStr = getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null)
|
||||
val defaultLongsFollowListStr = getString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, null)
|
||||
|
||||
val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)
|
||||
val defaultFileServerStr = getString(PrefKeys.DEFAULT_FILE_SERVER, null)
|
||||
|
||||
@@ -501,6 +516,12 @@ object LocalPreferences {
|
||||
val defaultStoriesFollowList = async { parseOrNull<TopFilter>(defaultStoriesFollowListStr) ?: TopFilter.Global }
|
||||
val defaultNotificationFollowList = async { parseOrNull<TopFilter>(defaultNotificationFollowListStr) ?: TopFilter.Global }
|
||||
val defaultDiscoveryFollowList = async { parseOrNull<TopFilter>(defaultDiscoveryFollowListStr) ?: TopFilter.Global }
|
||||
|
||||
val defaultPollsFollowList = async { parseOrNull<TopFilter>(defaultPollsFollowListStr) ?: TopFilter.Global }
|
||||
val defaultPicturesFollowList = async { parseOrNull<TopFilter>(defaultPicturesFollowListStr) ?: TopFilter.Global }
|
||||
val defaultShortsFollowList = async { parseOrNull<TopFilter>(defaultShortsFollowListStr) ?: TopFilter.Global }
|
||||
val defaultLongsFollowList = async { parseOrNull<TopFilter>(defaultLongsFollowListStr) ?: TopFilter.Global }
|
||||
|
||||
val zapPaymentRequestServer = async { parseOrNull<Nip47WalletConnect.Nip47URI>(zapPaymentRequestServerStr) }
|
||||
val defaultFileServer = async { parseOrNull<ServerName>(defaultFileServerStr) ?: DEFAULT_MEDIA_SERVERS[0] }
|
||||
|
||||
@@ -545,6 +566,10 @@ object LocalPreferences {
|
||||
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList.await()),
|
||||
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList.await()),
|
||||
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList.await()),
|
||||
defaultPollsFollowList = MutableStateFlow(defaultPollsFollowList.await()),
|
||||
defaultPicturesFollowList = MutableStateFlow(defaultPicturesFollowList.await()),
|
||||
defaultShortsFollowList = MutableStateFlow(defaultShortsFollowList.await()),
|
||||
defaultLongsFollowList = MutableStateFlow(defaultLongsFollowList.await()),
|
||||
zapPaymentRequest = MutableStateFlow(zapPaymentRequestServer.await()?.normalize()),
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
|
||||
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
|
||||
@@ -176,6 +177,7 @@ import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
@@ -224,6 +226,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
@@ -320,6 +323,7 @@ class Account(
|
||||
val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings)
|
||||
|
||||
val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope)
|
||||
val oldBookmarkState = OldBookmarkListState(signer, cache, scope)
|
||||
val bookmarkState = BookmarkListState(signer, cache, scope)
|
||||
val pinState = PinListState(signer, cache, scope)
|
||||
val emoji = EmojiPackState(signer, cache, scope)
|
||||
@@ -379,10 +383,9 @@ class Account(
|
||||
geohashCache = geohashListDecryptionCache,
|
||||
)
|
||||
|
||||
// App-ready Feeds
|
||||
val liveHomeFollowLists: StateFlow<IFeedTopNavFilter> =
|
||||
fun topNavFilterFlow(listName: MutableStateFlow<TopFilter>) =
|
||||
FeedTopNavFilterState(
|
||||
feedFilterListName = settings.defaultHomeFollowList,
|
||||
feedFilterListName = listName,
|
||||
kind3Follows = kind3FollowList.flow,
|
||||
allFollows = allFollows.flow,
|
||||
locationFlow = geolocationFlow,
|
||||
@@ -394,56 +397,31 @@ class Account(
|
||||
scope = scope,
|
||||
).flow
|
||||
|
||||
// App-ready Feeds
|
||||
val liveHomeFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultHomeFollowList)
|
||||
val liveHomeFollowListsPerRelay = OutboxLoaderState(liveHomeFollowLists, cache, scope).flow
|
||||
|
||||
val liveStoriesFollowLists: StateFlow<IFeedTopNavFilter> =
|
||||
FeedTopNavFilterState(
|
||||
feedFilterListName = settings.defaultStoriesFollowList,
|
||||
kind3Follows = kind3FollowList.flow,
|
||||
allFollows = allFollows.flow,
|
||||
locationFlow = geolocationFlow,
|
||||
followsRelays = defaultGlobalRelays.flow,
|
||||
blockedRelays = blockedRelayList.flow,
|
||||
proxyRelays = proxyRelayList.flow,
|
||||
caches = feedDecryptionCaches,
|
||||
signer = signer,
|
||||
scope = scope,
|
||||
).flow
|
||||
|
||||
val liveStoriesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultStoriesFollowList)
|
||||
val liveStoriesFollowListsPerRelay = OutboxLoaderState(liveStoriesFollowLists, cache, scope).flow
|
||||
|
||||
val liveDiscoveryFollowLists: StateFlow<IFeedTopNavFilter> =
|
||||
FeedTopNavFilterState(
|
||||
feedFilterListName = settings.defaultDiscoveryFollowList,
|
||||
kind3Follows = kind3FollowList.flow,
|
||||
allFollows = allFollows.flow,
|
||||
locationFlow = geolocationFlow,
|
||||
followsRelays = defaultGlobalRelays.flow,
|
||||
blockedRelays = blockedRelayList.flow,
|
||||
proxyRelays = proxyRelayList.flow,
|
||||
caches = feedDecryptionCaches,
|
||||
signer = signer,
|
||||
scope = scope,
|
||||
).flow
|
||||
|
||||
val liveDiscoveryFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultDiscoveryFollowList)
|
||||
val liveDiscoveryFollowListsPerRelay = OutboxLoaderState(liveDiscoveryFollowLists, cache, scope).flow
|
||||
|
||||
val liveNotificationFollowLists: StateFlow<IFeedTopNavFilter> =
|
||||
FeedTopNavFilterState(
|
||||
feedFilterListName = settings.defaultNotificationFollowList,
|
||||
kind3Follows = kind3FollowList.flow,
|
||||
allFollows = allFollows.flow,
|
||||
locationFlow = geolocationFlow,
|
||||
followsRelays = defaultGlobalRelays.flow,
|
||||
blockedRelays = blockedRelayList.flow,
|
||||
proxyRelays = proxyRelayList.flow,
|
||||
caches = feedDecryptionCaches,
|
||||
signer = signer,
|
||||
scope = scope,
|
||||
).flow
|
||||
|
||||
val liveNotificationFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultNotificationFollowList)
|
||||
val liveNotificationFollowListsPerRelay = OutboxLoaderState(liveNotificationFollowLists, cache, scope).flow
|
||||
|
||||
val livePollsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPollsFollowList)
|
||||
val livePollsFollowListsPerRelay = OutboxLoaderState(livePollsFollowLists, cache, scope).flow
|
||||
|
||||
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
|
||||
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
|
||||
|
||||
val liveShortsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultShortsFollowList)
|
||||
val liveShortsFollowListsPerRelay = OutboxLoaderState(liveShortsFollowLists, cache, scope).flow
|
||||
|
||||
val liveLongsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultLongsFollowList)
|
||||
val liveLongsFollowListsPerRelay = OutboxLoaderState(liveLongsFollowLists, cache, scope).flow
|
||||
|
||||
override fun isWriteable(): Boolean = settings.isWriteable()
|
||||
|
||||
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
|
||||
@@ -1815,6 +1793,49 @@ class Account(
|
||||
cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
suspend fun migrateOldBookmarksToNew() {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val oldList = oldBookmarkState.getBookmarkList() ?: return
|
||||
val oldPublic = oldList.publicBookmarks()
|
||||
val oldPrivate = oldList.privateBookmarks(signer) ?: emptyList()
|
||||
|
||||
if (oldPublic.isEmpty() && oldPrivate.isEmpty()) return
|
||||
|
||||
val existingNewList = bookmarkState.getBookmarkList()
|
||||
|
||||
val newEvent =
|
||||
if (existingNewList != null) {
|
||||
val existingPublic = existingNewList.publicBookmarks()
|
||||
val existingPrivate = existingNewList.privateBookmarks(signer) ?: emptyList()
|
||||
|
||||
val existingPublicIds = existingPublic.map { it.toTagIdOnly().toList() }.toSet()
|
||||
val existingPrivateIds = existingPrivate.map { it.toTagIdOnly().toList() }.toSet()
|
||||
|
||||
val newPublic = oldPublic.filter { it.toTagIdOnly().toList() !in existingPublicIds }
|
||||
val newPrivate = oldPrivate.filter { it.toTagIdOnly().toList() !in existingPrivateIds }
|
||||
|
||||
if (newPublic.isEmpty() && newPrivate.isEmpty()) return
|
||||
|
||||
val mergedPublic = existingPublic + newPublic
|
||||
val mergedPrivate = existingPrivate + newPrivate
|
||||
|
||||
BookmarkListEvent.create(
|
||||
publicBookmarks = mergedPublic,
|
||||
privateBookmarks = mergedPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
} else {
|
||||
BookmarkListEvent.create(
|
||||
publicBookmarks = oldPublic,
|
||||
privateBookmarks = oldPrivate,
|
||||
signer = signer,
|
||||
)
|
||||
}
|
||||
|
||||
sendMyPublicAndPrivateOutbox(newEvent)
|
||||
}
|
||||
|
||||
suspend fun addPin(note: Note) {
|
||||
if (!isWriteable() || note.isDraft()) return
|
||||
|
||||
|
||||
@@ -165,6 +165,10 @@ class AccountSettings(
|
||||
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val zapPaymentRequest: MutableStateFlow<Nip47WalletConnect.Nip47URINorm?> = MutableStateFlow(null),
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
@@ -321,6 +325,50 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultPollsFollowList(name: FeedDefinition) {
|
||||
changeDefaultPollsFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultPollsFollowList(name: TopFilter) {
|
||||
if (defaultPollsFollowList.value != name) {
|
||||
defaultPollsFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultPicturesFollowList(name: FeedDefinition) {
|
||||
changeDefaultPicturesFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultPicturesFollowList(name: TopFilter) {
|
||||
if (defaultPicturesFollowList.value != name) {
|
||||
defaultPicturesFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultShortsFollowList(name: FeedDefinition) {
|
||||
changeDefaultShortsFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultShortsFollowList(name: TopFilter) {
|
||||
if (defaultShortsFollowList.value != name) {
|
||||
defaultShortsFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultLongsFollowList(name: FeedDefinition) {
|
||||
changeDefaultLongsFollowList(name.code)
|
||||
}
|
||||
|
||||
fun changeDefaultLongsFollowList(name: TopFilter) {
|
||||
if (defaultLongsFollowList.value != name) {
|
||||
defaultLongsFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// language services
|
||||
// ---
|
||||
|
||||
@@ -144,6 +144,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEv
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
@@ -167,9 +168,10 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
@@ -580,84 +582,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: ExternalIdentitiesEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: ContactListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: BookmarkListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: TextNoteEvent,
|
||||
relay: NormalizedRelayUrl? = null,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: PublicMessageEvent,
|
||||
relay: NormalizedRelayUrl? = null,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: TorrentEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: InteractiveStoryPrologueEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: InteractiveStorySceneEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: InteractiveStoryReadingStateEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestationRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestorRecommendationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AttestorProficiencyEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consumeRegularEvent(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -694,135 +618,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: PictureEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: VoiceEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: VoiceReplyEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun consume(
|
||||
event: TorrentCommentEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NIP90ContentDiscoveryResponseEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NIP90ContentDiscoveryRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NIP90StatusEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NIP90UserDiscoveryResponseEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: RequestToVanishEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NIP90UserDiscoveryRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: GoalEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: GitPatchEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: GitIssueEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun consume(
|
||||
event: GitReplyEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
// Chess events (NIP-64 live chess)
|
||||
fun consume(
|
||||
event: ChessGameEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: JesterEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessGameChallengeEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessGameAcceptEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessMoveEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessGameEndEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LiveChessDrawOfferEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NipTextEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -1049,7 +844,12 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
|
||||
}
|
||||
|
||||
is BadgeProfilesEvent -> {
|
||||
is AcceptedBadgeSetEvent -> {
|
||||
event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) }
|
||||
}
|
||||
|
||||
is ProfileBadgesEvent -> {
|
||||
event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) }
|
||||
}
|
||||
@@ -1103,12 +903,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: ZapPollEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: LiveActivitiesEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -1150,234 +944,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: LabeledBookmarkListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: MuteListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: CommunityListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: GitRepositoryEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: RootSiteEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: NamedSiteEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: ChannelListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: BlossomServersEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: FileServersEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: PeopleListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: EphemeralChatListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: FollowListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: AdvertisedRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: ChatMessageRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: PrivateOutboxRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: HashtagListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: GeohashListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: SearchRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: BlockedRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: TrustedRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: RelayFeedsListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: TrustProviderListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: ProxyRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: IndexerRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: BroadcastRelayListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: CommunityDefinitionEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: EmojiPackSelectionEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: EmojiPackEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: ClassifiedsEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: PinListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: RelaySetEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: AudioTrackEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: VideoVerticalEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: VideoHorizontalEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: VideoNormalEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: VideoShortEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: RelayDiscoveryEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: RelayMonitorEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: StatusEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -1438,66 +1004,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: BadgeDefinitionEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: BadgeProfilesEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: BadgeAwardEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: NNSEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AppDefinitionEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: CalendarEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: WebBookmarkEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: CalendarDateSlotEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: CalendarTimeSlotEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: CalendarRSVPEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
private fun consumeBaseReplaceable(
|
||||
event: Event,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -1543,24 +1049,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: AppRecommendationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: AppSpecificDataEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: PrivateDmEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: DeletionEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -1993,12 +1481,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return new
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: CommentEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(
|
||||
event: ChannelHideMessageEvent,
|
||||
@@ -2080,36 +1562,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: AudioHeaderEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: FileHeaderEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: ProfileGalleryEntryEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: FileStorageHeaderEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: FhirResourceEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: TextNoteModificationEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -2145,30 +1597,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: HighlightEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: CodeSnippetEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: ChatEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: PollEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: PollResponseEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -2242,30 +1670,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun consume(
|
||||
event: ChatMessageEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
private fun consume(
|
||||
event: ChatMessageEncryptedFileHeaderEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: SealedRumorEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: GiftWrapEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: LnZapPaymentRequestEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -3141,134 +2545,136 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
): Boolean =
|
||||
try {
|
||||
when (event) {
|
||||
is AdvertisedRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is AppDefinitionEvent -> consume(event, relay, wasVerified)
|
||||
is AppRecommendationEvent -> consume(event, relay, wasVerified)
|
||||
is AppSpecificDataEvent -> consume(event, relay, wasVerified)
|
||||
is AttestationEvent -> consume(event, relay, wasVerified)
|
||||
is AttestationRequestEvent -> consume(event, relay, wasVerified)
|
||||
is AttestorRecommendationEvent -> consume(event, relay, wasVerified)
|
||||
is AttestorProficiencyEvent -> consume(event, relay, wasVerified)
|
||||
is AudioHeaderEvent -> consume(event, relay, wasVerified)
|
||||
is AudioTrackEvent -> consume(event, relay, wasVerified)
|
||||
is BadgeAwardEvent -> consume(event, relay, wasVerified)
|
||||
is BadgeDefinitionEvent -> consume(event, relay, wasVerified)
|
||||
is BadgeProfilesEvent -> consume(event, relay, wasVerified)
|
||||
is BlockedRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is BlossomServersEvent -> consume(event, relay, wasVerified)
|
||||
is BroadcastRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is BookmarkListEvent -> consume(event, relay, wasVerified)
|
||||
is CalendarEvent -> consume(event, relay, wasVerified)
|
||||
is CalendarDateSlotEvent -> consume(event, relay, wasVerified)
|
||||
is CalendarTimeSlotEvent -> consume(event, relay, wasVerified)
|
||||
is CalendarRSVPEvent -> consume(event, relay, wasVerified)
|
||||
is AcceptedBadgeSetEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AdvertisedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AppDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AppRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AppSpecificDataEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AttestationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AttestationRequestEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AttestorRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AttestorProficiencyEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is AudioHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is AudioTrackEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is BadgeAwardEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is BadgeDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is BlockedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is BlossomServersEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is BroadcastRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is BookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is OldBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CalendarEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CalendarDateSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CalendarTimeSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CalendarRSVPEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ChannelCreateEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelListEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ChannelHideMessageEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelMessageEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelMetadataEvent -> consume(event, relay, wasVerified)
|
||||
is ChannelMuteUserEvent -> consume(event, relay, wasVerified)
|
||||
is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay, wasVerified)
|
||||
is ChatMessageEvent -> consume(event, relay, wasVerified)
|
||||
is ChatMessageRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is ClassifiedsEvent -> consume(event, relay, wasVerified)
|
||||
is CommentEvent -> consume(event, relay, wasVerified)
|
||||
is CommunityDefinitionEvent -> consume(event, relay, wasVerified)
|
||||
is CommunityListEvent -> consume(event, relay, wasVerified)
|
||||
is ChatMessageEncryptedFileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is ChatMessageEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is ChatMessageRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ClassifiedsEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CommentEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is CommunityDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CommunityListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is CommunityPostApprovalEvent -> consume(event, relay, wasVerified)
|
||||
is ContactListEvent -> consume(event, relay, wasVerified)
|
||||
is ContactListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is DeletionEvent -> consume(event, relay, wasVerified)
|
||||
is DraftWrapEvent -> consume(event, relay, wasVerified)
|
||||
is EmojiPackEvent -> consume(event, relay, wasVerified)
|
||||
is EmojiPackSelectionEvent -> consume(event, relay, wasVerified)
|
||||
is EmojiPackEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is EmojiPackSelectionEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is EphemeralChatEvent -> consume(event, relay, wasVerified)
|
||||
is EphemeralChatListEvent -> consume(event, relay, wasVerified)
|
||||
is ExternalIdentitiesEvent -> consume(event, relay, wasVerified)
|
||||
is EphemeralChatListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ExternalIdentitiesEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is GenericRepostEvent -> consume(event, relay, wasVerified)
|
||||
is FhirResourceEvent -> consume(event, relay, wasVerified)
|
||||
is FileHeaderEvent -> consume(event, relay, wasVerified)
|
||||
is ProfileGalleryEntryEvent -> consume(event, relay, wasVerified)
|
||||
is FileServersEvent -> consume(event, relay, wasVerified)
|
||||
is FhirResourceEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is FileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is ProfileGalleryEntryEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is FileServersEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is FileStorageEvent -> consume(event, relay, wasVerified)
|
||||
is FileStorageHeaderEvent -> consume(event, relay, wasVerified)
|
||||
is FollowListEvent -> consume(event, relay, wasVerified)
|
||||
is GeohashListEvent -> consume(event, relay, wasVerified)
|
||||
is GoalEvent -> consume(event, relay, wasVerified)
|
||||
is GiftWrapEvent -> consume(event, relay, wasVerified)
|
||||
is GitIssueEvent -> consume(event, relay, wasVerified)
|
||||
is GitReplyEvent -> consume(event, relay, wasVerified)
|
||||
is GitPatchEvent -> consume(event, relay, wasVerified)
|
||||
is GitRepositoryEvent -> consume(event, relay, wasVerified)
|
||||
is RootSiteEvent -> consume(event, relay, wasVerified)
|
||||
is NamedSiteEvent -> consume(event, relay, wasVerified)
|
||||
is ChessGameEvent -> consume(event, relay, wasVerified)
|
||||
is RelayFeedsListEvent -> consume(event, relay, wasVerified)
|
||||
is JesterEvent -> consume(event, relay, wasVerified)
|
||||
is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified)
|
||||
is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified)
|
||||
is LiveChessMoveEvent -> consume(event, relay, wasVerified)
|
||||
is LiveChessGameEndEvent -> consume(event, relay, wasVerified)
|
||||
is LiveChessDrawOfferEvent -> consume(event, relay, wasVerified)
|
||||
is HashtagListEvent -> consume(event, relay, wasVerified)
|
||||
is HighlightEvent -> consume(event, relay, wasVerified)
|
||||
is IndexerRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is InteractiveStoryPrologueEvent -> consume(event, relay, wasVerified)
|
||||
is InteractiveStorySceneEvent -> consume(event, relay, wasVerified)
|
||||
is InteractiveStoryReadingStateEvent -> consume(event, relay, wasVerified)
|
||||
is LabeledBookmarkListEvent -> consume(event, relay, wasVerified)
|
||||
is FileStorageHeaderEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is FollowListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is GeohashListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is GoalEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is GiftWrapEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is GitIssueEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is GitReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is GitPatchEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is GitRepositoryEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is RootSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is NamedSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ChessGameEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is RelayFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is JesterEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is LiveChessGameChallengeEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveChessGameAcceptEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveChessMoveEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is InteractiveStorySceneEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is InteractiveStoryReadingStateEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
|
||||
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified)
|
||||
is LnZapEvent -> consume(event, relay, wasVerified)
|
||||
is LnZapRequestEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90StatusEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90ContentDiscoveryResponseEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90ContentDiscoveryRequestEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90UserDiscoveryResponseEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90UserDiscoveryRequestEvent -> consume(event, relay, wasVerified)
|
||||
is NIP90StatusEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is NIP90ContentDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is NIP90ContentDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is NIP90UserDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is NIP90UserDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is LnZapPaymentRequestEvent -> consume(event, relay, wasVerified)
|
||||
is LnZapPaymentResponseEvent -> consume(event, relay, wasVerified)
|
||||
is LongTextNoteEvent -> consume(event, relay, wasVerified)
|
||||
is MetadataEvent -> consume(event, relay, wasVerified)
|
||||
is MuteListEvent -> consume(event, relay, wasVerified)
|
||||
is NNSEvent -> consume(event, relay, wasVerified)
|
||||
is MuteListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is NNSEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is NipTextEvent -> consume(event, relay, wasVerified)
|
||||
is OtsEvent -> consume(event, relay, wasVerified)
|
||||
is PictureEvent -> consume(event, relay, wasVerified)
|
||||
is PrivateDmEvent -> consume(event, relay, wasVerified)
|
||||
is PrivateOutboxRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is ProxyRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is PinListEvent -> consume(event, relay, wasVerified)
|
||||
is PublicMessageEvent -> consume(event, relay, wasVerified)
|
||||
is PeopleListEvent -> consume(event, relay, wasVerified)
|
||||
is RequestToVanishEvent -> consume(event, relay, wasVerified)
|
||||
is CodeSnippetEvent -> consume(event, relay, wasVerified)
|
||||
is ZapPollEvent -> consume(event, relay, wasVerified)
|
||||
is ChatEvent -> consume(event, relay, wasVerified)
|
||||
is PollEvent -> consume(event, relay, wasVerified)
|
||||
is PictureEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is PrivateDmEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is PrivateOutboxRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ProfileBadgesEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ProxyRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is PinListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is PublicMessageEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is PeopleListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is RequestToVanishEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is CodeSnippetEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is ZapPollEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is ChatEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is PollEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is PollResponseEvent -> consume(event, relay, wasVerified)
|
||||
is RelayDiscoveryEvent -> consume(event, relay, wasVerified)
|
||||
is RelayMonitorEvent -> consume(event, relay, wasVerified)
|
||||
is RelayDiscoveryEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is RelayMonitorEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ReactionEvent -> consume(event, relay, wasVerified)
|
||||
is ContactCardEvent -> consume(event, relay, wasVerified)
|
||||
is RelaySetEvent -> consume(event, relay, wasVerified)
|
||||
is RelaySetEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is ReportEvent -> consume(event, relay, wasVerified)
|
||||
is RepostEvent -> consume(event, relay, wasVerified)
|
||||
is SealedRumorEvent -> consume(event, relay, wasVerified)
|
||||
is SearchRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is SealedRumorEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is SearchRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is StatusEvent -> consume(event, relay, wasVerified)
|
||||
is TextNoteEvent -> consume(event, relay, wasVerified)
|
||||
is TextNoteEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is TextNoteModificationEvent -> consume(event, relay, wasVerified)
|
||||
is TorrentEvent -> consume(event, relay, wasVerified)
|
||||
is TorrentCommentEvent -> consume(event, relay, wasVerified)
|
||||
is TrustedRelayListEvent -> consume(event, relay, wasVerified)
|
||||
is TrustProviderListEvent -> consume(event, relay, wasVerified)
|
||||
is VideoHorizontalEvent -> consume(event, relay, wasVerified)
|
||||
is VideoNormalEvent -> consume(event, relay, wasVerified)
|
||||
is VideoVerticalEvent -> consume(event, relay, wasVerified)
|
||||
is VideoShortEvent -> consume(event, relay, wasVerified)
|
||||
is VoiceEvent -> consume(event, relay, wasVerified)
|
||||
is VoiceReplyEvent -> consume(event, relay, wasVerified)
|
||||
is WebBookmarkEvent -> consume(event, relay, wasVerified)
|
||||
is TorrentEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is TorrentCommentEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is TrustedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is TrustProviderListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is VideoHorizontalEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is VideoNormalEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is VideoVerticalEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is VideoShortEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is VoiceEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is VoiceReplyEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is WebBookmarkEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is WikiNoteEvent -> consume(event, relay, wasVerified)
|
||||
is PaymentTargetsEvent -> consume(event, relay, wasVerified)
|
||||
else -> Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false }
|
||||
|
||||
@@ -21,3 +21,5 @@
|
||||
package com.vitorpamplona.amethyst.model.nip51Lists
|
||||
|
||||
typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState
|
||||
|
||||
typealias OldBookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState
|
||||
|
||||
@@ -29,7 +29,9 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -43,10 +45,14 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import java.math.BigDecimal
|
||||
@@ -104,10 +110,33 @@ class EventNotificationConsumer(
|
||||
Log.d(TAG) { "Unwrapped consume ${innerEvent.javaClass.simpleName}" }
|
||||
|
||||
when (innerEvent) {
|
||||
is PrivateDmEvent -> notify(innerEvent, account)
|
||||
is LnZapEvent -> notify(innerEvent, account)
|
||||
is ChatMessageEvent -> notify(innerEvent, account)
|
||||
is ChatMessageEncryptedFileHeaderEvent -> notify(innerEvent, account)
|
||||
is PrivateDmEvent -> {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
|
||||
is ChatMessageEvent -> {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
|
||||
is ChatMessageEncryptedFileHeaderEvent -> {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
|
||||
is ReactionEvent -> {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
|
||||
is LiveChessGameAcceptEvent -> {
|
||||
notifyChessEvent(innerEvent, account, R.string.app_notification_chess_challenge_accepted)
|
||||
}
|
||||
|
||||
is LiveChessMoveEvent -> {
|
||||
notifyChessEvent(innerEvent, account, R.string.app_notification_chess_your_turn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,6 +510,111 @@ class EventNotificationConsumer(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun notify(
|
||||
event: ReactionEvent,
|
||||
account: Account,
|
||||
) {
|
||||
Log.d(TAG, "New Reaction to Notify")
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
||||
|
||||
// don't notify for own reactions
|
||||
if (event.pubKey == account.signer.pubKey) return
|
||||
|
||||
// only notify if the reaction is for the current user
|
||||
if (!event.isTaggedUser(account.signer.pubKey)) return
|
||||
|
||||
val reactedPostId = event.originalPost().firstOrNull() ?: return
|
||||
val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId)
|
||||
|
||||
val author = LocalCache.getOrCreateUser(event.pubKey)
|
||||
val user = author.toBestDisplayName()
|
||||
val userPicture = author.profilePicture()
|
||||
|
||||
val reactionContent = event.content
|
||||
val reactionSymbol =
|
||||
when {
|
||||
reactionContent == ReactionEvent.LIKE || reactionContent.isBlank() -> "\uD83E\uDD19"
|
||||
reactionContent == ReactionEvent.DISLIKE -> "\uD83D\uDC4E"
|
||||
else -> reactionContent
|
||||
}
|
||||
|
||||
val title = "$reactionSymbol $user"
|
||||
|
||||
val reactedContent =
|
||||
reactedNote
|
||||
?.event
|
||||
?.content
|
||||
?.split("\n")
|
||||
?.firstOrNull() ?: ""
|
||||
|
||||
val content =
|
||||
if (reactedContent.isNotBlank()) {
|
||||
stringRes(
|
||||
applicationContext,
|
||||
R.string.app_notification_reactions_channel_message_for,
|
||||
reactedContent,
|
||||
)
|
||||
} else {
|
||||
stringRes(
|
||||
applicationContext,
|
||||
R.string.app_notification_reactions_channel_message,
|
||||
user,
|
||||
)
|
||||
}
|
||||
|
||||
val noteUri =
|
||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub()
|
||||
|
||||
notificationManager()
|
||||
.sendReactionNotification(
|
||||
event.id,
|
||||
content,
|
||||
title,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun notifyChessEvent(
|
||||
event: BaseChessEvent,
|
||||
account: Account,
|
||||
contentStringRes: Int,
|
||||
) {
|
||||
if (
|
||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
||||
event.pubKey != account.signer.pubKey
|
||||
) {
|
||||
val author = LocalCache.getOrCreateUser(event.pubKey)
|
||||
val user = author.toBestDisplayName()
|
||||
val userPicture = author.profilePicture()
|
||||
val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name)
|
||||
val content = stringRes(applicationContext, contentStringRes, user)
|
||||
val noteUri =
|
||||
"notifications$ACCOUNT_QUERY_PARAM" +
|
||||
account.signer.pubKey
|
||||
.hexToByteArray()
|
||||
.toNpub()
|
||||
|
||||
notificationManager()
|
||||
.sendChessNotification(
|
||||
event.id,
|
||||
content,
|
||||
title,
|
||||
event.createdAt,
|
||||
userPicture,
|
||||
noteUri,
|
||||
applicationContext,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun notificationManager(): NotificationManager =
|
||||
ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
|
||||
as NotificationManager
|
||||
|
||||
@@ -45,8 +45,14 @@ import kotlinx.coroutines.withContext
|
||||
object NotificationUtils {
|
||||
private var dmChannel: NotificationChannel? = null
|
||||
private var zapChannel: NotificationChannel? = null
|
||||
private var reactionChannel: NotificationChannel? = null
|
||||
private var chessChannel: NotificationChannel? = null
|
||||
|
||||
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
||||
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
|
||||
private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
|
||||
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION"
|
||||
|
||||
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
|
||||
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
|
||||
const val KEY_REPLY_TEXT = "key_reply_text"
|
||||
@@ -56,6 +62,8 @@ object NotificationUtils {
|
||||
|
||||
private const val DM_SUMMARY_ID = 0x10000
|
||||
private const val ZAP_SUMMARY_ID = 0x20000
|
||||
private const val REACTION_SUMMARY_ID = 0x40000
|
||||
private const val CHESS_SUMMARY_ID = 0x30000
|
||||
|
||||
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
if (dmChannel != null) return dmChannel!!
|
||||
@@ -99,6 +107,104 @@ object NotificationUtils {
|
||||
return zapChannel!!
|
||||
}
|
||||
|
||||
fun getOrCreateReactionChannel(applicationContext: Context): NotificationChannel {
|
||||
if (reactionChannel != null) return reactionChannel!!
|
||||
|
||||
reactionChannel =
|
||||
NotificationChannel(
|
||||
stringRes(applicationContext, R.string.app_notification_reactions_channel_id),
|
||||
stringRes(applicationContext, R.string.app_notification_reactions_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description =
|
||||
stringRes(applicationContext, R.string.app_notification_reactions_channel_description)
|
||||
}
|
||||
|
||||
val notificationManager: NotificationManager =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(reactionChannel!!)
|
||||
|
||||
return reactionChannel!!
|
||||
}
|
||||
|
||||
fun getOrCreateChessChannel(applicationContext: Context): NotificationChannel {
|
||||
if (chessChannel != null) return chessChannel!!
|
||||
|
||||
chessChannel =
|
||||
NotificationChannel(
|
||||
stringRes(applicationContext, R.string.app_notification_chess_channel_id),
|
||||
stringRes(applicationContext, R.string.app_notification_chess_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description =
|
||||
stringRes(applicationContext, R.string.app_notification_chess_channel_description)
|
||||
}
|
||||
|
||||
val notificationManager: NotificationManager =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(chessChannel!!)
|
||||
|
||||
return chessChannel!!
|
||||
}
|
||||
|
||||
suspend fun NotificationManager.sendReactionNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
time: Long,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
getOrCreateReactionChannel(applicationContext)
|
||||
val channelId = stringRes(applicationContext, R.string.app_notification_reactions_channel_id)
|
||||
|
||||
sendNotification(
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
time = time,
|
||||
pictureUrl = pictureUrl,
|
||||
uri = uri,
|
||||
channelId = channelId,
|
||||
notificationGroupKey = REACTION_GROUP_KEY,
|
||||
category = NotificationCompat.CATEGORY_SOCIAL,
|
||||
summaryId = REACTION_SUMMARY_ID,
|
||||
summaryText = stringRes(applicationContext, R.string.app_notification_reactions_summary),
|
||||
applicationContext = applicationContext,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun NotificationManager.sendChessNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
time: Long,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
getOrCreateChessChannel(applicationContext)
|
||||
val channelId = stringRes(applicationContext, R.string.app_notification_chess_channel_id)
|
||||
|
||||
sendNotification(
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
time = time,
|
||||
pictureUrl = pictureUrl,
|
||||
uri = uri,
|
||||
channelId = channelId,
|
||||
notificationGroupKey = CHESS_GROUP_KEY,
|
||||
category = NotificationCompat.CATEGORY_SOCIAL,
|
||||
summaryId = CHESS_SUMMARY_ID,
|
||||
summaryText = stringRes(applicationContext, R.string.app_notification_chess_summary),
|
||||
applicationContext = applicationContext,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun NotificationManager.sendZapNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
|
||||
@@ -27,8 +27,6 @@ import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@@ -42,20 +40,18 @@ class VideoCache {
|
||||
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
suspend fun initFileCache(
|
||||
fun initFileCache(
|
||||
context: Context,
|
||||
cachePath: File,
|
||||
) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
simpleCache =
|
||||
SimpleCache(
|
||||
cachePath,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider,
|
||||
)
|
||||
}
|
||||
simpleCache =
|
||||
SimpleCache(
|
||||
cachePath,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider,
|
||||
)
|
||||
}
|
||||
|
||||
// This method should be called when proxy setting changes.
|
||||
|
||||
@@ -22,18 +22,15 @@ package com.vitorpamplona.amethyst.service.playback.diskCache
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.service.safeCacheDir
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class VideoCacheFactory {
|
||||
companion object {
|
||||
fun new(app: Context): VideoCache {
|
||||
val newCache = VideoCache()
|
||||
runBlocking {
|
||||
newCache.initFileCache(
|
||||
app,
|
||||
app.safeCacheDir().resolve("exoplayer"),
|
||||
)
|
||||
}
|
||||
newCache.initFileCache(
|
||||
app,
|
||||
app.safeCacheDir().resolve("exoplayer"),
|
||||
)
|
||||
return newCache
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import net.freehaven.tor.control.TorControlCommands
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class RelayProxyClientConnector(
|
||||
@@ -79,16 +78,13 @@ class RelayProxyClientConnector(
|
||||
client.disconnect()
|
||||
}
|
||||
if (it.torStatus is TorServiceStatus.Active) {
|
||||
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_DORMANT)
|
||||
Log.d("ManageRelayServices", "Pausing Tor Activity")
|
||||
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
|
||||
}
|
||||
} else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) {
|
||||
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
|
||||
|
||||
if (it.torStatus is TorServiceStatus.Active) {
|
||||
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_ACTIVE)
|
||||
it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM)
|
||||
Log.d("ManageRelayServices", "Resuming Tor Activity with new nym")
|
||||
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
|
||||
}
|
||||
|
||||
// only calls this if the client is not active. Otherwise goes to the else below
|
||||
|
||||
@@ -37,10 +37,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.datasource.RelayInfoNip66FilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -63,7 +66,6 @@ class RelaySubscriptionsCoordinator(
|
||||
val chatroomList = ChatroomListFilterAssembler(client)
|
||||
val video = VideoFilterAssembler(client)
|
||||
val discovery = DiscoveryFilterAssembler(client)
|
||||
val polls = PollsFilterAssembler(client)
|
||||
|
||||
// loaders of content that is not yet in the device.
|
||||
// they are active when looking at events, users, channels.
|
||||
@@ -87,6 +89,11 @@ class RelaySubscriptionsCoordinator(
|
||||
val followPacks = FollowPackFeedFilterAssembler(client)
|
||||
val chess = ChessFilterAssembler(client)
|
||||
|
||||
val polls = PollsFilterAssembler(client)
|
||||
val pictures = PicturesFilterAssembler(client)
|
||||
val shorts = ShortsFilterAssembler(client)
|
||||
val longs = LongsFilterAssembler(client)
|
||||
|
||||
// active when sending zaps via NWC
|
||||
val nwc = NWCPaymentFilterAssembler(client)
|
||||
|
||||
@@ -98,6 +105,9 @@ class RelaySubscriptionsCoordinator(
|
||||
video,
|
||||
discovery,
|
||||
polls,
|
||||
pictures,
|
||||
shorts,
|
||||
longs,
|
||||
channelFinder,
|
||||
eventFinder,
|
||||
userFinder,
|
||||
|
||||
@@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
|
||||
@@ -33,6 +35,8 @@ val ReportsAndBookmarksFromKeyKinds =
|
||||
listOf(
|
||||
ReportEvent.KIND,
|
||||
BookmarkListEvent.KIND,
|
||||
OldBookmarkListEvent.KIND,
|
||||
LabeledBookmarkListEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
RequestToVanishEvent.KIND,
|
||||
)
|
||||
|
||||
@@ -30,14 +30,16 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEve
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent
|
||||
|
||||
val FollowAndMutesFromKeyKinds =
|
||||
listOf(
|
||||
PeopleListEvent.KIND,
|
||||
FollowListEvent.KIND,
|
||||
MuteListEvent.KIND,
|
||||
BadgeProfilesEvent.KIND,
|
||||
AcceptedBadgeSetEvent.KIND,
|
||||
ProfileBadgesEvent.KIND,
|
||||
EmojiPackSelectionEvent.KIND,
|
||||
EphemeralChatListEvent.KIND,
|
||||
ChannelListEvent.KIND,
|
||||
|
||||
@@ -47,7 +47,9 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
@@ -87,6 +89,8 @@ val NotificationsPerKeyKinds2 =
|
||||
CalendarRSVPEvent.KIND,
|
||||
InteractiveStoryPrologueEvent.KIND,
|
||||
InteractiveStorySceneEvent.KIND,
|
||||
LiveChessGameAcceptEvent.KIND,
|
||||
LiveChessMoveEvent.KIND,
|
||||
)
|
||||
|
||||
val NotificationsPerKeyKinds3 =
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -261,7 +262,7 @@ fun observeUserBookmarkCount(
|
||||
UserFinderFilterAssemblerSubscription(user, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow =
|
||||
val newFlow =
|
||||
remember(user) {
|
||||
accountViewModel
|
||||
.bookmarks(user)
|
||||
@@ -274,7 +275,27 @@ fun observeUserBookmarkCount(
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(0)
|
||||
val oldFlow =
|
||||
remember(user) {
|
||||
accountViewModel
|
||||
.oldBookmarks(user)
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.sample(200)
|
||||
.mapLatest { noteState ->
|
||||
(noteState.note.event as? OldBookmarkListEvent)?.countBookmarks() ?: 0
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
val combined =
|
||||
remember(user) {
|
||||
kotlinx.coroutines.flow.combine(newFlow, oldFlow) { newCount, oldCount ->
|
||||
newCount + oldCount
|
||||
}
|
||||
}
|
||||
|
||||
return combined.collectAsStateWithLifecycle(0)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
|
||||
@@ -39,13 +39,14 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
@@ -61,6 +62,7 @@ val SearchPostsByTextKinds1 =
|
||||
BadgeDefinitionEvent.KIND,
|
||||
PeopleListEvent.KIND,
|
||||
BookmarkListEvent.KIND,
|
||||
OldBookmarkListEvent.KIND,
|
||||
AudioHeaderEvent.KIND,
|
||||
AudioTrackEvent.KIND,
|
||||
PinListEvent.KIND,
|
||||
|
||||
@@ -195,7 +195,7 @@ fun uriToRoute(
|
||||
if (isWalletConnectRoute(uri)) {
|
||||
try {
|
||||
val url = UriParser(uri)
|
||||
val nip47Uri = url.getQueryParameter("value")
|
||||
val nip47Uri = url.getQueryParameter("value")?.firstOrNull()
|
||||
if (nip47Uri != null) {
|
||||
Nip47WalletConnect.parse(nip47Uri)
|
||||
return Route.Nip47NWCSetup(nip47Uri)
|
||||
|
||||
@@ -205,47 +205,50 @@ open class EditPostViewModel : ViewModel() {
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
results.successful.forEach { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 =
|
||||
myAccount.createNip95(
|
||||
byteArray = state.result.bytes,
|
||||
headerInfo = state.result.fileHeader,
|
||||
alt = alt,
|
||||
contentWarningReason = if (sensitiveContent) "" else null,
|
||||
)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
val urls =
|
||||
results.successful.mapNotNull { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 =
|
||||
myAccount.createNip95(
|
||||
byteArray = state.result.bytes,
|
||||
headerInfo = state.result.fileHeader,
|
||||
alt = alt,
|
||||
contentWarningReason = if (sensitiveContent) "" else null,
|
||||
)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
note?.let {
|
||||
"nostr:" + it.toNEvent()
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
if (sensitiveContent) sensitiveContent("")
|
||||
}.build()
|
||||
|
||||
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
||||
|
||||
state.result.url
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
if (sensitiveContent) sensitiveContent("")
|
||||
}.build()
|
||||
|
||||
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
|
||||
|
||||
message = message.insertUrlAtCursor(state.result.url)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
}
|
||||
|
||||
message = message.insertUrlAtCursor(urls.joinToString(" "))
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
this@EditPostViewModel.multiOrchestrator = null
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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
|
||||
|
||||
import androidx.compose.foundation.text.input.OutputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldBuffer
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class UrlUserTagOutputTransformation(
|
||||
val color: Color,
|
||||
) : OutputTransformation {
|
||||
override fun TextFieldBuffer.transformOutput() {
|
||||
val text = asCharSequence().toString()
|
||||
|
||||
// Find all user mentions using regex and replace in reverse order
|
||||
// so that earlier indices remain valid after replacements.
|
||||
// Matches: @npub1..., nostr:npub1..., @nprofile1..., nostr:nprofile1...
|
||||
val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)")
|
||||
val matches = mentionRegex.findAll(text).toList().reversed()
|
||||
|
||||
// Phase 1: Replace all mentions (reverse order keeps indices valid for replace).
|
||||
// Collect replacement info because addStyle must be called after all text mutations.
|
||||
// (originalStart, originalMatchLength, displayNameLength)
|
||||
val replacements = mutableListOf<Triple<Int, Int, Int>>()
|
||||
|
||||
for (match in matches) {
|
||||
try {
|
||||
val bech32 =
|
||||
match.value
|
||||
.removePrefix("@")
|
||||
.removePrefix("nostr:")
|
||||
val hex = decodePublicKeyAsHexOrNull(bech32) ?: continue
|
||||
val user = LocalCache.getOrCreateUser(hex)
|
||||
val displayName = "@${user.toBestDisplayName()}"
|
||||
|
||||
replace(match.range.first, match.range.last + 1, displayName)
|
||||
replacements.add(Triple(match.range.first, match.range.last + 1 - match.range.first, displayName.length))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Apply styles after all text mutations are finalized.
|
||||
// Iterate in forward document order, tracking cumulative shift from prior replacements.
|
||||
val style = SpanStyle(color = color, textDecoration = TextDecoration.None)
|
||||
var cumulativeShift = 0
|
||||
for ((originalStart, originalLen, newLen) in replacements.reversed()) {
|
||||
val adjustedStart = originalStart + cumulativeShift
|
||||
addStyle(style, adjustedStart, adjustedStart + newLen)
|
||||
cumulativeShift += newLen - originalLen
|
||||
}
|
||||
|
||||
// Highlight URLs in remaining text
|
||||
highlightUrls(color)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TextFieldBuffer.highlightUrls(color: Color) {
|
||||
val text = asCharSequence().toString()
|
||||
val urlPattern = android.util.Patterns.WEB_URL
|
||||
|
||||
val matcher = urlPattern.matcher(text)
|
||||
while (matcher.find()) {
|
||||
addStyle(
|
||||
SpanStyle(color = color, textDecoration = TextDecoration.None),
|
||||
matcher.start(),
|
||||
matcher.end(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,10 @@ import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
data class RangesChanges(
|
||||
@@ -60,20 +62,19 @@ fun buildAnnotatedStringWithUrlHighlighting(
|
||||
text.text.split('\n').joinToString("\n") { paragraph: String ->
|
||||
paragraph.split(' ').joinToString(" ") { word: String ->
|
||||
try {
|
||||
if (word.startsWith("@npub") && word.length >= 64) {
|
||||
val keyB32 = word.substring(0, 64)
|
||||
val restOfWord = word.substring(64)
|
||||
val mention = parseUserMention(word)
|
||||
if (mention != null) {
|
||||
val (keyPortion, restOfWord, hexKey) = mention
|
||||
|
||||
val startIndex = builderBefore.toString().length
|
||||
|
||||
builderBefore.append(
|
||||
"$keyB32$restOfWord ",
|
||||
"$keyPortion$restOfWord ",
|
||||
) // accounts for the \n at the end of each paragraph
|
||||
|
||||
val endIndex = startIndex + keyB32.length
|
||||
val endIndex = startIndex + keyPortion.length
|
||||
|
||||
val key = decodePublicKey(keyB32.removePrefix("@"))
|
||||
val user = LocalCache.getOrCreateUser(key.toHexKey())
|
||||
val user = LocalCache.getOrCreateUser(hexKey)
|
||||
|
||||
val newWord = "@${user.toBestDisplayName()}"
|
||||
val startNew = builderAfter.toString().length
|
||||
@@ -181,3 +182,46 @@ fun buildAnnotatedStringWithUrlHighlighting(
|
||||
numberOffsetTranslator,
|
||||
)
|
||||
}
|
||||
|
||||
private data class UserMention(
|
||||
val keyPortion: String,
|
||||
val restOfWord: String,
|
||||
val hexKey: String,
|
||||
)
|
||||
|
||||
private fun parseUserMention(word: String): UserMention? {
|
||||
var key = word
|
||||
val prefix: String
|
||||
|
||||
if (key.startsWith("nostr:", true)) {
|
||||
prefix = key.substring(0, 6)
|
||||
key = key.substring(6)
|
||||
} else if (key.startsWith("@")) {
|
||||
prefix = "@"
|
||||
key = key.substring(1)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
if (key.startsWith("npub1", true) && key.length >= 63) {
|
||||
val keyB32 = key.substring(0, 63)
|
||||
val restOfWord = key.substring(63)
|
||||
val hex = decodePublicKeyAsHexOrNull(keyB32) ?: return null
|
||||
return UserMention("$prefix$keyB32", restOfWord, hex)
|
||||
} else if (key.startsWith("nprofile1", true)) {
|
||||
val parsed = Nip19Parser.uriToRoute(key) ?: return null
|
||||
val entity = parsed.entity
|
||||
if (entity !is NProfile && entity !is NPub) return null
|
||||
val hex =
|
||||
when (entity) {
|
||||
is NProfile -> entity.hex
|
||||
is NPub -> entity.hex
|
||||
else -> return null
|
||||
}
|
||||
val bech32Len = parsed.nip19raw.length
|
||||
val restOfWord = key.substring(bech32Len)
|
||||
return UserMention("$prefix${parsed.nip19raw}", restOfWord, hex)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -439,6 +440,7 @@ fun Event.toKindName(): String =
|
||||
is VoiceEvent -> stringRes(R.string.voice_post)
|
||||
is VoiceReplyEvent -> stringRes(R.string.voice_reply)
|
||||
is BookmarkListEvent -> stringRes(R.string.bookmarks)
|
||||
is OldBookmarkListEvent -> stringRes(R.string.bookmarks)
|
||||
else -> stringRes(R.string.post)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.content.MediaType
|
||||
import androidx.compose.foundation.content.ReceiveContentListener
|
||||
import androidx.compose.foundation.content.TransferableContent
|
||||
import androidx.compose.foundation.content.consume
|
||||
import androidx.compose.foundation.content.contentReceiver
|
||||
import androidx.compose.foundation.content.hasMediaType
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -27,6 +35,10 @@ import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.InputTransformation
|
||||
import androidx.compose.foundation.text.input.OutputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
@@ -35,8 +47,11 @@ import androidx.compose.material3.TextFieldColors
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
@@ -50,6 +65,155 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
// COPIED FROM TEXT FIELD
|
||||
// The only change is the contentPadding below
|
||||
|
||||
// New TextFieldState-based overload for GIF keyboard support
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ThinPaddingTextField(
|
||||
state: TextFieldState,
|
||||
modifier: Modifier = Modifier,
|
||||
onTextChanged: (() -> Unit)? = null,
|
||||
onContentReceived: ((Uri, String?) -> Unit)? = null,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
textStyle: TextStyle = LocalTextStyle.current,
|
||||
inputTransformation: InputTransformation? = null,
|
||||
outputTransformation: OutputTransformation? = null,
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
prefix: @Composable (() -> Unit)? = null,
|
||||
suffix: @Composable (() -> Unit)? = null,
|
||||
supportingText: @Composable (() -> Unit)? = null,
|
||||
isError: Boolean = false,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
singleLine: Boolean = false,
|
||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
minLines: Int = 1,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
shape: Shape = TextFieldDefaults.shape,
|
||||
colors: TextFieldColors = TextFieldDefaults.colors(),
|
||||
contentPadding: PaddingValues =
|
||||
if (label == null) {
|
||||
TextFieldDefaults.contentPaddingWithoutLabel(
|
||||
start = 10.dp,
|
||||
top = 12.dp,
|
||||
end = 10.dp,
|
||||
bottom = 12.dp,
|
||||
)
|
||||
} else {
|
||||
TextFieldDefaults.contentPaddingWithLabel(
|
||||
start = 10.dp,
|
||||
top = 12.dp,
|
||||
end = 10.dp,
|
||||
bottom = 12.dp,
|
||||
)
|
||||
},
|
||||
) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val interactionSource = interactionSource ?: remember { MutableInteractionSource() }
|
||||
|
||||
if (onTextChanged != null) {
|
||||
val callback by rememberUpdatedState(onTextChanged)
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.text }
|
||||
.collect { callback() }
|
||||
}
|
||||
}
|
||||
|
||||
val textColor =
|
||||
textStyle.color.takeOrElse {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
when {
|
||||
!enabled -> MaterialTheme.colorScheme.placeholderText
|
||||
isError -> MaterialTheme.colorScheme.onSurface
|
||||
focused -> MaterialTheme.colorScheme.onSurface
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
}
|
||||
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
|
||||
|
||||
val lineLimits =
|
||||
if (singleLine) {
|
||||
TextFieldLineLimits.SingleLine
|
||||
} else {
|
||||
TextFieldLineLimits.MultiLine(minLines, maxLines)
|
||||
}
|
||||
|
||||
val contentModifier =
|
||||
if (onContentReceived != null) {
|
||||
modifier.contentReceiver(
|
||||
object : ReceiveContentListener {
|
||||
override fun onReceive(transferableContent: TransferableContent): TransferableContent? {
|
||||
if (!transferableContent.hasMediaType(MediaType.Image)) {
|
||||
return transferableContent
|
||||
}
|
||||
val remaining =
|
||||
transferableContent.consume { item ->
|
||||
val uri = item.uri
|
||||
if (uri != null) {
|
||||
onContentReceived(
|
||||
uri,
|
||||
transferableContent.clipEntry.clipData.description
|
||||
.getMimeType(0),
|
||||
)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
modifier
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
|
||||
BasicTextField(
|
||||
state = state,
|
||||
modifier =
|
||||
contentModifier
|
||||
.defaultMinSize(
|
||||
minWidth = TextFieldDefaults.MinWidth,
|
||||
minHeight = 36.dp,
|
||||
),
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
textStyle = mergedTextStyle,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = keyboardOptions,
|
||||
lineLimits = lineLimits,
|
||||
interactionSource = interactionSource,
|
||||
inputTransformation = inputTransformation,
|
||||
outputTransformation = outputTransformation,
|
||||
decorator = { innerTextField ->
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = state.text.toString(),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = placeholder,
|
||||
label = label,
|
||||
leadingIcon = leadingIcon,
|
||||
trailingIcon = trailingIcon,
|
||||
prefix = prefix,
|
||||
suffix = suffix,
|
||||
supportingText = supportingText,
|
||||
shape = shape,
|
||||
singleLine = singleLine,
|
||||
enabled = enabled,
|
||||
isError = isError,
|
||||
interactionSource = interactionSource,
|
||||
colors = colors,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy TextFieldValue-based overload (kept for readonly dropdown fields)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ThinPaddingTextField(
|
||||
@@ -76,7 +240,6 @@ fun ThinPaddingTextField(
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
shape: Shape = TextFieldDefaults.shape,
|
||||
colors: TextFieldColors = TextFieldDefaults.colors(),
|
||||
// new fields
|
||||
contentPadding: PaddingValues =
|
||||
if (label == null) {
|
||||
TextFieldDefaults.contentPaddingWithoutLabel(
|
||||
@@ -113,6 +276,7 @@ fun ThinPaddingTextField(
|
||||
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
|
||||
|
||||
CompositionLocalProvider(LocalTextSelectionColors provides colors.textSelectionColors) {
|
||||
@Suppress("DEPRECATION")
|
||||
BasicTextField(
|
||||
value = value,
|
||||
modifier =
|
||||
|
||||
@@ -56,6 +56,9 @@ object ScrollStateKeys {
|
||||
const val DISCOVER_CHATS = "DiscoverChatsFeed"
|
||||
|
||||
const val POLLS_SCREEN = "PollsFeed"
|
||||
const val PICTURES_SCREEN = "PicturesFeed"
|
||||
const val SHORTS_SCREEN = "ShortsFeed"
|
||||
const val LONGS_SCREEN = "LongsFeed"
|
||||
|
||||
const val SEARCH_SCREEN = "SearchFeed"
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
|
||||
import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
@@ -64,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfB
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata.BookmarkGroupMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
|
||||
@@ -97,10 +99,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.LongsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPickFollowsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
|
||||
@@ -124,6 +128,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScr
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
|
||||
@@ -175,6 +180,9 @@ fun BuildNavigation(
|
||||
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
||||
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
|
||||
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
|
||||
@@ -210,6 +218,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(nav) }
|
||||
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(nav) }
|
||||
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.OldBookmarks> { OldBookmarkListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -58,7 +58,10 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.Drafts
|
||||
import androidx.compose.material.icons.outlined.GroupAdd
|
||||
import androidx.compose.material.icons.outlined.Language
|
||||
import androidx.compose.material.icons.outlined.Photo
|
||||
import androidx.compose.material.icons.outlined.PlayCircle
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.SmartDisplay
|
||||
import androidx.compose.material.icons.outlined.Sync
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -581,6 +584,30 @@ fun ListContent(
|
||||
route = Route.Polls,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.pictures,
|
||||
icon = Icons.Outlined.Photo,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Pictures,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.shorts,
|
||||
icon = Icons.Outlined.PlayCircle,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Shorts,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.longs,
|
||||
icon = Icons.Outlined.SmartDisplay,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Longs,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.wallet,
|
||||
icon = Icons.Outlined.AccountBalanceWallet,
|
||||
|
||||
@@ -43,6 +43,12 @@ sealed class Route {
|
||||
|
||||
@Serializable object Polls : Route()
|
||||
|
||||
@Serializable object Pictures : Route()
|
||||
|
||||
@Serializable object Shorts : Route()
|
||||
|
||||
@Serializable object Longs : Route()
|
||||
|
||||
@Serializable object Chess : Route()
|
||||
|
||||
@Serializable object Wallet : Route()
|
||||
@@ -65,6 +71,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object Bookmarks : Route()
|
||||
|
||||
@Serializable object OldBookmarks : Route()
|
||||
|
||||
@Serializable object BookmarkGroups : Route()
|
||||
|
||||
@Serializable object ImportFollowsSelectUser : Route()
|
||||
|
||||
@@ -256,8 +256,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.messagefield
|
||||
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
|
||||
interface IMessageField {
|
||||
var message: TextFieldValue
|
||||
val message: TextFieldState
|
||||
|
||||
fun updateMessage(newMessage: TextFieldValue)
|
||||
fun onMessageChanged()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.messagefield
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
@@ -39,7 +40,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@@ -51,6 +52,7 @@ fun MessageField(
|
||||
placeholder: Int,
|
||||
viewModel: IMessageField,
|
||||
requestFocus: Boolean = true,
|
||||
onContentReceived: ((Uri, String?) -> Unit)? = null,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
@@ -65,8 +67,9 @@ fun MessageField(
|
||||
}
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = viewModel.message,
|
||||
onValueChange = viewModel::updateMessage,
|
||||
state = viewModel.message,
|
||||
onTextChanged = viewModel::onMessageChanged,
|
||||
onContentReceived = onContentReceived,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
@@ -91,7 +94,7 @@ fun MessageField(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
contentPadding =
|
||||
TextFieldDefaults.contentPaddingWithoutLabel(
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.previews
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.vitorpamplona.amethyst.service.CachedUrlParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -33,22 +32,22 @@ import kotlinx.coroutines.flow.map
|
||||
|
||||
@Stable
|
||||
class PreviewState {
|
||||
var source = MutableStateFlow(TextFieldValue(""))
|
||||
var source = MutableStateFlow("")
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val results =
|
||||
source
|
||||
.debounce(500)
|
||||
.map {
|
||||
CachedUrlParser.parseValidUrls(it.text)
|
||||
CachedUrlParser.parseValidUrls(it)
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun reset() {
|
||||
source.tryEmit(TextFieldValue(""))
|
||||
source.tryEmit("")
|
||||
}
|
||||
|
||||
fun update(text: TextFieldValue) {
|
||||
fun update(text: String) {
|
||||
source.tryEmit(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.creators.userSuggestions
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@@ -192,11 +193,24 @@ class UserSuggestionState(
|
||||
item: User,
|
||||
): TextFieldValue {
|
||||
val lastWordStart = message.selection.end - word.length
|
||||
val wordToInsert = "@${item.pubkeyNpub()}"
|
||||
val wordToInsert = "@${item.pubkeyNpub()} "
|
||||
|
||||
return TextFieldValue(
|
||||
message.text.replaceRange(lastWordStart, message.selection.end, wordToInsert),
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length),
|
||||
)
|
||||
}
|
||||
|
||||
fun replaceCurrentWord(
|
||||
state: TextFieldState,
|
||||
word: String,
|
||||
item: User,
|
||||
) {
|
||||
val wordToInsert = "@${item.pubkeyNpub()} "
|
||||
state.edit {
|
||||
val lastWordStart = selection.end - word.length
|
||||
replace(lastWordStart, selection.end, wordToInsert)
|
||||
selection = TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.note.nip22Comments
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -34,6 +36,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -147,7 +150,7 @@ open class CommentPostViewModel :
|
||||
|
||||
var notifying by mutableStateOf<List<User>?>(null)
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
override val message = TextFieldState()
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
@@ -242,7 +245,7 @@ open class CommentPostViewModel :
|
||||
}
|
||||
|
||||
open fun quote(quote: Note) {
|
||||
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
|
||||
message.setTextAndPlaceCursorAtBeginning(message.text.toString() + "\nnostr:${quote.toNEvent()}")
|
||||
|
||||
quote.author?.let { quotedUser ->
|
||||
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
@@ -262,7 +265,7 @@ open class CommentPostViewModel :
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
|
||||
urlPreviews.update(message)
|
||||
urlPreviews.update(message.text.toString())
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draft: Note) {
|
||||
@@ -322,11 +325,11 @@ open class CommentPostViewModel :
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
|
||||
message = TextFieldValue(draftEvent.content)
|
||||
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
|
||||
|
||||
iMetaAttachments.addAll(draftEvent.imetas())
|
||||
|
||||
urlPreviews.update(message)
|
||||
urlPreviews.update(message.text.toString())
|
||||
}
|
||||
|
||||
suspend fun sendPostSync() {
|
||||
@@ -359,7 +362,7 @@ open class CommentPostViewModel :
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
if (message.text.isBlank()) {
|
||||
if (message.text.toString().isBlank()) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
||||
} else {
|
||||
val attachments = mutableSetOf<Event>()
|
||||
@@ -376,7 +379,7 @@ open class CommentPostViewModel :
|
||||
private suspend fun createTemplate(): EventTemplate<out Event>? {
|
||||
val tagger =
|
||||
NewMessageTagger(
|
||||
message = message.text,
|
||||
message = message.text.toString(),
|
||||
dao = accountViewModel,
|
||||
)
|
||||
tagger.run()
|
||||
@@ -518,41 +521,45 @@ open class CommentPostViewModel :
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
results.successful.forEach { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
val urls =
|
||||
results.successful.mapNotNull { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
urlPreviews.update(message)
|
||||
note?.let {
|
||||
"nostr:" + it.toNEvent()
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
alt?.let { alt(it) }
|
||||
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
|
||||
}.build()
|
||||
|
||||
iMetaAttachments.replace(iMeta.url, iMeta)
|
||||
|
||||
state.result.url
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
alt?.let { alt(it) }
|
||||
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
|
||||
}.build()
|
||||
|
||||
iMetaAttachments.replace(iMeta.url, iMeta)
|
||||
|
||||
message = message.insertUrlAtCursor(state.result.url)
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
}
|
||||
|
||||
message.insertUrlAtCursor(urls.joinToString(" "))
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
multiOrchestrator = null
|
||||
} else {
|
||||
@@ -576,7 +583,7 @@ open class CommentPostViewModel :
|
||||
open fun cancel() {
|
||||
draftTag.rotate()
|
||||
|
||||
message = TextFieldValue("")
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
|
||||
replyingTo = null
|
||||
externalIdentity = null
|
||||
@@ -618,9 +625,8 @@ open class CommentPostViewModel :
|
||||
notifying = notifying?.filter { it != userToRemove }
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
urlPreviews.update(message)
|
||||
override fun onMessageChanged() {
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = message.currentWord()
|
||||
@@ -651,8 +657,8 @@ open class CommentPostViewModel :
|
||||
userSuggestions?.let { userSuggestions ->
|
||||
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
|
||||
val lastWord = message.currentWord()
|
||||
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message)
|
||||
userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message.text.toString())
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo.value.addItem(item)
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
@@ -668,8 +674,8 @@ open class CommentPostViewModel :
|
||||
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -685,8 +691,8 @@ open class CommentPostViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -694,14 +700,14 @@ open class CommentPostViewModel :
|
||||
}
|
||||
|
||||
fun canPost(): Boolean =
|
||||
message.text.isNotBlank() &&
|
||||
message.text.toString().isNotBlank() &&
|
||||
!mediaUploadTracker.isUploading &&
|
||||
!wantsInvoice &&
|
||||
(!wantsZapraiser || zapRaiserAmount.value != null) &&
|
||||
multiOrchestrator == null
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
@@ -718,7 +724,7 @@ open class CommentPostViewModel :
|
||||
override fun updateZapFromText() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val tagger =
|
||||
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
|
||||
NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
|
||||
tagger.run()
|
||||
tagger.pTags?.forEach { taggedUser ->
|
||||
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -46,7 +47,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -125,7 +125,8 @@ fun ReplyCommentPostScreen(
|
||||
postViewModel.quote(it)
|
||||
}
|
||||
message?.ifBlank { null }?.let {
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
|
||||
postViewModel.onMessageChanged()
|
||||
}
|
||||
attachment?.ifBlank { null }?.toUri()?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -53,8 +53,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
|
||||
@Composable
|
||||
fun BadgeDisplay(
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -97,11 +98,12 @@ fun RenderLiveChessChallenge(
|
||||
) {
|
||||
val event = (note.event as? LiveChessGameChallengeEvent) ?: return
|
||||
val gameId = event.gameId()
|
||||
val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity
|
||||
|
||||
val chessViewModel: ChessViewModelNew =
|
||||
viewModel(
|
||||
key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}",
|
||||
factory = ChessViewModelFactory(accountViewModel.account),
|
||||
factory = ChessViewModelFactory(accountViewModel.account, activity.application),
|
||||
)
|
||||
|
||||
val isOpenChallenge = event.opponentPubkey() == null
|
||||
|
||||
@@ -40,11 +40,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal.DraftEventsFeedF
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.dal.LongsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.dal.PictureFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal.ShortsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -72,6 +75,10 @@ class AccountFeedContentStates(
|
||||
|
||||
val pollsFeed = FeedContentState(PollsFeedFilter(account), scope, LocalCache)
|
||||
|
||||
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
|
||||
val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache)
|
||||
val longsFeed = FeedContentState(LongsFeedFilter(account), scope, LocalCache)
|
||||
|
||||
val notifications = CardFeedContentState(NotificationFeedFilter(account), scope)
|
||||
val notificationsOpenPolls = OpenPollsState(account, scope)
|
||||
val notificationSummary = NotificationSummaryState(account)
|
||||
@@ -108,6 +115,10 @@ class AccountFeedContentStates(
|
||||
|
||||
pollsFeed.updateFeedWith(newNotes)
|
||||
|
||||
picturesFeed.updateFeedWith(newNotes)
|
||||
shortsFeed.updateFeedWith(newNotes)
|
||||
longsFeed.updateFeedWith(newNotes)
|
||||
|
||||
notifications.updateFeedWith(newNotes)
|
||||
notificationSummary.invalidateInsertData(newNotes)
|
||||
|
||||
@@ -138,6 +149,10 @@ class AccountFeedContentStates(
|
||||
|
||||
pollsFeed.deleteFromFeed(newNotes)
|
||||
|
||||
picturesFeed.deleteFromFeed(newNotes)
|
||||
shortsFeed.deleteFromFeed(newNotes)
|
||||
longsFeed.deleteFromFeed(newNotes)
|
||||
|
||||
notifications.deleteFromFeed(newNotes)
|
||||
notificationSummary.invalidateInsertData(newNotes)
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -850,6 +851,8 @@ class AccountViewModel(
|
||||
|
||||
fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex))
|
||||
|
||||
fun oldBookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(OldBookmarkListEvent.createBookmarkAddress(user.pubkeyHex))
|
||||
|
||||
fun pinnedNotes(user: User): Note = LocalCache.getOrCreateAddressableNote(PinListEvent.createPinAddress(user.pubkeyHex))
|
||||
|
||||
fun addPin(note: Note) {
|
||||
|
||||
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.Discove
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssemblerSubscription
|
||||
import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -165,7 +166,22 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
|
||||
|
||||
@Composable
|
||||
private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
|
||||
if (accountViewModel.account.signer is IActivityLauncher) {
|
||||
val externalSignerLauncher =
|
||||
when (val signer = accountViewModel.account.signer) {
|
||||
is IActivityLauncher -> {
|
||||
signer
|
||||
}
|
||||
|
||||
is NostrSignerWithClientTag if signer.inner is IActivityLauncher -> {
|
||||
signer.inner as IActivityLauncher
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (externalSignerLauncher != null) {
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
@@ -173,7 +189,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.let {
|
||||
accountViewModel.runOnIO {
|
||||
accountViewModel.account.signer.newResponse(it)
|
||||
externalSignerLauncher.newResponse(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +197,7 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
|
||||
)
|
||||
|
||||
DisposableEffect(accountViewModel, accountViewModel.account, launcher) {
|
||||
val launcher: (Intent) -> Unit = { intent ->
|
||||
val intentLauncher: (Intent) -> Unit = { intent ->
|
||||
try {
|
||||
launcher.launch(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
@@ -193,9 +209,9 @@ private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
accountViewModel.account.signer.registerForegroundLauncher(launcher)
|
||||
externalSignerLauncher.registerForegroundLauncher(intentLauncher)
|
||||
onDispose {
|
||||
accountViewModel.account.signer.unregisterForegroundLauncher(launcher)
|
||||
externalSignerLauncher.unregisterForegroundLauncher(intentLauncher)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -55,8 +56,10 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
@Composable
|
||||
fun ListOfBookmarkGroupsFeedView(
|
||||
defaultBookmarks: BookmarkListState,
|
||||
oldBookmarks: OldBookmarkListState,
|
||||
groupListFeedSource: StateFlow<List<LabeledBookmarkList>>,
|
||||
openDefaultBookmarks: () -> Unit,
|
||||
openOldBookmarks: () -> Unit,
|
||||
onOpenItem: (String, BookmarkType) -> Unit,
|
||||
onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
@@ -74,6 +77,11 @@ fun ListOfBookmarkGroupsFeedView(
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
|
||||
item {
|
||||
OldBookmarkList(oldBookmarks, openOldBookmarks)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
bookmarkGroupFeedState,
|
||||
key = { _: Int, item: LabeledBookmarkList -> item.identifier },
|
||||
@@ -135,3 +143,47 @@ fun DefaultBookmarkList(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OldBookmarkList(
|
||||
oldBookmarks: OldBookmarkListState,
|
||||
openOldBookmarks: () -> Unit,
|
||||
) {
|
||||
val bookmarkState by oldBookmarks.bookmarks.collectAsStateWithLifecycle()
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = openOldBookmarks),
|
||||
headlineContent = {
|
||||
Text(stringRes(R.string.old_bookmarks_title), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
supportingContent = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
stringRes(R.string.old_bookmarks_explainer),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.BookmarkBorder,
|
||||
contentDescription = stringRes(R.string.bookmark_list_icon_label),
|
||||
modifier = Size40Modifier,
|
||||
)
|
||||
Spacer(StdVertSpacer)
|
||||
BookmarkMembershipStatusAndNumberDisplay(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
postBookmarksSize = bookmarkState.public.size + bookmarkState.private.size,
|
||||
articleBookmarksSize = 0,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
@@ -51,8 +52,10 @@ fun ListOfBookmarkGroupsScreen(
|
||||
) {
|
||||
ListOfBookmarkGroupsFeed(
|
||||
defaultBookmarks = accountViewModel.account.bookmarkState,
|
||||
oldBookmarks = accountViewModel.account.oldBookmarkState,
|
||||
listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow,
|
||||
openDefaultBookmarks = { nav.nav(Route.Bookmarks) },
|
||||
openOldBookmarks = { nav.nav(Route.OldBookmarks) },
|
||||
addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) },
|
||||
openBookmarkGroup = { identifier, bookmarkType ->
|
||||
nav.nav(Route.BookmarkGroupView(identifier, bookmarkType))
|
||||
@@ -88,8 +91,10 @@ fun ListOfBookmarkGroupsScreen(
|
||||
@Composable
|
||||
fun ListOfBookmarkGroupsFeed(
|
||||
defaultBookmarks: BookmarkListState,
|
||||
oldBookmarks: OldBookmarkListState,
|
||||
listSource: StateFlow<List<LabeledBookmarkList>>,
|
||||
openDefaultBookmarks: () -> Unit,
|
||||
openOldBookmarks: () -> Unit,
|
||||
addBookmarkGroup: () -> Unit,
|
||||
openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit,
|
||||
renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
@@ -115,8 +120,10 @@ fun ListOfBookmarkGroupsFeed(
|
||||
) {
|
||||
ListOfBookmarkGroupsFeedView(
|
||||
defaultBookmarks = defaultBookmarks,
|
||||
oldBookmarks = oldBookmarks,
|
||||
groupListFeedSource = listSource,
|
||||
openDefaultBookmarks = openDefaultBookmarks,
|
||||
openOldBookmarks = openOldBookmarks,
|
||||
onOpenItem = openBookmarkGroup,
|
||||
onRenameItem = renameBookmarkGroup,
|
||||
onItemDescriptionChange = changeBookmarkGroupDescription,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.bookmarkgroups.old
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.DriveFileMove
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal.OldBookmarkPrivateFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.dal.OldBookmarkPublicFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun OldBookmarkListScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val publicFeedViewModel: OldBookmarkPublicFeedViewModel =
|
||||
viewModel(
|
||||
key = "NostrOldBookmarkPublicFeedViewModel",
|
||||
factory = OldBookmarkPublicFeedViewModel.Factory(accountViewModel.account),
|
||||
)
|
||||
|
||||
val privateFeedViewModel: OldBookmarkPrivateFeedViewModel =
|
||||
viewModel(
|
||||
key = "NostrOldBookmarkPrivateFeedViewModel",
|
||||
factory = OldBookmarkPrivateFeedViewModel.Factory(accountViewModel.account),
|
||||
)
|
||||
|
||||
val bookmarkState by accountViewModel.account.oldBookmarkState.bookmarks
|
||||
.collectAsStateWithLifecycle(null)
|
||||
|
||||
LaunchedEffect(bookmarkState) {
|
||||
publicFeedViewModel.invalidateData()
|
||||
privateFeedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
RenderOldBookmarkScreen(publicFeedViewModel, privateFeedViewModel, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun RenderOldBookmarkScreen(
|
||||
publicFeedViewModel: OldBookmarkPublicFeedViewModel,
|
||||
privateFeedViewModel: OldBookmarkPrivateFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val pagerState = rememberPagerState { 2 }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
Column {
|
||||
TopBarWithBackButton(stringRes(id = R.string.old_bookmarks_title), nav::popBack)
|
||||
SecondaryTabRow(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
modifier = TabRowHeight,
|
||||
) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 0,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
|
||||
text = { Text(text = stringRes(R.string.private_bookmarks)) },
|
||||
)
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 1,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
|
||||
text = { Text(text = stringRes(R.string.public_bookmarks)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(stringRes(R.string.migrate_bookmarks_button)) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.DriveFileMove,
|
||||
contentDescription = stringRes(R.string.migrate_bookmarks_button),
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.migrateOldBookmarksToNew()
|
||||
coroutineScope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.migrate_bookmarks_success),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
Column(Modifier.padding(it).fillMaxHeight()) {
|
||||
HorizontalPager(state = pagerState) { page ->
|
||||
when (page) {
|
||||
0 -> {
|
||||
RefresheableFeedView(
|
||||
privateFeedViewModel,
|
||||
null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
RefresheableFeedView(
|
||||
publicFeedViewModel,
|
||||
null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.bookmarkgroups.old.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
|
||||
class OldBookmarkPrivateFeedFilter(
|
||||
val account: Account,
|
||||
) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String =
|
||||
account.oldBookmarkState.bookmarks.value
|
||||
.hashCode()
|
||||
.toString()
|
||||
|
||||
override fun feed(): List<Note> = account.oldBookmarkState.bookmarks.value.private
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.bookmarkgroups.old.dal
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
|
||||
|
||||
@Stable
|
||||
class OldBookmarkPrivateFeedViewModel(
|
||||
val account: Account,
|
||||
) : AndroidFeedViewModel(OldBookmarkPrivateFeedFilter(account)) {
|
||||
class Factory(
|
||||
val account: Account,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = OldBookmarkPrivateFeedViewModel(account) as T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.bookmarkgroups.old.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
|
||||
class OldBookmarkPublicFeedFilter(
|
||||
val account: Account,
|
||||
) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String =
|
||||
account.oldBookmarkState.bookmarks.value
|
||||
.hashCode()
|
||||
.toString()
|
||||
|
||||
override fun feed(): List<Note> = account.oldBookmarkState.bookmarks.value.public
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.bookmarkgroups.old.dal
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
|
||||
|
||||
@Stable
|
||||
class OldBookmarkPublicFeedViewModel(
|
||||
val account: Account,
|
||||
) : AndroidFeedViewModel(OldBookmarkPublicFeedFilter(account)) {
|
||||
class Factory(
|
||||
val account: Account,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = OldBookmarkPublicFeedViewModel(account) as T
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,11 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
@@ -108,7 +108,8 @@ fun ChatroomView(
|
||||
|
||||
if (draftMessage != null) {
|
||||
LaunchedEffect(key1 = draftMessage) {
|
||||
newPostModel.updateMessage(TextFieldValue(draftMessage))
|
||||
newPostModel.message.setTextAndPlaceCursorAtEnd(draftMessage)
|
||||
newPostModel.onMessageChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -203,7 +205,7 @@ class ChatNewMessageViewModel :
|
||||
|
||||
var uploadsWaitingToBeSent by mutableStateOf<List<SuccessfulUploads>>(emptyList())
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
override val message = TextFieldState()
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
@@ -215,8 +217,8 @@ class ChatNewMessageViewModel :
|
||||
|
||||
var emojiSuggestions: EmojiSuggestionState? = null
|
||||
|
||||
var toUsers by mutableStateOf(TextFieldValue(""))
|
||||
var subject by mutableStateOf(TextFieldValue(""))
|
||||
val toUsers = TextFieldState()
|
||||
val subject = TextFieldState()
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
@@ -273,10 +275,9 @@ class ChatNewMessageViewModel :
|
||||
|
||||
fun load(room: ChatroomKey) {
|
||||
this.room.tryEmit(room)
|
||||
this.toUsers =
|
||||
TextFieldValue(
|
||||
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
)
|
||||
this.toUsers.setTextAndPlaceCursorAtEnd(
|
||||
room.users.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
)
|
||||
}
|
||||
|
||||
fun reply(replyNote: Note) {
|
||||
@@ -290,8 +291,8 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
|
||||
fun quote(quote: Note) {
|
||||
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
|
||||
urlPreviews.update(message)
|
||||
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
// creates a split with that author.
|
||||
val quotedAuthor = quote.author ?: return
|
||||
@@ -371,14 +372,13 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
|
||||
draftEvent.subject()?.let {
|
||||
subject = TextFieldValue()
|
||||
subject.setTextAndPlaceCursorAtEnd("")
|
||||
}
|
||||
|
||||
if (draftEvent is NIP17Group) {
|
||||
toUsers =
|
||||
TextFieldValue(
|
||||
draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
)
|
||||
toUsers.setTextAndPlaceCursorAtEnd(
|
||||
draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
)
|
||||
|
||||
val replyId =
|
||||
when (draftEvent) {
|
||||
@@ -392,7 +392,7 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
} else if (draftEvent is PrivateDmEvent) {
|
||||
val recipientNPub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() }
|
||||
toUsers = TextFieldValue("@$recipientNPub")
|
||||
toUsers.setTextAndPlaceCursorAtEnd("@$recipientNPub")
|
||||
|
||||
val replyId = draftEvent.replyTo()
|
||||
if (replyId != null) {
|
||||
@@ -400,13 +400,14 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
message =
|
||||
val draftText =
|
||||
if (draftEvent is PrivateDmEvent) {
|
||||
TextFieldValue(accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: "")
|
||||
accountViewModel.account.privateDMDecryptionCache.cachedDM(draftEvent) ?: ""
|
||||
} else {
|
||||
TextFieldValue(draftEvent.content)
|
||||
draftEvent.content
|
||||
}
|
||||
urlPreviews.update(message)
|
||||
message.setTextAndPlaceCursorAtEnd(draftText)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
iMetaAttachments.addAll(draftEvent.imetas())
|
||||
}
|
||||
@@ -421,7 +422,7 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
if (message.text.isBlank()) {
|
||||
if (message.text.toString().isBlank()) {
|
||||
account.deleteDraftIgnoreErrors(draftTag.current)
|
||||
} else {
|
||||
innerSendPost(draftTag.current)
|
||||
@@ -563,11 +564,12 @@ class ChatNewMessageViewModel :
|
||||
private suspend fun innerSendPost(draftTag: String?) {
|
||||
val room = room.value ?: return
|
||||
|
||||
val urls = findURLs(message.text)
|
||||
val messageText = message.text.toString()
|
||||
val urls = findURLs(messageText)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value)
|
||||
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
|
||||
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
|
||||
val message = message.text
|
||||
val message = messageText
|
||||
|
||||
val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null
|
||||
val localExpirationDate = if (wantsExpirationDate) expirationDate else null
|
||||
@@ -633,8 +635,8 @@ class ChatNewMessageViewModel :
|
||||
fun cancel() {
|
||||
draftTag.rotate()
|
||||
|
||||
message = TextFieldValue("")
|
||||
subject = TextFieldValue("")
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
subject.setTextAndPlaceCursorAtEnd("")
|
||||
|
||||
replyTo.value = null
|
||||
|
||||
@@ -667,15 +669,15 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
|
||||
fun addToMessage(it: String) {
|
||||
updateMessage(TextFieldValue(message.text + " " + it))
|
||||
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
|
||||
onMessageChanged()
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
urlPreviews.update(newMessage)
|
||||
override fun onMessageChanged() {
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = newMessage.currentWord()
|
||||
val lastWord = message.currentWord()
|
||||
if (lastWord.startsWith("@")) {
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
@@ -689,11 +691,9 @@ class ChatNewMessageViewModel :
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun updateToUsers(newToUsersValue: TextFieldValue) {
|
||||
toUsers = newToUsersValue
|
||||
|
||||
if (newToUsersValue.selection.collapsed) {
|
||||
val lastWord = newToUsersValue.currentWord()
|
||||
fun onToUsersChanged() {
|
||||
if (toUsers.selection.collapsed) {
|
||||
val lastWord = toUsers.currentWord()
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
}
|
||||
@@ -706,7 +706,7 @@ class ChatNewMessageViewModel :
|
||||
fun updateRoomFromUsersInput() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
delay(300)
|
||||
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, accountViewModel)
|
||||
val toUsersTagger = NewMessageTagger(toUsers.text.toString(), null, null, accountViewModel)
|
||||
toUsersTagger.run()
|
||||
|
||||
val users = toUsersTagger.pTags?.mapTo(mutableSetOf()) { it.pubkeyHex }
|
||||
@@ -720,8 +720,7 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSubject(it: TextFieldValue) {
|
||||
subject = it
|
||||
fun onSubjectChanged() {
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
@@ -738,14 +737,14 @@ class ChatNewMessageViewModel :
|
||||
userSuggestions?.let { userSuggestions ->
|
||||
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
|
||||
val lastWord = message.currentWord()
|
||||
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message)
|
||||
userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message.text.toString())
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo.value.addItem(item)
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
|
||||
val lastWord = toUsers.currentWord()
|
||||
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
|
||||
userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
|
||||
updateRoomFromUsersInput()
|
||||
}
|
||||
|
||||
@@ -759,8 +758,8 @@ class ChatNewMessageViewModel :
|
||||
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -776,8 +775,8 @@ class ChatNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -794,8 +793,8 @@ class ChatNewMessageViewModel :
|
||||
recipientsMissingDmRelays.value.isEmpty()
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
urlPreviews.update(message)
|
||||
message.insertUrlAtCursor(newElement)
|
||||
urlPreviews.update(message.text.toString())
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
@@ -816,7 +815,7 @@ class ChatNewMessageViewModel :
|
||||
|
||||
override fun updateZapFromText() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
|
||||
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
|
||||
tagger.run()
|
||||
tagger.pTags?.forEach { taggedUser ->
|
||||
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
@@ -67,7 +68,6 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -80,7 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
@@ -158,7 +158,8 @@ fun NewGroupDMScreen(
|
||||
|
||||
LaunchedEffect(postViewModel, accountViewModel) {
|
||||
message?.ifBlank { null }?.let {
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
|
||||
postViewModel.onMessageChanged()
|
||||
}
|
||||
attachment?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -530,8 +531,8 @@ fun SendDirectMessageTo(
|
||||
)
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = postViewModel.toUsers,
|
||||
onValueChange = postViewModel::updateToUsers,
|
||||
state = postViewModel.toUsers,
|
||||
onTextChanged = postViewModel::onToUsersChanged,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
@@ -547,8 +548,8 @@ fun SendDirectMessageTo(
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
visualTransformation =
|
||||
UrlUserTagTransformation(
|
||||
outputTransformation =
|
||||
UrlUserTagOutputTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
colors =
|
||||
@@ -572,8 +573,8 @@ fun SendDirectMessageTo(
|
||||
)
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = postViewModel.subject,
|
||||
onValueChange = { postViewModel.updateSubject(it) },
|
||||
state = postViewModel.subject,
|
||||
onTextChanged = { postViewModel.onSubjectChanged() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
@@ -581,8 +582,8 @@ fun SendDirectMessageTo(
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
visualTransformation =
|
||||
UrlUserTagTransformation(
|
||||
outputTransformation =
|
||||
UrlUserTagOutputTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
colors =
|
||||
|
||||
@@ -54,7 +54,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
@@ -201,8 +201,8 @@ fun EditField(
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
ThinPaddingTextField(
|
||||
value = channelScreenModel.message,
|
||||
onValueChange = { channelScreenModel.updateMessage(it) },
|
||||
state = channelScreenModel.message,
|
||||
onTextChanged = { channelScreenModel.onMessageChanged() },
|
||||
keyboardOptions = PostKeyboard,
|
||||
shape = EditFieldBorder,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -231,7 +231,7 @@ fun EditField(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -137,7 +139,7 @@ open class ChannelNewMessageViewModel :
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
val message = TextFieldState()
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false
|
||||
val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false
|
||||
@@ -274,7 +276,7 @@ open class ChannelNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
message = TextFieldValue(draftEvent.content)
|
||||
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
|
||||
|
||||
iMetaAttachments.addAll(draftEvent.imetas())
|
||||
|
||||
@@ -302,7 +304,7 @@ open class ChannelNewMessageViewModel :
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
if (message.text.isBlank()) {
|
||||
if (message.text.toString().isBlank()) {
|
||||
account.deleteDraftIgnoreErrors(draftTag.current)
|
||||
} else {
|
||||
val attachments = mutableSetOf<Event>()
|
||||
@@ -358,24 +360,25 @@ open class ChannelNewMessageViewModel :
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
results.successful.forEach { upload ->
|
||||
if (upload.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 = account.createNip95(upload.result.bytes, headerInfo = upload.result.fileHeader, uploadState.caption, uploadState.contentWarningReason)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
val urls =
|
||||
results.successful.mapNotNull { upload ->
|
||||
if (upload.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 = account.createNip95(upload.result.bytes, headerInfo = upload.result.fileHeader, uploadState.caption, uploadState.contentWarningReason)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor(it.toNostrUri())
|
||||
note?.toNostrUri()
|
||||
} else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason)
|
||||
|
||||
upload.result.url
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
} else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason)
|
||||
|
||||
message = message.insertUrlAtCursor(upload.result.url)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
}
|
||||
|
||||
message.insertUrlAtCursor(urls.joinToString(" "))
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
uploadState.reset()
|
||||
onceUploaded()
|
||||
@@ -392,18 +395,19 @@ open class ChannelNewMessageViewModel :
|
||||
|
||||
private suspend fun createTemplate(): EventTemplate<out Event>? {
|
||||
val channel = channel ?: return null
|
||||
val messageText = message.text.toString()
|
||||
val tagger =
|
||||
NewMessageTagger(
|
||||
message = message.text,
|
||||
message = messageText,
|
||||
pTags = listOfNotNull(replyTo.value?.author),
|
||||
eTags = listOfNotNull(replyTo.value),
|
||||
dao = accountViewModel,
|
||||
)
|
||||
tagger.run()
|
||||
|
||||
val urls = findURLs(message.text)
|
||||
val urls = findURLs(messageText)
|
||||
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
|
||||
val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value)
|
||||
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
|
||||
|
||||
val channelRelays = channel.relays()
|
||||
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
|
||||
@@ -533,7 +537,7 @@ open class ChannelNewMessageViewModel :
|
||||
open fun cancel() {
|
||||
draftTag.rotate()
|
||||
|
||||
message = TextFieldValue("")
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
|
||||
replyTo.value = null
|
||||
|
||||
@@ -561,18 +565,18 @@ open class ChannelNewMessageViewModel :
|
||||
emojiSuggestions?.reset()
|
||||
}
|
||||
|
||||
open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text).withScheme.firstOrNull()
|
||||
open fun findUrlInMessage(): String? = UrlParser().parseValidUrls(message.text.toString()).withScheme.firstOrNull()
|
||||
|
||||
open fun addToMessage(it: String) {
|
||||
updateMessage(TextFieldValue(message.text + " " + it))
|
||||
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
|
||||
onMessageChanged()
|
||||
}
|
||||
|
||||
open fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
open fun onMessageChanged() {
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
if (newMessage.selection.collapsed) {
|
||||
val lastWord = newMessage.currentWord()
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = message.currentWord()
|
||||
if (lastWord.startsWith("@")) {
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
@@ -600,7 +604,7 @@ open class ChannelNewMessageViewModel :
|
||||
userSuggestions?.let {
|
||||
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
|
||||
val lastWord = message.currentWord()
|
||||
message = it.replaceCurrentWord(message, lastWord, item)
|
||||
it.replaceCurrentWord(message, lastWord, item)
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo.addItem(item)
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
@@ -615,7 +619,7 @@ open class ChannelNewMessageViewModel :
|
||||
|
||||
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -631,7 +635,7 @@ open class ChannelNewMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -648,7 +652,7 @@ open class ChannelNewMessageViewModel :
|
||||
uploadState?.multiOrchestrator == null
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
override fun locationManager(): LocationState = Amethyst.instance.locationManager
|
||||
@@ -675,7 +679,7 @@ open class ChannelNewMessageViewModel :
|
||||
|
||||
fun updateZapFromText() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
|
||||
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
|
||||
tagger.run()
|
||||
tagger.pTags?.forEach { taggedUser ->
|
||||
if (!forwardZapTo.items.any { it.key == taggedUser }) {
|
||||
|
||||
@@ -38,7 +38,7 @@ import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -114,8 +114,8 @@ fun EditFieldRow(
|
||||
}
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = channelScreenModel.message,
|
||||
onValueChange = { channelScreenModel.updateMessage(it) },
|
||||
state = channelScreenModel.message,
|
||||
onTextChanged = { channelScreenModel.onMessageChanged() },
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
@@ -151,7 +151,7 @@ fun EditFieldRow(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Android implementation of ChessEventPublisher using Jester protocol.
|
||||
@@ -59,9 +60,9 @@ class AndroidChessPublisher(
|
||||
* Uses ChessEventBroadcaster to ensure relay connections before sending.
|
||||
*/
|
||||
private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean {
|
||||
println("[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster")
|
||||
Log.d("chessdebug") { "[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster" }
|
||||
val result = broadcaster.broadcast(event)
|
||||
println("[AndroidChessPublisher] Broadcast result: ${result.message}")
|
||||
Log.d("chessdebug") { "[AndroidChessPublisher] Broadcast result: ${result.message}" }
|
||||
return result.success
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ class AndroidChessPublisher(
|
||||
account.cache.justConsumeMyOwnEvent(signedEvent)
|
||||
if (success) signedEvent.id else null
|
||||
} catch (e: Exception) {
|
||||
println("[AndroidChessPublisher] publishStart failed: ${e.message}")
|
||||
Log.d("chessdebug") { "[AndroidChessPublisher] publishStart failed: ${e.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
@@ -118,7 +119,7 @@ class AndroidChessPublisher(
|
||||
account.cache.justConsumeMyOwnEvent(signedEvent)
|
||||
if (success) signedEvent.id else null
|
||||
} catch (e: Exception) {
|
||||
println("[AndroidChessPublisher] publishMove failed: ${e.message}")
|
||||
Log.d("chessdebug") { "[AndroidChessPublisher] publishMove failed: ${e.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
@@ -145,7 +146,7 @@ class AndroidChessPublisher(
|
||||
account.cache.justConsumeMyOwnEvent(signedEvent)
|
||||
success
|
||||
} catch (e: Exception) {
|
||||
println("[AndroidChessPublisher] publishGameEnd failed: ${e.message}")
|
||||
Log.d("chessdebug") { "[AndroidChessPublisher] publishGameEnd failed: ${e.message}" }
|
||||
false
|
||||
}
|
||||
|
||||
@@ -180,7 +181,7 @@ class AndroidRelayFetcher(
|
||||
}
|
||||
|
||||
override fun getRelayUrls(): List<String> {
|
||||
println("[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays" }
|
||||
return ChessConfig.CHESS_RELAYS
|
||||
}
|
||||
|
||||
@@ -193,7 +194,7 @@ class AndroidRelayFetcher(
|
||||
* 2. Fetch moves that reference the start event via #e tag
|
||||
*/
|
||||
override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents {
|
||||
println("[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId" }
|
||||
|
||||
// Filter 1: Fetch the start event by its ID
|
||||
val startEventFilter =
|
||||
@@ -223,7 +224,7 @@ class AndroidRelayFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
println("[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}" }
|
||||
|
||||
return JesterGameEvents(
|
||||
startEvent = startEvent,
|
||||
@@ -237,30 +238,30 @@ class AndroidRelayFetcher(
|
||||
*/
|
||||
override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List<JesterEvent> {
|
||||
val relays = chessRelayUrls()
|
||||
println("[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays" }
|
||||
|
||||
if (relays.isEmpty()) {
|
||||
println("[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!" }
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val filter = ChessFilterBuilder.challengesFilter(userPubkey)
|
||||
println("[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}")
|
||||
println("[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}" }
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}" }
|
||||
|
||||
val relayFilters = relays.associateWith { listOf(filter) }
|
||||
val events = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress)
|
||||
println("[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events" }
|
||||
|
||||
// Debug: If no events, also try without #e filter to see if relays have ANY kind 30 events
|
||||
if (events.isEmpty()) {
|
||||
println("[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter...")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter..." }
|
||||
val debugFilter = ChessFilterBuilder.recentGamesFilter()
|
||||
val debugFilters = relays.associateWith { listOf(debugFilter) }
|
||||
val debugEvents = fetchHelper.fetchEvents(debugFilters, timeoutMs = 10_000)
|
||||
println("[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events" }
|
||||
debugEvents.take(5).forEach { e ->
|
||||
println("[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +287,7 @@ class AndroidRelayFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
println("[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events" }
|
||||
return challenges
|
||||
}
|
||||
|
||||
@@ -356,7 +357,7 @@ class AndroidRelayFetcher(
|
||||
* Uses one-shot fetch for reliability.
|
||||
*/
|
||||
override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set<String> {
|
||||
println("[AndroidRelayFetcher] fetchUserGameIds: fetching from relays")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchUserGameIds: fetching from relays" }
|
||||
|
||||
// Fetch events authored by user AND events tagging user
|
||||
val authoredFilter = ChessFilterBuilder.userGamesFilter(userPubkey)
|
||||
@@ -386,7 +387,7 @@ class AndroidRelayFetcher(
|
||||
}
|
||||
}
|
||||
|
||||
println("[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events")
|
||||
Log.d("chessdebug") { "[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events" }
|
||||
return gameIds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner
|
||||
import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -101,6 +102,7 @@ fun ChessGameScreen(
|
||||
factory =
|
||||
ChessViewModelFactory(
|
||||
accountViewModel.account,
|
||||
activity.application,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -283,7 +285,10 @@ fun ChessGameScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(onClick = { nav.popBack() }) {
|
||||
Button(onClick = {
|
||||
chessViewModel.removeGame(gameId)
|
||||
nav.popBack()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
@@ -295,12 +300,42 @@ fun ChessGameScreen(
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Resolve opponent display name
|
||||
// Resolve opponent display name and avatar
|
||||
val opponentUser =
|
||||
remember(gameState.opponentPubkey) {
|
||||
accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey)
|
||||
}
|
||||
val opponentDisplayName =
|
||||
remember(gameState.opponentPubkey) {
|
||||
accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey)?.toBestDisplayName()
|
||||
?: gameState.opponentPubkey.take(8)
|
||||
opponentUser?.toBestDisplayName() ?: gameState.opponentPubkey.take(8)
|
||||
}
|
||||
val opponentAvatarUrl =
|
||||
remember(gameState.opponentPubkey) {
|
||||
opponentUser?.profilePicture()
|
||||
}
|
||||
|
||||
// Resolve player display name and avatar
|
||||
val playerUser =
|
||||
remember(gameState.playerPubkey) {
|
||||
accountViewModel.checkGetOrCreateUser(gameState.playerPubkey)
|
||||
}
|
||||
val playerDisplayName =
|
||||
remember(gameState.playerPubkey) {
|
||||
playerUser?.toBestDisplayName() ?: gameState.playerPubkey.take(8)
|
||||
}
|
||||
val playerAvatarUrl =
|
||||
remember(gameState.playerPubkey) {
|
||||
playerUser?.profilePicture()
|
||||
}
|
||||
|
||||
// Determine white/black pubkeys, names and avatars based on player color
|
||||
val isPlayerWhite = gameState.playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE
|
||||
val whitePubkey = if (isPlayerWhite) gameState.playerPubkey else gameState.opponentPubkey
|
||||
val blackPubkey = if (isPlayerWhite) gameState.opponentPubkey else gameState.playerPubkey
|
||||
val whiteDisplayName = if (isPlayerWhite) playerDisplayName else opponentDisplayName
|
||||
val blackDisplayName = if (isPlayerWhite) opponentDisplayName else playerDisplayName
|
||||
val whiteAvatarUrl = if (isPlayerWhite) playerAvatarUrl else opponentAvatarUrl
|
||||
val blackAvatarUrl = if (isPlayerWhite) opponentAvatarUrl else playerAvatarUrl
|
||||
|
||||
// Determine spectator status:
|
||||
// 1. If game was accepted locally, user is definitely NOT a spectator
|
||||
@@ -319,6 +354,23 @@ fun ChessGameScreen(
|
||||
},
|
||||
onResign = { chessViewModel.resign(gameId) },
|
||||
isSpectatorOverride = isSpectating,
|
||||
onGameEndDismiss = { chessViewModel.dismissGame(gameId) },
|
||||
onLeaveSpectating =
|
||||
if (isSpectating) {
|
||||
{
|
||||
chessViewModel.stopSpectating(gameId)
|
||||
nav.popBack()
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
whiteName = whiteDisplayName,
|
||||
whiteHex = whitePubkey,
|
||||
whiteAvatarUrl = whiteAvatarUrl,
|
||||
blackName = blackDisplayName,
|
||||
blackHex = blackPubkey,
|
||||
blackAvatarUrl = blackAvatarUrl,
|
||||
onPlayerClick = { pubkeyHex -> nav.nav(Route.Profile(pubkeyHex)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -67,8 +68,10 @@ import com.vitorpamplona.amethyst.commons.chess.ActiveGameCard
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChallengeCard
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner
|
||||
import com.vitorpamplona.amethyst.commons.chess.CompletedGameCard
|
||||
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
|
||||
import com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard
|
||||
import com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars
|
||||
import com.vitorpamplona.amethyst.commons.chess.PublicGameCard
|
||||
import com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
@@ -94,7 +97,7 @@ fun ChessLobbyScreen(
|
||||
viewModel(
|
||||
viewModelStoreOwner = activity,
|
||||
key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}",
|
||||
factory = ChessViewModelFactory(accountViewModel.account),
|
||||
factory = ChessViewModelFactory(accountViewModel.account, activity.application),
|
||||
)
|
||||
|
||||
// Subscribe to chess events when screen is visible
|
||||
@@ -294,13 +297,19 @@ fun ChessLobbyContent(
|
||||
val spectatingGames by chessViewModel.spectatingGames.collectAsState()
|
||||
val publicGames by chessViewModel.publicGames.collectAsState()
|
||||
val challenges by chessViewModel.challenges.collectAsState()
|
||||
val completedGames by chessViewModel.completedGames.collectAsState()
|
||||
val userPubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||
val currentUser =
|
||||
remember(userPubkey) {
|
||||
accountViewModel.checkGetOrCreateUser(userPubkey)
|
||||
}
|
||||
|
||||
val hasContent =
|
||||
activeGames.isNotEmpty() ||
|
||||
spectatingGames.isNotEmpty() ||
|
||||
publicGames.isNotEmpty() ||
|
||||
challenges.isNotEmpty()
|
||||
challenges.isNotEmpty() ||
|
||||
completedGames.isNotEmpty()
|
||||
|
||||
if (!hasContent) {
|
||||
// Empty state - use LazyColumn so pull-to-refresh works
|
||||
@@ -359,15 +368,24 @@ fun ChessLobbyContent(
|
||||
}
|
||||
|
||||
items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) ->
|
||||
val displayName =
|
||||
val opponent =
|
||||
remember(state.opponentPubkey) {
|
||||
accountViewModel.checkGetOrCreateUser(state.opponentPubkey)?.toBestDisplayName() ?: state.opponentPubkey.take(8)
|
||||
accountViewModel.checkGetOrCreateUser(state.opponentPubkey)
|
||||
}
|
||||
val displayName = opponent?.toBestDisplayName() ?: state.opponentPubkey.take(8)
|
||||
ActiveGameCard(
|
||||
gameId = gameId,
|
||||
opponentName = displayName,
|
||||
isYourTurn = state.isPlayerTurn(),
|
||||
onClick = { onSelectGame(gameId) },
|
||||
avatar = {
|
||||
OverlappingAvatars(
|
||||
avatar1Hex = state.playerPubkey,
|
||||
avatar2Hex = state.opponentPubkey,
|
||||
avatar1Url = currentUser?.profilePicture(),
|
||||
avatar2Url = opponent?.profilePicture(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -386,14 +404,28 @@ fun ChessLobbyContent(
|
||||
}
|
||||
|
||||
items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge ->
|
||||
val opponentName =
|
||||
challenge.opponentPubkey?.let { pubkey ->
|
||||
accountViewModel.checkGetOrCreateUser(pubkey)?.toBestDisplayName() ?: pubkey.take(8)
|
||||
val opponentUser =
|
||||
remember(challenge.opponentPubkey) {
|
||||
challenge.opponentPubkey?.let { accountViewModel.checkGetOrCreateUser(it) }
|
||||
}
|
||||
val opponentName =
|
||||
opponentUser?.toBestDisplayName()
|
||||
?: challenge.opponentPubkey?.take(8)
|
||||
OutgoingChallengeCard(
|
||||
opponentName = opponentName,
|
||||
userPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
|
||||
onClick = { onOpenOwnChallenge(challenge) },
|
||||
avatar =
|
||||
challenge.opponentPubkey?.let { opPubkey ->
|
||||
{
|
||||
OverlappingAvatars(
|
||||
avatar1Hex = userPubkey,
|
||||
avatar2Hex = opPubkey,
|
||||
avatar1Url = currentUser?.profilePicture(),
|
||||
avatar2Url = opponentUser?.profilePicture(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -447,6 +479,14 @@ fun ChessLobbyContent(
|
||||
challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
|
||||
isIncoming = true,
|
||||
onAccept = { onAcceptChallenge(challenge) },
|
||||
avatar = {
|
||||
OverlappingAvatars(
|
||||
avatar1Hex = challenge.challengerPubkey,
|
||||
avatar2Hex = userPubkey,
|
||||
avatar1Url = challenge.challengerAvatarUrl,
|
||||
avatar2Url = currentUser?.profilePicture(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -476,6 +516,14 @@ fun ChessLobbyContent(
|
||||
challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
|
||||
isIncoming = false,
|
||||
onAccept = { onAcceptChallenge(challenge) },
|
||||
avatar = {
|
||||
OverlappingAvatars(
|
||||
avatar1Hex = challenge.challengerPubkey,
|
||||
avatar2Hex = userPubkey,
|
||||
avatar1Url = challenge.challengerAvatarUrl,
|
||||
avatar2Url = currentUser?.profilePicture(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -504,6 +552,63 @@ fun ChessLobbyContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Finished games section
|
||||
if (completedGames.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Finished Games",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (completedGames.size >= 2) {
|
||||
TextButton(onClick = { chessViewModel.dismissAllCompletedGames() }) {
|
||||
Text("Clear all")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
completedGames.take(10),
|
||||
key = { "completed-${it.gameId}-${it.completedAt}" },
|
||||
) { game ->
|
||||
val opponentPubkey =
|
||||
if (game.whitePubkey == userPubkey) game.blackPubkey else game.whitePubkey
|
||||
val opponentUser =
|
||||
remember(opponentPubkey) {
|
||||
accountViewModel.checkGetOrCreateUser(opponentPubkey)
|
||||
}
|
||||
val opponentName =
|
||||
opponentUser?.toBestDisplayName()
|
||||
?: game.blackDisplayName
|
||||
?: game.whiteDisplayName
|
||||
?: opponentPubkey.take(8)
|
||||
CompletedGameCard(
|
||||
opponentName = opponentName,
|
||||
result = game.result,
|
||||
didUserWin = game.didUserWin(userPubkey),
|
||||
isDraw = game.isDraw,
|
||||
moveCount = game.moveCount,
|
||||
onClick = { onSelectGame(game.gameId) },
|
||||
onDismiss = { chessViewModel.dismissCompletedGame(game.gameId) },
|
||||
avatar = {
|
||||
OverlappingAvatars(
|
||||
avatar1Hex = userPubkey,
|
||||
avatar2Hex = opponentPubkey,
|
||||
avatar1Url = currentUser?.profilePicture(),
|
||||
avatar2Url = opponentUser?.profilePicture(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom padding for FAB
|
||||
item {
|
||||
Spacer(Modifier.height(80.dp))
|
||||
|
||||
@@ -30,11 +30,12 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
*/
|
||||
class ChessViewModelFactory(
|
||||
private val account: Account,
|
||||
private val application: android.app.Application,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(ChessViewModelNew::class.java)) {
|
||||
return ChessViewModelNew(account) as T
|
||||
return ChessViewModelNew(account, application) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessDismissedGamesStorage
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus
|
||||
@@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol
|
||||
import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
@@ -50,6 +52,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
@Stable
|
||||
class ChessViewModelNew(
|
||||
private val account: Account,
|
||||
application: android.app.Application,
|
||||
) : ViewModel() {
|
||||
// Instance ID for debugging ViewModel sharing
|
||||
val instanceId = System.identityHashCode(this)
|
||||
@@ -58,6 +61,7 @@ class ChessViewModelNew(
|
||||
private val publisher = AndroidChessPublisher(account)
|
||||
private val fetcher = AndroidRelayFetcher(account)
|
||||
private val metadataProvider = AndroidMetadataProvider()
|
||||
private val dismissedStorage = ChessDismissedGamesStorage.create(application)
|
||||
|
||||
// Shared business logic (creates its own ChessLobbyState internally)
|
||||
private val logic =
|
||||
@@ -68,6 +72,7 @@ class ChessViewModelNew(
|
||||
metadataProvider = metadataProvider,
|
||||
scope = viewModelScope,
|
||||
pollingConfig = ChessPollingDefaults.android,
|
||||
dismissedStorage = dismissedStorage,
|
||||
)
|
||||
|
||||
// ============================================
|
||||
@@ -94,6 +99,7 @@ class ChessViewModelNew(
|
||||
// ============================================
|
||||
|
||||
init {
|
||||
Log.d("chessdebug") { "[AndroidVM] init: instanceId=$instanceId, userPubkey=${account.userProfile().pubkeyHex.take(8)}" }
|
||||
logic.startPolling()
|
||||
}
|
||||
|
||||
@@ -103,6 +109,10 @@ class ChessViewModelNew(
|
||||
|
||||
fun forceRefresh() = logic.forceRefresh()
|
||||
|
||||
fun dismissCompletedGame(gameId: String) = logic.dismissCompletedGame(gameId)
|
||||
|
||||
fun dismissAllCompletedGames() = logic.dismissAllCompletedGames()
|
||||
|
||||
/**
|
||||
* Ensure a game ID is being polled for updates.
|
||||
* Call this when entering a game screen.
|
||||
@@ -132,7 +142,12 @@ class ChessViewModelNew(
|
||||
|
||||
fun handleIncomingEvent(event: Event) {
|
||||
if (event.kind != JesterProtocol.KIND) return
|
||||
val jesterEvent = event.toJesterEvent() ?: return
|
||||
val jesterEvent =
|
||||
event.toJesterEvent() ?: run {
|
||||
Log.d("chessdebug") { "[AndroidVM] handleIncomingEvent: failed to parse kind ${event.kind} event ${event.id.take(8)} as JesterEvent" }
|
||||
return
|
||||
}
|
||||
Log.d("chessdebug") { "[AndroidVM] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${jesterEvent.isStartEvent()}, isMove=${jesterEvent.isMoveEvent()}" }
|
||||
logic.handleIncomingEvent(jesterEvent)
|
||||
}
|
||||
|
||||
@@ -166,6 +181,8 @@ class ChessViewModelNew(
|
||||
|
||||
fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId)
|
||||
|
||||
fun dismissGame(gameId: String) = logic.dismissGame(gameId)
|
||||
|
||||
// ============================================
|
||||
// Spectator operations
|
||||
// ============================================
|
||||
@@ -174,7 +191,9 @@ class ChessViewModelNew(
|
||||
|
||||
fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId)
|
||||
|
||||
fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId)
|
||||
fun stopSpectating(gameId: String) = logic.stopSpectating(gameId)
|
||||
|
||||
fun removeGame(gameId: String) = logic.removeGame(gameId)
|
||||
|
||||
// ============================================
|
||||
// Utility
|
||||
|
||||
@@ -22,9 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Query state for chess subscription
|
||||
@@ -39,16 +44,43 @@ data class ChessQueryState(
|
||||
)
|
||||
|
||||
/**
|
||||
* Sub-assembler that creates the actual relay filters
|
||||
* Sub-assembler that creates the actual relay filters.
|
||||
* Intercepts incoming chess events from relays and forwards them to the ViewModel.
|
||||
*/
|
||||
class ChessFeedFilterSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<ChessQueryState>,
|
||||
) : PerUniqueIdEoseManager<ChessQueryState, String>(client, allKeys) {
|
||||
var onChessEvent: ((Event) -> Unit)? = null
|
||||
|
||||
override fun updateFilter(
|
||||
key: ChessQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> = filterChessEvents(key, since)
|
||||
|
||||
override fun id(key: ChessQueryState): String = key.userPubkey
|
||||
|
||||
override fun newSub(key: ChessQueryState): Subscription =
|
||||
requestNewSubscription(
|
||||
object : SubscriptionListener {
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (isLive) {
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
onChessEvent?.invoke(event)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
/**
|
||||
@@ -29,10 +30,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
class ChessFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<ChessQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
ChessFeedFilterSubAssembler(client, ::allKeys),
|
||||
)
|
||||
private val subAssembler = ChessFeedFilterSubAssembler(client, ::allKeys)
|
||||
|
||||
val group = listOf(subAssembler)
|
||||
|
||||
fun setOnChessEvent(callback: ((Event) -> Unit)?) {
|
||||
subAssembler.onChessEvent = callback
|
||||
}
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
|
||||
@@ -66,8 +66,19 @@ fun ChessSubscription(
|
||||
)
|
||||
}
|
||||
|
||||
// Wire incoming chess events from relay subscriptions to the ViewModel
|
||||
val chessAssembler = accountViewModel.dataSources().chess
|
||||
DisposableEffect(chessAssembler, chessViewModel) {
|
||||
chessAssembler.setOnChessEvent { event ->
|
||||
chessViewModel.handleIncomingEvent(event)
|
||||
}
|
||||
onDispose {
|
||||
chessAssembler.setOnChessEvent(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Register subscription with Amethyst's subscription system
|
||||
KeyDataSourceSubscription(state, accountViewModel.dataSources().chess)
|
||||
KeyDataSourceSubscription(state, chessAssembler)
|
||||
|
||||
// Trigger ViewModel refresh when subscription state changes
|
||||
// This fetches challenges from LocalCache after events arrive
|
||||
|
||||
@@ -62,12 +62,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
|
||||
@@ -293,7 +294,7 @@ private fun MarkdownPostScreenBody(
|
||||
.padding(Size10dp),
|
||||
) {
|
||||
RenderContentAsMarkdown(
|
||||
content = postViewModel.message.text,
|
||||
content = postViewModel.message.text.toString(),
|
||||
tags = EmptyTagList,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
@@ -304,9 +305,9 @@ private fun MarkdownPostScreenBody(
|
||||
}
|
||||
} else {
|
||||
// Markdown editor
|
||||
OutlinedTextField(
|
||||
value = postViewModel.message,
|
||||
onValueChange = postViewModel::updateMessage,
|
||||
ThinPaddingTextField(
|
||||
state = postViewModel.message,
|
||||
onTextChanged = postViewModel::onMessageChanged,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -328,7 +329,7 @@ private fun MarkdownPostScreenBody(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
|
||||
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -147,7 +149,7 @@ class LongFormPostViewModel :
|
||||
|
||||
var isUploadingCoverImage by mutableStateOf(false)
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
override val message = TextFieldState()
|
||||
|
||||
var showPreview by mutableStateOf(false)
|
||||
|
||||
@@ -241,7 +243,7 @@ class LongFormPostViewModel :
|
||||
summary = TextFieldValue(noteEvent.summary() ?: "")
|
||||
publishedAt = noteEvent.publishedAt() ?: noteEvent.createdAt
|
||||
coverImageUrl = noteEvent.image() ?: ""
|
||||
message = TextFieldValue(noteEvent.content)
|
||||
message.setTextAndPlaceCursorAtEnd(noteEvent.content)
|
||||
existingDTag = noteEvent.dTag()
|
||||
}
|
||||
|
||||
@@ -265,7 +267,7 @@ class LongFormPostViewModel :
|
||||
summary = TextFieldValue(draftEvent.summary() ?: "")
|
||||
publishedAt = draftEvent.publishedAt() ?: draftEvent.createdAt
|
||||
coverImageUrl = draftEvent.image() ?: ""
|
||||
message = TextFieldValue(draftEvent.content)
|
||||
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
|
||||
existingDTag = draftEvent.dTag()
|
||||
|
||||
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
|
||||
@@ -335,7 +337,7 @@ class LongFormPostViewModel :
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
if (message.text.isBlank() && title.text.isBlank()) {
|
||||
if (message.text.toString().isBlank() && title.text.isBlank()) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
||||
} else {
|
||||
val template = createTemplate() ?: return
|
||||
@@ -349,7 +351,7 @@ class LongFormPostViewModel :
|
||||
|
||||
val tagger =
|
||||
NewMessageTagger(
|
||||
message.text,
|
||||
message.text.toString(),
|
||||
null,
|
||||
null,
|
||||
accountViewModel,
|
||||
@@ -517,31 +519,36 @@ class LongFormPostViewModel :
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
results.successful.forEach { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
|
||||
}.build()
|
||||
val urls =
|
||||
results.successful.mapNotNull { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
alt?.let { alt(it) }
|
||||
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
|
||||
}.build()
|
||||
|
||||
iMetaAttachments.replace(iMeta.url, iMeta)
|
||||
iMetaAttachments.replace(iMeta.url, iMeta)
|
||||
|
||||
val markdownImage = ""
|
||||
message = message.insertUrlAtCursor(markdownImage)
|
||||
val markdownImage = ""
|
||||
markdownImage
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
message.insertUrlAtCursor(urls.joinToString(" "))
|
||||
|
||||
multiOrchestrator = null
|
||||
} else {
|
||||
@@ -559,7 +566,7 @@ class LongFormPostViewModel :
|
||||
title = TextFieldValue("")
|
||||
summary = TextFieldValue("")
|
||||
coverImageUrl = ""
|
||||
message = TextFieldValue("")
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
publishedAt = TimeUtils.now()
|
||||
showPreview = false
|
||||
|
||||
@@ -594,11 +601,9 @@ class LongFormPostViewModel :
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
|
||||
override fun onMessageChanged() {
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = newMessage.currentWord()
|
||||
val lastWord = message.currentWord()
|
||||
if (lastWord.startsWith("@")) {
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
@@ -626,7 +631,7 @@ class LongFormPostViewModel :
|
||||
userSuggestions?.let { userSuggestions ->
|
||||
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
|
||||
val lastWord = message.currentWord()
|
||||
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo.value.addItem(item)
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
@@ -641,7 +646,7 @@ class LongFormPostViewModel :
|
||||
|
||||
fun autocompleteWithEmoji(item: EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
emojiSuggestions?.reset()
|
||||
draftTag.newVersion()
|
||||
}
|
||||
@@ -655,21 +660,21 @@ class LongFormPostViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
emojiSuggestions?.reset()
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun canPost(): Boolean =
|
||||
title.text.isNotBlank() &&
|
||||
message.text.isNotBlank() &&
|
||||
message.text.toString().isNotBlank() &&
|
||||
!isUploadingImage &&
|
||||
!wantsInvoice &&
|
||||
(!wantsZapRaiser || zapRaiserAmount.value != null) &&
|
||||
multiOrchestrator == null
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
@@ -699,7 +704,7 @@ class LongFormPostViewModel :
|
||||
override fun updateZapFromText() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val tagger =
|
||||
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
|
||||
NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
|
||||
tagger.run()
|
||||
tagger.pTags?.forEach { taggedUser ->
|
||||
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
|
||||
|
||||
@@ -75,5 +75,5 @@ open class DiscoverMarketplaceFeedFilter(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -41,7 +42,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -112,7 +112,8 @@ fun NewProductScreen(
|
||||
postViewModel.quote(it)
|
||||
}
|
||||
message?.ifBlank { null }?.let {
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
|
||||
postViewModel.onMessageChanged()
|
||||
}
|
||||
attachment?.ifBlank { null }?.toUri()?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -131,7 +133,7 @@ open class NewProductViewModel :
|
||||
var productImages by mutableStateOf<List<ProductImageMeta>>(emptyList())
|
||||
val iMetaDescription = IMetaAttachments()
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
override val message = TextFieldState()
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
@@ -151,10 +153,10 @@ open class NewProductViewModel :
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// Classifieds
|
||||
var title by mutableStateOf(TextFieldValue(""))
|
||||
var price by mutableStateOf(TextFieldValue(""))
|
||||
var locationText by mutableStateOf(TextFieldValue(""))
|
||||
var category by mutableStateOf(TextFieldValue(""))
|
||||
val title = TextFieldState()
|
||||
val price = TextFieldState()
|
||||
val locationText = TextFieldState()
|
||||
val category = TextFieldState()
|
||||
var condition by mutableStateOf(ConditionTag.CONDITION.USED_LIKE_NEW)
|
||||
|
||||
// Invoices
|
||||
@@ -226,7 +228,7 @@ open class NewProductViewModel :
|
||||
open fun quote(quote: Note) {
|
||||
val accountViewModel = accountViewModel
|
||||
|
||||
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
|
||||
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
|
||||
|
||||
quote.author?.let { quotedUser ->
|
||||
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
|
||||
@@ -246,7 +248,7 @@ open class NewProductViewModel :
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
|
||||
urlPreviews.update(message)
|
||||
urlPreviews.update(message.text.toString())
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draft: Note) {
|
||||
@@ -284,10 +286,10 @@ open class NewProductViewModel :
|
||||
zapRaiserAmount.value = zapraiser
|
||||
}
|
||||
|
||||
title = TextFieldValue(draftEvent.title() ?: "")
|
||||
price = TextFieldValue(draftEvent.price()?.amount ?: "")
|
||||
category = TextFieldValue(draftEvent.categories().firstOrNull() ?: "")
|
||||
locationText = TextFieldValue(draftEvent.location() ?: "")
|
||||
title.setTextAndPlaceCursorAtEnd(draftEvent.title() ?: "")
|
||||
price.setTextAndPlaceCursorAtEnd(draftEvent.price()?.amount ?: "")
|
||||
category.setTextAndPlaceCursorAtEnd(draftEvent.categories().firstOrNull() ?: "")
|
||||
locationText.setTextAndPlaceCursorAtEnd(draftEvent.location() ?: "")
|
||||
condition = draftEvent.conditionValid() ?: ConditionTag.CONDITION.USED_LIKE_NEW
|
||||
|
||||
val imageSet = draftEvent.images().toMutableSet()
|
||||
@@ -305,9 +307,9 @@ open class NewProductViewModel :
|
||||
productImages = productImages + ProductImageMeta(it)
|
||||
}
|
||||
|
||||
message = TextFieldValue(draftEvent.content)
|
||||
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
|
||||
|
||||
urlPreviews.update(message)
|
||||
urlPreviews.update(message.text.toString())
|
||||
}
|
||||
|
||||
suspend fun sendPostSync() {
|
||||
@@ -323,7 +325,7 @@ open class NewProductViewModel :
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
if (message.text.isBlank()) {
|
||||
if (message.text.toString().isBlank()) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
||||
} else {
|
||||
val template = createTemplate() ?: return
|
||||
@@ -336,7 +338,7 @@ open class NewProductViewModel :
|
||||
|
||||
val tagger =
|
||||
NewMessageTagger(
|
||||
message = message.text,
|
||||
message = message.text.toString(),
|
||||
dao = accountViewModel,
|
||||
)
|
||||
tagger.run()
|
||||
@@ -356,15 +358,15 @@ open class NewProductViewModel :
|
||||
|
||||
val template =
|
||||
ClassifiedsEvent.build(
|
||||
title.text,
|
||||
PriceTag(price.text, "SATS", null),
|
||||
title.text.toString(),
|
||||
PriceTag(price.text.toString(), "SATS", null),
|
||||
tagger.message,
|
||||
locationText.text.ifBlank { null },
|
||||
locationText.text.toString().ifBlank { null },
|
||||
condition,
|
||||
) {
|
||||
productImages.forEach { image(it.url) }
|
||||
|
||||
hashtags(listOfNotNull(category.text.ifBlank { null }) + findHashtags(tagger.message))
|
||||
hashtags(listOfNotNull(category.text.toString().ifBlank { null }) + findHashtags(tagger.message))
|
||||
quotes(quotes)
|
||||
|
||||
geoHash?.let { geohash(it) }
|
||||
@@ -419,30 +421,35 @@ open class NewProductViewModel :
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
results.successful.forEach {
|
||||
if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
if (it.result.fileHeader.mimeType
|
||||
?.startsWith("image") == true
|
||||
) {
|
||||
productImages = productImages +
|
||||
ProductImageMeta(
|
||||
it.result.url,
|
||||
it.result.fileHeader.mimeType,
|
||||
it.result.fileHeader.blurHash
|
||||
?.blurhash,
|
||||
it.result.fileHeader.dim,
|
||||
alt,
|
||||
it.result.fileHeader.hash,
|
||||
it.result.fileHeader.size,
|
||||
)
|
||||
} else {
|
||||
iMetaDescription.add(it.result, alt, contentWarningReason)
|
||||
val urls =
|
||||
results.successful.mapNotNull {
|
||||
if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
if (it.result.fileHeader.mimeType
|
||||
?.startsWith("image") == true
|
||||
) {
|
||||
productImages = productImages +
|
||||
ProductImageMeta(
|
||||
it.result.url,
|
||||
it.result.fileHeader.mimeType,
|
||||
it.result.fileHeader.blurHash
|
||||
?.blurhash,
|
||||
it.result.fileHeader.dim,
|
||||
alt,
|
||||
it.result.fileHeader.hash,
|
||||
it.result.fileHeader.size,
|
||||
)
|
||||
} else {
|
||||
iMetaDescription.add(it.result, alt, contentWarningReason)
|
||||
|
||||
message = message.insertUrlAtCursor(it.result.url)
|
||||
urlPreviews.update(message)
|
||||
it.result.url
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
message.insertUrlAtCursor(urls.joinToString(" "))
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
multiOrchestrator = null
|
||||
} else {
|
||||
@@ -458,7 +465,7 @@ open class NewProductViewModel :
|
||||
open fun cancel() {
|
||||
draftTag.rotate()
|
||||
|
||||
message = TextFieldValue("")
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
|
||||
multiOrchestrator = null
|
||||
mediaUploadTracker.finishUpload()
|
||||
@@ -468,10 +475,10 @@ open class NewProductViewModel :
|
||||
zapRaiserAmount.value = null
|
||||
|
||||
condition = ConditionTag.CONDITION.USED_LIKE_NEW
|
||||
locationText = TextFieldValue("")
|
||||
title = TextFieldValue("")
|
||||
category = TextFieldValue("")
|
||||
price = TextFieldValue("")
|
||||
locationText.setTextAndPlaceCursorAtEnd("")
|
||||
title.setTextAndPlaceCursorAtEnd("")
|
||||
category.setTextAndPlaceCursorAtEnd("")
|
||||
price.setTextAndPlaceCursorAtEnd("")
|
||||
|
||||
wantsForwardZapTo = false
|
||||
wantsToMarkAsSensitive = false
|
||||
@@ -505,9 +512,8 @@ open class NewProductViewModel :
|
||||
this.multiOrchestrator?.remove(selected)
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
urlPreviews.update(message)
|
||||
override fun onMessageChanged() {
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = message.currentWord()
|
||||
@@ -538,8 +544,8 @@ open class NewProductViewModel :
|
||||
userSuggestions?.let { userSuggestions ->
|
||||
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
|
||||
val lastWord = message.currentWord()
|
||||
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message)
|
||||
userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message.text.toString())
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo.value.addItem(item)
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
@@ -555,8 +561,8 @@ open class NewProductViewModel :
|
||||
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -572,8 +578,8 @@ open class NewProductViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -591,7 +597,7 @@ open class NewProductViewModel :
|
||||
multiOrchestrator == null
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
@@ -620,7 +626,7 @@ open class NewProductViewModel :
|
||||
|
||||
override fun updateZapFromText() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
|
||||
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
|
||||
tagger.run()
|
||||
tagger.pTags?.forEach { taggedUser ->
|
||||
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
|
||||
@@ -648,19 +654,11 @@ open class NewProductViewModel :
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun updateTitle(it: TextFieldValue) {
|
||||
title = it
|
||||
fun onTitleChanged() {
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun updatePrice(it: TextFieldValue) {
|
||||
runCatching {
|
||||
if (it.text.isEmpty()) {
|
||||
price = TextFieldValue("")
|
||||
} else if (it.text.toLongOrNull() != null) {
|
||||
price = it
|
||||
}
|
||||
}
|
||||
fun onPriceChanged() {
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
@@ -669,13 +667,12 @@ open class NewProductViewModel :
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun updateCategory(value: TextFieldValue) {
|
||||
category = value
|
||||
fun updateCategory(text: String) {
|
||||
category.setTextAndPlaceCursorAtEnd(text)
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun updateLocation(it: TextFieldValue) {
|
||||
locationText = it
|
||||
fun onLocationChanged() {
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
|
||||
@@ -44,12 +44,12 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.TextFieldValue // kept for readonly dropdown ThinPaddingTextField
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||
@@ -109,10 +109,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
)
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = postViewModel.title,
|
||||
onValueChange = {
|
||||
postViewModel.updateTitle(it)
|
||||
},
|
||||
state = postViewModel.title,
|
||||
onTextChanged = { postViewModel.onTitleChanged() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
@@ -120,8 +118,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
visualTransformation =
|
||||
UrlUserTagTransformation(
|
||||
outputTransformation =
|
||||
UrlUserTagOutputTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
colors =
|
||||
@@ -146,10 +144,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
|
||||
ThinPaddingTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.price,
|
||||
onValueChange = {
|
||||
postViewModel.updatePrice(it)
|
||||
},
|
||||
state = postViewModel.price,
|
||||
onTextChanged = { postViewModel.onPriceChanged() },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "1000",
|
||||
@@ -275,10 +271,10 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
categoryTypes.map { TitleExplainer(it.second, null) }.toImmutableList()
|
||||
}
|
||||
TextSpinner(
|
||||
placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text }?.second ?: "",
|
||||
placeholder = categoryTypes.firstOrNull { it.second == postViewModel.category.text.toString() }?.second ?: "",
|
||||
options = categoryOptions,
|
||||
onSelect = {
|
||||
postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second))
|
||||
postViewModel.updateCategory(categoryTypes[it].second)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -313,10 +309,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
)
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = postViewModel.locationText,
|
||||
onValueChange = {
|
||||
postViewModel.updateLocation(it)
|
||||
},
|
||||
state = postViewModel.locationText,
|
||||
onTextChanged = { postViewModel.onLocationChanged() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
@@ -324,8 +318,8 @@ fun SellProduct(postViewModel: NewProductViewModel) {
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
visualTransformation =
|
||||
UrlUserTagTransformation(
|
||||
outputTransformation =
|
||||
UrlUserTagOutputTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
colors =
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash
|
||||
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
@@ -67,7 +67,8 @@ fun GeoHashPostScreen(
|
||||
postViewModel.quote(it)
|
||||
}
|
||||
message?.ifBlank { null }?.let {
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
|
||||
postViewModel.onMessageChanged()
|
||||
}
|
||||
attachment?.ifBlank { null }?.toUri()?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag
|
||||
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
@@ -67,7 +67,8 @@ fun HashtagPostScreen(
|
||||
postViewModel.quote(it)
|
||||
}
|
||||
message?.ifBlank { null }?.let {
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
|
||||
postViewModel.onMessageChanged()
|
||||
}
|
||||
attachment?.ifBlank { null }?.toUri()?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
@@ -47,11 +48,12 @@ fun NewChessGameButton(
|
||||
nav: INav,
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity
|
||||
|
||||
val chessViewModel: ChessViewModelNew =
|
||||
viewModel(
|
||||
key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}",
|
||||
factory = ChessViewModelFactory(accountViewModel.account),
|
||||
factory = ChessViewModelFactory(accountViewModel.account, activity.application),
|
||||
)
|
||||
|
||||
FloatingActionButton(
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Poll
|
||||
@@ -54,7 +55,6 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.IntentCompat
|
||||
@@ -150,7 +150,8 @@ fun ShortNotePostScreen(
|
||||
val draft = draftId?.let { accountViewModel.getNoteIfExists(it) }
|
||||
postViewModel.load(baseReplyTo, quote, fork, version, draft)
|
||||
message?.ifBlank { null }?.let {
|
||||
postViewModel.updateMessage(TextFieldValue(it))
|
||||
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
|
||||
postViewModel.onMessageChanged()
|
||||
}
|
||||
attachment?.ifBlank { null }?.toUri()?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -322,6 +323,13 @@ private fun NewPostScreenBody(
|
||||
MessageField(
|
||||
R.string.what_s_on_your_mind,
|
||||
postViewModel,
|
||||
onContentReceived = { uri, mimeType ->
|
||||
postViewModel.selectImage(
|
||||
persistentListOf(
|
||||
SelectedMedia(uri, mimeType),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -36,6 +38,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
|
||||
import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning
|
||||
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -183,7 +186,7 @@ open class ShortNotePostViewModel :
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
override val message = TextFieldState()
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
@@ -367,7 +370,7 @@ open class ShortNotePostViewModel :
|
||||
multiOrchestrator = null
|
||||
|
||||
quote?.let { quotedNote ->
|
||||
message = TextFieldValue(message.text + "\nnostr:${quotedNote.toNEvent()}")
|
||||
message.setTextAndPlaceCursorAtBeginning(message.text.toString() + "\nnostr:${quotedNote.toNEvent()}")
|
||||
|
||||
quotedNote.author?.let { quotedUser ->
|
||||
if (quotedUser.pubkeyHex != user.pubkeyHex) {
|
||||
@@ -385,7 +388,7 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
fork?.let { forkedNoted ->
|
||||
message = TextFieldValue(version?.event?.content ?: forkedNoted.event?.content ?: "")
|
||||
message.setTextAndPlaceCursorAtEnd(version?.event?.content ?: forkedNoted.event?.content ?: "")
|
||||
|
||||
forkedNoted.event?.isSensitiveOrNSFW()?.let {
|
||||
if (it) wantsToMarkAsSensitive = true
|
||||
@@ -436,7 +439,7 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
urlPreviews.update(message)
|
||||
urlPreviews.update(message.text.toString())
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draft: Note) {
|
||||
@@ -533,11 +536,11 @@ open class ShortNotePostViewModel :
|
||||
wantsPoll = false
|
||||
wantsZapPoll = false
|
||||
|
||||
message = TextFieldValue(draftEvent.content)
|
||||
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
|
||||
|
||||
iMetaAttachments.addAll(draftEvent.imetas())
|
||||
|
||||
urlPreviews.update(message)
|
||||
urlPreviews.update(message.text.toString())
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draftEvent: PollEvent) {
|
||||
@@ -605,11 +608,11 @@ open class ShortNotePostViewModel :
|
||||
pollType = draftEvent.pollType()
|
||||
closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead()
|
||||
|
||||
message = TextFieldValue(draftEvent.content)
|
||||
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
|
||||
|
||||
iMetaAttachments.addAll(draftEvent.imetas())
|
||||
|
||||
urlPreviews.update(message)
|
||||
urlPreviews.update(message.text.toString())
|
||||
}
|
||||
|
||||
private fun loadFromDraft(draftEvent: ZapPollEvent) {
|
||||
@@ -679,11 +682,11 @@ open class ShortNotePostViewModel :
|
||||
zapPollConsensusThreshold = draftEvent.consensusThreshold()?.let { (it * 100).toInt() }
|
||||
zapPollClosedAt = draftEvent.closedAt() ?: TimeUtils.oneDayAhead()
|
||||
|
||||
message = TextFieldValue(draftEvent.content)
|
||||
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
|
||||
|
||||
iMetaAttachments.addAll(draftEvent.imetas())
|
||||
|
||||
urlPreviews.update(message)
|
||||
urlPreviews.update(message.text.toString())
|
||||
}
|
||||
|
||||
suspend fun sendPostSync() {
|
||||
@@ -750,7 +753,7 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
if (message.text.isBlank()) {
|
||||
if (message.text.toString().isBlank()) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
||||
} else {
|
||||
val attachments = mutableSetOf<Event>()
|
||||
@@ -811,7 +814,7 @@ open class ShortNotePostViewModel :
|
||||
|
||||
val tagger =
|
||||
NewMessageTagger(
|
||||
message.text,
|
||||
message.text.toString(),
|
||||
pTags,
|
||||
eTags,
|
||||
accountViewModel,
|
||||
@@ -993,41 +996,45 @@ open class ShortNotePostViewModel :
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
results.successful.forEach { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
val urls =
|
||||
results.successful.mapNotNull { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
urlPreviews.update(message)
|
||||
note?.let {
|
||||
"nostr:" + it.toNEvent()
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
alt?.let { alt(it) }
|
||||
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
|
||||
}.build()
|
||||
|
||||
iMetaAttachments.replace(iMeta.url, iMeta)
|
||||
|
||||
state.result.url
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
alt?.let { alt(it) }
|
||||
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
|
||||
}.build()
|
||||
|
||||
iMetaAttachments.replace(iMeta.url, iMeta)
|
||||
|
||||
message = message.insertUrlAtCursor(state.result.url)
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
}
|
||||
|
||||
message.insertUrlAtCursor(urls.joinToString(" "))
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
multiOrchestrator = null
|
||||
} else {
|
||||
@@ -1042,7 +1049,7 @@ open class ShortNotePostViewModel :
|
||||
open fun cancel() {
|
||||
draftTag.rotate()
|
||||
|
||||
message = TextFieldValue("")
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
|
||||
forkedFromNote = null
|
||||
|
||||
@@ -1104,15 +1111,15 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
open fun addToMessage(it: String) {
|
||||
updateMessage(TextFieldValue(message.text + " " + it))
|
||||
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
|
||||
onMessageChanged()
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
urlPreviews.update(message)
|
||||
override fun onMessageChanged() {
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = newMessage.currentWord()
|
||||
val lastWord = message.currentWord()
|
||||
if (lastWord.startsWith("@")) {
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
@@ -1140,8 +1147,8 @@ open class ShortNotePostViewModel :
|
||||
userSuggestions?.let { userSuggestions ->
|
||||
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
|
||||
val lastWord = message.currentWord()
|
||||
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message)
|
||||
userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message.text.toString())
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo.value.addItem(item)
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
@@ -1157,8 +1164,8 @@ open class ShortNotePostViewModel :
|
||||
open fun autocompleteWithEmoji(item: EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -1174,8 +1181,8 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -1201,7 +1208,7 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
// Regular text/media posts require text
|
||||
return message.text.isNotBlank() &&
|
||||
return message.text.toString().isNotBlank() &&
|
||||
!mediaUploadTracker.isUploading &&
|
||||
!isUploadingVoice &&
|
||||
!wantsInvoice &&
|
||||
@@ -1226,7 +1233,7 @@ open class ShortNotePostViewModel :
|
||||
}
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
@@ -1378,7 +1385,7 @@ open class ShortNotePostViewModel :
|
||||
override fun updateZapFromText() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val tagger =
|
||||
NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
|
||||
NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
|
||||
tagger.run()
|
||||
tagger.pTags?.forEach { taggedUser ->
|
||||
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.VideoCardCompose
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
|
||||
@Composable
|
||||
fun LongsFeedLoaded(
|
||||
loaded: FeedState.Loaded,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
state = listState,
|
||||
) {
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, item -> item.event?.kind ?: -1 },
|
||||
) { _, item ->
|
||||
if (item.event is VideoEvent) {
|
||||
VideoCardCompose(
|
||||
baseNote = item,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssemblerSubscription
|
||||
|
||||
@Composable
|
||||
fun LongsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LongsScreen(
|
||||
longsFeedContentState = accountViewModel.feedStates.longsFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LongsScreen(
|
||||
longsFeedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(longsFeedContentState)
|
||||
WatchAccountForLongsScreen(videoFeedState = longsFeedContentState, accountViewModel = accountViewModel)
|
||||
LongsFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
LongsTopBar(accountViewModel, nav)
|
||||
},
|
||||
bottomBar = {
|
||||
AppBottomBar(Route.Longs, accountViewModel) { route ->
|
||||
if (route == Route.Longs) {
|
||||
longsFeedContentState.sendToTop()
|
||||
} else {
|
||||
nav.newStack(route)
|
||||
}
|
||||
}
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { paddingValues ->
|
||||
Column(Modifier.padding(paddingValues)) {
|
||||
RefresheableBox(longsFeedContentState, true) {
|
||||
SaveableFeedContentState(longsFeedContentState, scrollStateKey = ScrollStateKeys.LONGS_SCREEN) { listState ->
|
||||
RenderFeedContentState(
|
||||
feedContentState = longsFeedContentState,
|
||||
accountViewModel = accountViewModel,
|
||||
listState = listState,
|
||||
nav = nav,
|
||||
routeForLastRead = "LongsFeed",
|
||||
onLoaded = { loaded ->
|
||||
LongsFeedLoaded(
|
||||
loaded = loaded,
|
||||
listState = listState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAccountForLongsScreen(
|
||||
videoFeedState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val listState by accountViewModel.account.liveLongsFollowLists.collectAsStateWithLifecycle()
|
||||
val hiddenUsers =
|
||||
accountViewModel.account.hiddenUsers.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
|
||||
videoFeedState.checkKeysInvalidateDataAndSendToTop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun LongsTopBar(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
UserDrawerSearchTopBar(accountViewModel, nav) {
|
||||
val list by accountViewModel.account.settings.defaultLongsFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
LongsTopNavFilterBar(
|
||||
followListsModel = accountViewModel.feedStates.feedListOptions,
|
||||
listName = list,
|
||||
accountViewModel = accountViewModel,
|
||||
onChange = accountViewModel.account.settings::changeDefaultLongsFollowList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LongsTopNavFilterBar(
|
||||
followListsModel: TopNavFilterState,
|
||||
listName: TopFilter,
|
||||
accountViewModel: AccountViewModel,
|
||||
onChange: (FeedDefinition) -> Unit,
|
||||
) {
|
||||
val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle()
|
||||
|
||||
FeedFilterSpinner(
|
||||
placeholderCode = listName,
|
||||
explainer = stringRes(R.string.select_list_to_filter),
|
||||
options = allLists,
|
||||
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.SupportedContent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoMeta
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import kotlin.collections.plus
|
||||
|
||||
class LongsFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
|
||||
|
||||
override fun limit() = 200
|
||||
|
||||
fun followList(): TopFilter = account.settings.defaultLongsFollowList.value
|
||||
|
||||
fun TopFilter.isMuteList() = this is TopFilter.MuteList
|
||||
|
||||
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
|
||||
|
||||
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
|
||||
|
||||
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
val notes =
|
||||
LocalCache.notes.filterIntoSet { _, it ->
|
||||
acceptableEvent(it, params)
|
||||
} +
|
||||
LocalCache.addressables.filterIntoSet(VideoHorizontalEvent.KIND) { _, it ->
|
||||
acceptableEvent(it, params)
|
||||
}
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
account.liveLongsFollowLists.value,
|
||||
account.hiddenUsers.flow.value,
|
||||
)
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
|
||||
return collection.filterTo(HashSet()) {
|
||||
acceptableEvent(it, params)
|
||||
}
|
||||
}
|
||||
|
||||
val videoFeedSupport =
|
||||
SupportedContent(
|
||||
blockedUrls = listOf("youtu.be", "youtube.com"),
|
||||
mimeTypes = SUPPORTED_VIDEO_FEED_MIME_TYPES_SET,
|
||||
supportedFileExtensions = (RichTextParser.videoExtensions + RichTextParser.imageExtensions).toSet(),
|
||||
)
|
||||
|
||||
fun acceptableVideoiMetas(iMetas: List<VideoMeta>): Boolean = iMetas.any { videoFeedSupport.acceptableUrl(it.url, it.mimeType) }
|
||||
|
||||
fun acceptanceEvent(noteEvent: VideoHorizontalEvent) = acceptableVideoiMetas(noteEvent.imetaTags())
|
||||
|
||||
fun acceptanceEvent(noteEvent: VideoNormalEvent) = acceptableVideoiMetas(noteEvent.imetaTags())
|
||||
|
||||
fun acceptableEvent(
|
||||
note: Note,
|
||||
params: FilterByListParams,
|
||||
): Boolean {
|
||||
val noteEvent = note.event
|
||||
|
||||
if (noteEvent is AddressableEvent && note !is AddressableNote) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
(noteEvent is VideoHorizontalEvent && acceptanceEvent(noteEvent)) ||
|
||||
(noteEvent is VideoNormalEvent && acceptanceEvent(noteEvent))
|
||||
) &&
|
||||
params.match(noteEvent, note.relays) &&
|
||||
(params.isHiddenList || account.isAcceptable(note))
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs.datasource
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class LongsQueryState(
|
||||
val account: Account,
|
||||
val feedStates: AccountFeedContentStates,
|
||||
val scope: CoroutineScope,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class LongsFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<LongsQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
LongsSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun LongsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
|
||||
LongsFilterAssemblerSubscription(
|
||||
accountViewModel.dataSources().longs,
|
||||
accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LongsFilterAssemblerSubscription(
|
||||
dataSource: LongsFilterAssembler,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val state =
|
||||
remember(accountViewModel.account) {
|
||||
LongsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
|
||||
}
|
||||
|
||||
KeyDataSourceSubscription(state, dataSource)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LongsSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<LongsQueryState>,
|
||||
) : PerUserAndFollowListEoseManager<LongsQueryState, TopFilter>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: LongsQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val feedSettings = key.followsPerRelay()
|
||||
|
||||
return makeLongsFilter(feedSettings, since, key.feedStates.longsFeed.lastNoteCreatedAtIfFilled())
|
||||
}
|
||||
|
||||
override fun user(key: LongsQueryState) = key.account.userProfile()
|
||||
|
||||
override fun list(key: LongsQueryState) = key.listName()
|
||||
|
||||
fun LongsQueryState.listNameFlow() = account.settings.defaultLongsFollowList
|
||||
|
||||
fun LongsQueryState.listName() = listNameFlow().value
|
||||
|
||||
fun LongsQueryState.followsPerRelayFlow() = account.liveLongsFollowListsPerRelay
|
||||
|
||||
fun LongsQueryState.followsPerRelay() = followsPerRelayFlow().value
|
||||
|
||||
val userJobMap = mutableMapOf<User, List<Job>>()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
override fun newSub(key: LongsQueryState): Subscription {
|
||||
val user = user(key)
|
||||
userJobMap[user]?.forEach { it.cancel() }
|
||||
userJobMap[user] =
|
||||
listOf(
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.listNameFlow().collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.followsPerRelayFlow().sample(500).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.feedStates.longsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return super.newSub(key)
|
||||
}
|
||||
|
||||
override fun endSub(
|
||||
key: User,
|
||||
subId: String,
|
||||
) {
|
||||
super.endSub(key, subId)
|
||||
userJobMap[key]?.forEach { it.cancel() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByAllCommunities
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByAuthors
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByCommunity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByFollows
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByGeohashes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByHashtag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsByMutedAuthors
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.subassemblies.filterLongsGlobal
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
fun makeLongsFilter(
|
||||
feedSettings: IFeedTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
when (feedSettings) {
|
||||
is AllCommunitiesTopNavPerRelayFilterSet -> filterLongsByAllCommunities(feedSettings, since, defaultSince)
|
||||
is AllFollowsTopNavPerRelayFilterSet -> filterLongsByFollows(feedSettings, since, defaultSince)
|
||||
is AuthorsTopNavPerRelayFilterSet -> filterLongsByAuthors(feedSettings, since, defaultSince)
|
||||
is GlobalTopNavPerRelayFilterSet -> filterLongsGlobal(feedSettings, since, defaultSince)
|
||||
is HashtagTopNavPerRelayFilterSet -> filterLongsByHashtag(feedSettings, since, defaultSince)
|
||||
is LocationTopNavPerRelayFilterSet -> filterLongsByGeohashes(feedSettings, since, defaultSince)
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterLongsByMutedAuthors(feedSettings, since, defaultSince)
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> filterLongsByCommunity(feedSettings, since, defaultSince)
|
||||
else -> emptyList()
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
|
||||
val LongsFromCommunityKinds =
|
||||
listOf(
|
||||
VideoNormalEvent.KIND,
|
||||
VideoHorizontalEvent.KIND,
|
||||
)
|
||||
|
||||
val LongsFromCommunityKindsStr =
|
||||
listOf(
|
||||
VideoNormalEvent.KIND.toString(),
|
||||
VideoHorizontalEvent.KIND.toString(),
|
||||
)
|
||||
|
||||
fun filterLongsFromAllCommunities(
|
||||
relay: NormalizedRelayUrl,
|
||||
communities: Set<String>,
|
||||
since: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
val communityList = communities.sorted()
|
||||
|
||||
return listOf(
|
||||
// approved
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = CommunityPostApprovalEvent.KIND_LIST,
|
||||
tags =
|
||||
mapOf(
|
||||
"a" to communityList,
|
||||
"k" to LongsFromCommunityKindsStr,
|
||||
),
|
||||
limit = communityList.size * 20,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
// not approved
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
tags = mapOf("a" to communityList),
|
||||
kinds = LongsFromCommunityKinds,
|
||||
limit = communityList.size * 20,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun filterLongsByAllCommunities(
|
||||
communitySet: AllCommunitiesTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (communitySet.set.isEmpty()) return emptyList()
|
||||
|
||||
return communitySet.set
|
||||
.mapNotNull {
|
||||
filterLongsFromAllCommunities(
|
||||
relay = it.key,
|
||||
communities = it.value.communities,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun filterLongsFromCommunity(
|
||||
relay: NormalizedRelayUrl,
|
||||
community: String,
|
||||
authors: Set<String>?,
|
||||
since: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
val authors = authors?.sorted()
|
||||
return listOf(
|
||||
// approved
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
authors = authors,
|
||||
kinds = CommunityPostApprovalEvent.KIND_LIST,
|
||||
tags =
|
||||
mapOf(
|
||||
"a" to listOf(community),
|
||||
"k" to LongsFromCommunityKindsStr,
|
||||
),
|
||||
limit = 100,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
// not approved
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
authors = authors,
|
||||
tags = mapOf("a" to listOf(community)),
|
||||
kinds = LongsFromCommunityKinds,
|
||||
limit = 100,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun filterLongsByCommunity(
|
||||
communitySet: SingleCommunityTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (communitySet.set.isEmpty()) return emptyList()
|
||||
|
||||
return communitySet.set
|
||||
.mapNotNull {
|
||||
filterLongsFromCommunity(
|
||||
relay = it.key,
|
||||
community = it.value.community,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}.flatten()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
|
||||
fun filterLongsByAuthors(
|
||||
relay: NormalizedRelayUrl,
|
||||
authors: Set<HexKey>,
|
||||
since: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
val authorList = authors.sorted()
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
authors = authorList,
|
||||
kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun filterLongsByAuthors(
|
||||
authorSet: AuthorsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return authorSet.set
|
||||
.mapNotNull {
|
||||
if (it.value.authors.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterLongsByAuthors(
|
||||
relay = it.key,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun filterLongsByMutedAuthors(
|
||||
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (authorSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return authorSet.set
|
||||
.mapNotNull {
|
||||
if (it.value.authors.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterLongsByAuthors(
|
||||
relay = it.key,
|
||||
authors = it.value.authors,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
fun filterLongsByFollows(
|
||||
followsSet: AllFollowsTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (followsSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return followsSet.set.flatMap {
|
||||
val since = since?.get(it.key)?.time ?: defaultSince
|
||||
val relay = it.key
|
||||
|
||||
listOfNotNull(
|
||||
it.value.authors?.let {
|
||||
filterLongsByAuthors(relay, it, since)
|
||||
},
|
||||
).flatten()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
|
||||
fun filterLongsByGeohashes(
|
||||
relay: NormalizedRelayUrl,
|
||||
geotags: Set<String>,
|
||||
since: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (geotags.isEmpty()) return emptyList()
|
||||
|
||||
return listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND),
|
||||
tags = mapOf("g" to geotags.sorted()),
|
||||
limit = 100,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun filterLongsByGeohashes(
|
||||
geoSet: LocationTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (geoSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return geoSet.set
|
||||
.mapNotNull {
|
||||
if (it.value.geotags.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterLongsByGeohashes(
|
||||
relay = it.key,
|
||||
geotags = it.value.geotags,
|
||||
since = since?.get(it.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
|
||||
fun filterLongsByHashtag(
|
||||
relay: NormalizedRelayUrl,
|
||||
hashtags: Set<String>,
|
||||
since: Long? = null,
|
||||
): List<RelayBasedFilter> =
|
||||
listOf(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND),
|
||||
tags = mapOf("t" to hashtags.toList()),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun filterLongsByHashtag(
|
||||
hashtagSet: HashtagTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (hashtagSet.set.isEmpty()) return emptyList()
|
||||
|
||||
return hashtagSet.set
|
||||
.mapNotNull { relayHashSet ->
|
||||
if (relayHashSet.value.hashtags.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
filterLongsByHashtag(
|
||||
relay = relayHashSet.key,
|
||||
hashtags = relayHashSet.value.hashtags,
|
||||
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.longs.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
fun filterLongsGlobal(
|
||||
relays: GlobalTopNavPerRelayFilterSet,
|
||||
since: SincePerRelayMap?,
|
||||
defaultSince: Long? = null,
|
||||
): List<RelayBasedFilter> {
|
||||
if (relays.set.isEmpty()) return emptyList()
|
||||
|
||||
return relays.set.map {
|
||||
val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo()
|
||||
RelayBasedFilter(
|
||||
relay = it.key,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(VideoNormalEvent.KIND, VideoHorizontalEvent.KIND),
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.flattenToSet
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -262,8 +262,7 @@ class CardFeedContentState(
|
||||
val sortedList =
|
||||
singleList
|
||||
.get(string)
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.reversed()
|
||||
?.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
|
||||
|
||||
sortedList?.chunked(30)?.map { chunk ->
|
||||
MultiSetCard(
|
||||
@@ -293,8 +292,7 @@ class CardFeedContentState(
|
||||
ZapUserSetCard(
|
||||
user.key,
|
||||
zaps
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex() }))
|
||||
.reversed()
|
||||
.sortedWith(compareByDescending<CombinedZap> { it.createdAt() }.thenBy { it.idHex() })
|
||||
.toImmutableList(),
|
||||
)
|
||||
}
|
||||
@@ -318,8 +316,7 @@ class CardFeedContentState(
|
||||
}
|
||||
|
||||
return (multiCards + textNoteCards + userZaps)
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
|
||||
.reversed()
|
||||
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
|
||||
}
|
||||
|
||||
private fun updateFeed(notes: ImmutableList<Card>) {
|
||||
@@ -383,8 +380,7 @@ class CardFeedContentState(
|
||||
val updatedCards =
|
||||
(oldNotesState.feed.value.list + newCards)
|
||||
.distinctBy { it.id() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
|
||||
.reversed()
|
||||
.sortedWith(compareByDescending<Card> { it.createdAt() }.thenBy { it.id() })
|
||||
.take(localFilter.limit())
|
||||
.toImmutableList()
|
||||
|
||||
|
||||
@@ -80,6 +80,6 @@ class OpenPollsState(
|
||||
false
|
||||
}
|
||||
}
|
||||
}.sortedByDescending { it.createdAt() }
|
||||
}.sortedWith(compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
@@ -83,6 +85,8 @@ class NotificationFeedFilter(
|
||||
CalendarRSVPEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
LiveActivitiesEvent.KIND,
|
||||
LiveChessGameAcceptEvent.KIND,
|
||||
LiveChessMoveEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
NipTextEvent.KIND,
|
||||
VideoVerticalEvent.KIND,
|
||||
@@ -120,7 +124,7 @@ class NotificationFeedFilter(
|
||||
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
|
||||
|
||||
fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value
|
||||
fun followList(): TopFilter = account.settings.defaultNotificationFollowList.value
|
||||
|
||||
fun TopFilter.isMuteList() = this is TopFilter.MuteList
|
||||
|
||||
@@ -185,9 +189,12 @@ class NotificationFeedFilter(
|
||||
}
|
||||
}
|
||||
|
||||
// Chess events bypass the follow filter — opponents may not be followed
|
||||
val isChessEvent = noteEvent is LiveChessGameAcceptEvent || noteEvent is LiveChessMoveEvent
|
||||
|
||||
return noteEvent?.kind in NOTIFICATION_KINDS &&
|
||||
(noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) &&
|
||||
(filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
|
||||
(isChessEvent || filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
|
||||
noteEvent?.isTaggedUser(loggedInUserHex) ?: false &&
|
||||
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
|
||||
(noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) &&
|
||||
|
||||
@@ -95,7 +95,7 @@ fun ZapTheDevsCardPreview() {
|
||||
sig = "e036ecce534e22efd47634c56328af62576ab3a36c565f7c8c5fbea67f48cd46d4041ecfc0ca01dafa0ebe8a0b119d125527a28f88aa30356b80c26dd0953aed",
|
||||
)
|
||||
|
||||
LocalCache.consume(releaseNotes, null, true)
|
||||
LocalCache.consumeRegularEvent(releaseNotes, null, true)
|
||||
}
|
||||
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
|
||||
@@ -415,8 +415,8 @@ fun SendDirectMessageTo(
|
||||
)
|
||||
|
||||
ThinPaddingTextField(
|
||||
value = postViewModel.toUsers,
|
||||
onValueChange = postViewModel::updateToUsers,
|
||||
state = postViewModel.toUsers,
|
||||
onTextChanged = postViewModel::onToUsersChanged,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
@@ -432,8 +432,8 @@ fun SendDirectMessageTo(
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
visualTransformation =
|
||||
UrlUserTagTransformation(
|
||||
outputTransformation =
|
||||
UrlUserTagOutputTransformation(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
colors =
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -147,7 +149,7 @@ class NewPublicMessageViewModel :
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
|
||||
override var message by mutableStateOf(TextFieldValue(""))
|
||||
override val message = TextFieldState()
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
|
||||
@@ -160,7 +162,7 @@ class NewPublicMessageViewModel :
|
||||
|
||||
var emojiSuggestions: EmojiSuggestionState? = null
|
||||
|
||||
var toUsers by mutableStateOf(TextFieldValue(""))
|
||||
val toUsers = TextFieldState()
|
||||
|
||||
// Images and Videos
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
@@ -218,10 +220,9 @@ class NewPublicMessageViewModel :
|
||||
fun load(users: Set<HexKey>) {
|
||||
val userSet = users - account.userProfile().pubkeyHex
|
||||
|
||||
toUsers =
|
||||
TextFieldValue(
|
||||
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
)
|
||||
toUsers.setTextAndPlaceCursorAtEnd(
|
||||
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
)
|
||||
}
|
||||
|
||||
fun reply(post: Note) {
|
||||
@@ -229,8 +230,8 @@ class NewPublicMessageViewModel :
|
||||
}
|
||||
|
||||
fun quote(quote: Note) {
|
||||
message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}")
|
||||
urlPreviews.update(message)
|
||||
message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}")
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
// creates a split with that author.
|
||||
val quotedAuthor = quote.author ?: return
|
||||
@@ -306,13 +307,12 @@ class NewPublicMessageViewModel :
|
||||
|
||||
val userSet = draftEvent.groupKeys() - account.userProfile().pubkeyHex
|
||||
|
||||
toUsers =
|
||||
TextFieldValue(
|
||||
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
)
|
||||
toUsers.setTextAndPlaceCursorAtEnd(
|
||||
userSet.mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
|
||||
)
|
||||
|
||||
message = TextFieldValue(draftEvent.content)
|
||||
urlPreviews.update(message)
|
||||
message.setTextAndPlaceCursorAtEnd(draftEvent.content)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
iMetaAttachments.addAll(draftEvent.imetas())
|
||||
}
|
||||
@@ -349,7 +349,7 @@ class NewPublicMessageViewModel :
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync() {
|
||||
if (message.text.isBlank()) {
|
||||
if (message.text.toString().isBlank()) {
|
||||
accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current)
|
||||
} else {
|
||||
val broadcast = mutableSetOf<Event>()
|
||||
@@ -364,13 +364,14 @@ class NewPublicMessageViewModel :
|
||||
}
|
||||
|
||||
private suspend fun createTemplate(): EventTemplate<PublicMessageEvent> {
|
||||
val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text, null, null, accountViewModel)
|
||||
val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text.toString(), null, null, accountViewModel)
|
||||
toUsersTagger.run()
|
||||
|
||||
val messageText = message.text.toString()
|
||||
val msg =
|
||||
replyingTo?.let {
|
||||
it.toNostrUri() + "\n" + message.text
|
||||
} ?: message.text
|
||||
it.toNostrUri() + "\n" + messageText
|
||||
} ?: messageText
|
||||
|
||||
val tagger = NewMessageTagger(msg, null, null, accountViewModel)
|
||||
tagger.run()
|
||||
@@ -463,41 +464,45 @@ class NewPublicMessageViewModel :
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
results.successful.forEach { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
val urls =
|
||||
results.successful.mapNotNull { state ->
|
||||
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
|
||||
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
|
||||
nip95attachments = nip95attachments + nip95
|
||||
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
|
||||
|
||||
note?.let {
|
||||
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
|
||||
urlPreviews.update(message)
|
||||
note?.let {
|
||||
"nostr:" + it.toNEvent()
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
alt?.let { alt(it) }
|
||||
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
|
||||
}.build()
|
||||
|
||||
iMetaAttachments.replace(iMeta.url, iMeta)
|
||||
|
||||
state.result.url
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
|
||||
val iMeta =
|
||||
IMetaTagBuilder(state.result.url)
|
||||
.apply {
|
||||
hash(state.result.fileHeader.hash)
|
||||
size(state.result.fileHeader.size)
|
||||
state.result.fileHeader.mimeType
|
||||
?.let { mimeType(it) }
|
||||
state.result.fileHeader.dim
|
||||
?.let { dims(it) }
|
||||
state.result.fileHeader.blurHash
|
||||
?.let { blurhash(it.blurhash) }
|
||||
state.result.magnet?.let { magnet(it) }
|
||||
state.result.uploadedHash?.let { originalHash(it) }
|
||||
|
||||
alt?.let { alt(it) }
|
||||
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
|
||||
}.build()
|
||||
|
||||
iMetaAttachments.replace(iMeta.url, iMeta)
|
||||
|
||||
message = message.insertUrlAtCursor(state.result.url)
|
||||
urlPreviews.update(message)
|
||||
}
|
||||
}
|
||||
|
||||
message.insertUrlAtCursor(urls.joinToString(" "))
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
multiOrchestrator = null
|
||||
} else {
|
||||
@@ -512,8 +517,8 @@ class NewPublicMessageViewModel :
|
||||
fun cancel() {
|
||||
draftTag.rotate()
|
||||
|
||||
toUsers = TextFieldValue("")
|
||||
message = TextFieldValue("")
|
||||
toUsers.setTextAndPlaceCursorAtEnd("")
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
multiOrchestrator = null
|
||||
|
||||
wantsInvoice = false
|
||||
@@ -546,15 +551,15 @@ class NewPublicMessageViewModel :
|
||||
}
|
||||
|
||||
fun addToMessage(it: String) {
|
||||
updateMessage(TextFieldValue(message.text + " " + it))
|
||||
message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it)
|
||||
onMessageChanged()
|
||||
}
|
||||
|
||||
override fun updateMessage(newMessage: TextFieldValue) {
|
||||
message = newMessage
|
||||
urlPreviews.update(newMessage)
|
||||
override fun onMessageChanged() {
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = newMessage.currentWord()
|
||||
val lastWord = message.currentWord()
|
||||
if (lastWord.startsWith("@")) {
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
@@ -569,11 +574,9 @@ class NewPublicMessageViewModel :
|
||||
draftTag.newVersion()
|
||||
}
|
||||
|
||||
fun updateToUsers(newToUsersValue: TextFieldValue) {
|
||||
toUsers = newToUsersValue
|
||||
|
||||
if (newToUsersValue.selection.collapsed) {
|
||||
val lastWord = newToUsersValue.currentWord()
|
||||
fun onToUsersChanged() {
|
||||
if (toUsers.selection.collapsed) {
|
||||
val lastWord = toUsers.currentWord()
|
||||
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
|
||||
userSuggestions?.processCurrentWord(lastWord)
|
||||
}
|
||||
@@ -594,14 +597,14 @@ class NewPublicMessageViewModel :
|
||||
userSuggestions?.let { userSuggestions ->
|
||||
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
|
||||
val lastWord = message.currentWord()
|
||||
message = userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message)
|
||||
userSuggestions.replaceCurrentWord(message, lastWord, item)
|
||||
urlPreviews.update(message.text.toString())
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
|
||||
forwardZapTo.value.addItem(item)
|
||||
forwardZapToEditting.value = TextFieldValue("")
|
||||
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
|
||||
val lastWord = toUsers.currentWord()
|
||||
toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
|
||||
userSuggestions.replaceCurrentWord(toUsers, lastWord, item)
|
||||
}
|
||||
|
||||
userSuggestionsMainMessage = null
|
||||
@@ -614,8 +617,8 @@ class NewPublicMessageViewModel :
|
||||
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
|
||||
val wordToInsert = ":${item.code}:"
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -631,8 +634,8 @@ class NewPublicMessageViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
message = message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message)
|
||||
message.replaceCurrentWord(wordToInsert)
|
||||
urlPreviews.update(message.text.toString())
|
||||
|
||||
emojiSuggestions?.reset()
|
||||
|
||||
@@ -648,7 +651,7 @@ class NewPublicMessageViewModel :
|
||||
multiOrchestrator == null
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message = message.insertUrlAtCursor(newElement)
|
||||
message.insertUrlAtCursor(newElement)
|
||||
}
|
||||
|
||||
fun selectImage(uris: ImmutableList<SelectedMedia>) {
|
||||
@@ -671,7 +674,7 @@ class NewPublicMessageViewModel :
|
||||
|
||||
override fun updateZapFromText() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel)
|
||||
val tagger = NewMessageTagger(message.text.toString(), emptyList(), emptyList(), accountViewModel)
|
||||
tagger.run()
|
||||
tagger.pTags?.forEach { taggedUser ->
|
||||
if (!forwardZapTo.value.items.any { it.key == taggedUser }) {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.pictures
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
|
||||
import com.vitorpamplona.amethyst.ui.note.observeEdits
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun PictureCardCompose(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = (baseNote.event as? PictureEvent) ?: return
|
||||
val backgroundColor = remember { mutableStateOf(Color.Transparent) }
|
||||
val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
// Author header row
|
||||
UserCardHeader(baseNote, accountViewModel, nav)
|
||||
|
||||
// Image content
|
||||
PictureCardImage(baseNote, event, backgroundColor, accountViewModel)
|
||||
|
||||
// Reactions row
|
||||
ReactionsRow(
|
||||
baseNote = baseNote,
|
||||
showReactionDetail = true,
|
||||
addPadding = true,
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
// Title and content
|
||||
PictureCardCaption(event)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PictureCardImage(
|
||||
note: Note,
|
||||
event: PictureEvent,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val uri = note.toNostrUri()
|
||||
|
||||
val images by
|
||||
remember(note) {
|
||||
mutableStateOf(
|
||||
event
|
||||
.imetaTags()
|
||||
.map {
|
||||
MediaUrlImage(
|
||||
url = it.url,
|
||||
description = it.alt,
|
||||
hash = it.hash,
|
||||
blurhash = it.blurhash,
|
||||
dim = it.dimension,
|
||||
uri = uri,
|
||||
mimeType = it.mimeType,
|
||||
)
|
||||
}.toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
if (images.isNotEmpty()) {
|
||||
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
|
||||
if (images.size == 1) {
|
||||
ZoomableContentView(
|
||||
content = images.first(),
|
||||
images = images,
|
||||
roundedCorner = false,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
} else {
|
||||
AutoNonlazyGrid(images.size) {
|
||||
ZoomableContentView(
|
||||
content = images[it],
|
||||
images = images,
|
||||
roundedCorner = false,
|
||||
contentScale = ContentScale.Crop,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PictureCardCaption(event: PictureEvent) {
|
||||
val title = event.title()
|
||||
val content = event.content
|
||||
|
||||
if (title != null || content.isNotBlank()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
|
||||
if (content.isNotBlank()) {
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.pictures
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val SAMPLE_PICTURE_EVENT_JSON =
|
||||
"{\"id\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"," +
|
||||
"\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\"," +
|
||||
"\"created_at\":1708695717," +
|
||||
"\"kind\":20," +
|
||||
"\"tags\":[[\"title\",\"Sunset at the Beach\"]," +
|
||||
"[\"imeta\",\"url https://image.nostr.build/sample-sunset.jpg\"," +
|
||||
"\"m image/jpeg\",\"dim 1200x800\",\"alt A beautiful sunset over the ocean\"," +
|
||||
"\"blurhash LKO2:N%2Tw=w]~RBVZRi};RPxuwH\"]]," +
|
||||
"\"content\":\"Caught this amazing sunset while walking along the shore. The colors were absolutely breathtaking, painting the sky in shades of orange and purple.\"," +
|
||||
"\"sig\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"}"
|
||||
|
||||
private const val SAMPLE_PICTURE_EVENT_NO_TITLE_JSON =
|
||||
"{\"id\":\"b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3\"," +
|
||||
"\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\"," +
|
||||
"\"created_at\":1708695000," +
|
||||
"\"kind\":20," +
|
||||
"\"tags\":[[\"imeta\",\"url https://image.nostr.build/sample-mountain.jpg\"," +
|
||||
"\"m image/jpeg\",\"dim 1080x1080\",\"alt Mountain landscape\"]]," +
|
||||
"\"content\":\"Mountain vibes today. Fresh air and clear skies.\"," +
|
||||
"\"sig\":\"b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3\"}"
|
||||
|
||||
private const val SAMPLE_MULTI_IMAGE_EVENT_JSON =
|
||||
"{\"id\":\"c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\"," +
|
||||
"\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\"," +
|
||||
"\"created_at\":1708694000," +
|
||||
"\"kind\":20," +
|
||||
"\"tags\":[[\"title\",\"Travel Photos\"]," +
|
||||
"[\"imeta\",\"url https://image.nostr.build/sample-travel1.jpg\"," +
|
||||
"\"m image/jpeg\",\"dim 800x600\",\"alt City street\"]," +
|
||||
"[\"imeta\",\"url https://image.nostr.build/sample-travel2.jpg\"," +
|
||||
"\"m image/jpeg\",\"dim 800x600\",\"alt Market square\"]]," +
|
||||
"\"content\":\"Exploring the old town district. Every corner has a story to tell. The architecture here is incredible and the food is even better.\"," +
|
||||
"\"sig\":\"c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\"}"
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PictureCardComposePreview() {
|
||||
val event = Event.fromJson(SAMPLE_PICTURE_EVENT_JSON) as PictureEvent
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
val nav = EmptyNav()
|
||||
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalCache.justConsume(event, null, false)
|
||||
}
|
||||
}
|
||||
|
||||
LoadNote(
|
||||
baseNoteHex = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
||||
accountViewModel = accountViewModel,
|
||||
) { baseNote ->
|
||||
ThemeComparisonColumn {
|
||||
if (baseNote != null) {
|
||||
PictureCardCompose(
|
||||
baseNote = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PictureCardComposeNoTitlePreview() {
|
||||
val event = Event.fromJson(SAMPLE_PICTURE_EVENT_NO_TITLE_JSON) as PictureEvent
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
val nav = EmptyNav()
|
||||
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalCache.justConsume(event, null, false)
|
||||
}
|
||||
}
|
||||
|
||||
LoadNote(
|
||||
baseNoteHex = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3",
|
||||
accountViewModel = accountViewModel,
|
||||
) { baseNote ->
|
||||
ThemeComparisonColumn {
|
||||
if (baseNote != null) {
|
||||
PictureCardCompose(
|
||||
baseNote = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PictureCardComposeMultiImagePreview() {
|
||||
val event = Event.fromJson(SAMPLE_MULTI_IMAGE_EVENT_JSON) as PictureEvent
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
val nav = EmptyNav()
|
||||
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
LocalCache.justConsume(event, null, false)
|
||||
}
|
||||
}
|
||||
|
||||
LoadNote(
|
||||
baseNoteHex = "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
|
||||
accountViewModel = accountViewModel,
|
||||
) { baseNote ->
|
||||
ThemeComparisonColumn {
|
||||
if (baseNote != null) {
|
||||
PictureCardCompose(
|
||||
baseNote = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PictureCardCaptionWithTitlePreview() {
|
||||
val event = Event.fromJson(SAMPLE_PICTURE_EVENT_JSON) as PictureEvent
|
||||
|
||||
ThemeComparisonColumn {
|
||||
PictureCardCaption(event)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PictureCardCaptionNoTitlePreview() {
|
||||
val event = Event.fromJson(SAMPLE_PICTURE_EVENT_NO_TITLE_JSON) as PictureEvent
|
||||
|
||||
ThemeComparisonColumn {
|
||||
PictureCardCaption(event)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PictureCardCaptionLongContentPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "A Very Long Title That Should Be Truncated When It Exceeds The Available Width",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text =
|
||||
"This is a long description that spans multiple lines to test the three-line " +
|
||||
"truncation behavior. The text should be cut off after three lines with an " +
|
||||
"ellipsis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do " +
|
||||
"eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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.screen.loggedIn.pictures
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
|
||||
@Composable
|
||||
fun PictureFeedLoaded(
|
||||
loaded: FeedState.Loaded,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
state = listState,
|
||||
) {
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, item -> item.event?.kind ?: -1 },
|
||||
) { _, item ->
|
||||
if (item.event is PictureEvent) {
|
||||
PictureCardCompose(
|
||||
baseNote = item,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user