diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index e62736ca8..8f28543b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -200,7 +200,7 @@ class AppModules( val relayStats = RelayStats(client) // Logs debug messages when needed - val detailedLogger = if (isDebug) RelayLogger(client, true, false) else null + val detailedLogger = if (isDebug) RelayLogger(client, false, false) else null val relayReqStats = if (isDebug) RelayReqStats(client) else null val logger = if (isDebug) RelaySpeedLogger(client) else null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index d2678ca68..18916ef15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -248,10 +248,8 @@ object LocalPreferences { withContext(Dispatchers.IO) { val prefsDir = File(prefsDirPath) prefsDir.list()?.forEach { - if (it.contains(npub)) { - if (!File(prefsDir, it).delete()) { - Log.w("LocalPreferences", "Failed to delete preference file: $it") - } + if (it.contains(npub) && !File(prefsDir, it).delete()) { + Log.w("LocalPreferences", "Failed to delete preference file: $it") } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e91d9171f..3efb1c9d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1563,6 +1563,15 @@ class Account( } } + suspend fun removeBookmark(note: Note) { + if (!isWriteable() || note.isDraft()) return + + val event = bookmarkState.removeBookmark(note) + if (event != null) { + sendMyPublicAndPrivateOutbox(event) + } + } + suspend fun createAuthEvent( relay: NormalizedRelayUrl, challenge: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 902a9015b..67b8afb51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -95,7 +95,7 @@ class AntiSpamFilter { if (spammer.shouldHide() && relay != null) { Amethyst.instance.relayStats .get(relay) - .newSpam("$link1 $link2") + .newSpam(link1, link2) } flowSpam.tryEmit(AntiSpamState(this)) @@ -123,7 +123,7 @@ class AntiSpamFilter { if (spammer.shouldHide() && relay != null) { Amethyst.instance.relayStats .get(relay) - .newSpam("$link1 $link2") + .newSpam(link1, link2) } flowSpam.tryEmit(AntiSpamState(this)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 28b05c3f2..1b452063f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1106,7 +1106,6 @@ object LocalCache : ILocalCache { val new = consumeBaseReplaceable(event, relay, wasVerified) if (new) { - println("AABBCC New ContactCard about ${event.aboutUser()}") val about = checkGetOrCreateUser(event.aboutUser()) ?: return new about.cards().addCard(note) } @@ -2890,7 +2889,7 @@ object LocalCache : ILocalCache { } } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("LocalCache", "Cannot consume ${event.kind}", e) + Log.w("LocalCache", "Cannot consume ${event.toJson()} from ${relay?.url}", e) false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt index 6606229ec..e97ab1585 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model.nip51Lists +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -41,6 +42,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +@Stable class BookmarkListState( val signer: NostrSigner, val cache: LocalCache, @@ -274,4 +276,26 @@ class BookmarkListState( null } } + + suspend fun removeBookmark(note: Note): BookmarkListEvent? { + val bookmarkList = getBookmarkList() + + return if (bookmarkList != null) { + if (note is AddressableNote) { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = AddressBookmark(note.address, note.relayHintUrl()), + signer = signer, + ) + } else { + BookmarkListEvent.remove( + earlierVersion = bookmarkList, + bookmarkIdTag = EventBookmark(note.idHex, note.relayHintUrl()), + signer = signer, + ) + } + } else { + null + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/Logging.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/Logging.kt index 3175a2824..be4565568 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/Logging.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/Logging.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.service.logging import android.os.Build -import android.os.Looper import android.os.StrictMode import android.os.StrictMode.ThreadPolicy import android.os.StrictMode.VmPolicy @@ -59,8 +58,8 @@ class Logging { }.penaltyLog() .build(), ) - Looper.getMainLooper().setMessageLogging(LogMonitor()) - ChoreographerHelper.start() + // Looper.getMainLooper().setMessageLogging(LogMonitor()) + // ChoreographerHelper.start() // Enable recomposition tracking ONLY in debug builds ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index a601837be..7633a47d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -74,64 +74,23 @@ fun VoiceMessagePreview( var progress by remember { mutableFloatStateOf(0f) } var mediaPlayer by remember { mutableStateOf(null) } - // Initialize MediaPlayer - DisposableEffect(voiceMetadata.url, localFile) { - val player = createMediaPlayer(voiceMetadata.url, localFile) - player?.setOnCompletionListener { + ManageMediaPlayer( + voiceMetadata = voiceMetadata, + localFile = localFile, + onCompletion = { isPlaying = false progress = 0f - } - mediaPlayer = player + }, + onPlayerChanged = { mediaPlayer = it }, + onRelease = { isPlaying = false }, + ) - onDispose { - // Stop playback and clean up - try { - player?.stop() - } catch (e: IllegalStateException) { - // Player might already be stopped - Log.d("VoiceMessagePreview", "MediaPlayer stop failed (already stopped)", e) - } - player?.release() - mediaPlayer = null - isPlaying = false - } - } - - // Update progress while playing - LaunchedEffect(mediaPlayer, isPlaying) { - // Capture player reference to avoid reading volatile state repeatedly - val player = mediaPlayer - if (player != null && isPlaying) { - while (isActive) { - try { - if (player.isPlaying) { - val current = player.currentPosition.toFloat() - val duration = player.duration.toFloat() - // Validate values before calculating progress - val newProgress = - if (duration > 0 && current >= 0) { - (current / duration).coerceIn(0f, 1f) - } else { - 0f - } - // Only update if value is valid (not NaN or Infinity) - if (newProgress.isFinite()) { - progress = newProgress - } - } else { - // Player stopped, exit loop and let LaunchedEffect restart - break - } - } catch (e: IllegalStateException) { - // Player in invalid state, stop tracking - Log.w("VoiceMessagePreview", "MediaPlayer in invalid state during progress tracking", e) - isPlaying = false - break - } - delay(100) - } - } - } + TrackPlaybackProgress( + mediaPlayer = mediaPlayer, + isPlaying = isPlaying, + onProgressUpdate = { progress = it }, + onInvalidState = { isPlaying = false }, + ) Box( modifier = @@ -150,27 +109,13 @@ fun VoiceMessagePreview( // Play/Pause Button IconButton( onClick = { - val player = mediaPlayer - if (player != null) { - try { - if (isPlaying) { - player.pause() - isPlaying = false - } else { - // Validate progress before comparison - if (progress.isFinite() && progress >= 1f) { - player.seekTo(0) - progress = 0f - } - player.start() - isPlaying = true - } - } catch (e: IllegalStateException) { - // MediaPlayer in invalid state, ignore - Log.w("VoiceMessagePreview", "MediaPlayer operation failed in onClick handler", e) - isPlaying = false - } - } + handlePlayPauseClick( + mediaPlayer = mediaPlayer, + isPlaying = isPlaying, + progress = progress, + onProgressReset = { progress = 0f }, + onPlayingChanged = { isPlaying = it }, + ) }, modifier = Modifier.size(48.dp), ) { @@ -194,24 +139,11 @@ fun VoiceMessagePreview( waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)), progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)), onProgressChange = { newProgress -> - // Validate incoming progress value - if (newProgress.isFinite() && newProgress >= 0f && newProgress <= 1f) { - val player = mediaPlayer - if (player != null) { - try { - val duration = player.duration - // Only seek if duration is valid - if (duration > 0) { - val newPosition = (newProgress * duration).toInt() - player.seekTo(newPosition) - progress = newProgress - } - } catch (e: IllegalStateException) { - // MediaPlayer in invalid state, ignore - Log.w("VoiceMessagePreview", "MediaPlayer seek failed in onProgressChange", e) - } - } - } + handleWaveformScrub( + newProgress = newProgress, + mediaPlayer = mediaPlayer, + onProgressChanged = { progress = it }, + ) }, ) @@ -240,6 +172,115 @@ fun VoiceMessagePreview( } } +@Composable +private fun ManageMediaPlayer( + voiceMetadata: AudioMeta, + localFile: File?, + onCompletion: () -> Unit, + onPlayerChanged: (MediaPlayer?) -> Unit, + onRelease: () -> Unit, +) { + DisposableEffect(voiceMetadata.url, localFile) { + val player = createMediaPlayer(voiceMetadata.url, localFile) + player?.setOnCompletionListener { onCompletion() } + onPlayerChanged(player) + + onDispose { + try { + player?.stop() + } catch (e: IllegalStateException) { + Log.d("VoiceMessagePreview", "MediaPlayer stop failed (already stopped)", e) + } + player?.release() + onPlayerChanged(null) + onRelease() + } + } +} + +@Composable +private fun TrackPlaybackProgress( + mediaPlayer: MediaPlayer?, + isPlaying: Boolean, + onProgressUpdate: (Float) -> Unit, + onInvalidState: () -> Unit, +) { + LaunchedEffect(mediaPlayer, isPlaying) { + val player = mediaPlayer ?: return@LaunchedEffect + if (!isPlaying) return@LaunchedEffect + + while (isActive) { + try { + if (!player.isPlaying) break + calculateProgress(player.currentPosition.toFloat(), player.duration.toFloat())?.let { onProgressUpdate(it) } + } catch (e: IllegalStateException) { + Log.w("VoiceMessagePreview", "MediaPlayer in invalid state during progress tracking", e) + onInvalidState() + break + } + delay(100) + } + } +} + +private fun calculateProgress( + current: Float, + duration: Float, +): Float? { + val progress = + if (duration > 0 && current >= 0) { + (current / duration).coerceIn(0f, 1f) + } else { + 0f + } + return progress.takeIf { it.isFinite() } +} + +private fun handlePlayPauseClick( + mediaPlayer: MediaPlayer?, + isPlaying: Boolean, + progress: Float, + onProgressReset: () -> Unit, + onPlayingChanged: (Boolean) -> Unit, +) { + val player = mediaPlayer ?: return + try { + if (isPlaying) { + player.pause() + onPlayingChanged(false) + return + } + if (progress.isFinite() && progress >= 1f) { + player.seekTo(0) + onProgressReset() + } + player.start() + onPlayingChanged(true) + } catch (e: IllegalStateException) { + Log.w("VoiceMessagePreview", "MediaPlayer operation failed in onClick handler", e) + onPlayingChanged(false) + } +} + +private fun handleWaveformScrub( + newProgress: Float, + mediaPlayer: MediaPlayer?, + onProgressChanged: (Float) -> Unit, +) { + if (!newProgress.isFinite() || newProgress < 0f || newProgress > 1f) return + val player = mediaPlayer ?: return + try { + val duration = player.duration + if (duration > 0) { + val newPosition = (newProgress * duration).toInt() + player.seekTo(newPosition) + onProgressChanged(newProgress) + } + } catch (e: IllegalStateException) { + Log.w("VoiceMessagePreview", "MediaPlayer seek failed in onProgressChange", e) + } +} + private fun createMediaPlayer( url: String, localFile: File?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 9cd3b289d..9525b7ef2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -34,8 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.myapplication.DisappearingBottomBar import com.vitorpamplona.myapplication.DisappearingFloatingButton -import com.vitorpamplona.myapplication.DisappearingTopBar -import com.vitorpamplona.myapplication.enterAlwaysScrollBehavior @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt index 26c9a82be..d03aca957 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingTopBar.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.myapplication +package com.vitorpamplona.amethyst.ui.layouts import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationState @@ -58,7 +58,7 @@ fun DisappearingTopBar( scrollBehavior: CustomEnterAlwaysScrollBehavior, content: @Composable (ColumnScope.() -> Unit), ) { - ResetDisappearingOnResume(scrollBehavior) + // ResetDisappearingOnResume(scrollBehavior) // Set up support for resizing the top app bar when vertically dragging the bar itself. val appBarDragModifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 30d7e9a14..9266e299e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -58,12 +58,12 @@ import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen 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.bookmarks.BookmarkListScreen 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index fb16001a6..3ae138470 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -46,7 +46,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts @@ -458,14 +457,6 @@ fun ListContent( NavigationRow( title = R.string.bookmarks, - icon = Icons.Outlined.BookmarkBorder, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Bookmarks, - ) - - NavigationRow( - title = R.string.bookmark_lists, icon = Icons.Outlined.CollectionsBookmark, tint = MaterialTheme.colorScheme.onBackground, nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 070403e87..c1bc1c0a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -116,6 +116,14 @@ val njumpLink = { nip19BechAddress: String -> "https://njump.to/$nip19BechAddress" } +val nipLink = { nipNumber: String -> + "https://nostrhub.io/$nipNumber" +} + +val graspLink = { graspNumber: String -> + "https://gitworkshop.dev/danconwaydev.com/grasp/tree/master/$graspNumber.md" +} + val externalLinkForNote = { note: Note -> if (note is AddressableNote) { if (note.event?.bountyBaseReward() != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt index 2b33c9b50..e6b09d2ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt @@ -56,6 +56,12 @@ fun showAmountInteger(amount: BigDecimal?): String { } } +fun showAmountInteger(amount: Int?): String { + if (amount == null) return "0" + + return showAmountIntegerWithZero(BigDecimal.valueOf(amount.toLong())) +} + fun showAmountIntegerWithZero(amount: BigDecimal?): String { if (amount == null) return "0" if (amount.abs() < BigDecimal(0.01)) return "0" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt index ed958bead..111fc3876 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column @@ -44,8 +44,8 @@ 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.bookmarks.dal.BookmarkPrivateFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal.BookmarkPublicFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPrivateFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPublicFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.coroutines.launch @@ -93,7 +93,7 @@ private fun RenderBookmarkScreen( isInvertedLayout = false, topBar = { Column { - TopBarWithBackButton(stringRes(id = R.string.bookmarks), nav::popBack) + TopBarWithBackButton(stringRes(id = R.string.bookmarks_title), nav::popBack) TabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedFilter.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedFilter.kt index 85cdc1d77..ad15db9ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedViewModel.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedViewModel.kt index 3ddbd8e09..a0df51e0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPrivateFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPrivateFeedViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedFilter.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedFilter.kt index 34a3962fb..e6e1ab1d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedViewModel.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedViewModel.kt index fb8ec6c8e..96b71053e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarks/dal/BookmarkPublicFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkPublicFeedViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index e4ca1fc88..b4d21b5b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -59,6 +58,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -118,7 +118,7 @@ fun BookmarkGroupScreenView( Scaffold( topBar = { Column { - TopAppBar( + ShorterTopAppBar( title = { TitleAndDescription(bookmarkGroupViewModel) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index 9e603d023..dba0abacc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -20,34 +20,43 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.Size40Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import kotlinx.coroutines.flow.StateFlow @Composable fun ListOfBookmarkGroupsFeedView( + defaultBookmarks: BookmarkListState, groupListFeedSource: StateFlow>, + openDefaultBookmarks: () -> Unit, onOpenItem: (String, BookmarkType) -> Unit, onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -56,40 +65,73 @@ fun ListOfBookmarkGroupsFeedView( ) { val bookmarkGroupFeedState by groupListFeedSource.collectAsStateWithLifecycle() - if (bookmarkGroupFeedState.isEmpty()) { - BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) - } else { - LazyColumn( - state = rememberLazyListState(), - contentPadding = FeedPadding, - ) { - itemsIndexed( - bookmarkGroupFeedState, - key = { _: Int, item: LabeledBookmarkList -> item.identifier }, - ) { _, groupItem -> - BookmarkGroupItem( - modifier = Modifier.fillMaxSize().animateItem(), - bookmarkList = groupItem, - onClick = { bookmarkType -> onOpenItem(groupItem.identifier, bookmarkType) }, - onRename = { onRenameItem(groupItem) }, - onDescriptionChange = { onItemDescriptionChange(groupItem) }, - onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) }, - onDelete = { onDeleteItem(groupItem) }, - ) - HorizontalDivider(thickness = DividerThickness) - } + LazyColumn( + state = rememberLazyListState(), + contentPadding = FeedPadding, + ) { + item { + DefaultBookmarkList(defaultBookmarks, openDefaultBookmarks) + HorizontalDivider(thickness = DividerThickness) + } + + itemsIndexed( + bookmarkGroupFeedState, + key = { _: Int, item: LabeledBookmarkList -> item.identifier }, + ) { _, groupItem -> + BookmarkGroupItem( + modifier = Modifier.fillMaxSize().animateItem(), + bookmarkList = groupItem, + onClick = { bookmarkType -> onOpenItem(groupItem.identifier, bookmarkType) }, + onRename = { onRenameItem(groupItem) }, + onDescriptionChange = { onItemDescriptionChange(groupItem) }, + onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) }, + onDelete = { onDeleteItem(groupItem) }, + ) + HorizontalDivider(thickness = DividerThickness) } } } @Composable -fun BookmarkGroupsFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) { - Column( - Modifier.fillMaxSize().padding(horizontal = Size40dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(message) - Spacer(modifier = StdVertSpacer) - } +fun DefaultBookmarkList( + defaultBookmarks: BookmarkListState, + openDefaultBookmarks: () -> Unit, +) { + val bookmarkState by defaultBookmarks.bookmarks.collectAsStateWithLifecycle() + + ListItem( + modifier = Modifier.clickable(onClick = openDefaultBookmarks), + headlineContent = { + Text(stringRes(R.string.bookmarks_title), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringRes(R.string.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, + ) + } + }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index 8903b1b48..6e5ad7581 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material3.Text 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.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -49,7 +50,9 @@ fun ListOfBookmarkGroupsScreen( nav: INav, ) { ListOfBookmarkGroupsFeed( + defaultBookmarks = accountViewModel.account.bookmarkState, listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow, + openDefaultBookmarks = { nav.nav(Route.Bookmarks) }, addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) }, openBookmarkGroup = { identifier, bookmarkType -> nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) @@ -84,7 +87,9 @@ fun ListOfBookmarkGroupsScreen( @Composable fun ListOfBookmarkGroupsFeed( + defaultBookmarks: BookmarkListState, listSource: StateFlow>, + openDefaultBookmarks: () -> Unit, addBookmarkGroup: () -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -109,7 +114,9 @@ fun ListOfBookmarkGroupsFeed( ).fillMaxHeight(), ) { ListOfBookmarkGroupsFeedView( + defaultBookmarks = defaultBookmarks, groupListFeedSource = listSource, + openDefaultBookmarks = openDefaultBookmarks, onOpenItem = openBookmarkGroup, onRenameItem = renameBookmarkGroup, onItemDescriptionChange = changeBookmarkGroupDescription, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt deleted file mode 100644 index 2f8680c6e..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt +++ /dev/null @@ -1,105 +0,0 @@ -/** - * 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.list - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer - -@Composable -fun NewBookmarkGroupCreationDialog( - modifier: Modifier = Modifier, - onDismiss: () -> Unit, - onCreateGroup: (name: String, description: String?) -> Unit, -) { - val newGroupName = remember { mutableStateOf("") } - val newGroupDescription = remember { mutableStateOf(null) } - - AlertDialog( - modifier = modifier, - onDismissRequest = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "New Bookmark Group", - ) - } - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(5.dp), - ) { - // For the new bookmark group name - TextField( - value = newGroupName.value, - onValueChange = { newGroupName.value = it }, - label = { - Text(text = "Group name") - }, - ) - Spacer(modifier = DoubleVertSpacer) - // For the group description - TextField( - value = - (if (newGroupDescription.value != null) newGroupDescription.value else "").toString(), - onValueChange = { newGroupDescription.value = it }, - label = { - Text(text = "Group description(optional)") - }, - ) - } - }, - confirmButton = { - Button( - onClick = { - onCreateGroup(newGroupName.value, newGroupDescription.value) - onDismiss() - }, - ) { - Text("Create Group") - } - }, - dismissButton = { - Button( - onClick = onDismiss, - ) { - Text(stringRes(R.string.cancel)) - } - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt index ec36b977e..a5614fdf9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt @@ -44,7 +44,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType -import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkGroupsFeedEmpty import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.Address @@ -75,8 +74,6 @@ private fun ListManagementView( accountViewModel: AccountViewModel, nav: INav, ) { - val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow - .collectAsStateWithLifecycle() Scaffold( modifier = modifier, topBar = { @@ -95,48 +92,86 @@ private fun ListManagementView( ).consumeWindowInsets(contentPadding) .imePadding(), ) { - if (bookmarkGroups.isEmpty()) { - BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) - } else { - LazyColumn( - state = rememberLazyListState(), - modifier = Modifier.fillMaxWidth(), - ) { - itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> - val maybePublicBookmark = bookmarkList.publicArticleBookmarks.firstOrNull { it.address == note.address } - val maybePrivateBookmark = bookmarkList.privateArticleBookmarks.firstOrNull { it.address == note.address } - BookmarkGroupManagementItem( - modifier = Modifier.fillMaxWidth().animateItem(), - listTitle = bookmarkList.title, - isPublicMemberBookmark = maybePublicBookmark != null, - isPrivateMemberBookmark = maybePrivateBookmark != null, - totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size, - totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size, - onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.ArticleBookmark)) }, - onAddBookmarkToGroup = { shouldBePrivate -> - accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.addBookmarkToList( - bookmark = AddressBookmark(address = note.address, relayHint = note.relayHintUrl()), - bookmarkListIdentifier = bookmarkList.identifier, - isBookmarkPrivate = shouldBePrivate, - account = accountViewModel.account, - ) - } - }, - onRemoveBookmarkFromGroup = { - accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( - bookmark = AddressBookmark(address = note.address), - bookmarkListIdentifier = bookmarkList.identifier, - isBookmarkPrivate = maybePrivateBookmark != null, - account = accountViewModel.account, - ) - } - }, - ) - } - } - } + ListManagementViewBody(note, accountViewModel, nav) + } + } +} + +@Composable +private fun ListManagementViewBody( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow + .collectAsStateWithLifecycle() + + val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks + .collectAsStateWithLifecycle() + + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier.fillMaxWidth(), + ) { + item { + val maybePublicBookmark = defaultBookmarks.public.contains(note) + val maybePrivateBookmark = defaultBookmarks.private.contains(note) + + BookmarkGroupManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + listTitle = stringRes(R.string.bookmarks_title), + isPublicMemberBookmark = maybePublicBookmark, + isPrivateMemberBookmark = maybePrivateBookmark, + totalPostBookmarkSize = defaultBookmarks.public.size + defaultBookmarks.private.size, + totalArticleBookmarkSize = 0, + onClick = { + nav.nav(Route.Bookmarks) + }, + onAddBookmarkToGroup = { shouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.addBookmark(note, shouldBePrivate) + } + }, + onRemoveBookmarkFromGroup = { + accountViewModel.launchSigner { + accountViewModel.account.removeBookmark(note) + } + }, + ) + } + + itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> + val maybePublicBookmark = bookmarkList.publicArticleBookmarks.firstOrNull { it.address == note.address } + val maybePrivateBookmark = bookmarkList.privateArticleBookmarks.firstOrNull { it.address == note.address } + BookmarkGroupManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + listTitle = bookmarkList.title, + isPublicMemberBookmark = maybePublicBookmark != null, + isPrivateMemberBookmark = maybePrivateBookmark != null, + totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size, + totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size, + onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.ArticleBookmark)) }, + onAddBookmarkToGroup = { shouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.addBookmarkToList( + bookmark = AddressBookmark(address = note.address, relayHint = note.relayHintUrl()), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = shouldBePrivate, + account = accountViewModel.account, + ) + } + }, + onRemoveBookmarkFromGroup = { + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( + bookmark = AddressBookmark(address = note.address), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = maybePrivateBookmark != null, + account = accountViewModel.account, + ) + } + }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt index 14bb6478d..e38ea0a9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt @@ -44,7 +44,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType -import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkGroupsFeedEmpty import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark @@ -74,8 +73,6 @@ private fun ListManagementView( accountViewModel: AccountViewModel, nav: INav, ) { - val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow - .collectAsStateWithLifecycle() Scaffold( modifier = modifier, topBar = { @@ -94,48 +91,86 @@ private fun ListManagementView( ).consumeWindowInsets(contentPadding) .imePadding(), ) { - if (bookmarkGroups.isEmpty()) { - BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg)) - } else { - LazyColumn( - state = rememberLazyListState(), - modifier = Modifier.fillMaxWidth(), - ) { - itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> - val maybePublicBookmark = bookmarkList.publicPostBookmarks.firstOrNull { it.eventId == note.idHex } - val maybePrivateBookmark = bookmarkList.privatePostBookmarks.firstOrNull { it.eventId == note.idHex } - BookmarkGroupManagementItem( - modifier = Modifier.fillMaxWidth().animateItem(), - listTitle = bookmarkList.title, - isPublicMemberBookmark = maybePublicBookmark != null, - isPrivateMemberBookmark = maybePrivateBookmark != null, - totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size, - totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size, - onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.PostBookmark)) }, - onAddBookmarkToGroup = { shouldBePrivate -> - accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.addBookmarkToList( - bookmark = EventBookmark(eventId = note.idHex, relay = note.relayHintUrl(), author = note.author?.pubkeyHex), - bookmarkListIdentifier = bookmarkList.identifier, - isBookmarkPrivate = shouldBePrivate, - account = accountViewModel.account, - ) - } - }, - onRemoveBookmarkFromGroup = { - accountViewModel.launchSigner { - accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( - bookmark = EventBookmark(eventId = note.idHex), - bookmarkListIdentifier = bookmarkList.identifier, - isBookmarkPrivate = maybePrivateBookmark != null, - account = accountViewModel.account, - ) - } - }, - ) - } - } - } + ListManagementViewBody(note, accountViewModel, nav) + } + } +} + +@Composable +private fun ListManagementViewBody( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow + .collectAsStateWithLifecycle() + + val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks + .collectAsStateWithLifecycle() + + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier.fillMaxWidth(), + ) { + item { + val maybePublicBookmark = defaultBookmarks.public.contains(note) + val maybePrivateBookmark = defaultBookmarks.private.contains(note) + + BookmarkGroupManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + listTitle = stringRes(R.string.bookmarks_title), + isPublicMemberBookmark = maybePublicBookmark, + isPrivateMemberBookmark = maybePrivateBookmark, + totalPostBookmarkSize = defaultBookmarks.public.size + defaultBookmarks.private.size, + totalArticleBookmarkSize = 0, + onClick = { + nav.nav(Route.Bookmarks) + }, + onAddBookmarkToGroup = { shouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.addBookmark(note, shouldBePrivate) + } + }, + onRemoveBookmarkFromGroup = { + accountViewModel.launchSigner { + accountViewModel.account.removeBookmark(note) + } + }, + ) + } + + itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList -> + val maybePublicBookmark = bookmarkList.publicPostBookmarks.firstOrNull { it.eventId == note.idHex } + val maybePrivateBookmark = bookmarkList.privatePostBookmarks.firstOrNull { it.eventId == note.idHex } + BookmarkGroupManagementItem( + modifier = Modifier.fillMaxWidth().animateItem(), + listTitle = bookmarkList.title, + isPublicMemberBookmark = maybePublicBookmark != null, + isPrivateMemberBookmark = maybePrivateBookmark != null, + totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size, + totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size, + onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.PostBookmark)) }, + onAddBookmarkToGroup = { shouldBePrivate -> + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.addBookmarkToList( + bookmark = EventBookmark(eventId = note.idHex, relay = note.relayHintUrl(), author = note.author?.pubkeyHex), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = shouldBePrivate, + account = accountViewModel.account, + ) + } + }, + onRemoveBookmarkFromGroup = { + accountViewModel.launchSigner { + accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList( + bookmark = EventBookmark(eventId = note.idHex), + bookmarkListIdentifier = bookmarkList.identifier, + isBookmarkPrivate = maybePrivateBookmark != null, + account = accountViewModel.account, + ) + } + }, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 652cc425f..cd479b134 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -972,7 +972,6 @@ open class ShortNotePostViewModel : val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed) - val uploadVoiceUnexpected = stringRes(appContext, R.string.upload_error_voice_message_unexpected_state) val uploadVoiceExceptionMessage: (String) -> String = { detail -> stringRes(appContext, R.string.upload_error_voice_message_exception, detail) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/BookmarkTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/BookmarkTabHeader.kt index 730d1dc3e..41a7cdca6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/BookmarkTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/bookmarks/BookmarkTabHeader.kt @@ -36,5 +36,5 @@ fun BookmarkTabHeader( ) { val userBookmarks by observeUserBookmarkCount(baseUser, accountViewModel) - Text(text = "$userBookmarks ${stringRes(R.string.bookmarks)}") + Text(text = "$userBookmarks ${stringRes(R.string.bookmarks_title)}") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index ce9cc9466..5e4fc7b20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -20,65 +20,113 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.automirrored.filled.ContactSupport +import androidx.compose.material.icons.automirrored.filled.Label +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.EditOff +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.Gavel +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Payment +import androidx.compose.material.icons.filled.PrivacyTip +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.filled.Topic +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.util.timeDiffAgoShortish import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.components.ClickableEmail -import com.vitorpamplona.amethyst.ui.components.ClickableUrl -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.appendLink +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.UserCompose -import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.note.graspLink +import com.vitorpamplona.amethyst.ui.note.nipLink +import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer -import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier -import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size100dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList -import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayDebugMessage +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.NoticeDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat +import com.vitorpamplona.quartz.nip01Core.relay.client.stats.SpamDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract @Composable fun RelayInformationScreen( @@ -138,267 +186,177 @@ fun RelayInformationScreen( .toImmutableList() } - LazyColumn( - modifier = - Modifier - .padding(pad) - .consumeWindowInsets(pad) - .padding(bottom = Size10dp, start = Size10dp, end = Size10dp) - .fillMaxSize(), - ) { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = StdPadding.fillMaxWidth(), - ) { - Column { - RenderRelayIcon( - displayUrl = relay.displayUrl(), - iconUrl = relayInfo.icon, - loadProfilePicture = accountViewModel.settings.showProfilePictures(), - loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - pingInMs = - Amethyst.instance.relayStats - .get(relay) - .pingInMs, - iconModifier = LargeRelayIconModifier, - ) - } - - Spacer(modifier = DoubleHorzSpacer) - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Title(relayInfo.name?.trim() ?: "") - Spacer(modifier = HalfVertSpacer) - SubtitleContent(relay.url) - } - } - } - item { - Section(stringRes(R.string.description)) - - SectionContent(relayInfo.description?.trim() ?: stringRes(R.string.no_description)) - } - relayInfo.pubkey?.let { - item { - Section(stringRes(R.string.owner)) - DisplayOwnerInformation(it, accountViewModel, nav) - } - } - relayInfo.contact?.let { - item { - Section(stringRes(R.string.contact)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - if (it.startsWith("https:")) { - ClickableUrl(urlText = it, url = it) - } else if (it.startsWith("mailto:") || it.contains('@')) { - ClickableEmail(it) - } else { - SectionContent(it) - } - } - } - } - relayInfo.software?.let { - item { - Section(stringRes(R.string.software)) - - DisplaySoftwareInformation(it) - - Section(stringRes(R.string.version)) - - SectionContent(relayInfo.version ?: "") - } - } - relayInfo.supported_nips?.let { - if (it.isNotEmpty()) { - item { - Section(stringRes(R.string.supports)) - - DisplaySupportedNips(it, relayInfo.supported_nip_extensions) - } - } - } - relayInfo.fees?.admission?.let { - item { - if (it.isNotEmpty()) { - Section(stringRes(R.string.admission_fees)) - - it.forEach { item -> SectionContent("${item.amount?.div(1000) ?: 0} sats") } - } - } - } - - relayInfo.payments_url?.let { - item { - Section(stringRes(R.string.payments_url)) - - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = it, - url = it, - ) - } - } - } - - relayInfo.limitation?.let { - item { - Section(stringRes(R.string.limitations)) - val authRequiredText = - if (it.auth_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - val paymentRequiredText = - if (it.payment_required ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - val restrictedWritesText = - if (it.restricted_writes ?: false) stringRes(R.string.yes) else stringRes(R.string.no) - - Column { - SectionContent( - "${stringRes(R.string.message_length)}: ${it.max_message_length ?: 0}", - ) - SectionContent( - "${stringRes(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}", - ) - SectionContent("${stringRes(R.string.filters)}: ${it.max_filters ?: 0}") - SectionContent( - "${stringRes(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}", - ) - SectionContent("${stringRes(R.string.minimum_prefix)}: ${it.min_prefix ?: 0}") - SectionContent( - "${stringRes(R.string.maximum_event_tags)}: ${it.max_event_tags ?: 0}", - ) - SectionContent( - "${stringRes(R.string.content_length)}: ${it.max_content_length ?: 0}", - ) - SectionContent( - "${stringRes(R.string.max_limit)}: ${it.max_limit ?: 0}", - ) - SectionContent("${stringRes(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}") - SectionContent("${stringRes(R.string.auth)}: $authRequiredText") - SectionContent("${stringRes(R.string.payment)}: $paymentRequiredText") - SectionContent("${stringRes(R.string.restricted_writes)}: $restrictedWritesText") - } - } - } - relayInfo.relay_countries?.let { - item { - Section(stringRes(R.string.countries)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - relayInfo.language_tags?.let { - item { - Section(stringRes(R.string.languages)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - relayInfo.tags?.let { - item { - Section(stringRes(R.string.tags)) - - FlowRow { it.forEach { item -> SectionContent(item) } } - } - } - relayInfo.posting_policy?.let { - item { - Section(stringRes(R.string.posting_policy)) - - Box(Modifier.padding(10.dp)) { - ClickableUrl( - it, - it, - ) - } - } - } - - item { - Section(stringRes(R.string.relay_error_messages)) - } - - items(messages) { msg -> - Row { - RenderDebugMessage(msg, accountViewModel, nav) - } - - Spacer(modifier = StdVertSpacer) - } - } + RelayInformationBody(relay, relayInfo, Amethyst.instance.relayStats.get(relay), messages, pad, accountViewModel, nav) } } @Composable -private fun RenderDebugMessage( - msg: RelayDebugMessage, +fun RelayInformationBody( + relay: NormalizedRelayUrl, + relayInfo: Nip11RelayInformation, + relayStats: RelayStat, + messages: ImmutableList, + pad: PaddingValues, accountViewModel: AccountViewModel, - newNav: INav, + nav: INav, ) { - SelectionContainer { - val context = LocalContext.current - val color = - remember { - mutableStateOf(Color.Transparent) - } - TranslatableRichTextViewer( - content = - remember { - "${timeAgo(msg.time, context)}, ${msg.type.name}: ${msg.message}" - }, - canPreview = false, - quotesLeft = 0, - modifier = Modifier.fillMaxWidth(), - tags = EmptyTagList, - backgroundColor = color, - id = msg.hashCode().toString(), - accountViewModel = accountViewModel, - nav = newNav, - ) - } -} + LazyColumn( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .fillMaxSize(), + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // 1. Header Section + item { + RelayHeader(relay, relayStats, relayInfo, accountViewModel) + } -@Composable -@OptIn(ExperimentalLayoutApi::class) -private fun DisplaySupportedNips( - supportedNips: List, - supportedNipExtensions: List?, -) { - FlowRow { - supportedNips.forEach { item -> - val text = item.toString().padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", - ) + val targetAudience = + relayInfo.tags != null || + relayInfo.language_tags != null || + relayInfo.relay_countries != null + + if (targetAudience) { + item { SectionHeader(stringRes(R.string.target_audience)) } + item { TargetAudienceCard(relayInfo, nav) } + } + + relayInfo.pubkey?.let { + item { + SectionHeader(stringRes(R.string.owner)) + DisplayOwnerInformation(it, accountViewModel, nav) } } - supportedNipExtensions?.forEach { item -> - val text = item.padStart(2, '0') - Box(Modifier.padding(10.dp)) { - ClickableUrl( - urlText = text, - url = "https://github.com/nostr-protocol/nips/blob/master/$text.md", - ) + relayInfo.self?.let { + item { + SectionHeader(stringRes(R.string.self)) + DisplayOwnerInformation(it, accountViewModel, nav) } } + + item { SectionHeader(stringRes(R.string.policies_and_links)) } + item { PoliciesCard(relayInfo) } + + relayInfo.fees?.let { fees -> + item { SectionHeader(stringRes(R.string.fees_and_payments)) } + item { FeesCard(fees, relayInfo.payments_url) } + } + + relayInfo.limitation?.let { + item { SectionHeader(stringRes(R.string.limitations)) } + item { LimitationsCard(it) } + } + + val atLeastOneSoftware = + relayInfo.software != null || + relayInfo.version != null || + relayInfo.supported_grasps != null || + relayInfo.supported_nips != null + + if (atLeastOneSoftware) { + item { SectionHeader(stringRes(R.string.software)) } + item { SoftwareCard(relayInfo) } + } + + item { + SectionHeader(stringRes(R.string.relay_error_messages)) + } + + items(messages) { msg -> + RenderDebugMessage(msg) + + Spacer(modifier = StdVertSpacer) + } } } @Composable -private fun DisplaySoftwareInformation(software: String) { - val url = software.replace("git+", "") - Box(modifier = Modifier.padding(start = 10.dp)) { - ClickableUrl( - urlText = url, - url = url, - ) +private fun RenderDebugMessage(msg: IRelayDebugMessage) { + val context = LocalContext.current + + Column(Modifier.padding(horizontal = 12.dp)) { + Row( + modifier = Modifier.padding(vertical = 6.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Timestamp with Monospace font for alignment + Text( + text = timeAgoNoDot(msg.time, context), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + + // Type Tag + Text( + text = + when (msg) { + is ErrorDebugMessage -> stringRes(R.string.errors) + is NoticeDebugMessage -> stringRes(R.string.relay_notice) + is SpamDebugMessage -> stringRes(R.string.spam) + }, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + ), + color = + when (msg) { + is ErrorDebugMessage -> MaterialTheme.colorScheme.error + is NoticeDebugMessage -> MaterialTheme.colorScheme.primary + is SpamDebugMessage -> MaterialTheme.colorScheme.outline + }, + ) + } + + when (msg) { + is ErrorDebugMessage -> + SelectionContainer { + Text( + text = msg.message, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + is NoticeDebugMessage -> + SelectionContainer { + Text( + text = msg.message, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + is SpamDebugMessage -> + SelectionContainer { + val uri = LocalUriHandler.current + val start = stringRes(R.string.duplicated_post) + Text( + text = + remember { + buildAnnotatedString { + append(start) + append(" ") + appendLink(msg.link1) { + runCatching { + uri.openUri(msg.link1) + } + } + appendLink(msg.link2) { + runCatching { + uri.openUri(msg.link2) + } + } + } + }, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } } } @@ -408,50 +366,616 @@ private fun DisplayOwnerInformation( accountViewModel: AccountViewModel, nav: INav, ) { - LoadUser(baseUserHex = userHex, accountViewModel) { - CrossfadeIfEnabled(it, accountViewModel = accountViewModel) { + LoadUser(baseUserHex = userHex, accountViewModel) { loadedUser -> + CrossfadeIfEnabled(loadedUser, accountViewModel = accountViewModel) { if (it != null) { - UserCompose( - baseUser = it, - accountViewModel = accountViewModel, - nav = nav, - ) + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + UserCompose( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } } @Composable -fun Title(text: String) { +private fun RelayHeader( + relay: NormalizedRelayUrl, + relayStats: RelayStat, + relayInfo: Nip11RelayInformation, + accountViewModel: AccountViewModel, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + RenderRelayIcon( + displayUrl = relay.displayUrl(), + iconUrl = relayInfo.icon, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + pingInMs = relayStats.pingInMs, + iconModifier = + Modifier + .size(Size100dp) + .clip(shape = CircleShape), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = relayInfo.description ?: relay.displayUrl(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +fun FeesCard( + fees: Nip11RelayInformation.RelayInformationFees, + payUrl: String?, +) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + fees.admission?.forEach { + FeeRow(stringRes(R.string.admission), it) + } + fees.subscription?.forEach { + FeeRow(stringRes(R.string.subscription), it) + } + fees.publication?.forEach { + FeeRow(stringRes(R.string.publication), it) + } + payUrl?.let { + val uri = LocalUriHandler.current + ClickableInfoRow(Icons.Default.Payment, stringRes(R.string.payments_url), it.removePrefix("https://")) { + runCatching { + uri.openUri(it) + } + } + } + } + } +} + +@Composable +fun LimitationsCard(lim: Nip11RelayInformation.RelayInformationLimitation) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + val atLeastOneAccessControl = + lim.auth_required != null || + lim.payment_required != null || + lim.restricted_writes != null || + lim.min_pow_difficulty != null || + lim.min_prefix != null + + if (atLeastOneAccessControl) { + Column { + Text(stringRes(R.string.access_control), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + val yes = stringRes(R.string.yes) + val no = stringRes(R.string.no) + + lim.auth_required?.let { + InfoRow(Icons.Default.History, stringRes(R.string.auth_required), if (it) yes else no) + } + lim.payment_required?.let { + InfoRow(Icons.Default.Lock, stringRes(R.string.payment_required), if (it) yes else no) + } + lim.restricted_writes?.let { + InfoRow(Icons.Default.EditOff, stringRes(R.string.restricted_writes), if (it) yes else no) + } + + val minPoW = lim.min_pow_difficulty + + if (minPoW != null && minPoW > 0) { + InfoRow(Icons.Default.Bolt, stringRes(R.string.minimum_pow), stringRes(R.string.amount_in_bits, minPoW)) + } else { + lim.min_prefix?.let { + if (it > 0) { + InfoRow(Icons.Default.Key, stringRes(R.string.minimum_prefix), stringRes(R.string.amount_in_bits, it * 8)) + } + } + } + } + } + + val atLeastOneConnectivity = + lim.max_message_length.isNotNullAndNotZero() || + lim.max_subscriptions.isNotNullAndNotZero() || + lim.max_filters.isNotNullAndNotZero() || + lim.max_limit.isNotNullAndNotZero() || + lim.default_limit.isNotNullAndNotZero() || + lim.max_subid_length.isNotNullAndNotZero() + + if (atLeastOneConnectivity) { + Column { + Text(stringRes(R.string.connectivity), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + lim.max_message_length?.let { + InfoRow(Icons.AutoMirrored.Default.Message, stringRes(R.string.max_message_length), "${it / 1024} kb") + } + lim.max_subscriptions?.let { + InfoRow(Icons.Default.Dns, stringRes(R.string.max_subs), it.toString()) + } + lim.max_filters?.let { + InfoRow(Icons.Default.FilterAlt, stringRes(R.string.max_filters_per_sub), it.toString()) + } + lim.max_limit?.let { + InfoRow(Icons.AutoMirrored.Default.List, stringRes(R.string.max_limit_events_returning), it.toString()) + } + lim.default_limit?.let { + InfoRow(Icons.AutoMirrored.Default.List, stringRes(R.string.max_limit_events_returning), it.toString()) + } + lim.max_subid_length?.let { + InfoRow(Icons.AutoMirrored.Default.Label, stringRes(R.string.max_subid_length), it.toString()) + } + } + } + + val atLeastOneContentSize = + lim.max_event_tags != null || + lim.max_content_length != null + + if (atLeastOneContentSize) { + Column { + Text(stringRes(R.string.content_size), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + lim.max_event_tags?.let { + InfoRow(Icons.Default.Tag, stringRes(R.string.maximum_event_tags), it.toString()) + } + + lim.max_content_length?.let { + InfoRow(Icons.AutoMirrored.Default.Article, stringRes(R.string.max_content_length), "${it / 1024} kb") + } + } + } + + val atLeastOneRestriction = + lim.created_at_lower_limit.isNotNullAndNotZero() || + lim.created_at_upper_limit.isNotNullAndNotZero() + + if (atLeastOneRestriction) { + Column { + Text(stringRes(R.string.event_retention), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + lim.created_at_lower_limit?.let { + if (it > 0) { + InfoRow(Icons.Default.History, stringRes(R.string.discards_older_than), stringRes(R.string.time_in_the_past, timeDiffAgoShortish(it))) + } + } + lim.created_at_upper_limit?.let { + if (it > 0) { + InfoRow(Icons.Default.History, stringRes(R.string.accepts_up_to), stringRes(R.string.time_in_the_future, timeDiffAgoShortish(it))) + } + } + } + } + } + } +} + +@OptIn(ExperimentalContracts::class) +fun Int?.isNotNullAndNotZero(): Boolean { + contract { + returns(true) implies (this@isNotNullAndNotZero != null) + } + + return this != null && this != 0 +} + +@Composable +fun SoftwareCard(relayInfo: Nip11RelayInformation) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + val uri = LocalUriHandler.current + relayInfo.software?.let { + if (it.contains("https://")) { + ClickableInfoRow(Icons.Default.Code, stringRes(R.string.software), it.removePrefix("git+https://").removePrefix("https://")) { + runCatching { + uri.openUri(it.removePrefix("git+")) + } + } + } else { + InfoRow(Icons.Default.Code, stringRes(R.string.software), it) + } + } + + relayInfo.version?.let { + InfoRow(Icons.Default.Storage, stringRes(R.string.version), it) + } + + relayInfo.supported_nips?.let { nips -> + Text( + stringRes(R.string.supports), + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + val uri = LocalUriHandler.current + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + nips.forEach { nip -> + val nipStr = nip.padStart(2, '0') + SuggestionChip( + onClick = { + runCatching { + uri.openUri(nipLink(nipStr)) + } + }, + label = { + Text(nipStr) + }, + ) + } + } + } + + relayInfo.supported_grasps?.let { grasps -> + Text( + stringRes(R.string.supported_grasps), + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + val uri = LocalUriHandler.current + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + grasps.forEach { grasp -> + val graspStr = grasp.padStart(2, '0') + SuggestionChip( + onClick = { + runCatching { + uri.openUri(graspLink(graspStr)) + } + }, + label = { + Text(graspStr) + }, + ) + } + } + } + } + } +} + +@Composable +fun TargetAudienceCard( + relay: Nip11RelayInformation, + nav: INav, +) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + relay.tags?.let { tags -> + if (tags.size > 2) { + Column { + Text(stringRes(R.string.topics), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + tags.forEach { tag -> + SuggestionChip( + onClick = { nav.nav(Route.Hashtag(tag)) }, + label = { + Text(tag) + }, + ) + } + } + } + } else if (tags.isNotEmpty()) { + InfoRow(Icons.Default.Topic, stringRes(R.string.topics), tags.joinToString()) + } + } + relay.relay_countries?.let { countries -> + val allCountries = stringRes(R.string.all_countries) + if (countries.size > 2) { + Column { + Text(stringRes(R.string.countries), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + countries.forEach { country -> + if (country == "*") { + SuggestionChip( + onClick = { }, + label = { + Text(allCountries) + }, + ) + } else { + SuggestionChip( + onClick = { }, + label = { + Text(country) + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } else if (countries.isNotEmpty()) { + InfoRow( + Icons.Default.Language, + stringRes(R.string.countries), + countries.joinToString { + if (it == "*") allCountries else it + }, + ) + } + } + relay.language_tags?.let { languages -> + val allLang = stringRes(R.string.all_languages) + if (languages.size > 2) { + Column { + Text(stringRes(R.string.languages), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + languages.forEach { lang -> + if (lang == "*") { + SuggestionChip( + onClick = { }, + label = { + Text(allLang) + }, + ) + } else { + SuggestionChip( + onClick = { }, + label = { + Text(lang) + }, + ) + } + } + } + } + } else if (languages.isNotEmpty()) { + InfoRow( + Icons.Default.Translate, + stringRes(R.string.languages), + languages.joinToString { + if (it == "*") allLang else it + }, + ) + } + } + } + } +} + +@Composable +fun PoliciesCard(relay: Nip11RelayInformation) { + OutlinedCard(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.padding(16.dp)) { + val uri = LocalUriHandler.current + relay.contact?.let { + if (it.contains("@")) { + ClickableInfoRow(Icons.AutoMirrored.Default.ContactSupport, stringRes(R.string.contact), it) { + runCatching { + uri.openUri("mailto:$it") + } + } + } else { + InfoRow(Icons.AutoMirrored.Default.ContactSupport, stringRes(R.string.contact), it) + } + } + + relay.posting_policy?.let { + ClickableInfoRow(Icons.Default.EditNote, stringRes(R.string.posting_policy), it) { + runCatching { + uri.openUri(it) + } + } + } + + val pp = relay.privacy_policy + + if (pp != null) { + ClickableInfoRow(Icons.Default.PrivacyTip, stringRes(R.string.privacy_policy), pp.removePrefix("https://")) { + runCatching { + uri.openUri(pp) + } + } + } else { + InfoRow(Icons.Default.PrivacyTip, stringRes(R.string.privacy_policy), stringRes(R.string.not_available_acronym)) + } + + val ts = relay.terms_of_service + if (ts != null) { + ClickableInfoRow(Icons.Default.Gavel, stringRes(R.string.terms_and_conditions), ts.removePrefix("https://")) { + runCatching { + uri.openUri(ts) + } + } + } else { + InfoRow(Icons.Default.Gavel, stringRes(R.string.terms_and_conditions), stringRes(R.string.not_available_acronym)) + } + } + } +} + +@Composable +fun SectionHeader(title: String) { Text( - text = text, + text = title, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - fontSize = 24.sp, + modifier = Modifier.padding(top = 5.dp), + color = MaterialTheme.colorScheme.primary, ) } @Composable -fun SubtitleContent(text: String) { - Text( - text = text, - ) +private fun InfoRow( + icon: ImageVector, + label: String, + value: String, +) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.secondary) + Spacer(Modifier.width(12.dp)) + Text(text = label, style = MaterialTheme.typography.labelLarge, maxLines = 1) + Text(text = value, textAlign = TextAlign.End, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1) + } } @Composable -fun Section(text: String) { - Spacer(modifier = DoubleVertSpacer) - Text( - text = text, - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) - Spacer(modifier = DoubleVertSpacer) +private fun ClickableInfoRow( + icon: ImageVector, + label: String, + value: String, + onClickValue: () -> Unit, +) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.secondary) + Spacer(Modifier.width(12.dp)) + Text(text = label, style = MaterialTheme.typography.labelLarge, maxLines = 1) + Text(text = value, textAlign = TextAlign.End, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable(onClick = onClickValue).weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1) + } } @Composable -fun SectionContent(text: String) { - Text( - modifier = Modifier.padding(start = 10.dp), - text = text, - ) +fun FeeRow( + label: String, + fee: Nip11RelayInformation.RelayInformationFee, +) { + fee.amount?.let { + val period = fee.period + val combinedLabel = + if (period != null) { + label + " (${timeDiffAgoShortish(period)})" + } else { + label + } + + if (fee.unit == "msats") { + InfoRow(Icons.Default.AttachMoney, combinedLabel, "${it / 1000} sats") + } else { + InfoRow(Icons.Default.AttachMoney, combinedLabel, "$it ${fee.unit}") + } + } +} + +@Composable +@Preview(showBackground = true, name = "Nost.wine Relay Info", device = "spec:width=2160px,height=5640px,dpi=440") +fun RelayHeaderPreview() { + ThemeComparisonRow { + RelayInformationBody( + relay = NormalizedRelayUrl("wss://nostr.wine/"), + relayInfo = + Nip11RelayInformation( + name = "Nostr.wine", + icon = "https://image.nostr.build/30acdce4a81926f386622a07343228ae99fa68d012d54c538c0b2129dffe400c.png", + description = "A paid nostr relay for wine enthusiasts and everyone else", + software = "https://nostr.wine", + contact = "wino@nostr.wine", + version = "0.3.3", + self = "4918eb332a41b71ba9a74b1dc64276cfff592e55107b93baae38af3520e55975", + pubkey = "4918eb332a41b71ba9a74b1dc64276cfff592e55107b93baae38af3520e55975", + payments_url = "https://nostr.wine/invoices", + privacy_policy = "https://nostr.wine/terms", + terms_of_service = "https://nostr.wine/terms", + tags = listOf("Bitcoin", "Amethyst"), + supported_nips = listOf("1", "2", "4", "9", "11", "40", "42", "50", "70", "77"), + relay_countries = listOf("*"), + language_tags = listOf("*"), + limitation = + Nip11RelayInformation.RelayInformationLimitation( + auth_required = false, + created_at_lower_limit = 94608000, + created_at_upper_limit = 300, + max_event_tags = 4000, + max_limit = 1000, + default_limit = 20, + max_message_length = 524288, + max_subid_length = 71, + max_subscriptions = 50, + min_pow_difficulty = 0, + payment_required = true, + restricted_writes = true, + ), + fees = + Nip11RelayInformation.RelayInformationFees( + admission = + listOf( + Nip11RelayInformation.RelayInformationFee( + amount = 3000, + unit = "msats", + period = 2628003, + ), + Nip11RelayInformation.RelayInformationFee( + amount = 8000, + unit = "sats", + period = 7884009, + ), + ), + ), + supported_grasps = listOf("GRASP-01"), + ), + pad = PaddingValues(0.dp), + relayStats = RelayStat(), + messages = + persistentListOf( + NoticeDebugMessage( + time = TimeUtils.now(), + message = "Subscription closed: AccountNotificationsEoseFromRandomRelaysManagerugZU9o auth-required: At least one matching event requires AUTH", + ), + ErrorDebugMessage( + time = TimeUtils.now() - 24000, + message = "No such subscription", + ), + SpamDebugMessage( + time = TimeUtils.now() - 24000, + link1 = "http://test1.com", + link2 = "http://test2.com", + ), + ), + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 5f876fb25..a635810d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -215,13 +215,13 @@ val LightSelectedReactionBoxModifier = val DarkChannelNotePictureModifier = Modifier - .size(30.dp) + .size(20.dp) .clip(shape = CircleShape) .border(2.dp, DarkColorPalette.background, CircleShape) val LightChannelNotePictureModifier = Modifier - .size(30.dp) + .size(20.dp) .clip(shape = CircleShape) .border(2.dp, LightColorPalette.background, CircleShape) diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg index f0c93eb83..126d9edf6 100644 Binary files a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg and b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner.jpg differ diff --git a/amethyst/src/main/res/drawable-xxxhdpi/profile_banner_old.jpg b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner_old.jpg new file mode 100644 index 000000000..f0c93eb83 Binary files /dev/null and b/amethyst/src/main/res/drawable-xxxhdpi/profile_banner_old.jpg differ diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index fda052418..b889daa52 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -158,6 +158,7 @@ Hangüzenet rögzítése Hangüzenet rögzítése Hangüzenet rögzítéséhez kattintson és tartsa lenyomva a gombot + Újrarögzítés Rögzítés %1$s rögzítése Feltöltés… @@ -480,8 +481,8 @@ Adatvédelmi beállítások Tor és Orbot beállítása Kapcsolódás az Orbot beállításain keresztül - Beállítás - Tor beállítások + Módosítás + Tor Beállítások Kapcsolat bontása az Orbottal / Torral? Az Ön adatai azonnal átkerülnek a normál hálózatra Igen diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index e0303c84f..95a8ed79b 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -158,6 +158,7 @@ Nagraj wiadomość Nagrywanie wiadomości Kliknij i przytrzymaj aby nagrać wiadomość + Nagraj ponownie Nagrywanie Nagrywanie %1$s Wgrywanie… @@ -356,6 +357,8 @@ Zablokuj Ręczny podział zapów Zakładki + Domyślne zakładki + Twoje domyślne zakładki obsługiwane przez wielu klientów Projekty Prywatne Zakładki Publiczne zakładki diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index cc4646375..7f89d981a 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -21,6 +21,7 @@ Noqonuniy xatti-harakat Boshqa sabab Ta’qib + Zo\'ravon Noma\'lum Relay belgisi Nomaʼlum muallif @@ -45,4 +46,26 @@ Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Xabar yozish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Postni targ‘ib qilish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Postga reaksiya qilish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting + Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat o‘qish huquqiga ega. Obuna bo‘lish uchun shaxsiy kalit bilan tizimga kiring + Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat o‘qish huquqiga ega. Yuklash uchun shaxsiy kalit bilan tizimga kiring + Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat o‘qish huquqiga ega. Xabarlarni imzolash uchun shaxsiy kalit bilan tizimga kiring + Ruxsatsiz deshifrlash + Imzolovchi ushbu operatsiyani amalga oshirish uchun zarur boʻlgan shifrlashni ochishga ruxsat bermadi. Imzolovchi ilovangizda NIP-44 shifrlashni ochish funksiyasini yoqing va qaytadan urinib koʻring + Imzolovchi ilova topilmadi + Imzolovchi ilova oʻchirib tashlandimi? Imzolovchi oʻrnatilganligini va ushbu hisobga ega ekanligini tekshiring. Agar imzolovchi ilova oʻzgargan boʻlsa, tizimdan chiqing va qayta kiring. + Zeplar + Koʻrishlar soni + Targʻib qilish + Targ\'ib qilidi + Tahrirlangan + Tahrir № %1$s + Asl + Iqtibos qilish + Nusxa olish + Tahrir taklif qilish + Qo\'shish + "Kimga javob yozilmoqda: " + " va " + "bu kanalda: " + Profil banneri diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index cb23aebc8..714143826 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -158,6 +158,7 @@ 录制消息 录制消息 点按以录制消息 + 重新录制 正在录制 正在录制 %1$s 上传中… diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7893b01aa..212407e46 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -17,6 +17,8 @@ Could not decrypt the message Group Picture Explicit Content + Relay Notice + Duplicated Post Spam The number of spamming events coming from this relay Impersonation @@ -133,6 +135,7 @@ Relay Address Posts Bytes + Error Errors The number of connection errors in this session Home Feed @@ -391,6 +394,8 @@ Manual Zap Splits Bookmarks + Default Bookmarks + Your default Bookmarks that many clients support Drafts Private Bookmarks Public Bookmarks @@ -745,28 +750,64 @@ The amount in bytes that was received from this relay, including filters and events An error occurred trying to get relay information from %1$s Owner + Service Key + Running %1$s + Running %1$s (%2$s) Version Software Contact Supported NIPs + Supported Grasps Admission Fees + Admission + Subscription + Publication + Payments %1$s Payments url + Target Audience + Policies & Links + Fees & Payments Limitations Countries Languages Tags + Topics + All Countries + All Languages Posting policy + Privacy Policy + Terms & Conditions + N/A Errors and Notices from this Relay Message length Subscriptions Filters Subscription id length - Minimum prefix - Maximum event tags + Min Prefix + Max Event Tags Content length - Minimum PoW + Max Content Length + Discards older than + Accepts up to + %1$s in the future + %1$s ago + %1$s zeros + %1$s bits + Event Retention + Content Size + Connectivity + Access Control + Min PoW Difficulty Auth + Auth Required Payment + Payment Required + Max Message Length + Max Subscriptions + Max Filters per Sub + Max Limit (Events Returning) + Default Limit (Events Returning) + Max SubID Length Cashu Token Redeem Send to Zap Wallet diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt index 3afa19761..924d8e70a 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.account +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt index 88ef514b9..12307ecd2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.util +import com.sun.org.apache.xalan.internal.lib.ExsltDatetime.time import com.vitorpamplona.quartz.utils.TimeUtils import java.text.SimpleDateFormat import java.util.Locale @@ -81,6 +82,50 @@ fun timeAgo( } } +fun timeDiffAgoLong(timeDifference: Int): String = + when { + timeDifference > TimeUtils.ONE_YEAR -> { + (timeDifference / TimeUtils.ONE_YEAR).toString() + " years" + } + timeDifference > TimeUtils.ONE_MONTH -> { + (timeDifference / TimeUtils.ONE_MONTH).toString() + " months" + } + timeDifference > TimeUtils.ONE_DAY -> { + (timeDifference / TimeUtils.ONE_DAY).toString() + " days" + } + timeDifference > TimeUtils.ONE_HOUR -> { + (timeDifference / TimeUtils.ONE_HOUR).toString() + " hours" + } + timeDifference > TimeUtils.ONE_MINUTE -> { + (timeDifference / TimeUtils.ONE_MINUTE).toString() + " minutes" + } + else -> { + "now" + } + } + +fun timeDiffAgoShortish(timeDifference: Int): String = + when { + timeDifference > TimeUtils.ONE_YEAR -> { + (timeDifference / TimeUtils.ONE_YEAR).toString() + " yrs" + } + timeDifference > TimeUtils.ONE_MONTH -> { + (timeDifference / TimeUtils.ONE_MONTH).toString() + " mos" + } + timeDifference > TimeUtils.ONE_DAY -> { + (timeDifference / TimeUtils.ONE_DAY).toString() + " days" + } + timeDifference > TimeUtils.ONE_HOUR -> { + (timeDifference / TimeUtils.ONE_HOUR).toString() + " hrs" + } + timeDifference > TimeUtils.ONE_MINUTE -> { + (timeDifference / TimeUtils.ONE_MINUTE).toString() + " mins" + } + else -> { + "now" + } + } + /** * Formats a Unix timestamp as a date string. * For recent dates (< 1 day), returns the provided today string. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5717cae99..3f1c53630 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,7 +53,7 @@ torAndroid = "0.4.8.21.1" translate = "17.0.3" unifiedpush = "3.0.10" urlDetector = "0.1.23" -vico-charts = "2.3.6" +vico-charts = "2.4.0" zelory = "3.0.1" zoomable = "2.9.0" zxing = "3.5.4" @@ -174,6 +174,6 @@ serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } -stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.1" } +stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.6" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt index 6214aecc6..d5b2b7ae7 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -20,9 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context import android.database.sqlite.SQLiteConstraintException -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -30,91 +28,91 @@ import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail -import org.junit.After -import org.junit.Before import org.junit.Test -class AddressableTest { - private lateinit var db: EventStore - +class AddressableTest : BaseDBTest() { val signer = NostrSignerSync() - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testReplacingAddressables() { - val time = TimeUtils.now() - val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + fun testReplacingAddressables() = + forEachDB { db -> + val time = TimeUtils.now() + val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - db.insert(version1) - - db.assertQuery(version1, Filter(ids = listOf(version1.id))) - db.assertQuery(version1, addressableQuery) - - db.insert(version2) - - db.assertQuery(null, Filter(ids = listOf(version1.id))) - db.assertQuery(version2, Filter(ids = listOf(version2.id))) - db.assertQuery(version2, addressableQuery) - - db.insert(version3) - - db.assertQuery(null, Filter(ids = listOf(version1.id))) - db.assertQuery(null, Filter(ids = listOf(version2.id))) - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - db.assertQuery(version3, addressableQuery) - } - - @Test - fun testBlockingOldAddressables() { - val time = TimeUtils.now() - val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) - - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - - db.insert(version3) - - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - - try { - db.insert(version2) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) - } - - try { db.insert(version1) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) + + db.assertQuery(version1, Filter(ids = listOf(version1.id))) + db.assertQuery(version1, addressableQuery) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + db.assertQuery(version2, addressableQuery) + + db.insert(version3) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) } - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - db.assertQuery(version3, addressableQuery) - db.assertQuery(null, Filter(ids = listOf(version2.id))) - db.assertQuery(null, Filter(ids = listOf(version1.id))) - } + @Test + fun testBlockingOldAddressables() = + forEachDB { db -> + val time = TimeUtils.now() + val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + + db.insert(version3) + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + + try { + db.insert(version2) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + try { + db.insert(version1) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } @Test - fun testTriggersIndexUsage() { - val explainer = - db.store.explainQuery( + fun testTriggersIndexUsage() = + forEachDB { db -> + val explainer = + db.store.explainQuery( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 30000 AND + event_headers.pubkey = 'aa' AND + event_headers.d_tag = 'test-tag' AND + event_headers.created_at < 1766686500 AND + event_headers.kind >= 30000 AND event_headers.kind < 40000 + """.trimIndent(), + ) + + assertEquals( """ SELECT * FROM event_headers WHERE @@ -123,21 +121,9 @@ class AddressableTest { event_headers.d_tag = 'test-tag' AND event_headers.created_at < 1766686500 AND event_headers.kind >= 30000 AND event_headers.kind < 40000 + └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) """.trimIndent(), + explainer, ) - - assertEquals( - """ - SELECT * FROM event_headers - WHERE - event_headers.kind = 30000 AND - event_headers.pubkey = 'aa' AND - event_headers.d_tag = 'test-tag' AND - event_headers.created_at < 1766686500 AND - event_headers.kind >= 30000 AND event_headers.kind < 40000 - └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - """.trimIndent(), - explainer, - ) - } + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt new file mode 100644 index 000000000..6926af8de --- /dev/null +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt @@ -0,0 +1,84 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Before + +open class BaseDBTest { + private lateinit var dbs: MutableMap + + fun DefaultIndexingStrategy.name(): String = + """ + indexEventsByCreatedAtAlone=$indexEventsByCreatedAtAlone + indexTagsByCreatedAtAlone=$indexTagsByCreatedAtAlone + indexTagsWithKindAndPubkey=$indexTagsWithKindAndPubkey + useAndIndexIdOnOrderBy=$useAndIndexIdOnOrderBy + """.trimIndent() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + + val booleans = listOf(true, false) + + dbs = mutableMapOf() + + // tests all possible DBs + for (indexEventsByCreatedAtAlone in booleans) { + for (indexTagsByCreatedAtAlone in booleans) { + for (indexTagsWithKindAndPubkey in booleans) { + for (useAndIndexIdOnOrderBy in booleans) { + val indexStrategy = + DefaultIndexingStrategy( + indexEventsByCreatedAtAlone, + indexTagsByCreatedAtAlone, + indexTagsWithKindAndPubkey, + useAndIndexIdOnOrderBy, + ) + dbs[indexStrategy.name()] = + EventStore( + context = context, + dbName = null, + indexStrategy = indexStrategy, + ) + } + } + } + } + } + + @After + fun tearDown() { + dbs.forEach { it.value.close() } + } + + fun forEachDB(action: (EventStore) -> Unit) { + dbs.forEach { + println("--------------------") + println(it.key) + println("--------------------") + action(it.value) + } + } +} diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt index e0a6537bf..256689628 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -30,18 +28,14 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test -class BasicTest { - private lateinit var db: SQLiteEventStore - +class BasicTest : BaseDBTest() { val signer = NostrSignerSync() - companion object Companion { + companion object { val profile = MetadataEvent( id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", @@ -82,168 +76,166 @@ class BasicTest { ) } - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = SQLiteEventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testInsertDeleteEvent() { - val note = signer.sign(TextNoteEvent.build("test1")) + fun testInsertDeleteEvent() = + forEachDB { db -> + val note = signer.sign(TextNoteEvent.build("test1")) - db.insertEvent(note) + db.store.insertEvent(note) - db.assertQuery(note, Filter(ids = listOf(note.id))) + db.store.assertQuery(note, Filter(ids = listOf(note.id))) - db.delete(note.id) + db.store.delete(note.id) - db.assertQuery(null, Filter(ids = listOf(note.id))) + db.store.assertQuery(null, Filter(ids = listOf(note.id))) - db.insertEvent(note) + db.store.insertEvent(note) - db.assertQuery(note, Filter(ids = listOf(note.id))) - } - - @Test - fun testEmptyFilter() { - val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) - val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) - - db.insertEvent(note1) - - db.assertQuery(note1, Filter()) - - db.insertEvent(note2) - - db.assertQuery(listOf(note2, note1), Filter()) - } - - @Test - fun testLimitFilter() { - val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) - val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) - val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = 3)) - val note4 = signer.sign(TextNoteEvent.build("test4", createdAt = 4)) - - db.insertEvent(note1) - - db.assertQuery(note1, Filter(limit = 1)) - - db.insertEvent(note2) - db.insertEvent(note3) - db.insertEvent(note4) - - db.assertQuery(listOf(note4), Filter(limit = 1)) - } - - @Test - fun testPubkeyTag() { - db.insertEvent(comment) - db.insertEvent(profile) - - db.assertQuery( - comment, - Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))), - ) - } - - @Test - fun testTagOnly() { - db.insertEvent(comment) - db.insertEvent(profile) - - db.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n")))) - } - - @Test - fun testTagWithSinceOnly() { - db.insertEvent(comment) - db.insertEvent(profile) - - db.assertQuery( - comment, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1), - ) - db.assertQuery( - comment, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt), - ) - db.assertQuery( - null, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1), - ) - } - - @Test - fun testTagWithUntilOnly() { - db.insertEvent(comment) - db.insertEvent(profile) - - db.assertQuery( - null, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1), - ) - db.assertQuery( - comment, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt), - ) - db.assertQuery( - comment, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1), - ) - } - - @Test - fun testTagWithUntilOnlyEmitting() { - db.insertEvent(comment) - db.insertEvent(profile) - - db.query(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event -> - assertEquals(comment.toJson(), event.toJson()) + db.store.assertQuery(note, Filter(ids = listOf(note.id))) } - } @Test - fun hashCodeTest() { - val note1 = - signer.sign( - TextNoteEvent.build("test1") { - hashtag("AaAa") - }, - ) - val note2 = - signer.sign( - TextNoteEvent.build("test2") { - hashtag("AaAa") - }, - ) - val note3 = - signer.sign( - TextNoteEvent.build("test3") { - hashtag("BBBB") - }, - ) + fun testEmptyFilter() = + forEachDB { db -> + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) - db.insertEvent(note1) - db.insertEvent(note2) - db.insertEvent(note3) + db.store.insertEvent(note1) - val list = - db.query( - Filter( - tags = mapOf("t" to listOf("AaAa")), - ), - ) + db.store.assertQuery(note1, Filter()) - assertEquals(2, list.size) - list.forEach { - assertTrue(it.isTaggedHash("AaAa")) + db.store.insertEvent(note2) + + db.store.assertQuery(listOf(note2, note1), Filter()) + } + + @Test + fun testLimitFilter() = + forEachDB { db -> + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) + val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = 3)) + val note4 = signer.sign(TextNoteEvent.build("test4", createdAt = 4)) + + db.store.insertEvent(note1) + + db.store.assertQuery(note1, Filter(limit = 1)) + + db.store.insertEvent(note2) + db.store.insertEvent(note3) + db.store.insertEvent(note4) + + db.store.assertQuery(listOf(note4), Filter(limit = 1)) + } + + @Test + fun testPubkeyTag() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery( + comment, + Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))), + ) + } + + @Test + fun testTagOnly() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n")))) + } + + @Test + fun testTagWithSinceOnly() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1), + ) + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt), + ) + db.store.assertQuery( + null, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1), + ) + } + + @Test + fun testTagWithUntilOnly() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery( + null, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1), + ) + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt), + ) + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1), + ) + } + + @Test + fun testTagWithUntilOnlyEmitting() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.query(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event -> + assertEquals(comment.toJson(), event.toJson()) + } + } + + @Test + fun hashCodeTest() = + forEachDB { db -> + val note1 = + signer.sign( + TextNoteEvent.build("test1") { + hashtag("AaAa") + }, + ) + val note2 = + signer.sign( + TextNoteEvent.build("test2") { + hashtag("AaAa") + }, + ) + val note3 = + signer.sign( + TextNoteEvent.build("test3") { + hashtag("BBBB") + }, + ) + + db.store.insertEvent(note1) + db.store.insertEvent(note2) + db.store.insertEvent(note3) + + val list = + db.query( + Filter( + tags = mapOf("t" to listOf("AaAa")), + ), + ) + + assertEquals(2, list.size) + list.forEach { + assertTrue(it.isTaggedHash("AaAa")) + } } - } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt index 1f7f771b3..0a0d9b90a 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -20,9 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context import android.database.sqlite.SQLiteConstraintException -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -33,380 +31,378 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase import junit.framework.TestCase.fail -import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Before import org.junit.Test -class DeletionTest { - private lateinit var db: EventStore - +class DeletionTest : BaseDBTest() { val signer = NostrSignerSync() - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testInsertDeleteEvent() { - val note1 = signer.sign(TextNoteEvent.build("test1")) - val note2 = signer.sign(TextNoteEvent.build("test2")) - val note3 = signer.sign(TextNoteEvent.build("test3")) + fun testInsertDeleteEvent() = + forEachDB { db -> + val note1 = signer.sign(TextNoteEvent.build("test1")) + val note2 = signer.sign(TextNoteEvent.build("test2")) + val note3 = signer.sign(TextNoteEvent.build("test3")) - db.insert(note1) - db.insert(note2) - db.insert(note3) - - db.assertQuery(note1, Filter(ids = listOf(note1.id))) - db.assertQuery(note2, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - val deletion = signer.sign(DeletionEvent.build(listOf(note1))) - - db.insert(deletion) - - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(note2, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - // trying to insert again should fail. - try { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + db.insert(note2) + db.insert(note3) + + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.build(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) } - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(note2, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - } - @Test - fun testInsertDeleteEventOfAddressable() { - val time = TimeUtils.now() - val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + fun testInsertDeleteEventOfAddressable() = + forEachDB { db -> + val time = TimeUtils.now() + val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) - db.insert(note1) - - db.assertQuery(note1, Filter(ids = listOf(note1.id))) - - db.insert(note2) - - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(note2, Filter(ids = listOf(note2.id))) - - db.insert(note3) - - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - val deletion = signer.sign(DeletionEvent.build(listOf(note1))) - - db.insert(deletion) - - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(null, Filter(ids = listOf(note3.id))) - - // trying to insert again should fail. - try { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + + db.insert(note2) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + + db.insert(note3) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.build(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) } - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(null, Filter(ids = listOf(note3.id))) - } - @Test - fun testInsertDeleteEventOfAddressable2() { - val time = TimeUtils.now() - val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + fun testInsertDeleteEventOfAddressable2() = + forEachDB { db -> + val time = TimeUtils.now() + val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) - db.insert(note1) - db.insert(note2) - db.insert(note3) - - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1))) - - db.insert(deletion) - - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(null, Filter(ids = listOf(note3.id))) - - // trying to insert again should fail. - try { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + db.insert(note2) + db.insert(note3) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) } - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(null, Filter(ids = listOf(note3.id))) - } - @Test - fun testInsertDeleteWrap() { - val me = NostrSignerSync() - val myFriend = NostrSignerSync() + fun testInsertDeleteWrap() = + forEachDB { db -> + val me = NostrSignerSync() + val myFriend = NostrSignerSync() - val note1 = me.sign(TextNoteEvent.build("test1")) - val wrap1 = GiftWrapEvent.create(note1, me.pubKey) - val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) + val note1 = me.sign(TextNoteEvent.build("test1")) + val wrap1 = GiftWrapEvent.create(note1, me.pubKey) + val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) - db.insert(wrap1) - db.insert(wrap2) - - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1))) - - db.insert(randomDeletionToWrap) - - db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id))) - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val deletion = me.sign(DeletionEvent.build(listOf(wrap1))) - - db.insert(deletion) - - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - // trying to insert again should fail. - try { db.insert(wrap1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + db.insert(wrap2) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1))) + + db.insert(randomDeletionToWrap) + + db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id))) + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val deletion = me.sign(DeletionEvent.build(listOf(wrap1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + // trying to insert again should fail. + try { + db.insert(wrap1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) } - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - } + @Test + fun testTriggersIndexUsage() = + forEachDB { db -> + var sql = db.store.deletionModule.rejectDeletedEventsSQLTemplate() + + sql = sql.replace("NEW.etag_hash", "3221122") + sql = sql.replace("NEW.atag_hash", "223322") + sql = sql.replace("NEW.pubkey_owner_hash", "22332323") + sql = sql.replace("NEW.created_at", "1766686500") + + val explainer = db.store.explainQuery(sql) + + if (db.indexStrategy.indexTagsWithKindAndPubkey) { + TestCase.assertEquals( + """ + |$sql + |└── SEARCH event_tags USING COVERING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?) + """.trimMargin(), + explainer, + ) + } else { + TestCase.assertEquals( + """ + |$sql + |└── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?) + """.trimMargin(), + explainer, + ) + } + } @Test - fun testTriggersIndexUsage() { - val sql = - """ - SELECT 1 FROM event_tags - INNER JOIN event_headers - ON event_headers.row_id = event_tags.event_header_row_id - WHERE - event_tags.tag_hash IN (3221122, 223322) AND - event_headers.kind = 5 AND - event_headers.created_at >= 1766686500 AND - event_headers.pubkey_owner_hash = 22332323 - """.trimIndent() + fun testDeleteById() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = listOf("ca29c211f", "ca29c211d"), + addresses = emptyList(), + hasher = TagNameValueHasher(0), + ).first() - val explainer = - db.store.explainQuery(sql) - - TestCase.assertEquals( - """ - ${sql.replace("\n","\n ")} - ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - └── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - """.trimIndent(), - explainer, - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE + id IN ("ca29c211f","ca29c211d") AND + pubkey_owner_hash = "1573573083296714675" + ├── SEARCH event_headers USING INDEX event_headers_id (id=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteById() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = listOf("ca29c211f", "ca29c211d"), - addresses = emptyList(), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteAddressable() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE - id IN ("ca29c211f","ca29c211d") AND - pubkey_owner_hash = "1573573083296714675" - ├── SEARCH event_headers USING INDEX event_headers_id (id=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag = "a") + ) AND + kind >= 30000 AND kind < 40000 + ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteAddressable() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(30000, "key1", "a"), - ), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteAddressablesSingleKind() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + Address(30000, "key1", "b"), + Address(30000, "key1", "c"), + Address(30000, "key1", "d"), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE ( - (kind = "30000" AND pubkey = "key1" AND d_tag = "a") - ) AND - kind >= 30000 AND kind < 40000 - ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d")) + ) AND + kind >= 30000 AND kind < 40000 + ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteAddressablesSingleKind() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(30000, "key1", "a"), - Address(30000, "key1", "b"), - Address(30000, "key1", "c"), - Address(30000, "key1", "d"), - ), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteAddressablesMultipleKinds() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + Address(30000, "key1", "b"), + Address(30101, "key1", "c"), + Address(30101, "key1", "d"), + Address(30001, "key2", "e"), + Address(30001, "key2", "f"), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE ( - (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d")) - ) AND - kind >= 30000 AND kind < 40000 - ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b")) + OR + (kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d")) + ) AND + kind >= 30000 AND kind < 40000 + ├── MULTI-INDEX OR + │ ├── INDEX 1 + │ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + │ └── INDEX 2 + │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteAddressablesMultipleKinds() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(30000, "key1", "a"), - Address(30000, "key1", "b"), - Address(30101, "key1", "c"), - Address(30101, "key1", "d"), - Address(30001, "key2", "e"), - Address(30001, "key2", "f"), - ), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteReplaceables() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(10000, "key1", ""), + Address(10000, "key1", ""), + Address(10001, "key1", ""), + Address(10001, "key1", ""), + Address(10001, "key2", ""), + Address(10001, "key2", ""), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE ( - (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b")) - OR - (kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d")) - ) AND - kind >= 30000 AND kind < 40000 - ├── MULTI-INDEX OR - │ ├── INDEX 1 - │ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - │ └── INDEX 2 - │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } - - @Test - fun testDeleteReplaceables() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(10000, "key1", ""), - Address(10000, "key1", ""), - Address(10001, "key1", ""), - Address(10001, "key1", ""), - Address(10001, "key2", ""), - Address(10001, "key2", ""), - ), - hasher = TagNameValueHasher(0), - ).first() - - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE - kind IN ("10000","10001") AND - pubkey = "key1" AND - ((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) - ├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE + kind IN ("10000","10001") AND + pubkey = "key1" AND + ((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) + ├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt index 6197a8967..0b86746bc 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt @@ -20,84 +20,69 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context import android.database.sqlite.SQLiteConstraintException -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase.fail -import org.junit.After import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test -class ExpirationTest { - private lateinit var db: EventStore - +class ExpirationTest : BaseDBTest() { val signer = NostrSignerSync() - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testDeletingExpiredEvents() { - val time = TimeUtils.now() + fun testDeletingExpiredEvents() = + forEachDB { db -> + val time = TimeUtils.now() - val noteSafe = - signer.sign( - TextNoteEvent.build("test1", createdAt = time + 1) { - expiration(time + 100) - }, - ) + val noteSafe = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 100) + }, + ) - db.insert(noteSafe) + db.insert(noteSafe) - val noteToExpire = - signer.sign( - TextNoteEvent.build("test1", createdAt = time + 1) { - expiration(time + 1) - }, - ) + val noteToExpire = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 1) + }, + ) - db.insert(noteToExpire) + db.insert(noteToExpire) - db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) + db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) - Thread.sleep(2000) + Thread.sleep(2000) - db.deleteExpiredEvents() + db.deleteExpiredEvents() - db.assertQuery(null, Filter(ids = listOf(noteToExpire.id))) - db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id))) - } - - @Test - fun testInsertingExpiredEvents() { - val time = TimeUtils.now() - - val note1 = - signer.sign( - TextNoteEvent.build("test1", createdAt = time - 12) { - expiration(time - 10) - }, - ) - - try { - db.insert(note1) - fail("Should not be able to insert expired events") - } catch (e: Exception) { - assertTrue(e is SQLiteConstraintException) + db.assertQuery(null, Filter(ids = listOf(noteToExpire.id))) + db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id))) + } + + @Test + fun testInsertingExpiredEvents() = + forEachDB { db -> + val time = TimeUtils.now() + + val note1 = + signer.sign( + TextNoteEvent.build("test1", createdAt = time - 12) { + expiration(time - 10) + }, + ) + + try { + db.insert(note1) + fail("Should not be able to insert expired events") + } catch (e: Exception) { + assertTrue(e is SQLiteConstraintException) + } } - } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt new file mode 100644 index 000000000..88a53e734 --- /dev/null +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt @@ -0,0 +1,165 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import org.junit.Test + +class FilterMatcherTest : BaseDBTest() { + val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758" + val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + val createdAt: Long = 1683596206 + val kind = 1 + + val pTag1 = "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" + val pTag2 = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + val pTag3 = "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2" + val pTag4 = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac" + val pTag5 = "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + val pTag6 = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + val pTag7 = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0" + val pTag8 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + val rootETag = "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3" + val replyETag = "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc" + + val note = + Event( + id, + pubkey, + createdAt, + kind, + arrayOf( + arrayOf("e", rootETag, "", "root"), + arrayOf("e", replyETag, "", "reply"), + arrayOf("p", pTag1), + arrayOf("p", pTag1), + arrayOf("p", pTag2), + arrayOf("p", pTag3), + arrayOf("p", pTag4), + arrayOf("p", pTag5), + arrayOf("p", pTag6), + arrayOf("p", pTag7), + arrayOf("p", pTag8), + ), + "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", + "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce", + ) + + @Test + fun matchIds() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(ids = listOf(id))) + db.assertQuery(note, Filter(ids = listOf(id, rootETag))) + db.assertQuery(null, Filter(ids = listOf(rootETag))) + } + + @Test + fun matchPubkeys() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(authors = listOf(pubkey))) + db.assertQuery(note, Filter(authors = listOf(pubkey, rootETag))) + db.assertQuery(null, Filter(authors = listOf(rootETag))) + } + + @Test + fun matchTags() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id)))) + db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id)))) + } + + @Test + fun matchDualTags() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) + db.assertQuery(null, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) + db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) + } + + @Test + fun matchAllTags() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1)))) + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id)))) + } + + @Test + fun matchDualAllTags() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) + } + + @Test + fun matchKinds() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(kinds = listOf(kind))) + db.assertQuery(note, Filter(kinds = listOf(kind, 1221))) + db.assertQuery(null, Filter(kinds = listOf(1221))) + } + + @Test + fun matchSince() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(since = createdAt)) + db.assertQuery(note, Filter(since = createdAt - 1)) + db.assertQuery(null, Filter(since = createdAt + 1)) + } + + @Test + fun matchUntil() = + forEachDB { db -> + db.insert(note) + + db.assertQuery(note, Filter(until = createdAt)) + db.assertQuery(note, Filter(until = createdAt + 1)) + db.assertQuery(null, Filter(until = createdAt - 1)) + } +} diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt index a16e2909b..a62de74ee 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.Log import org.junit.After import org.junit.Before +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import java.util.zip.GZIPInputStream @@ -82,6 +83,7 @@ class LargeDBTests { } @Test + @Ignore("Not testing") fun insertDatabase() { events.forEach { event -> try { diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt index 2a3983a8d..3dd656c27 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -20,139 +20,208 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import junit.framework.TestCase -import org.junit.After +import junit.framework.TestCase.assertEquals import org.junit.Assert -import org.junit.Before import org.junit.Test -class QueryAssemblerTest { - val hasher = TagNameValueHasher(0L) - val builder = EventIndexesModule(FullTextSearchModule(), { hasher }) - +class QueryAssemblerTest : BaseDBTest() { + val hasher = TagNameValueHasher(0) val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" - private lateinit var db: EventStore + fun EventStore.explain(f: Filter) = store.queryBuilder.planQuery(f, hasher, store.readableDatabase) - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - - fun explain(f: Filter) = builder.planQuery(f, hasher, db.store.readableDatabase) - - fun explain(f: List) = builder.planQuery(f, hasher, db.store.readableDatabase) + fun EventStore.explain(f: List) = store.queryBuilder.planQuery(f, hasher, store.readableDatabase) @Test - fun testEmpty() { - Assert.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id - └── SCAN event_headers USING INDEX query_by_created_at_id - """.trimIndent(), - explain(Filter()), - ) - } + fun testEmpty() = + forEachDB { db -> + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexEventsByCreatedAtAlone) { + Assert.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + ORDER BY $orderBy + └── SCAN event_headers USING INDEX query_by_created_at_id + """.trimIndent(), + db.explain(Filter()), + ) + } else { + Assert.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + ORDER BY $orderBy + ├── SCAN event_headers + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(Filter()), + ) + } + } @Test - fun testCheckDeletionEventExists() { - val query = - explain( + fun testCheckDeletionEventExists() = + forEachDB { db -> + val filter = Filter( kinds = listOf(5), authors = listOf(key1), tags = mapOf("e" to listOf(key2)), since = 1750889190, - ), - ) - - println(query) - - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_headers.created_at >= "1750889190") AND (event_tags.tag_hash = "2657743813502222172") AND (event_headers.kind = "5") AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - query, - ) - } + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsWithKindAndPubkey) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "2657743813502222172") AND (event_tags.kind = "5") AND (event_tags.pubkey_hash = "1730514094536529999") AND (event_tags.created_at >= "1750889190") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "2657743813502222172") AND (event_tags.kind = "5") AND (event_tags.pubkey_hash = "1730514094536529999") AND (event_tags.created_at >= "1750889190") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testLimit() { - val explainer = explain(Filter(limit = 10)) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - explainer, - ) - } + fun testLimit() = + forEachDB { db -> + val filter = Filter(limit = 10) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + + if (db.indexStrategy.indexEventsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + ORDER BY $orderBy + LIMIT 10 + └── SCAN event_headers USING INDEX query_by_created_at_id + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + ORDER BY $orderBy + LIMIT 10 + ├── SCAN event_headers + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testLimits() { - val explainer = explain(listOf(Filter(limit = 10), Filter(limit = 30))) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10) - UNION - SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30) - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ └── COMPOUND QUERY - │ ├── LEFT-MOST SUBQUERY - │ │ ├── CO-ROUTINE (subquery-1) - │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id - │ │ └── SCAN (subquery-1) - │ └── UNION USING TEMP B-TREE - │ ├── CO-ROUTINE (subquery-3) - │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id - │ └── SCAN (subquery-3) - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - explainer, - ) - } + fun testLimits() = + forEachDB { db -> + val filter = listOf(Filter(limit = 10), Filter(limit = 30)) + val orderBy = + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + "created_at DESC, id ASC" + } else { + "created_at DESC" + } + if (db.indexStrategy.indexEventsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ │ └── SCAN (subquery-1) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-3) + │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ └── SCAN (subquery-3) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ ├── SCAN event_headers USING COVERING INDEX query_by_kind_created + │ │ │ └── USE TEMP B-TREE FOR ORDER BY + │ │ └── SCAN (subquery-1) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-3) + │ │ ├── SCAN event_headers USING COVERING INDEX query_by_kind_created + │ │ └── USE TEMP B-TREE FOR ORDER BY + │ └── SCAN (subquery-3) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testAllFeatures() { - val sql = - explain( + fun testAllFeatures() = + forEachDB { db -> + val filter = listOf( Filter(limit = 10), Filter( @@ -162,209 +231,368 @@ class QueryAssemblerTest { limit = 100, ), Filter(kinds = listOf(20), search = "cats", limit = 30), - ), - ) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10) - UNION - SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 100) - UNION - SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30) - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ └── COMPOUND QUERY - │ ├── LEFT-MOST SUBQUERY - │ │ ├── CO-ROUTINE (subquery-1) - │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id - │ │ └── SCAN (subquery-1) - │ ├── UNION USING TEMP B-TREE - │ │ ├── CO-ROUTINE (subquery-3) - │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: - │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ │ │ └── USE TEMP B-TREE FOR ORDER BY - │ │ └── SCAN (subquery-3) - │ └── UNION USING TEMP B-TREE - │ ├── CO-ROUTINE (subquery-5) - │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: - │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ │ └── USE TEMP B-TREE FOR ORDER BY - │ └── SCAN (subquery-5) - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexEventsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC LIMIT 100) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ │ └── SCAN (subquery-1) + │ ├── UNION USING TEMP B-TREE + │ │ ├── CO-ROUTINE (subquery-3) + │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ │ └── USE TEMP B-TREE FOR ORDER BY + │ │ └── SCAN (subquery-3) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-5) + │ │ ├── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) + │ │ └── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ └── SCAN (subquery-5) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC LIMIT 100) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ ├── SCAN event_headers USING COVERING INDEX query_by_kind_created + │ │ │ └── USE TEMP B-TREE FOR ORDER BY + │ │ └── SCAN (subquery-1) + │ ├── UNION USING TEMP B-TREE + │ │ ├── CO-ROUTINE (subquery-3) + │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ │ └── USE TEMP B-TREE FOR ORDER BY + │ │ └── SCAN (subquery-3) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-5) + │ │ ├── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) + │ │ └── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ └── SCAN (subquery-5) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testKind() { - val sql = - explain( + fun testSingleFilterConversionToSimpleQuery() = + forEachDB { db -> + val filter = listOf( Filter( kinds = listOf(ContactListEvent.KIND), limit = 30, ), - ), - ) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.kind = "3" ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + ) + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE kind = "3" + ORDER BY created_at DESC, id ASC + LIMIT 30 + └── SEARCH event_headers USING INDEX query_by_kind_created (kind=?) + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE kind = "3" + ORDER BY created_at DESC + LIMIT 30 + └── SEARCH event_headers USING INDEX query_by_kind_created (kind=?) + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testKindAndDTag() { - val sql = - explain( + fun testKinds() = + forEachDB { db -> + val filter = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + limit = 30, + ), + Filter( + kinds = listOf(TextNoteEvent.KIND), + limit = 30, + ), + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.kind = "3" ORDER BY event_headers.created_at DESC LIMIT 30) + UNION + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.kind = "1" ORDER BY event_headers.created_at DESC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ └── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) + │ │ └── SCAN (subquery-1) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-3) + │ │ └── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) + │ └── SCAN (subquery-3) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + + @Test + fun testKindAndDTag() = + forEachDB { db -> + val filter = listOf( Filter( kinds = listOf(ContactListEvent.KIND), tags = mapOf("d" to listOf("")), limit = 30, ), - ), - ) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind = "3") AND (event_headers.d_tag = "") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + ) + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind = "3") AND (d_tag = "") + ORDER BY created_at DESC, id ASC + LIMIT 30 + └── SEARCH event_headers USING INDEX query_by_kind_created (kind=?) + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind = "3") AND (d_tag = "") + ORDER BY created_at DESC + LIMIT 30 + └── SEARCH event_headers USING INDEX query_by_kind_created (kind=?) + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testFollowersOf() { - val sql = - explain( - listOf( - Filter( - tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), - limit = 30, - ), - ), - ) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE event_tags.tag_hash = "-4551135004136952885" ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } - - @Test - fun testTagsAndKinds() { - val sql = - explain( + fun testFollowersOf() = + forEachDB { db -> + val filter = listOf( Filter( kinds = listOf(ContactListEvent.KIND), tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), limit = 30, ), - ), + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), ) - - println(sql) - - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_headers.kind = "3") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + } @Test - fun testTagsAndAuthors() { - val sql = - explain( + fun testNotificationsOf() = + forEachDB { db -> + val filter = + listOf( + Filter( + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, + ), + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE event_tags.tag_hash = "-4551135004136952885" ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash (tag_hash=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE event_tags.tag_hash = "-4551135004136952885" ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=?) + │ ├── USE TEMP B-TREE FOR DISTINCT + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testTagsAndKinds() = + forEachDB { db -> + val filter = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, + ), + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + + @Test + fun testTagsAndAuthors() = + forEachDB { db -> + val filter = listOf( Filter( authors = listOf(key1), tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), limit = 30, ), - ), - ) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.pubkey_hash = "1730514094536529999") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash (tag_hash=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.pubkey_hash = "1730514094536529999") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=?) + │ ├── USE TEMP B-TREE FOR DISTINCT + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testTwoTags() { - val sql = - explain( + fun testTwoTags() = + forEachDB { db -> + val filter = listOf( Filter( kinds = listOf(1), @@ -375,106 +603,418 @@ class QueryAssemblerTest { ), limit = 30, ), - ), + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsIn1 ON event_tagsIn1.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tagsIn1.tag_hash = "-6379614208644810021") AND (event_tags.kind = "1") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) + │ ├── SEARCH event_tagsIn1 USING INDEX query_by_tags_hash (tag_hash=? AND created_at=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsIn1 ON event_tagsIn1.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tagsIn1.tag_hash = "-6379614208644810021") AND (event_tags.kind = "1") ORDER BY event_tags.created_at DESC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) + │ ├── SEARCH event_tagsIn1 USING INDEX fk_event_tags_header_id (event_header_row_id=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testIdQuery() = + forEachDB { db -> + val filter = Filter(ids = listOf(key1)) + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + ORDER BY created_at DESC, id ASC + └── SEARCH event_headers USING INDEX event_headers_id (id=?) + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + ORDER BY created_at DESC + └── SEARCH event_headers USING INDEX event_headers_id (id=?) + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testAuthors() = + forEachDB { db -> + val filter = Filter(authors = listOf(key1, key2), kinds = listOf(1, 30023), limit = 300) + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("1", "30023")) AND (pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) + ORDER BY created_at DESC, id ASC + LIMIT 300 + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("1", "30023")) AND (pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) + ORDER BY created_at DESC + LIMIT 300 + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testAuthorsAndSearch() = + forEachDB { db -> + val filter = Filter(authors = listOf(key1, key2, key3), search = "keywords") + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + TestCase.assertEquals( + """ + SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers + INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id + WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) + ORDER BY event_headers.created_at DESC, event_headers.id ASC + ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers + INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id + WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) + ORDER BY event_headers.created_at DESC + ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testKindAndSearch() = + forEachDB { db -> + val filter = Filter(kinds = listOf(1, 1111, 10000), search = "keywords") + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "event_headers.created_at DESC, event_headers.id ASC" else "event_headers.created_at DESC" + TestCase.assertEquals( + """ + SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers + INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id + WHERE (event_fts MATCH "keywords") AND (event_headers.kind IN ("1", "1111", "10000")) + ORDER BY $orderBy + ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), ) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_tags as event_tagst ON event_tagst.event_header_row_id = event_tags.event_header_row_id INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tagst.tag_hash = "-6379614208644810021") AND (event_headers.kind = "1") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) - │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ ├── SEARCH event_tagst USING COVERING INDEX query_by_tags_hash (tag_hash=? AND event_header_row_id=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + } @Test - fun testIdQuery() { - val sql = explain(Filter(ids = listOf(key1))) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── SEARCH event_headers USING COVERING INDEX event_headers_id (id=?) - └── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - """.trimIndent(), - sql, - ) - } + fun testAllTag() = + forEachDB { db -> + val filter = Filter(tagsAll = mapOf("p" to listOf(key1, key2))) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsByCreatedAtAlone) { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsAll0_1 ON event_tagsAll0_1.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll0_1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "884286737453847614") AND (event_tagsAll0_1.tag_hash = "-4988851810256311323") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=?) + │ ├── SEARCH event_tagsAll0_1 USING INDEX query_by_tags_hash (tag_hash=? AND created_at=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsAll0_1 ON event_tagsAll0_1.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll0_1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "884286737453847614") AND (event_tagsAll0_1.tag_hash = "-4988851810256311323") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=?) + │ ├── SEARCH event_tagsAll0_1 USING INDEX fk_event_tags_header_id (event_header_row_id=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testAuthors() { - val sql = explain(Filter(authors = listOf(key1, key2), kinds = listOf(1, 30023), limit = 300)) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind IN ("1", "30023")) AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 300 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=? AND pubkey=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + fun testReportLikeFilter() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(ContactListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + tags = + mapOf( + "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + limit = 500, + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + if (db.indexStrategy.indexTagsWithKindAndPubkey) { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") AND (event_tags.pubkey_hash = "5446767199141196776") ORDER BY event_tags.created_at DESC LIMIT 500 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") AND (event_tags.pubkey_hash = "5446767199141196776") ORDER BY event_tags.created_at DESC LIMIT 500 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } @Test - fun testAuthorsAndSearch() { - val sql = explain(Filter(authors = listOf(key1, key2, key3), search = "keywords")) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) AND (event_fts MATCH "keywords") - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── SCAN event_fts VIRTUAL TABLE INDEX 4: - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + fun testFollowersSinceNov2025() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = + mapOf( + "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + since = 1764553447, // Nov 2025 + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") AND (event_tags.created_at >= "1764553447") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY $orderBy + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } @Test - fun testKindAndSearch() { - val sql = explain(Filter(kinds = listOf(1, 1111, 10000), search = "keywords")) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111", "10000")) AND (event_fts MATCH "keywords") - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── SCAN event_fts VIRTUAL TABLE INDEX 4: - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY - """.trimIndent(), - sql, - ) - } + fun testAllAddressablesOfAKindDownload() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(DraftWrapEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + since = 1764553447, // Nov 2025 + ) + val orderBy = if (db.indexStrategy.useAndIndexIdOnOrderBy) "created_at DESC, id ASC" else "created_at DESC" + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind = "31234") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") + ORDER BY $orderBy + └── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at>?) + """.trimIndent(), + db.explain(filter), + ) + } + + @Test + fun testReplaceablesOfMultipleKindsDownloadByDateLimit() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND, SearchRelayListEvent.KIND, AdvertisedRelayListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + limit = 20, + since = 1764553447, // Nov 2025 + ) + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("0", "10007", "10002")) AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") + ORDER BY created_at DESC, id ASC + LIMIT 20 + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at>?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("0", "10007", "10002")) AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") + ORDER BY created_at DESC + LIMIT 20 + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at>?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testReplaceablesOfMultipleKindsDownload() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND, SearchRelayListEvent.KIND, AdvertisedRelayListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ) + + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("0", "10007", "10002")) AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + ORDER BY created_at DESC, id ASC + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind IN ("0", "10007", "10002")) AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + ORDER BY created_at DESC + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } + + @Test + fun testContactCardDownloadFromTrustedKeys() = + forEachDB { db -> + val filter = + Filter( + kinds = listOf(ContactCardEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + tags = + mapOf( + "d" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + since = 1764553447, // Nov 2025 + ) + + if (db.indexStrategy.useAndIndexIdOnOrderBy) { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind = "30382") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (d_tag = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") AND ((kind >= 30000 AND kind < 40000)) + ORDER BY created_at DESC, id ASC + ├── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } else { + assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE (kind = "30382") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (d_tag = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") AND ((kind >= 30000 AND kind < 40000)) + ORDER BY created_at DESC + ├── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + db.explain(filter), + ) + } + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt index 97c8aa75d..620b5301f 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -20,9 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context import android.database.sqlite.SQLiteConstraintException -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -30,140 +28,125 @@ import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail -import org.junit.After -import org.junit.Before import org.junit.Test -class ReplaceableTest { - private lateinit var db: EventStore - +class ReplaceableTest : BaseDBTest() { val signer = NostrSignerSync() - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testReplacing() { - val time = TimeUtils.now() - val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) - val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) - val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) + fun testReplacing() = + forEachDB { db -> + val time = TimeUtils.now() + val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - db.insert(version1) - - db.assertQuery(version1, Filter(ids = listOf(version1.id))) - db.assertQuery(version1, addressableQuery) - - db.insert(version2) - - db.assertQuery(null, Filter(ids = listOf(version1.id))) - db.assertQuery(version2, Filter(ids = listOf(version2.id))) - db.assertQuery(version2, addressableQuery) - - db.insert(version3) - - db.assertQuery(null, Filter(ids = listOf(version1.id))) - db.assertQuery(null, Filter(ids = listOf(version2.id))) - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - db.assertQuery(version3, addressableQuery) - } - - @Test - fun testBlockingOldVersions() { - val time = TimeUtils.now() - val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) - val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) - val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) - - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - - db.insert(version3) - - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - - try { - db.insert(version2) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) - } - - try { db.insert(version1) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) + + db.assertQuery(version1, Filter(ids = listOf(version1.id))) + db.assertQuery(version1, addressableQuery) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + db.assertQuery(version2, addressableQuery) + + db.insert(version3) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) } - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - db.assertQuery(version3, addressableQuery) - db.assertQuery(null, Filter(ids = listOf(version2.id))) - db.assertQuery(null, Filter(ids = listOf(version1.id))) - } + @Test + fun testBlockingOldVersions() = + forEachDB { db -> + val time = TimeUtils.now() + val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) + + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + + db.insert(version3) + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + + try { + db.insert(version2) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + try { + db.insert(version1) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } @Test - fun testTriggersIndexUsageKind0() { - val explainer = - db.store.explainQuery( + fun testTriggersIndexUsageKind0() = + forEachDB { db -> + val sql = """ SELECT * FROM event_headers WHERE event_headers.kind = 0 AND event_headers.pubkey = 'aa' AND - event_headers.created_at < 1766686500 AND - ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); - """.trimIndent(), - ) + event_headers.created_at < 1766686500 + """.trimIndent() - assertEquals( - """ - SELECT * FROM event_headers - WHERE - event_headers.kind = 0 AND - event_headers.pubkey = 'aa' AND - event_headers.created_at < 1766686500 AND - ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); - └── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?) - """.trimIndent(), - explainer, - ) - } + val explainer = db.store.explainQuery(sql) + + assertEquals( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 0 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 + └── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at + val sql = """ SELECT * FROM event_headers WHERE event_headers.kind = 3 AND event_headers.pubkey = 'aa' AND - event_headers.created_at < 1766686500 AND - ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); - """.trimIndent(), - ) + event_headers.created_at < 1766686500 + """.trimIndent() - assertEquals( - """ - SELECT * FROM event_headers - WHERE - event_headers.kind = 3 AND - event_headers.pubkey = 'aa' AND - event_headers.created_at < 1766686500 AND - ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); - └── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?) - """.trimIndent(), - explainer, - ) - } + val explainer = db.store.explainQuery(sql) + + assertEquals( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 3 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 + └── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at() - db = EventStore(context, null, relayUrl = "testUrl") - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testInsertDeleteEvent() { - val time = TimeUtils.now() - val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time)) - val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1)) - val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = time + 2)) + fun testInsertDeleteEvent() = + forEachDB { db -> + val time = TimeUtils.now() + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1)) + val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = time + 2)) - db.insert(note1) - db.insert(note2) - db.insert(note3) - - db.assertQuery(note1, Filter(ids = listOf(note1.id))) - db.assertQuery(note2, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - val vanish = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) - - db.insert(vanish) - - db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - // trying to insert again should fail. - try { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) - } + db.insert(note2) + db.insert(note3) - db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - } + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val vanish = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2)) + + db.insert(vanish) + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + } @Test - fun testInsertDeleteGiftWrap() { - val time = TimeUtils.now() + fun testInsertDeleteGiftWrap() = + forEachDB { db -> + val time = TimeUtils.now() - val me = NostrSignerSync() - val myFriend = NostrSignerSync() + val me = NostrSignerSync() + val myFriend = NostrSignerSync() - val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time)) - val wrap1 = GiftWrapEvent.create(note1, me.pubKey) - val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) + val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time)) + val wrap1 = GiftWrapEvent.create(note1, me.pubKey) + val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) - db.insert(wrap1) - db.insert(wrap2) - - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) - - db.insert(randomVanishToWrap) - - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val vanish = me.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) - - db.insert(vanish) - - db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - // trying to insert again should fail. - try { db.insert(wrap1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) - } + db.insert(wrap2) - db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - } + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2)) + + db.insert(randomVanishToWrap) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val vanish = me.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2)) + + db.insert(vanish) + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + // trying to insert again should fail. + try { + db.insert(wrap1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index f73a3372d..851c95417 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -20,20 +20,14 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import org.junit.After -import org.junit.Before import org.junit.Test -class SearchTest { - private lateinit var db: SQLiteEventStore - - companion object Companion { +class SearchTest : BaseDBTest() { + companion object { val profile = MetadataEvent( id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", @@ -74,35 +68,25 @@ class SearchTest { ) } - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = SQLiteEventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testTagWithSearch() { - db.insertEvent(comment) - db.insertEvent(profile) + fun testTagWithSearch() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) - db.assertQuery(null, Filter(search = "testing1")) - db.assertQuery(comment, Filter(search = "testing")) - db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) - db.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "testing")) + db.assertQuery(null, Filter(search = "testing1")) + db.assertQuery(comment, Filter(search = "testing")) + db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + db.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "testing")) - db.delete(comment.id) + db.store.delete(comment.id) - db.assertQuery(null, Filter(search = "testing")) - db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + db.assertQuery(null, Filter(search = "testing")) + db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) - db.insertEvent(comment) + db.store.insertEvent(comment) - db.assertQuery(comment, Filter(search = "testing")) - db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) - } + db.assertQuery(comment, Filter(search = "testing")) + db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt index 32e567fd1..6905889e6 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -30,30 +30,45 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent class DeletionRequestModule( val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IModule { + fun rejectDeletedEventsSQLTemplate(): String = + if (indexStrategy.indexTagsWithKindAndPubkey) { + """ + |SELECT 1 FROM event_tags + |WHERE + | event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND + | event_tags.kind = 5 AND + | event_tags.pubkey_hash = NEW.pubkey_owner_hash AND + | event_tags.created_at >= NEW.created_at + """.trimMargin() + } else { + """ + |SELECT 1 FROM event_tags + |WHERE + | event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND + | event_tags.kind = 5 AND + | event_tags.created_at >= NEW.created_at AND + | event_tags.pubkey_hash = NEW.pubkey_owner_hash + """.trimMargin() + } + /** * Creates a trigger to reject events that have been * deleted by ID or ATag including GiftWraps that * must be checked against the p-tag (pubkey_owner_hash) */ override fun create(db: SQLiteDatabase) { + val sql = rejectDeletedEventsSQLTemplate().replace("\n", "\n ") db.execSQL( """ CREATE TRIGGER reject_deleted_events BEFORE INSERT ON event_headers FOR EACH ROW BEGIN - -- Check for ID-based deletion record SELECT RAISE(ABORT, 'blocked: a deletion event exists') WHERE EXISTS ( - SELECT 1 FROM event_tags - INNER JOIN event_headers - ON event_headers.row_id = event_tags.event_header_row_id - WHERE - event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND - event_headers.kind = 5 AND - event_headers.pubkey_owner_hash = NEW.pubkey_owner_hash AND - event_headers.created_at >= NEW.created_at + $sql ); END; """.trimIndent(), diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index 10fcf8301..8aefa83e4 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -20,23 +20,16 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.database.Cursor import android.database.sqlite.SQLiteDatabase import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.utils.EventFactory class EventIndexesModule( - val fts: FullTextSearchModule, val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, - val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), + val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IModule { override fun create(db: SQLiteDatabase) { db.execSQL( @@ -63,19 +56,53 @@ class EventIndexesModule( CREATE TABLE event_tags ( event_header_row_id INTEGER NOT NULL, tag_hash INTEGER NOT NULL, + created_at INTEGER NOT NULL, + kind INTEGER NOT NULL, + pubkey_hash INTEGER NOT NULL, FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE ) """.trimIndent(), ) + // queries by ID (load events) db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)") - db.execSQL("CREATE INDEX query_by_kind_pubkey_dtag_idx ON event_headers (kind, pubkey, d_tag)") - db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers (created_at desc, id)") - // need to check if this is actually needed. - db.execSQL("CREATE INDEX query_by_created_at_kind_key ON event_headers (created_at desc, kind, pubkey)") + val orderBy = + if (indexStrategy.useAndIndexIdOnOrderBy) { + "created_at DESC, id ASC" + } else { + "created_at DESC" + } + + // queries by limit (latest records), since, until (sync all) alone without any filter by kind.. rare + if (indexStrategy.indexEventsByCreatedAtAlone) { + db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers ($orderBy)") + } + + // queries by kind only, mostly used in Global Feeds when author is not important. + db.execSQL("CREATE INDEX query_by_kind_created ON event_headers (kind, $orderBy)") + + // queries by kind + pubkey, but not d-tag, even if they are replaceables and addressables, by date. + db.execSQL("CREATE INDEX query_by_kind_pubkey_created ON event_headers (kind, pubkey, $orderBy)") + + // makes deletions on the event_header fast db.execSQL("CREATE INDEX fk_event_tags_header_id ON event_tags (event_header_row_id)") - db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, event_header_row_id)") + + // --------------------------------------------------------------------------- + // These next 3 are a very slow indexes (80% of the insert time goes here) + // --------------------------------------------------------------------------- + if (indexStrategy.indexTagsByCreatedAtAlone) { + // First one is only needed if the user is searching by tags without a kind. + db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, created_at DESC)") + } + + // This is the default index for most clients: tags by specific kinds that are supported by the client. + db.execSQL("CREATE INDEX query_by_tags_hash_kind ON event_tags (tag_hash, kind, created_at DESC)") + + // this one is to allow search of tags by kind and author at the same time: NIP-04 DMs, reports, + if (indexStrategy.indexTagsWithKindAndPubkey) { + db.execSQL("CREATE INDEX query_by_tags_hash_kind_pubkey ON event_tags (tag_hash, kind, pubkey_hash, created_at DESC)") + } // Prevent updates to maintain immutability db.execSQL( @@ -117,9 +144,9 @@ class EventIndexesModule( val sqlInsertTags = """ INSERT OR ROLLBACK INTO event_tags - (event_header_row_id, tag_hash) + (event_header_row_id, tag_hash, created_at, kind, pubkey_hash) VALUES - (?,?) + (?,?,?,?,?) """.trimIndent() fun insert( @@ -169,7 +196,7 @@ class EventIndexesModule( // rebalancing the tree every new insert val indexableTags = ArrayList() for (idx in event.tags.indices) { - if (tagIndexStrategy.shouldIndex(event.kind, event.tags[idx])) { + if (indexStrategy.shouldIndex(event.kind, event.tags[idx])) { indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1])) } } @@ -177,387 +204,17 @@ class EventIndexesModule( indexableTags.forEach { stmtTags.bindLong(1, headerId) stmtTags.bindLong(2, it) + stmtTags.bindLong(3, event.createdAt) + stmtTags.bindLong(4, kindLong) + stmtTags.bindLong(5, pubkeyHash) stmtTags.executeInsert() } return headerId } - fun planQuery( - filter: Filter, - hasher: TagNameValueHasher, - db: SQLiteDatabase, - ): String { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher) - - return if (rowIdSubQuery == null) { - val query = makeEverythingQuery() - db.explainQuery(query) - } else { - val query = makeQueryIn(rowIdSubQuery.sql) - db.explainQuery(query, rowIdSubQuery.args.toTypedArray()) - } - } - - fun query( - filter: Filter, - db: SQLiteDatabase, - ): List { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) - - return if (rowIdSubQuery == null) { - db.runQuery(makeEverythingQuery()) - } else { - db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args) - } - } - - fun query( - filter: Filter, - db: SQLiteDatabase, - onEach: (T) -> Unit, - ) { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) - - return if (rowIdSubQuery == null) { - db.runQuery(makeEverythingQuery(), onEach = onEach) - } else { - db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach) - } - } - - fun planQuery( - filters: List, - hasher: TagNameValueHasher, - db: SQLiteDatabase, - ): String { - val rowIdSubQuery = unionSubqueriesIfNeeded(filters, hasher) - - return if (rowIdSubQuery == null) { - val query = makeEverythingQuery() - db.explainQuery(query) - } else { - val query = makeQueryIn(rowIdSubQuery.sql) - db.explainQuery(query, rowIdSubQuery.args.toTypedArray()) - } - } - - fun query( - filters: List, - db: SQLiteDatabase, - ): List { - val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQuery(makeEverythingQuery()) - return db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args) - } - - fun query( - filters: List, - db: SQLiteDatabase, - onEach: (T) -> Unit, - ) { - val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) - - if (rowIdSubqueries == null) { - db.runQuery(makeEverythingQuery(), onEach = onEach) - } else { - db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args, onEach) - } - } - - private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id" - - private fun makeQueryIn(rowIdQuery: String) = - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - $rowIdQuery - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent() - - private fun SQLiteDatabase.runQuery( - sql: String, - args: List = emptyList(), - ): List = - rawQuery(sql, args.toTypedArray()).use { cursor -> - ArrayList(cursor.count).apply { - while (cursor.moveToNext()) { - add(cursor.toEvent()) - } - } - } - - private inline fun SQLiteDatabase.runQuery( - sql: String, - args: List = emptyList(), - onEach: (T) -> Unit, - ) = rawQuery(sql, args.toTypedArray()).use { cursor -> - while (cursor.moveToNext()) { - onEach(cursor.toEvent()) - } - } - - private fun Cursor.toEvent() = - EventFactory.create( - getString(0).intern(), - getString(1).intern(), - getLong(2), - getInt(3), - OptimizedJsonMapper.fromJsonToTagArray(getString(4)), - getString(5), - getString(6), - ) - - class RawEvent( - val id: HexKey, - val pubKey: HexKey, - val createdAt: Long, - val kind: Kind, - val jsonTags: String, - val content: String, - val sig: HexKey, - ) { - fun toEvent() = - EventFactory.create( - id.intern(), - pubKey.intern(), - createdAt, - kind, - OptimizedJsonMapper.fromJsonToTagArray(jsonTags), - content, - sig, - ) - } - - private fun Cursor.toRawEvent() = - RawEvent( - getString(0), - getString(1), - getLong(2), - getInt(3), - getString(4), - getString(5), - getString(6), - ) - - // -------------- - // Counts - // ------------- - fun count( - filter: Filter, - db: SQLiteDatabase, - ): Int { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) - - return if (rowIdSubQuery == null) { - db.countEverything() - } else { - db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args) - } - } - - fun count( - filters: List, - db: SQLiteDatabase, - ): Int { - val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything() - - return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args) - } - - private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers") - - private fun SQLiteDatabase.countIn( - rowIdQuery: String, - args: List, - ) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args) - - private fun SQLiteDatabase.runCount( - sql: String, - args: List = emptyList(), - ): Int = - rawQuery(sql, args.toTypedArray()).use { cursor -> - cursor.moveToNext() - cursor.getInt(0) - } - - // -------------- - // Deletes - // ------------- - fun delete( - filter: Filter, - db: SQLiteDatabase, - ): Int { - val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db)) - - return if (rowIdQuery == null) { - 0 - } else { - db.runDelete(rowIdQuery.sql, rowIdQuery.args) - } - } - - fun delete( - filters: List, - db: SQLiteDatabase, - ): Int { - val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0 - - return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args) - } - - private fun SQLiteDatabase.runDelete( - sql: String, - args: List = emptyList(), - ): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray()) - - // --------------------------------- - // Prepare unions of all the filters - // --------------------------------- - fun unionSubqueriesIfNeeded( - filters: List, - hasher: TagNameValueHasher, - ): RowIdSubQuery? { - val inner = - filters.mapNotNull { filter -> - prepareRowIDSubQueries(filter, hasher) - } - - if (inner.isEmpty()) return null - - return if (inner.size == 1) { - inner.first() - } else { - RowIdSubQuery( - sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" }, - args = inner.flatMap { it.args }, - ) - } - } - - // ---------------------------- - // Inner row id selections - // ---------------------------- - fun prepareRowIDSubQueries( - filter: Filter, - hasher: TagNameValueHasher, - ): RowIdSubQuery? { - if (!filter.isFilledFilter()) return null - - val mustJoinSearch = (filter.search != null) - - val nonDTags = filter.tags?.filter { it.key != "d" } ?: emptyMap() - - val hasHeaders = - with(filter) { - (ids != null) || - (authors != null && authors.isNotEmpty()) || - (kinds != null && kinds.isNotEmpty()) || - (tags != null && tags.containsKey("d")) || - (since != null) || - (until != null) || - (limit != null) - } - - var defaultTagKey: String? = null - - val projection = - buildString { - // always do tags if there are any - if (nonDTags.isNotEmpty()) { - append("SELECT event_tags.event_header_row_id as row_id FROM event_tags ") - - // it's quite rare to have 2 tags in the filter, but possible - nonDTags.keys.forEachIndexed { index, tagName -> - if (index > 0) { - append("INNER JOIN event_tags as event_tags$tagName ON event_tags$tagName.event_header_row_id = event_tags.event_header_row_id ") - } else { - defaultTagKey = tagName - } - } - - if (hasHeaders) { - append("INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id ") - } - - if (mustJoinSearch) { - append("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id ") - } - } else if (mustJoinSearch) { - append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName} ") - - if (hasHeaders) { - append("INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") - } - } else { - // no tags and no search. - append("SELECT event_headers.row_id as row_id FROM event_headers ") - } - } - - val clause = - where { - // the order should match indexes - // ids reduce the filter the most - filter.ids?.let { equalsOrIn("event_headers.id", it) } - - // range search is bad but most of the time these are up the top with few elements. - filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) } - filter.until?.let { lessThanOrEquals("event_headers.created_at", it) } - - // there are indexes for these, starting with tags. - nonDTags.forEach { (tagName, tagValues) -> - val column = - if (defaultTagKey == null || defaultTagKey == tagName) { - "event_tags.tag_hash" - } else { - "event_tags$tagName.tag_hash" - } - - equalsOrIn( - column, - tagValues.map { - hasher.hash(tagName, it) - }, - ) - } - - filter.kinds?.let { equalsOrIn("event_headers.kind", it) } - filter.authors?.let { equalsOrIn("event_headers.pubkey", it) } - - // there are indexes for these, starting with tags. - filter.tags?.forEach { (tagName, tagValues) -> - if (tagName == "d") { - equalsOrIn("event_headers.d_tag", tagValues) - } - } - - // if search is included, SQLLite will always start here. - filter.search?.let { - if (it.isNotBlank()) { - match(fts.tableName, it) - } - } - } - - val whereClause = - if (filter.limit != null) { - "${clause.conditions} ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT ${filter.limit}" - } else { - clause.conditions - } - - return RowIdSubQuery("$projection WHERE $whereClause", clause.args) - } - override fun deleteAll(db: SQLiteDatabase) { db.execSQL("DELETE FROM event_tags") db.execSQL("DELETE FROM event_headers") } - - data class RowIdSubQuery( - val sql: String, - val args: List, - ) } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index a4c85102e..53653f55d 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -29,9 +29,9 @@ class EventStore( context: Context, dbName: String? = "events.db", val relayUrl: String? = "wss://quartz.local", - val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), + val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IEventStore { - val store = SQLiteEventStore(context, dbName, relayUrl, tagIndexStrategy) + val store = SQLiteEventStore(context, dbName, relayUrl, indexStrategy) override fun insert(event: Event) = store.insertEvent(event) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt index 4e50b4225..a0a6ea1d4 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt @@ -23,6 +23,64 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.nip01Core.core.Tag interface IndexingStrategy { + /** + * Activate this if you see too many Filters with just LIMIT, SINCE and + * UNTIL filled up. + * + * Clients never support all kinds, so this is usually + * only done with syncing services that must download ALL kinds from + * ALL authors. + * + * The index will make these queries significantly faster, but maybe speed + * is not a requirement on Sync services. The size of this index is + * considerable. + * + * Keep in mind that activating too many indexes increases the size of the + * DB so much that the indexes themselves won't fit in memory, requiring + * frequent reloadings of the index itself from disk. + */ + val indexEventsByCreatedAtAlone: Boolean + + /** + * Activate this if you see too many Tag-centric Filters without + * kind, pubkey or id. + * + * Clients never support all kinds, so this is usually + * only done in rare usecases where the client supports all + * kinds. + * + * The index will make these queries significantly faster, but maybe speed + * is not a requirement on such services. Because this is an index in + * event tags, it becomes QUITE BIG. + * + * Keep in mind that activating too many indexes increases the size of the + * DB so much that the indexes themselves won't fit in memory, requiring + * frequent reloadings of the index itself from disk. + */ + val indexTagsByCreatedAtAlone: Boolean + + /** + * Activate this if you see too many Tag-centric Filters without + * kind AND pubkey at the same time. + * + * This is a rarely used index (reports by your follows or + * NIP-04 DMs for instance) that becomes quite large without + * major gains. + * + * Keep in mind that activating too many indexes increases the size of the + * DB so much that the indexes themselves won't fit in memory, requiring + * frequent reloadings of the index itself from disk. + */ + val indexTagsWithKindAndPubkey: Boolean + + /** + * Activate this to make sure queries are always in order when + * the same created_at exists. This will impact performance and + * the size of indexes, but it provides results that are compliant + * with the Nostr Spec + */ + val useAndIndexIdOnOrderBy: Boolean + fun shouldIndex( kind: Int, tag: Tag, @@ -32,7 +90,12 @@ interface IndexingStrategy { /** * By default, we index all tags that have a single letter name and some value */ -class DefaultIndexingStrategy : IndexingStrategy { +class DefaultIndexingStrategy( + override val indexEventsByCreatedAtAlone: Boolean = false, + override val indexTagsByCreatedAtAlone: Boolean = false, + override val indexTagsWithKindAndPubkey: Boolean = false, + override val useAndIndexIdOnOrderBy: Boolean = false, +) : IndexingStrategy { override fun shouldIndex( kind: Int, tag: Tag, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt new file mode 100644 index 000000000..d4ab144f6 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -0,0 +1,710 @@ +/** + * 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.quartz.nip01Core.store.sqlite + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.core.isAddressable +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where +import com.vitorpamplona.quartz.utils.EventFactory + +class QueryBuilder( + val fts: FullTextSearchModule, + val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val indexStrategy: IndexingStrategy, +) { + // ------------ + // Main methods + // ------------ + fun query( + filter: Filter, + db: SQLiteDatabase, + ): List = db.runQuery(toSql(filter, hasher(db))) + + fun query( + filter: Filter, + db: SQLiteDatabase, + onEach: (T) -> Unit, + ) = db.runQuery(toSql(filter, hasher(db)), onEach) + + fun query( + filters: List, + db: SQLiteDatabase, + ): List = db.runQuery(toSql(filters, hasher(db))) + + fun query( + filters: List, + db: SQLiteDatabase, + onEach: (T) -> Unit, + ) = db.runQuery(toSql(filters, hasher(db)), onEach) + + // --------------------------- + // Raw methods for performance + // --------------------------- + fun rawQuery( + filter: Filter, + db: SQLiteDatabase, + ): List = db.runRawQuery(toSql(filter, hasher(db))) + + fun rawQuery( + filter: Filter, + db: SQLiteDatabase, + onEach: (RawEvent) -> Unit, + ) = db.runRawQuery(toSql(filter, hasher(db)), onEach) + + fun rawQuery( + filters: List, + db: SQLiteDatabase, + ): List = db.runRawQuery(toSql(filters, hasher(db))) + + fun rawQuery( + filters: List, + db: SQLiteDatabase, + onEach: (RawEvent) -> Unit, + ) = db.runRawQuery(toSql(filters, hasher(db)), onEach) + + // ----------- + // Debug Tools + // ----------- + fun planQuery( + filter: Filter, + hasher: TagNameValueHasher, + db: SQLiteDatabase, + ): String { + val query = toSql(filter, hasher) + return db.explainQuery(query.sql, query.args.toTypedArray()) + } + + fun planQuery( + filters: List, + hasher: TagNameValueHasher, + db: SQLiteDatabase, + ): String { + val query = toSql(filters, hasher) + return db.explainQuery(query.sql, query.args.toTypedArray()) + } + + fun toSql( + filter: Filter, + hasher: TagNameValueHasher, + ): QuerySpec { + val newFilter = filter.toFilterWithDTags() + + if (newFilter.isSimpleQuery()) { + return makeSimpleQuery( + project = true, + ids = newFilter.ids, + authors = newFilter.authors, + kinds = newFilter.kinds, + dTags = newFilter.dTags, + since = newFilter.since, + until = newFilter.until, + limit = newFilter.limit, + ) + } + + if (newFilter.isSimpleSearch()) { + return makeSimpleSearch( + search = newFilter.search!!, + ids = newFilter.ids, + authors = newFilter.authors, + kinds = newFilter.kinds, + dTags = newFilter.dTags, + since = newFilter.since, + until = newFilter.until, + limit = newFilter.limit, + ) + } + + val rowIdSubqueries = prepareRowIDSubQueries(filter, hasher) + + return if (rowIdSubqueries == null) { + QuerySpec(makeEverythingQuery()) + } else { + QuerySpec( + makeQueryIn(rowIdSubqueries.sql), + rowIdSubqueries.args, + ) + } + } + + fun toSql( + filters: List, + hasher: TagNameValueHasher, + ): QuerySpec { + if (filters.size == 1) return toSql(filters.first(), hasher) + + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher) + + return if (rowIdSubqueries == null) { + QuerySpec( + makeEverythingQuery(), + emptyList(), + ) + } else { + QuerySpec( + makeQueryIn(rowIdSubqueries.sql), + rowIdSubqueries.args, + ) + } + } + + private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}" + + private fun makeQueryIn(rowIdQuery: String) = + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + $rowIdQuery + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""} + """.trimIndent() + + private fun SQLiteDatabase.runQuery(query: QuerySpec): List = + rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> + ArrayList(cursor.count).apply { + while (cursor.moveToNext()) { + add(cursor.toEvent()) + } + } + } + + private fun SQLiteDatabase.runRawQuery(query: QuerySpec): List = + rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> + ArrayList(cursor.count).apply { + while (cursor.moveToNext()) { + add(cursor.toRawEvent()) + } + } + } + + private inline fun SQLiteDatabase.runQuery( + query: QuerySpec, + onEach: (T) -> Unit, + ) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> + while (cursor.moveToNext()) { + onEach(cursor.toEvent()) + } + } + + private inline fun SQLiteDatabase.runRawQuery( + query: QuerySpec, + onEach: (RawEvent) -> Unit, + ) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor -> + while (cursor.moveToNext()) { + onEach(cursor.toRawEvent()) + } + } + + private fun Cursor.toEvent() = + EventFactory.create( + getString(0).intern(), + getString(1).intern(), + getLong(2), + getInt(3), + OptimizedJsonMapper.fromJsonToTagArray(getString(4)), + getString(5), + getString(6), + ) + + private fun Cursor.toRawEvent() = + RawEvent( + getString(0), + getString(1), + getLong(2), + getInt(3), + getString(4), + getString(5), + getString(6), + ) + + // -------------- + // Counts + // ------------- + fun count( + filter: Filter, + db: SQLiteDatabase, + ): Int { + val newFilter = filter.toFilterWithDTags() + + if (newFilter.isSimpleQuery()) { + val sql = + makeSimpleQuery( + project = false, + ids = newFilter.ids, + authors = newFilter.authors, + kinds = newFilter.kinds, + dTags = newFilter.dTags, + since = newFilter.since, + until = newFilter.until, + limit = newFilter.limit, + ) + return db.countIn(sql.sql, sql.args) + } + + val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) + + return if (rowIdSubQuery == null) { + db.countEverything() + } else { + db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args) + } + } + + fun count( + filters: List, + db: SQLiteDatabase, + ): Int { + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything() + + return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args) + } + + private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers") + + private fun SQLiteDatabase.countIn( + rowIdQuery: String, + args: List, + ) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args) + + private fun SQLiteDatabase.runCount( + sql: String, + args: List = emptyList(), + ): Int = + rawQuery(sql, args.toTypedArray()).use { cursor -> + cursor.moveToNext() + cursor.getInt(0) + } + + // -------------- + // Deletes + // ------------- + fun delete( + filter: Filter, + db: SQLiteDatabase, + ): Int { + val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db)) + + return if (rowIdQuery == null) { + 0 + } else { + db.runDelete(rowIdQuery.sql, rowIdQuery.args) + } + } + + fun delete( + filters: List, + db: SQLiteDatabase, + ): Int { + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0 + + return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args) + } + + private fun SQLiteDatabase.runDelete( + sql: String, + args: List = emptyList(), + ): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray()) + + // --------------------------------- + // Prepare unions of all the filters + // --------------------------------- + fun unionSubqueriesIfNeeded( + filters: List, + hasher: TagNameValueHasher, + ): QuerySpec? { + val inner = + filters.mapNotNull { filter -> + prepareRowIDSubQueries(filter, hasher) + } + + if (inner.isEmpty()) return null + + return if (inner.size == 1) { + inner.first() + } else { + QuerySpec( + sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" }, + args = inner.flatMap { it.args }, + ) + } + } + + sealed class TagNameForQuery { + class InTags( + val tagName: String, + ) : TagNameForQuery() + + class AllTags( + val tagName: String, + val tagValueIndex: Int, + ) : TagNameForQuery() + } + + // ---------------------------- + // Inner row id selections + // ---------------------------- + fun prepareRowIDSubQueries( + filter: Filter, + hasher: TagNameValueHasher, + ): QuerySpec? { + if (filter.isEmpty()) return null + + val mustJoinSearch = (filter.search != null) + + val nonDTagsIn = filter.tags?.filter { it.key != "d" } ?: emptyMap() + + val nonDTagsAll = filter.tagsAll?.filter { it.key != "d" } ?: emptyMap() + + val reverseLookup = nonDTagsIn.isNotEmpty() || nonDTagsAll.isNotEmpty() + + val needHeaders = + with(filter) { + (ids != null) || (tags != null && tags.containsKey("d")) + } + + val hasHeaders = + with(filter) { + (ids != null) || + (authors != null && authors.isNotEmpty()) || + (kinds != null && kinds.isNotEmpty()) || + (tags != null && tags.containsKey("d")) || + (since != null) || + (until != null) || + (limit != null) + } + + var defaultTagKey: TagNameForQuery? = null + + val projection = + buildString { + // always do tags if there are any + if (reverseLookup) { + append("SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags") + + // it's quite rare to have 2 tags in the filter, but possible + nonDTagsIn.keys.forEachIndexed { index, tagName -> + if (defaultTagKey != null) { + append(" INNER JOIN event_tags as event_tagsIn$index ON event_tagsIn$index.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn$index.created_at = event_tags.created_at") + } else { + defaultTagKey = TagNameForQuery.InTags(tagName) + } + } + + nonDTagsAll.keys.forEachIndexed { index, tagName -> + nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue -> + if (defaultTagKey != null) { + append(" INNER JOIN event_tags as event_tagsAll${index}_$valueIndex ON event_tagsAll${index}_$valueIndex.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll${index}_$valueIndex.created_at = event_tags.created_at") + } else { + defaultTagKey = TagNameForQuery.AllTags(tagName, valueIndex) + } + } + } + + if (needHeaders) { + append(" INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id") + } + + if (mustJoinSearch) { + append(" INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id") + } + } else if (mustJoinSearch) { + append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}") + + if (hasHeaders) { + append(" INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") + } + } else { + // no tags and no search. + append("SELECT event_headers.row_id as row_id FROM event_headers") + } + } + + val clause = + where { + // the order should match indexes + // ids reduce the filter the most + filter.ids?.let { equalsOrIn("event_headers.id", it) } + + // it's quite rare to have 2 tags in the filter, but possible + nonDTagsIn.keys.forEachIndexed { index, tagName -> + val column = + if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.InTags && defaultTagKey.tagName == tagName)) { + "event_tags.tag_hash" + } else { + "event_tagsIn$index.tag_hash" + } + + equalsOrIn( + column, + nonDTagsIn[tagName]!!.map { + hasher.hash(tagName, it) + }, + ) + } + + // there are indexes for these, starting with tags. + nonDTagsAll.keys.forEachIndexed { index, tagName -> + nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue -> + val column = + if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.AllTags && defaultTagKey.tagName == tagName && defaultTagKey.tagValueIndex == valueIndex)) { + "event_tags.tag_hash" + } else { + "event_tagsAll${index}_$valueIndex.tag_hash" + } + + equals(column, hasher.hash(tagName, tagValue)) + } + } + + // range search is bad but most of the time these are up the top with few elements. + if (reverseLookup) { + filter.kinds?.let { equalsOrIn("event_tags.kind", it) } + filter.authors?.let { equalsOrIn("event_tags.pubkey_hash", it.map { hasher.hash(it) }) } + + filter.since?.let { greaterThanOrEquals("event_tags.created_at", it) } + filter.until?.let { lessThanOrEquals("event_tags.created_at", it) } + + // there are indexes for these, starting with tags. + filter.tags?.forEach { (tagName, tagValues) -> + if (tagName == "d") { + equalsOrIn("event_headers.d_tag", tagValues) + } + } + } else { + filter.kinds?.let { equalsOrIn("event_headers.kind", it) } + filter.authors?.let { equalsOrIn("event_headers.pubkey", it) } + + // there are indexes for these, starting with tags. + filter.tags?.forEach { (tagName, tagValues) -> + if (tagName == "d") { + equalsOrIn("event_headers.d_tag", tagValues) + } + } + + filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) } + filter.until?.let { lessThanOrEquals("event_headers.created_at", it) } + + // no need to add the replaceable because query_by_kind_pubkey_created already covers it + val isAllAddressable = filter.kinds?.all { it.isAddressable() } ?: false + if (isAllAddressable) { + // matches unique index kind >= 30000 AND kind < 40000 + raw("(event_headers.kind >= 30000 AND event_headers.kind < 40000)") + } + } + + // if search is included, SQLLite will always start here. + filter.search?.let { + if (it.isNotBlank()) { + match(fts.tableName, it) + } + } + } + + val sql = + buildString { + append(projection) + if (clause.conditions.isNotEmpty()) { + append(" WHERE ${clause.conditions}") + } + if (filter.limit != null) { + if (reverseLookup) { + append(" ORDER BY event_tags.created_at DESC") + append(" LIMIT ") + append(filter.limit) + } else { + append(" ORDER BY event_headers.created_at DESC") + append(" LIMIT ") + append(filter.limit) + } + } + } + + return QuerySpec(sql, clause.args) + } + + private fun makeSimpleSearch( + search: String, + ids: List? = null, + authors: List? = null, + kinds: List? = null, + dTags: List? = null, + since: Long? = null, + until: Long? = null, + limit: Int? = null, + ): QuerySpec { + val clause = + where { + // the order should match indexes + // ids reduce the filter the most + ids?.let { equalsOrIn("event_headers.id", it) } + + match(fts.tableName, search) + + kinds?.let { equalsOrIn("event_headers.kind", it) } + authors?.let { equalsOrIn("event_headers.pubkey", it) } + + // there are indexes for these, starting with tags. + dTags?.let { equalsOrIn("event_headers.d_tag", it) } + + since?.let { greaterThanOrEquals("event_headers.created_at", it) } + until?.let { lessThanOrEquals("event_headers.created_at", it) } + + // if this is a dTag filter, it is likely that all kinds are addressables + // and so force the use of the addressable index + if (dTags != null && kinds != null) { + if (kinds.all { it.isAddressable() }) { + // matches unique index kind >= 30000 AND kind < 40000 + raw("(event_headers.kind >= 30000 AND kind < 40000)") + } + } + } + + val sql = + buildString { + append("SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers") + append("\nINNER JOIN ${fts.tableName} ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") + if (clause.conditions.isNotEmpty()) { + append("\nWHERE ${clause.conditions}") + } + append("\nORDER BY event_headers.created_at DESC") + if (indexStrategy.useAndIndexIdOnOrderBy) { + append(", event_headers.id ASC") + } + if (limit != null) { + append("\nLIMIT ") + append(limit) + } + } + + return QuerySpec(sql, clause.args) + } + + private fun makeSimpleQuery( + project: Boolean, + ids: List? = null, + authors: List? = null, + kinds: List? = null, + dTags: List? = null, + since: Long? = null, + until: Long? = null, + limit: Int? = null, + ): QuerySpec { + val clause = + where { + // the order should match indexes + // ids reduce the filter the most + ids?.let { equalsOrIn("id", it) } + + kinds?.let { equalsOrIn("kind", it) } + authors?.let { equalsOrIn("pubkey", it) } + + // there are indexes for these, starting with tags. + dTags?.let { equalsOrIn("d_tag", it) } + + since?.let { greaterThanOrEquals("created_at", it) } + until?.let { lessThanOrEquals("created_at", it) } + + // if this is a dTag filter, it is likely that all kinds are addressables + // and so force the use of the addressable index + if (dTags != null && kinds != null) { + if (kinds.all { it.isAddressable() }) { + // matches unique index kind >= 30000 AND kind < 40000 + raw("(kind >= 30000 AND kind < 40000)") + } + } + } + + val sql = + buildString { + if (project) { + append("SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers") + } else { + append("SELECT row_id FROM event_headers") + } + if (clause.conditions.isNotEmpty()) { + append("\nWHERE ") + append(clause.conditions) + } + if (project) { + append("\nORDER BY created_at DESC") + if (indexStrategy.useAndIndexIdOnOrderBy) { + append(", id ASC") + } + } + if (limit != null) { + append("\nLIMIT ") + append(limit) + } + } + + return QuerySpec(sql, clause.args) + } + + class FilterWithDTags( + val ids: List? = null, + val authors: List? = null, + val kinds: List? = null, + val dTags: List? = null, + val nonDTagsIn: Map>? = null, + val nonDTagsAll: Map>? = null, + val since: Long? = null, + val until: Long? = null, + val limit: Int? = null, + val search: String? = null, + ) { + fun isSimpleSearch() = + search != null && search.isNotEmpty() && + (nonDTagsIn == null || nonDTagsIn.isEmpty()) && + (nonDTagsAll == null || nonDTagsAll.isEmpty()) + + // can be resolved with just event_headers + fun isSimpleQuery() = + (nonDTagsIn == null || nonDTagsIn.isEmpty()) && + (nonDTagsAll == null || nonDTagsAll.isEmpty()) && + (search == null || search.isEmpty()) + } + + fun Filter.toFilterWithDTags(): FilterWithDTags = + FilterWithDTags( + ids = ids, + authors = authors, + kinds = kinds, + dTags = tags?.get("d") ?: tagsAll?.get("d"), + nonDTagsIn = tags?.filter { it.key != "d" }?.ifEmpty { null }, + nonDTagsAll = tagsAll?.filter { it.key != "d" }?.ifEmpty { null }, + since = since, + until = until, + limit = limit, + search = search, + ) + + data class QuerySpec( + val sql: String, + val args: List = emptyList(), + ) +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt index 7aa87e594..9b6a2c964 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt @@ -48,8 +48,7 @@ class ReplaceableModule : IModule { WHERE event_headers.kind = NEW.kind AND event_headers.pubkey = NEW.pubkey AND - event_headers.created_at < NEW.created_at AND - ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); + event_headers.created_at < NEW.created_at; END; """.trimIndent(), ) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index c1e72a3bb..515d492c4 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -27,10 +27,13 @@ import android.database.sqlite.SQLiteOpenHelper import androidx.core.database.sqlite.transaction import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.isEphemeral import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip40Expiration.isExpired +import com.vitorpamplona.quartz.utils.EventFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -38,7 +41,7 @@ class SQLiteEventStore( val context: Context, val dbName: String? = "events.db", val relayUrl: String? = null, - val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), + val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) { companion object { const val DATABASE_VERSION = 2 @@ -47,7 +50,7 @@ class SQLiteEventStore( val seedModule = SeedModule() val fullTextSearchModule = FullTextSearchModule() - val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule::hasher, tagIndexStrategy) + val eventIndexModule = EventIndexesModule(seedModule::hasher, indexStrategy) val replaceableModule = ReplaceableModule() val addressableModule = AddressableModule() @@ -57,6 +60,8 @@ class SQLiteEventStore( val expirationModule = ExpirationModule() val rightToVanishModule = RightToVanishModule(seedModule::hasher) + val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher, indexStrategy) + val modules = listOf( seedModule, @@ -73,6 +78,9 @@ class SQLiteEventStore( override fun onConfigure(db: SQLiteDatabase) { super.onConfigure(db) + // 32MB memory cache + db.execSQL("PRAGMA cache_size=-32000;") + // makes sure the FKs are sane db.setForeignKeyConstraintsEnabled(true) @@ -82,7 +90,14 @@ class SQLiteEventStore( // The DB can be corrupted if the OS is shutdown before sync, which generally // doesn't happen on Android - db.execSQL("PRAGMA synchronous = OFF") + db.execSQL("PRAGMA synchronous = OFF;") + } + + fun dbSizeMB(): Int { + val f1 = context.getDatabasePath(dbName) + val f2 = context.getDatabasePath("$dbName-wal") + val total = f1.length() + f2.length() + return (total / (1024 * 1024)).toInt() } override fun onCreate(db: SQLiteDatabase) { @@ -171,33 +186,72 @@ class SQLiteEventStore( } } - fun query(filter: Filter): List = eventIndexModule.query(filter, readableDatabase) + fun query(filter: Filter): List = queryBuilder.query(filter, readableDatabase) - fun query(filters: List): List = eventIndexModule.query(filters, readableDatabase) + fun query(filters: List): List = queryBuilder.query(filters, readableDatabase) fun query( filter: Filter, onEach: (T) -> Unit, - ) = eventIndexModule.query(filter, readableDatabase, onEach) + ) = queryBuilder.query(filter, readableDatabase, onEach) fun query( filters: List, onEach: (T) -> Unit, - ) = eventIndexModule.query(filters, readableDatabase, onEach) + ) = queryBuilder.query(filters, readableDatabase, onEach) - fun count(filter: Filter): Int = eventIndexModule.count(filter, readableDatabase) + fun rawQuery(filter: Filter): List = queryBuilder.rawQuery(filter, readableDatabase) - fun count(filters: List): Int = eventIndexModule.count(filters, readableDatabase) + fun rawQuery(filters: List): List = queryBuilder.rawQuery(filters, readableDatabase) + + fun rawQuery( + filter: Filter, + onEach: (RawEvent) -> Unit, + ) = queryBuilder.rawQuery(filter, readableDatabase, onEach) + + fun rawQuery( + filters: List, + onEach: (RawEvent) -> Unit, + ) = queryBuilder.rawQuery(filters, readableDatabase, onEach) + + fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(readableDatabase), readableDatabase) + + fun planQuery(filters: List) = queryBuilder.planQuery(filters, seedModule.hasher(readableDatabase), readableDatabase) + + fun count(filter: Filter): Int = queryBuilder.count(filter, readableDatabase) + + fun count(filters: List): Int = queryBuilder.count(filters, readableDatabase) fun delete(filter: Filter) { - eventIndexModule.delete(filter, writableDatabase) + queryBuilder.delete(filter, writableDatabase) } fun delete(filters: List) { - eventIndexModule.delete(filters, writableDatabase) + queryBuilder.delete(filters, writableDatabase) } fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id)) fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase) } + +class RawEvent( + val id: HexKey, + val pubKey: HexKey, + val createdAt: Long, + val kind: Kind, + val jsonTags: String, + val content: String, + val sig: HexKey, +) { + fun toEvent() = + EventFactory.create( + id.intern(), + pubKey.intern(), + createdAt, + kind, + OptimizedJsonMapper.fromJsonToTagArray(jsonTags), + content, + sig, + ) +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt index c2cd3a6e6..dd4eb90ca 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt @@ -21,6 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql sealed class Condition { + data class Raw( + val condition: String, + ) : Condition() + data class Equals( val column: String, val value: Any?, @@ -81,4 +85,6 @@ sealed class Condition { data class Or( val conditions: List, ) : Condition() + + class Empty : Condition() } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt index 7e898c244..b109aff26 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt @@ -38,13 +38,27 @@ class SqlSelectionBuilder( */ private fun buildCondition(cond: Condition): String = when (cond) { + is Condition.Empty -> { + "" + } + is Condition.Raw -> { + cond.condition + } is Condition.Equals -> { - selectionArgs.add(cond.value.toString()) - "${cond.column} = ?" + if (cond.value == null) { + "${cond.column} IS NULL" + } else { + selectionArgs.add(cond.value.toString()) + "${cond.column} = ?" + } } is Condition.NotEquals -> { - selectionArgs.add(cond.value.toString()) - "${cond.column} != ?" + if (cond.value == null) { + "${cond.column} IS NULL" + } else { + selectionArgs.add(cond.value.toString()) + "${cond.column} != ?" + } } is Condition.GreaterThan -> { selectionArgs.add(cond.value.toString()) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt index 817ab0ea2..0cb89f573 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql class WhereClauseBuilder { private val conditions = mutableListOf() + fun raw(condition: String) = apply { conditions.add(Condition.Raw(condition)) } + fun equals( column: String, value: Any?, @@ -86,7 +88,7 @@ class WhereClauseBuilder { fun and(block: WhereClauseBuilder.() -> Unit) = apply { val builder = WhereClauseBuilder().apply(block) - val builtCondition = builder.build() + val builtCondition = builder.buildAnd() if (builtCondition != null) { conditions.add(builtCondition) } @@ -95,22 +97,29 @@ class WhereClauseBuilder { fun or(block: WhereClauseBuilder.() -> Unit) = apply { val builder = WhereClauseBuilder().apply(block) - val builtCondition = builder.build() + val builtCondition = builder.buildOr() if (builtCondition != null) { conditions.add(builtCondition) } } - fun build(): Condition? = + fun buildAnd(): Condition? = when (conditions.size) { 0 -> null 1 -> conditions.first() else -> Condition.And(conditions.toList()) } + + fun buildOr(): Condition? = + when (conditions.size) { + 0 -> null + 1 -> conditions.first() + else -> Condition.Or(conditions.toList()) + } } fun where(block: WhereClauseBuilder.() -> Unit): WhereClause { - val condition = WhereClauseBuilder().apply(block).build() ?: Condition.And(emptyList()) + val condition = WhereClauseBuilder().apply(block).buildAnd() ?: Condition.Empty() return SqlSelectionBuilder(condition).build() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt index ca60a06cd..23c9cf33d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt @@ -44,7 +44,13 @@ class AddressSerializer { return try { val parts = addressId.split(":", limit = 3) if (parts.size > 2 && parts[1].length == 64 && Hex.isHex(parts[1])) { - Address(parts[0].toInt(), parts[1], parts.getOrNull(2) ?: "") + if (parts[0].length > 5) { + // invalid kind + Log.w("AddressableId", "Error parsing. invalid kind $addressId") + null + } else { + Address(parts[0].toInt(), parts[1], parts.getOrNull(2) ?: "") + } } else { if (addressId.startsWith("naddr1")) { val addr = Nip19Parser.uriToRoute(addressId)?.entity @@ -60,7 +66,7 @@ class AddressSerializer { } } } catch (t: Throwable) { - Log.e("AddressableId", "Error parsing: $addressId: ${t.message}", t) + Log.w("AddressableId", "Error parsing: $addressId: ${t.message}", t) null } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Kind.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Kind.kt index 0aea6ba6f..7bf8cefb3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Kind.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Kind.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent typealias Kind = Int +fun Kind.isRegular() = this > 0 && this != ContactListEvent.KIND && this < 10_000 + fun Kind.isEphemeral() = this >= 20_000 && this < 30_000 fun Kind.isReplaceable() = this == MetadataEvent.KIND || this == ContactListEvent.KIND || (this >= 10_000 && this < 20_000) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt index 9e9b6de35..a671865a7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.kt @@ -54,6 +54,7 @@ object JsonMapper { val jsonInstance = Json { ignoreUnknownKeys = true + isLenient = true } inline fun fromJson(json: String): T = jsonInstance.decodeFromString(json) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt index ba5b30576..584df7a8f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayBasedFilter.kt @@ -35,10 +35,10 @@ class RelayBasedFilter( fun List.groupByRelay(): Map> { val result = mutableMapOf>() for (relayBasedFilter in this) { - if (relayBasedFilter.filter.isFilledFilter()) { - result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter) - } else { + if (relayBasedFilter.filter.isEmpty()) { Log.e("FilterError", "Ignoring empty filter for ${relayBasedFilter.relay}") + } else { + result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter) } } return result diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 0d765370c..71e420f13 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -128,7 +128,7 @@ open class BasicRelayClient( } catch (e: Throwable) { if (e is CancellationException) throw e // doesn't expose parsing errors to lib users as errors - Log.e("BasicRelayClient", "Failure to parse message from Relay", e) + Log.e("BasicRelayClient", "Failure to parse message from Relay: $text", e) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt index c7576094f..b51b8428c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStat.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.stats import androidx.collection.LruCache +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.utils.TimeUtils +@Stable class RelayStat( var receivedBytes: Int = 0, var sentBytes: Int = 0, @@ -31,12 +33,11 @@ class RelayStat( var pingInMs: Int = 0, var compression: Boolean = false, ) { - val messages = LruCache(100) + val messages = LruCache(100) fun newNotice(notice: String?) { val debugMessage = - RelayDebugMessage( - type = RelayDebugMessageType.NOTICE, + NoticeDebugMessage( message = notice ?: "No error message provided", ) @@ -47,8 +48,7 @@ class RelayStat( errorCounter++ val debugMessage = - RelayDebugMessage( - type = RelayDebugMessageType.ERROR, + ErrorDebugMessage( message = error ?: "No error message provided", ) @@ -63,27 +63,35 @@ class RelayStat( sentBytes += bytesUsedInMemory } - fun newSpam(spamDescriptor: String) { + fun newSpam( + link1: String, + link2: String, + ) { spamCounter++ - val debugMessage = - RelayDebugMessage( - type = RelayDebugMessageType.SPAM, - message = spamDescriptor, - ) + val debugMessage = SpamDebugMessage(link1, link2) messages.put(debugMessage, debugMessage) } } -class RelayDebugMessage( - val type: RelayDebugMessageType, - val message: String, - val time: Long = TimeUtils.now(), -) - -enum class RelayDebugMessageType { - SPAM, - NOTICE, - ERROR, +@Stable +sealed interface IRelayDebugMessage { + val time: Long } + +class SpamDebugMessage( + val link1: String, + val link2: String, + override val time: Long = TimeUtils.now(), +) : IRelayDebugMessage + +class NoticeDebugMessage( + val message: String, + override val time: Long = TimeUtils.now(), +) : IRelayDebugMessage + +class ErrorDebugMessage( + val message: String, + override val time: Long = TimeUtils.now(), +) : IRelayDebugMessage diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt index a34cd01fb..eae7fc8e9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable import com.vitorpamplona.quartz.utils.Log @@ -35,6 +37,7 @@ import com.vitorpamplona.quartz.utils.Log * - authors: Optional list of author public keys (must be 64 characters). * - kinds: Optional list of event kinds to include. * - tags: Optional map of tag names to values arrays (common tags like 'p', 'e', 'a' are validated). + * - tagsAll: Optional map of tag names to values arrays that must all match (common tags like 'p', 'e', 'a' are validated). * - since: Optional timestamp for filtering events with publication time ≥ this value. * - until: Optional timestamp for filtering events with publication time ≤ this value. * - limit: Optional maximum number of events to request. @@ -44,10 +47,11 @@ import com.vitorpamplona.quartz.utils.Log * follow Nostr requirements (64-char hex, onion addresses) and logs errors for invalid inputs. */ class Filter( - val ids: List? = null, - val authors: List? = null, - val kinds: List? = null, + val ids: List? = null, + val authors: List? = null, + val kinds: List? = null, val tags: Map>? = null, + val tagsAll: Map>? = null, val since: Long? = null, val until: Long? = null, val limit: Int? = null, @@ -55,31 +59,33 @@ class Filter( ) : OptimizedSerializable { fun toJson() = OptimizedJsonMapper.toJson(this) - fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, since, until) + fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, tagsAll, since, until) fun copy( ids: List? = this.ids, authors: List? = this.authors, kinds: List? = this.kinds, tags: Map>? = this.tags, + tagsAll: Map>? = this.tagsAll, since: Long? = this.since, until: Long? = this.until, limit: Int? = this.limit, search: String? = this.search, - ) = Filter(ids, authors, kinds, tags, since, until, limit, search) + ) = Filter(ids, authors, kinds, tags, tagsAll, since, until, limit, search) /** - * Returns true if this filter contains any non-null and non-empty criteria. + * Returns true if this filter doesn't filter for anything. */ - fun isFilledFilter() = - (ids != null && ids.isNotEmpty()) || - (authors != null && authors.isNotEmpty()) || - (kinds != null && kinds.isNotEmpty()) || - (tags != null && tags.isNotEmpty() && tags.values.all { it.isNotEmpty() }) || - (since != null) || - (until != null) || - (limit != null) || - (search != null && search.isNotEmpty()) + fun isEmpty() = + (ids == null || ids.isEmpty()) && + (authors == null || authors.isEmpty()) && + (kinds == null || kinds.isEmpty()) && + (tags == null || tags.isEmpty() && tags.values.all { it.isNotEmpty() }) && + (tagsAll == null || tagsAll.isEmpty() && tagsAll.values.all { it.isNotEmpty() }) && + (since == null) && + (until == null) && + (limit == null) && + (search == null || search.isEmpty()) init { ids?.forEach { @@ -89,14 +95,27 @@ class Filter( if (it.length != 64) Log.e("FilterError", "Invalid author length $it on ${toJson()}") } // tests common tags. - tags?.get("p")?.forEach { - if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}") + if (tags != null) { + tags["p"]?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}") + } + tags["e"]?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}") + } + tags["a"]?.forEach { + if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}") + } } - tags?.get("e")?.forEach { - if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}") - } - tags?.get("a")?.forEach { - if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}") + if (tagsAll != null) { + tagsAll["p"]?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}") + } + tagsAll["e"]?.forEach { + if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}") + } + tagsAll["a"]?.forEach { + if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}") + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt index 1367984ee..0539a9d76 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt @@ -29,6 +29,7 @@ object FilterMatcher { authors: List? = null, kinds: List? = null, tags: Map>? = null, + tagsAll: Map>? = null, since: Long? = null, until: Long? = null, ): Boolean { @@ -36,7 +37,23 @@ object FilterMatcher { if (kinds?.contains(event.kind) == false) return false if (authors?.contains(event.pubKey) == false) return false tags?.forEach { tag -> - if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false + val valueSet = tag.value.toSet() + // AND between keys, OR between values + if (!event.tags.any { it.size > 1 && it[0] == tag.key && it[1] in valueSet }) return false + } + tagsAll?.forEach { tag -> + val eventTagValueSet = + event.tags.mapNotNullTo(mutableSetOf()) { + if (it.size > 1 && it[0] == tag.key) { + it[1] + } else { + null + } + } + // AND between keys, AND between values + for (tagValue in tag.value) { + if (tagValue !in eventTagValueSet) return false + } } if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) { return false diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt index dd9ec6f7c..1fb5ef244 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.normalizer +import androidx.compose.runtime.Stable + +@Stable data class NormalizedRelayUrl( val url: String, ) : Comparable { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt index f29779ceb..eaf719fbc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip11RelayInfo import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.text import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer @@ -32,15 +33,14 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonPrimitive -object FlexibleIntListSerializer : KSerializer?> { - private val listSerializer = ListSerializer(Int.serializer()) +object FlexibleIntListSerializer : KSerializer?> { + private val listSerializer = ListSerializer(String.serializer()) override val descriptor: SerialDescriptor = listSerializer.descriptor - override fun deserialize(decoder: Decoder): List? { + override fun deserialize(decoder: Decoder): List? { require(decoder is JsonDecoder) { "This serializer can only be used with Json format" } return when (val element = decoder.decodeJsonElement()) { @@ -51,7 +51,7 @@ object FlexibleIntListSerializer : KSerializer?> { is JsonArray -> { element.mapNotNull { arrayElement -> try { - arrayElement.jsonPrimitive.int + arrayElement.jsonPrimitive.text } catch (e: Exception) { // Skip elements that aren't valid integers (strings, booleans, floats, etc.) Log.w("FlexibleIntListSerializer", "Invalid element in array: $arrayElement", e) @@ -63,7 +63,7 @@ object FlexibleIntListSerializer : KSerializer?> { // Handle single integer format (malformed but found in the wild): 1 is JsonPrimitive if !element.isString -> { try { - listOf(element.int) + listOf(element.text) } catch (e: Exception) { // Can't parse as integer (e.g., float, boolean), treat as missing data Log.w("FlexibleIntListSerializer", "Invalid primitive: $element", e) @@ -79,7 +79,7 @@ object FlexibleIntListSerializer : KSerializer?> { @OptIn(ExperimentalSerializationApi::class) override fun serialize( encoder: Encoder, - value: List?, + value: List?, ) { if (value == null) { encoder.encodeNull() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt index 593f20494..cdcaad58d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformation.kt @@ -21,19 +21,22 @@ package com.vitorpamplona.quartz.nip11RelayInfo import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import kotlinx.serialization.Serializable +@Stable @Serializable class Nip11RelayInformation( val id: String? = null, val name: String? = null, val description: String? = null, val icon: String? = null, - val pubkey: String? = null, + val pubkey: HexKey? = null, + val self: HexKey? = null, val contact: String? = null, @Serializable(with = FlexibleIntListSerializer::class) - val supported_nips: List? = null, + val supported_nips: List? = null, val supported_nip_extensions: List? = null, val software: String? = null, val version: String? = null, @@ -42,53 +45,60 @@ class Nip11RelayInformation( val language_tags: List? = null, val tags: List? = null, val posting_policy: String? = null, + val privacy_policy: String? = null, + val terms_of_service: String? = null, val payments_url: String? = null, val retention: List? = null, val fees: RelayInformationFees? = null, val nip50: List? = null, + val supported_grasps: List? = null, ) { companion object { fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson(json) } + + @Stable + @Serializable + class RelayInformationFee( + val amount: Int? = null, + val unit: String? = null, + val period: Int? = null, + val kinds: List? = null, + ) + + @Stable + @Serializable + class RelayInformationFees( + val admission: List? = null, + val subscription: List? = null, + val publication: List? = null, + ) + + @Stable + @Serializable + class RelayInformationLimitation( + val max_message_length: Int? = null, + val max_subscriptions: Int? = null, + val max_filters: Int? = null, + val max_limit: Int? = null, + val default_limit: Int? = null, + val max_subid_length: Int? = null, + val min_prefix: Int? = null, + val max_event_tags: Int? = null, + val max_content_length: Int? = null, + val min_pow_difficulty: Int? = null, + val auth_required: Boolean? = null, + val payment_required: Boolean? = null, + val restricted_writes: Boolean? = null, + val created_at_lower_limit: Int? = null, + val created_at_upper_limit: Int? = null, + ) + + @Stable + @Serializable + class RelayInformationRetentionData( + val kinds: ArrayList? = null, + val time: Int? = null, + val count: Int? = null, + ) } - -@Stable -@Serializable -class RelayInformationFee( - val amount: Int? = null, - val unit: String? = null, - val period: Int? = null, - val kinds: List? = null, -) - -@Serializable -class RelayInformationFees( - val admission: List? = null, - val subscription: List? = null, - val publication: List? = null, -) - -@Serializable -class RelayInformationLimitation( - val max_message_length: Int? = null, - val max_subscriptions: Int? = null, - val max_filters: Int? = null, - val max_limit: Int? = null, - val max_subid_length: Int? = null, - val min_prefix: Int? = null, - val max_event_tags: Int? = null, - val max_content_length: Int? = null, - val min_pow_difficulty: Int? = null, - val auth_required: Boolean? = null, - val payment_required: Boolean? = null, - val restricted_writes: Boolean? = null, - val created_at_lower_limit: Int? = null, - val created_at_upper_limit: Int? = null, -) - -@Serializable -class RelayInformationRetentionData( - val kinds: ArrayList? = null, - val time: Int? = null, - val count: Int? = null, -) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt index 1118b2135..3c4e2ba5d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/BookmarkListEvent.kt @@ -148,6 +148,21 @@ class BookmarkListEvent( ) } + suspend fun remove( + earlierVersion: BookmarkListEvent, + bookmarkIdTag: BookmarkIdTag, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): BookmarkListEvent { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + return resign( + privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()), + tags = earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()), + signer = signer, + createdAt = createdAt, + ) + } + suspend fun resign( tags: TagArray, privateTags: TagArray, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt index c5fc2131e..e03ac1d72 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip94FileMetadata/tags/DimensionTag.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.quartz.nip94FileMetadata.tags +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure import kotlinx.serialization.Serializable @Serializable +@Stable class DimensionTag( val width: Int, val height: Int, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcherTest.kt new file mode 100644 index 000000000..ea697b2f0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcherTest.kt @@ -0,0 +1,149 @@ +/** + * 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.quartz.nip01Core.relay.filters + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FilterMatcherTest { + val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758" + val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" + val createdAt: Long = 1683596206 + val kind = 1 + + val pTag1 = "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954" + val pTag2 = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24" + val pTag3 = "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2" + val pTag4 = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac" + val pTag5 = "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168" + val pTag6 = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed" + val pTag7 = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0" + val pTag8 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + val rootETag = "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3" + val replyETag = "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc" + + val note = + Event( + id, + pubkey, + createdAt, + kind, + arrayOf( + arrayOf("e", rootETag, "", "root"), + arrayOf("e", replyETag, "", "reply"), + arrayOf("p", pTag1), + arrayOf("p", pTag1), + arrayOf("p", pTag2), + arrayOf("p", pTag3), + arrayOf("p", pTag4), + arrayOf("p", pTag5), + arrayOf("p", pTag6), + arrayOf("p", pTag7), + arrayOf("p", pTag8), + ), + "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ", + "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce", + ) + + @Test + fun matchIds() { + assertTrue(note, Filter(ids = listOf(id))) + assertTrue(note, Filter(ids = listOf(id, rootETag))) + assertFalse(note, Filter(ids = listOf(rootETag))) + } + + @Test + fun matchPubkeys() { + assertTrue(note, Filter(authors = listOf(pubkey))) + assertTrue(note, Filter(authors = listOf(pubkey, rootETag))) + assertFalse(note, Filter(authors = listOf(rootETag))) + } + + @Test + fun matchTags() { + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1)))) + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id)))) + assertFalse(note, Filter(tags = mapOf("p" to listOf(id)))) + } + + @Test + fun matchDualTags() { + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) + assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) + assertFalse(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) + assertFalse(note, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) + } + + @Test + fun matchAllTags() { + assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) + assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(id)))) + } + + @Test + fun matchDualAllTags() { + assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) + assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) + assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) + } + + @Test + fun matchKinds() { + assertTrue(note, Filter(kinds = listOf(kind))) + assertTrue(note, Filter(kinds = listOf(kind, 1221))) + assertFalse(note, Filter(kinds = listOf(1221))) + } + + @Test + fun matchSince() { + assertTrue(note, Filter(since = createdAt)) + assertTrue(note, Filter(since = createdAt - 1)) + assertFalse(note, Filter(since = createdAt + 1)) + } + + @Test + fun matchUntil() { + assertTrue(note, Filter(until = createdAt)) + assertTrue(note, Filter(until = createdAt + 1)) + assertFalse(note, Filter(until = createdAt - 1)) + } + + fun assertTrue( + event: Event, + filter: Filter, + ) = assertTrue(filter.match(event)) + + fun assertFalse( + event: Event, + filter: Filter, + ) = assertFalse(filter.match(event)) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationTest.kt index 3f8653f2d..358474f3f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationTest.kt @@ -74,7 +74,7 @@ class Nip11RelayInformationTest { assertEquals("purplepag.es", info.name) assertEquals("Nostr's Purple Pages", info.description) assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey) - assertEquals(listOf(1, 2, 9, 11), info.supported_nips) + assertEquals(listOf("1", "2", "9", "11"), info.supported_nips) assertEquals("1.0.4", info.version) assertEquals("git+https://github.com/hoytech/strfry.git", info.software) } @@ -88,7 +88,7 @@ class Nip11RelayInformationTest { assertEquals("Nostr's Purple Pages", info.description) assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey) // Single integer should be converted to a list with one element - assertEquals(listOf(1), info.supported_nips) + assertEquals(listOf("1"), info.supported_nips) assertEquals("1.0.4", info.version) assertEquals("git+https://github.com/hoytech/strfry.git", info.software) } @@ -132,7 +132,7 @@ class Nip11RelayInformationTest { assertNotNull(info) assertEquals("test relay", info.name) // Should skip invalid elements and keep only valid integers - assertEquals(listOf(1, 3, 11), info.supported_nips) + assertEquals(listOf("1", "invalid", "3", "4.5", "true", "11"), info.supported_nips) } @Test @@ -144,7 +144,7 @@ class Nip11RelayInformationTest { assertNotNull(info) assertEquals("test relay", info.name) // All elements invalid, should result in empty list - assertEquals(emptyList(), info.supported_nips) + assertEquals(listOf("one", "two", "three"), info.supported_nips) } @Test @@ -156,7 +156,7 @@ class Nip11RelayInformationTest { assertNotNull(info) assertEquals("test relay", info.name) // Cannot parse float as integer, should be null - assertNull(info.supported_nips) + assertEquals(listOf("1.5"), info.supported_nips) } @Test @@ -168,7 +168,7 @@ class Nip11RelayInformationTest { assertNotNull(info) assertEquals("test relay", info.name) // Cannot parse boolean as integer, should be null - assertNull(info.supported_nips) + assertEquals(listOf("true"), info.supported_nips) } @Test @@ -204,6 +204,6 @@ class Nip11RelayInformationTest { assertNotNull(info) assertEquals("test relay", info.name) - assertEquals((1..100).toList(), info.supported_nips) + assertEquals((1..100).map { it.toString() }, info.supported_nips) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt index 58eb50846..072c6e5bc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt @@ -106,9 +106,9 @@ class NIP19ParserTest { val actual = Nip19Parser.uriToRoute("nostr:nprofile1qqsyvrp9u6p0mfur9dfdru3d853tx9mdjuhkphxuxgfwmryja7zsvhqpzamhxue69uhhv6t5daezumn0wd68yvfwvdhk6tcpz9mhxue69uhkummnw3ezuamfdejj7qgwwaehxw309ahx7uewd3hkctcscpyug") assertNotNull(actual) - assertTrue(actual?.entity is NProfile) + assertTrue(actual.entity is NProfile) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", actual.entity.hex) - assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), actual.entity.relay?.first()) + assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), actual.entity.relay.first()) } @Test() @@ -267,7 +267,7 @@ class NIP19ParserTest { "31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", result.aTag(), ) - assertEquals(true, result.relay?.isEmpty()) + assertEquals(true, result.relay.isEmpty()) assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result.author) assertEquals(31337, result.kind) } @@ -285,7 +285,7 @@ class NIP19ParserTest { "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", result.aTag(), ) - assertEquals(true, result.relay?.isEmpty()) + assertEquals(true, result.relay.isEmpty()) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author) assertEquals(30023, result.kind) } @@ -303,7 +303,7 @@ class NIP19ParserTest { "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result.aTag(), ) - assertEquals(true, result.relay?.isEmpty()) + assertEquals(true, result.relay.isEmpty()) assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author) assertEquals(30023, result.kind) } @@ -356,7 +356,7 @@ class NIP19ParserTest { assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result.hex) assertEquals( NormalizedRelayUrl("wss://nostr.mom/"), - result.relay?.get(0), + result.relay[0], ) assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result.author) assertEquals(1, result.kind) @@ -403,7 +403,7 @@ class NIP19ParserTest { assertNotNull(result) assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result.hex) - assertEquals("wss://relay.damus.io/", result.relay.get(0)?.url) + assertEquals("wss://relay.damus.io/", result.relay[0].url) assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author) assertEquals(1, result.kind) } diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt index 26f0ed41d..cd27fd9ad 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/SecureRandom.ios.kt @@ -24,7 +24,6 @@ import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.refTo import platform.Security.SecRandomCopyBytes import platform.Security.kSecRandomDefault -import kotlin.random.Random.Default.nextBytes actual class SecureRandom { actual fun nextInt(): Int { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt index 2dd3c09b1..7ea980703 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializer.kt @@ -63,6 +63,10 @@ class EventDeserializer : StdDeserializer(Event::class.java) { } } + if (pubKey.isEmpty()) { + throw IllegalArgumentException("Event not found") + } + return EventFactory.create(id, pubKey, createdAt, kind, tags, content, sig) } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index a6ca2615b..77c56012a 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -59,7 +59,6 @@ import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer -import kotlinx.serialization.json.JsonNull.content import java.io.InputStream class JacksonMapper { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt index 7b7cbeeca..556dda5de 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt @@ -38,10 +38,16 @@ class FilterDeserializer : StdDeserializer(Filter::class.java) { class ManualFilterDeserializer { companion object { fun fromJson(jsonObject: ObjectNode): Filter { - val tags = mutableListOf() + val tagsIn = mutableListOf() jsonObject.fieldNames().forEach { if (it.startsWith("#")) { - tags.add(it.substring(1)) + tagsIn.add(it.substring(1)) + } + } + val tagsAll = mutableListOf() + jsonObject.fieldNames().forEach { + if (it.startsWith("&")) { + tagsAll.add(it.substring(1)) } } @@ -49,7 +55,8 @@ class ManualFilterDeserializer { ids = jsonObject.get("ids").mapNotNull { it.asTextOrNull() }, authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() }, kinds = jsonObject.get("kinds").mapNotNull { it.asIntOrNull() }, - tags = tags.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } }, + tags = tagsIn.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } }, + tagsAll = tagsAll.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } }, since = jsonObject.get("since").asLongOrNull(), until = jsonObject.get("until").asLongOrNull(), limit = jsonObject.get("limit").asIntOrNull(), diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt index afd06b36b..47fdcc674 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt @@ -66,6 +66,16 @@ class FilterSerializer : StdSerializer(Filter::class.java) { } } + filter.tagsAll?.run { + entries.forEach { kv -> + gen.writeArrayFieldStart("&${kv.key}") + for (i in kv.value.indices) { + gen.writeString(kv.value[i]) + } + gen.writeEndArray() + } + } + filter.since?.run { gen.writeNumberField("since", this) } filter.until?.run { gen.writeNumberField("until", this) } filter.limit?.run { gen.writeNumberField("limit", this) }