Merge branch 'main' into nrobi144/desktop-phase1
This commit is contained in:
@@ -200,7 +200,7 @@ class AppModules(
|
|||||||
val relayStats = RelayStats(client)
|
val relayStats = RelayStats(client)
|
||||||
|
|
||||||
// Logs debug messages when needed
|
// 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 relayReqStats = if (isDebug) RelayReqStats(client) else null
|
||||||
val logger = if (isDebug) RelaySpeedLogger(client) else null
|
val logger = if (isDebug) RelaySpeedLogger(client) else null
|
||||||
|
|
||||||
|
|||||||
@@ -248,10 +248,8 @@ object LocalPreferences {
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val prefsDir = File(prefsDirPath)
|
val prefsDir = File(prefsDirPath)
|
||||||
prefsDir.list()?.forEach {
|
prefsDir.list()?.forEach {
|
||||||
if (it.contains(npub)) {
|
if (it.contains(npub) && !File(prefsDir, it).delete()) {
|
||||||
if (!File(prefsDir, it).delete()) {
|
Log.w("LocalPreferences", "Failed to delete preference file: $it")
|
||||||
Log.w("LocalPreferences", "Failed to delete preference file: $it")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
suspend fun createAuthEvent(
|
||||||
relay: NormalizedRelayUrl,
|
relay: NormalizedRelayUrl,
|
||||||
challenge: String,
|
challenge: String,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class AntiSpamFilter {
|
|||||||
if (spammer.shouldHide() && relay != null) {
|
if (spammer.shouldHide() && relay != null) {
|
||||||
Amethyst.instance.relayStats
|
Amethyst.instance.relayStats
|
||||||
.get(relay)
|
.get(relay)
|
||||||
.newSpam("$link1 $link2")
|
.newSpam(link1, link2)
|
||||||
}
|
}
|
||||||
|
|
||||||
flowSpam.tryEmit(AntiSpamState(this))
|
flowSpam.tryEmit(AntiSpamState(this))
|
||||||
@@ -123,7 +123,7 @@ class AntiSpamFilter {
|
|||||||
if (spammer.shouldHide() && relay != null) {
|
if (spammer.shouldHide() && relay != null) {
|
||||||
Amethyst.instance.relayStats
|
Amethyst.instance.relayStats
|
||||||
.get(relay)
|
.get(relay)
|
||||||
.newSpam("$link1 $link2")
|
.newSpam(link1, link2)
|
||||||
}
|
}
|
||||||
|
|
||||||
flowSpam.tryEmit(AntiSpamState(this))
|
flowSpam.tryEmit(AntiSpamState(this))
|
||||||
|
|||||||
@@ -1106,7 +1106,6 @@ object LocalCache : ILocalCache {
|
|||||||
val new = consumeBaseReplaceable(event, relay, wasVerified)
|
val new = consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
|
|
||||||
if (new) {
|
if (new) {
|
||||||
println("AABBCC New ContactCard about ${event.aboutUser()}")
|
|
||||||
val about = checkGetOrCreateUser(event.aboutUser()) ?: return new
|
val about = checkGetOrCreateUser(event.aboutUser()) ?: return new
|
||||||
about.cards().addCard(note)
|
about.cards().addCard(note)
|
||||||
}
|
}
|
||||||
@@ -2890,7 +2889,7 @@ object LocalCache : ILocalCache {
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
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
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.model.nip51Lists
|
package com.vitorpamplona.amethyst.model.nip51Lists
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
@@ -41,6 +42,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
@Stable
|
||||||
class BookmarkListState(
|
class BookmarkListState(
|
||||||
val signer: NostrSigner,
|
val signer: NostrSigner,
|
||||||
val cache: LocalCache,
|
val cache: LocalCache,
|
||||||
@@ -274,4 +276,26 @@ class BookmarkListState(
|
|||||||
null
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
package com.vitorpamplona.amethyst.service.logging
|
package com.vitorpamplona.amethyst.service.logging
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Looper
|
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import android.os.StrictMode.ThreadPolicy
|
import android.os.StrictMode.ThreadPolicy
|
||||||
import android.os.StrictMode.VmPolicy
|
import android.os.StrictMode.VmPolicy
|
||||||
@@ -59,8 +58,8 @@ class Logging {
|
|||||||
}.penaltyLog()
|
}.penaltyLog()
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
Looper.getMainLooper().setMessageLogging(LogMonitor())
|
// Looper.getMainLooper().setMessageLogging(LogMonitor())
|
||||||
ChoreographerHelper.start()
|
// ChoreographerHelper.start()
|
||||||
|
|
||||||
// Enable recomposition tracking ONLY in debug builds
|
// Enable recomposition tracking ONLY in debug builds
|
||||||
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
|
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
|
||||||
|
|||||||
+135
-94
@@ -74,64 +74,23 @@ fun VoiceMessagePreview(
|
|||||||
var progress by remember { mutableFloatStateOf(0f) }
|
var progress by remember { mutableFloatStateOf(0f) }
|
||||||
var mediaPlayer by remember { mutableStateOf<MediaPlayer?>(null) }
|
var mediaPlayer by remember { mutableStateOf<MediaPlayer?>(null) }
|
||||||
|
|
||||||
// Initialize MediaPlayer
|
ManageMediaPlayer(
|
||||||
DisposableEffect(voiceMetadata.url, localFile) {
|
voiceMetadata = voiceMetadata,
|
||||||
val player = createMediaPlayer(voiceMetadata.url, localFile)
|
localFile = localFile,
|
||||||
player?.setOnCompletionListener {
|
onCompletion = {
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
progress = 0f
|
progress = 0f
|
||||||
}
|
},
|
||||||
mediaPlayer = player
|
onPlayerChanged = { mediaPlayer = it },
|
||||||
|
onRelease = { isPlaying = false },
|
||||||
|
)
|
||||||
|
|
||||||
onDispose {
|
TrackPlaybackProgress(
|
||||||
// Stop playback and clean up
|
mediaPlayer = mediaPlayer,
|
||||||
try {
|
isPlaying = isPlaying,
|
||||||
player?.stop()
|
onProgressUpdate = { progress = it },
|
||||||
} catch (e: IllegalStateException) {
|
onInvalidState = { isPlaying = false },
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -150,27 +109,13 @@ fun VoiceMessagePreview(
|
|||||||
// Play/Pause Button
|
// Play/Pause Button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val player = mediaPlayer
|
handlePlayPauseClick(
|
||||||
if (player != null) {
|
mediaPlayer = mediaPlayer,
|
||||||
try {
|
isPlaying = isPlaying,
|
||||||
if (isPlaying) {
|
progress = progress,
|
||||||
player.pause()
|
onProgressReset = { progress = 0f },
|
||||||
isPlaying = false
|
onPlayingChanged = { isPlaying = it },
|
||||||
} 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier.size(48.dp),
|
||||||
) {
|
) {
|
||||||
@@ -194,24 +139,11 @@ fun VoiceMessagePreview(
|
|||||||
waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)),
|
waveformBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant)),
|
||||||
progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)),
|
progressBrush = Brush.linearGradient(listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary)),
|
||||||
onProgressChange = { newProgress ->
|
onProgressChange = { newProgress ->
|
||||||
// Validate incoming progress value
|
handleWaveformScrub(
|
||||||
if (newProgress.isFinite() && newProgress >= 0f && newProgress <= 1f) {
|
newProgress = newProgress,
|
||||||
val player = mediaPlayer
|
mediaPlayer = mediaPlayer,
|
||||||
if (player != null) {
|
onProgressChanged = { progress = it },
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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(
|
private fun createMediaPlayer(
|
||||||
url: String,
|
url: String,
|
||||||
localFile: File?,
|
localFile: File?,
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
|||||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||||
import com.vitorpamplona.myapplication.DisappearingBottomBar
|
import com.vitorpamplona.myapplication.DisappearingBottomBar
|
||||||
import com.vitorpamplona.myapplication.DisappearingFloatingButton
|
import com.vitorpamplona.myapplication.DisappearingFloatingButton
|
||||||
import com.vitorpamplona.myapplication.DisappearingTopBar
|
|
||||||
import com.vitorpamplona.myapplication.enterAlwaysScrollBehavior
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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.AnimationSpec
|
||||||
import androidx.compose.animation.core.AnimationState
|
import androidx.compose.animation.core.AnimationState
|
||||||
@@ -58,7 +58,7 @@ fun DisappearingTopBar(
|
|||||||
scrollBehavior: CustomEnterAlwaysScrollBehavior,
|
scrollBehavior: CustomEnterAlwaysScrollBehavior,
|
||||||
content: @Composable (ColumnScope.() -> Unit),
|
content: @Composable (ColumnScope.() -> Unit),
|
||||||
) {
|
) {
|
||||||
ResetDisappearingOnResume(scrollBehavior)
|
// ResetDisappearingOnResume(scrollBehavior)
|
||||||
|
|
||||||
// Set up support for resizing the top app bar when vertically dragging the bar itself.
|
// Set up support for resizing the top app bar when vertically dragging the bar itself.
|
||||||
val appBarDragModifier =
|
val appBarDragModifier =
|
||||||
|
|||||||
@@ -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.AccountStateViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
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.display.BookmarkGroupScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen
|
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.list.metadata.BookmarkGroupMetadataScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.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.ChatroomByAuthorScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
|
||||||
|
|||||||
-9
@@ -46,7 +46,6 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
import androidx.compose.material.icons.filled.AccountCircle
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
import androidx.compose.material.icons.filled.Delete
|
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.CloudUpload
|
||||||
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||||
import androidx.compose.material.icons.outlined.Drafts
|
import androidx.compose.material.icons.outlined.Drafts
|
||||||
@@ -458,14 +457,6 @@ fun ListContent(
|
|||||||
|
|
||||||
NavigationRow(
|
NavigationRow(
|
||||||
title = R.string.bookmarks,
|
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,
|
icon = Icons.Outlined.CollectionsBookmark,
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ val njumpLink = { nip19BechAddress: String ->
|
|||||||
"https://njump.to/$nip19BechAddress"
|
"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 ->
|
val externalLinkForNote = { note: Note ->
|
||||||
if (note is AddressableNote) {
|
if (note is AddressableNote) {
|
||||||
if (note.event?.bountyBaseReward() != null) {
|
if (note.event?.bountyBaseReward() != null) {
|
||||||
|
|||||||
@@ -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 {
|
fun showAmountIntegerWithZero(amount: BigDecimal?): String {
|
||||||
if (amount == null) return "0"
|
if (amount == null) return "0"
|
||||||
if (amount.abs() < BigDecimal(0.01)) return "0"
|
if (amount.abs() < BigDecimal(0.01)) return "0"
|
||||||
|
|||||||
+4
-4
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Column
|
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.navigation.topbars.TopBarWithBackButton
|
||||||
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
|
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
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.bookmarkgroups.default.dal.BookmarkPrivateFeedViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.dal.BookmarkPublicFeedViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPublicFeedViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
|
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -93,7 +93,7 @@ private fun RenderBookmarkScreen(
|
|||||||
isInvertedLayout = false,
|
isInvertedLayout = false,
|
||||||
topBar = {
|
topBar = {
|
||||||
Column {
|
Column {
|
||||||
TopBarWithBackButton(stringRes(id = R.string.bookmarks), nav::popBack)
|
TopBarWithBackButton(stringRes(id = R.string.bookmarks_title), nav::popBack)
|
||||||
TabRow(
|
TabRow(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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.Account
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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.compose.runtime.Stable
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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.Account
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
* 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.
|
* 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.compose.runtime.Stable
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
+2
-2
@@ -42,7 +42,6 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRow
|
import androidx.compose.material3.TabRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -59,6 +58,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
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.ArrowBackIcon
|
||||||
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
|
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
@@ -118,7 +118,7 @@ fun BookmarkGroupScreenView(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
Column {
|
Column {
|
||||||
TopAppBar(
|
ShorterTopAppBar(
|
||||||
title = {
|
title = {
|
||||||
TitleAndDescription(bookmarkGroupViewModel)
|
TitleAndDescription(bookmarkGroupViewModel)
|
||||||
},
|
},
|
||||||
|
|||||||
+75
-33
@@ -20,34 +20,43 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
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.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
|
||||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
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 com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ListOfBookmarkGroupsFeedView(
|
fun ListOfBookmarkGroupsFeedView(
|
||||||
|
defaultBookmarks: BookmarkListState,
|
||||||
groupListFeedSource: StateFlow<List<LabeledBookmarkList>>,
|
groupListFeedSource: StateFlow<List<LabeledBookmarkList>>,
|
||||||
|
openDefaultBookmarks: () -> Unit,
|
||||||
onOpenItem: (String, BookmarkType) -> Unit,
|
onOpenItem: (String, BookmarkType) -> Unit,
|
||||||
onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit,
|
onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||||
onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||||
@@ -56,40 +65,73 @@ fun ListOfBookmarkGroupsFeedView(
|
|||||||
) {
|
) {
|
||||||
val bookmarkGroupFeedState by groupListFeedSource.collectAsStateWithLifecycle()
|
val bookmarkGroupFeedState by groupListFeedSource.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
if (bookmarkGroupFeedState.isEmpty()) {
|
LazyColumn(
|
||||||
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg))
|
state = rememberLazyListState(),
|
||||||
} else {
|
contentPadding = FeedPadding,
|
||||||
LazyColumn(
|
) {
|
||||||
state = rememberLazyListState(),
|
item {
|
||||||
contentPadding = FeedPadding,
|
DefaultBookmarkList(defaultBookmarks, openDefaultBookmarks)
|
||||||
) {
|
HorizontalDivider(thickness = DividerThickness)
|
||||||
itemsIndexed(
|
}
|
||||||
bookmarkGroupFeedState,
|
|
||||||
key = { _: Int, item: LabeledBookmarkList -> item.identifier },
|
itemsIndexed(
|
||||||
) { _, groupItem ->
|
bookmarkGroupFeedState,
|
||||||
BookmarkGroupItem(
|
key = { _: Int, item: LabeledBookmarkList -> item.identifier },
|
||||||
modifier = Modifier.fillMaxSize().animateItem(),
|
) { _, groupItem ->
|
||||||
bookmarkList = groupItem,
|
BookmarkGroupItem(
|
||||||
onClick = { bookmarkType -> onOpenItem(groupItem.identifier, bookmarkType) },
|
modifier = Modifier.fillMaxSize().animateItem(),
|
||||||
onRename = { onRenameItem(groupItem) },
|
bookmarkList = groupItem,
|
||||||
onDescriptionChange = { onItemDescriptionChange(groupItem) },
|
onClick = { bookmarkType -> onOpenItem(groupItem.identifier, bookmarkType) },
|
||||||
onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) },
|
onRename = { onRenameItem(groupItem) },
|
||||||
onDelete = { onDeleteItem(groupItem) },
|
onDescriptionChange = { onItemDescriptionChange(groupItem) },
|
||||||
)
|
onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) },
|
||||||
HorizontalDivider(thickness = DividerThickness)
|
onDelete = { onDeleteItem(groupItem) },
|
||||||
}
|
)
|
||||||
|
HorizontalDivider(thickness = DividerThickness)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BookmarkGroupsFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) {
|
fun DefaultBookmarkList(
|
||||||
Column(
|
defaultBookmarks: BookmarkListState,
|
||||||
Modifier.fillMaxSize().padding(horizontal = Size40dp),
|
openDefaultBookmarks: () -> Unit,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
) {
|
||||||
verticalArrangement = Arrangement.Center,
|
val bookmarkState by defaultBookmarks.bookmarks.collectAsStateWithLifecycle()
|
||||||
) {
|
|
||||||
Text(message)
|
ListItem(
|
||||||
Spacer(modifier = StdVertSpacer)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -34,6 +34,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
|
||||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
@@ -49,7 +50,9 @@ fun ListOfBookmarkGroupsScreen(
|
|||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
ListOfBookmarkGroupsFeed(
|
ListOfBookmarkGroupsFeed(
|
||||||
|
defaultBookmarks = accountViewModel.account.bookmarkState,
|
||||||
listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow,
|
listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow,
|
||||||
|
openDefaultBookmarks = { nav.nav(Route.Bookmarks) },
|
||||||
addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) },
|
addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) },
|
||||||
openBookmarkGroup = { identifier, bookmarkType ->
|
openBookmarkGroup = { identifier, bookmarkType ->
|
||||||
nav.nav(Route.BookmarkGroupView(identifier, bookmarkType))
|
nav.nav(Route.BookmarkGroupView(identifier, bookmarkType))
|
||||||
@@ -84,7 +87,9 @@ fun ListOfBookmarkGroupsScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ListOfBookmarkGroupsFeed(
|
fun ListOfBookmarkGroupsFeed(
|
||||||
|
defaultBookmarks: BookmarkListState,
|
||||||
listSource: StateFlow<List<LabeledBookmarkList>>,
|
listSource: StateFlow<List<LabeledBookmarkList>>,
|
||||||
|
openDefaultBookmarks: () -> Unit,
|
||||||
addBookmarkGroup: () -> Unit,
|
addBookmarkGroup: () -> Unit,
|
||||||
openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit,
|
openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit,
|
||||||
renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||||
@@ -109,7 +114,9 @@ fun ListOfBookmarkGroupsFeed(
|
|||||||
).fillMaxHeight(),
|
).fillMaxHeight(),
|
||||||
) {
|
) {
|
||||||
ListOfBookmarkGroupsFeedView(
|
ListOfBookmarkGroupsFeedView(
|
||||||
|
defaultBookmarks = defaultBookmarks,
|
||||||
groupListFeedSource = listSource,
|
groupListFeedSource = listSource,
|
||||||
|
openDefaultBookmarks = openDefaultBookmarks,
|
||||||
onOpenItem = openBookmarkGroup,
|
onOpenItem = openBookmarkGroup,
|
||||||
onRenameItem = renameBookmarkGroup,
|
onRenameItem = renameBookmarkGroup,
|
||||||
onItemDescriptionChange = changeBookmarkGroupDescription,
|
onItemDescriptionChange = changeBookmarkGroupDescription,
|
||||||
|
|||||||
-105
@@ -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<String?>(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))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+80
-45
@@ -44,7 +44,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
|||||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
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.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.screen.loggedIn.lists.list.NewListButton
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
@@ -75,8 +74,6 @@ private fun ListManagementView(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
|
|
||||||
.collectAsStateWithLifecycle()
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -95,48 +92,86 @@ private fun ListManagementView(
|
|||||||
).consumeWindowInsets(contentPadding)
|
).consumeWindowInsets(contentPadding)
|
||||||
.imePadding(),
|
.imePadding(),
|
||||||
) {
|
) {
|
||||||
if (bookmarkGroups.isEmpty()) {
|
ListManagementViewBody(note, accountViewModel, nav)
|
||||||
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg))
|
}
|
||||||
} else {
|
}
|
||||||
LazyColumn(
|
}
|
||||||
state = rememberLazyListState(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
@Composable
|
||||||
) {
|
private fun ListManagementViewBody(
|
||||||
itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList ->
|
note: AddressableNote,
|
||||||
val maybePublicBookmark = bookmarkList.publicArticleBookmarks.firstOrNull { it.address == note.address }
|
accountViewModel: AccountViewModel,
|
||||||
val maybePrivateBookmark = bookmarkList.privateArticleBookmarks.firstOrNull { it.address == note.address }
|
nav: INav,
|
||||||
BookmarkGroupManagementItem(
|
) {
|
||||||
modifier = Modifier.fillMaxWidth().animateItem(),
|
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
|
||||||
listTitle = bookmarkList.title,
|
.collectAsStateWithLifecycle()
|
||||||
isPublicMemberBookmark = maybePublicBookmark != null,
|
|
||||||
isPrivateMemberBookmark = maybePrivateBookmark != null,
|
val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks
|
||||||
totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size,
|
.collectAsStateWithLifecycle()
|
||||||
totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size,
|
|
||||||
onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.ArticleBookmark)) },
|
LazyColumn(
|
||||||
onAddBookmarkToGroup = { shouldBePrivate ->
|
state = rememberLazyListState(),
|
||||||
accountViewModel.launchSigner {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
accountViewModel.account.labeledBookmarkLists.addBookmarkToList(
|
) {
|
||||||
bookmark = AddressBookmark(address = note.address, relayHint = note.relayHintUrl()),
|
item {
|
||||||
bookmarkListIdentifier = bookmarkList.identifier,
|
val maybePublicBookmark = defaultBookmarks.public.contains(note)
|
||||||
isBookmarkPrivate = shouldBePrivate,
|
val maybePrivateBookmark = defaultBookmarks.private.contains(note)
|
||||||
account = accountViewModel.account,
|
|
||||||
)
|
BookmarkGroupManagementItem(
|
||||||
}
|
modifier = Modifier.fillMaxWidth().animateItem(),
|
||||||
},
|
listTitle = stringRes(R.string.bookmarks_title),
|
||||||
onRemoveBookmarkFromGroup = {
|
isPublicMemberBookmark = maybePublicBookmark,
|
||||||
accountViewModel.launchSigner {
|
isPrivateMemberBookmark = maybePrivateBookmark,
|
||||||
accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList(
|
totalPostBookmarkSize = defaultBookmarks.public.size + defaultBookmarks.private.size,
|
||||||
bookmark = AddressBookmark(address = note.address),
|
totalArticleBookmarkSize = 0,
|
||||||
bookmarkListIdentifier = bookmarkList.identifier,
|
onClick = {
|
||||||
isBookmarkPrivate = maybePrivateBookmark != null,
|
nav.nav(Route.Bookmarks)
|
||||||
account = accountViewModel.account,
|
},
|
||||||
)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-45
@@ -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.navigation.topbars.TopBarWithBackButton
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
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.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.screen.loggedIn.lists.list.NewListButton
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
||||||
@@ -74,8 +73,6 @@ private fun ListManagementView(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
|
|
||||||
.collectAsStateWithLifecycle()
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -94,48 +91,86 @@ private fun ListManagementView(
|
|||||||
).consumeWindowInsets(contentPadding)
|
).consumeWindowInsets(contentPadding)
|
||||||
.imePadding(),
|
.imePadding(),
|
||||||
) {
|
) {
|
||||||
if (bookmarkGroups.isEmpty()) {
|
ListManagementViewBody(note, accountViewModel, nav)
|
||||||
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg))
|
}
|
||||||
} else {
|
}
|
||||||
LazyColumn(
|
}
|
||||||
state = rememberLazyListState(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
@Composable
|
||||||
) {
|
private fun ListManagementViewBody(
|
||||||
itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList ->
|
note: Note,
|
||||||
val maybePublicBookmark = bookmarkList.publicPostBookmarks.firstOrNull { it.eventId == note.idHex }
|
accountViewModel: AccountViewModel,
|
||||||
val maybePrivateBookmark = bookmarkList.privatePostBookmarks.firstOrNull { it.eventId == note.idHex }
|
nav: INav,
|
||||||
BookmarkGroupManagementItem(
|
) {
|
||||||
modifier = Modifier.fillMaxWidth().animateItem(),
|
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
|
||||||
listTitle = bookmarkList.title,
|
.collectAsStateWithLifecycle()
|
||||||
isPublicMemberBookmark = maybePublicBookmark != null,
|
|
||||||
isPrivateMemberBookmark = maybePrivateBookmark != null,
|
val defaultBookmarks by accountViewModel.account.bookmarkState.bookmarks
|
||||||
totalPostBookmarkSize = bookmarkList.publicPostBookmarks.size + bookmarkList.privatePostBookmarks.size,
|
.collectAsStateWithLifecycle()
|
||||||
totalArticleBookmarkSize = bookmarkList.publicArticleBookmarks.size + bookmarkList.privateArticleBookmarks.size,
|
|
||||||
onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.PostBookmark)) },
|
LazyColumn(
|
||||||
onAddBookmarkToGroup = { shouldBePrivate ->
|
state = rememberLazyListState(),
|
||||||
accountViewModel.launchSigner {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
accountViewModel.account.labeledBookmarkLists.addBookmarkToList(
|
) {
|
||||||
bookmark = EventBookmark(eventId = note.idHex, relay = note.relayHintUrl(), author = note.author?.pubkeyHex),
|
item {
|
||||||
bookmarkListIdentifier = bookmarkList.identifier,
|
val maybePublicBookmark = defaultBookmarks.public.contains(note)
|
||||||
isBookmarkPrivate = shouldBePrivate,
|
val maybePrivateBookmark = defaultBookmarks.private.contains(note)
|
||||||
account = accountViewModel.account,
|
|
||||||
)
|
BookmarkGroupManagementItem(
|
||||||
}
|
modifier = Modifier.fillMaxWidth().animateItem(),
|
||||||
},
|
listTitle = stringRes(R.string.bookmarks_title),
|
||||||
onRemoveBookmarkFromGroup = {
|
isPublicMemberBookmark = maybePublicBookmark,
|
||||||
accountViewModel.launchSigner {
|
isPrivateMemberBookmark = maybePrivateBookmark,
|
||||||
accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList(
|
totalPostBookmarkSize = defaultBookmarks.public.size + defaultBookmarks.private.size,
|
||||||
bookmark = EventBookmark(eventId = note.idHex),
|
totalArticleBookmarkSize = 0,
|
||||||
bookmarkListIdentifier = bookmarkList.identifier,
|
onClick = {
|
||||||
isBookmarkPrivate = maybePrivateBookmark != null,
|
nav.nav(Route.Bookmarks)
|
||||||
account = accountViewModel.account,
|
},
|
||||||
)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -972,7 +972,6 @@ open class ShortNotePostViewModel :
|
|||||||
val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title)
|
val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title)
|
||||||
val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported)
|
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 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 ->
|
val uploadVoiceExceptionMessage: (String) -> String = { detail ->
|
||||||
stringRes(appContext, R.string.upload_error_voice_message_exception, detail)
|
stringRes(appContext, R.string.upload_error_voice_message_exception, detail)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -36,5 +36,5 @@ fun BookmarkTabHeader(
|
|||||||
) {
|
) {
|
||||||
val userBookmarks by observeUserBookmarkCount(baseUser, accountViewModel)
|
val userBookmarks by observeUserBookmarkCount(baseUser, accountViewModel)
|
||||||
|
|
||||||
Text(text = "$userBookmarks ${stringRes(R.string.bookmarks)}")
|
Text(text = "$userBookmarks ${stringRes(R.string.bookmarks_title)}")
|
||||||
}
|
}
|
||||||
|
|||||||
+813
-289
File diff suppressed because it is too large
Load Diff
@@ -215,13 +215,13 @@ val LightSelectedReactionBoxModifier =
|
|||||||
|
|
||||||
val DarkChannelNotePictureModifier =
|
val DarkChannelNotePictureModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(30.dp)
|
.size(20.dp)
|
||||||
.clip(shape = CircleShape)
|
.clip(shape = CircleShape)
|
||||||
.border(2.dp, DarkColorPalette.background, CircleShape)
|
.border(2.dp, DarkColorPalette.background, CircleShape)
|
||||||
|
|
||||||
val LightChannelNotePictureModifier =
|
val LightChannelNotePictureModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.size(30.dp)
|
.size(20.dp)
|
||||||
.clip(shape = CircleShape)
|
.clip(shape = CircleShape)
|
||||||
.border(2.dp, LightColorPalette.background, CircleShape)
|
.border(2.dp, LightColorPalette.background, CircleShape)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 530 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
@@ -158,6 +158,7 @@
|
|||||||
<string name="record_a_message">Hangüzenet rögzítése</string>
|
<string name="record_a_message">Hangüzenet rögzítése</string>
|
||||||
<string name="record_a_message_title">Hangüzenet rögzítése</string>
|
<string name="record_a_message_title">Hangüzenet rögzítése</string>
|
||||||
<string name="record_a_message_description">Hangüzenet rögzítéséhez kattintson és tartsa lenyomva a gombot</string>
|
<string name="record_a_message_description">Hangüzenet rögzítéséhez kattintson és tartsa lenyomva a gombot</string>
|
||||||
|
<string name="re_record">Újrarögzítés</string>
|
||||||
<string name="recording_indicator_description">Rögzítés</string>
|
<string name="recording_indicator_description">Rögzítés</string>
|
||||||
<string name="recording_indicator_with_time">%1$s rögzítése</string>
|
<string name="recording_indicator_with_time">%1$s rögzítése</string>
|
||||||
<string name="uploading">Feltöltés…</string>
|
<string name="uploading">Feltöltés…</string>
|
||||||
@@ -480,8 +481,8 @@
|
|||||||
<string name="privacy_options">Adatvédelmi beállítások</string>
|
<string name="privacy_options">Adatvédelmi beállítások</string>
|
||||||
<string name="connect_via_tor_short">Tor és Orbot beállítása</string>
|
<string name="connect_via_tor_short">Tor és Orbot beállítása</string>
|
||||||
<string name="connect_via_tor">Kapcsolódás az Orbot beállításain keresztül</string>
|
<string name="connect_via_tor">Kapcsolódás az Orbot beállításain keresztül</string>
|
||||||
<string name="connect_via_tor1">Beállítás</string>
|
<string name="connect_via_tor1">Módosítás</string>
|
||||||
<string name="connect_via_tor2">Tor beállítások</string>
|
<string name="connect_via_tor2">Tor Beállítások</string>
|
||||||
<string name="do_you_really_want_to_disable_tor_title">Kapcsolat bontása az Orbottal / Torral?</string>
|
<string name="do_you_really_want_to_disable_tor_title">Kapcsolat bontása az Orbottal / Torral?</string>
|
||||||
<string name="do_you_really_want_to_disable_tor_text">Az Ön adatai azonnal átkerülnek a normál hálózatra</string>
|
<string name="do_you_really_want_to_disable_tor_text">Az Ön adatai azonnal átkerülnek a normál hálózatra</string>
|
||||||
<string name="yes">Igen</string>
|
<string name="yes">Igen</string>
|
||||||
|
|||||||
@@ -158,6 +158,7 @@
|
|||||||
<string name="record_a_message">Nagraj wiadomość</string>
|
<string name="record_a_message">Nagraj wiadomość</string>
|
||||||
<string name="record_a_message_title">Nagrywanie wiadomości</string>
|
<string name="record_a_message_title">Nagrywanie wiadomości</string>
|
||||||
<string name="record_a_message_description">Kliknij i przytrzymaj aby nagrać wiadomość</string>
|
<string name="record_a_message_description">Kliknij i przytrzymaj aby nagrać wiadomość</string>
|
||||||
|
<string name="re_record">Nagraj ponownie</string>
|
||||||
<string name="recording_indicator_description">Nagrywanie</string>
|
<string name="recording_indicator_description">Nagrywanie</string>
|
||||||
<string name="recording_indicator_with_time">Nagrywanie %1$s</string>
|
<string name="recording_indicator_with_time">Nagrywanie %1$s</string>
|
||||||
<string name="uploading">Wgrywanie…</string>
|
<string name="uploading">Wgrywanie…</string>
|
||||||
@@ -356,6 +357,8 @@
|
|||||||
<string name="block_only">Zablokuj</string>
|
<string name="block_only">Zablokuj</string>
|
||||||
<string name="manual_zaps">Ręczny podział zapów</string>
|
<string name="manual_zaps">Ręczny podział zapów</string>
|
||||||
<string name="bookmarks">Zakładki</string>
|
<string name="bookmarks">Zakładki</string>
|
||||||
|
<string name="bookmarks_title">Domyślne zakładki</string>
|
||||||
|
<string name="bookmarks_explainer">Twoje domyślne zakładki obsługiwane przez wielu klientów</string>
|
||||||
<string name="drafts">Projekty</string>
|
<string name="drafts">Projekty</string>
|
||||||
<string name="private_bookmarks">Prywatne Zakładki</string>
|
<string name="private_bookmarks">Prywatne Zakładki</string>
|
||||||
<string name="public_bookmarks">Publiczne zakładki</string>
|
<string name="public_bookmarks">Publiczne zakładki</string>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<string name="illegal_behavior">Noqonuniy xatti-harakat</string>
|
<string name="illegal_behavior">Noqonuniy xatti-harakat</string>
|
||||||
<string name="other">Boshqa sabab</string>
|
<string name="other">Boshqa sabab</string>
|
||||||
<string name="harassment">Ta’qib</string>
|
<string name="harassment">Ta’qib</string>
|
||||||
|
<string name="violence">Zo\'ravon</string>
|
||||||
<string name="unknown">Noma\'lum</string>
|
<string name="unknown">Noma\'lum</string>
|
||||||
<string name="relay_icon">Relay belgisi</string>
|
<string name="relay_icon">Relay belgisi</string>
|
||||||
<string name="unknown_author">Nomaʼlum muallif</string>
|
<string name="unknown_author">Nomaʼlum muallif</string>
|
||||||
@@ -45,4 +46,26 @@
|
|||||||
<string name="login_with_a_private_key_to_be_able_to_reply">Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Xabar yozish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting</string>
|
<string name="login_with_a_private_key_to_be_able_to_reply">Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Xabar yozish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting</string>
|
||||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Postni targ‘ib qilish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting</string>
|
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Postni targ‘ib qilish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting</string>
|
||||||
<string name="login_with_a_private_key_to_like_posts">Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Postga reaksiya qilish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting</string>
|
<string name="login_with_a_private_key_to_like_posts">Sizda ommaviy kalit faol. Ommaviy kalitlar orqali faqat o‘qish mumkin. Postga reaksiya qilish uchun, maxfiy kalit bilan avtorizatsiyadan o‘ting</string>
|
||||||
|
<string name="login_with_a_private_key_to_be_able_to_follow">Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat o‘qish huquqiga ega. Obuna bo‘lish uchun shaxsiy kalit bilan tizimga kiring</string>
|
||||||
|
<string name="login_with_a_private_key_to_be_able_to_upload">Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat o‘qish huquqiga ega. Yuklash uchun shaxsiy kalit bilan tizimga kiring</string>
|
||||||
|
<string name="login_with_a_private_key_to_be_able_to_sign_events">Siz ommaviy kalitdan foydalanmoqdasiz va ommaviy kalitlar faqat o‘qish huquqiga ega. Xabarlarni imzolash uchun shaxsiy kalit bilan tizimga kiring</string>
|
||||||
|
<string name="unauthorized_exception">Ruxsatsiz deshifrlash</string>
|
||||||
|
<string name="unauthorized_exception_description">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</string>
|
||||||
|
<string name="signer_not_found_exception">Imzolovchi ilova topilmadi</string>
|
||||||
|
<string name="signer_not_found_exception_description">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.</string>
|
||||||
|
<string name="zaps">Zeplar</string>
|
||||||
|
<string name="view_count">Koʻrishlar soni</string>
|
||||||
|
<string name="boost">Targʻib qilish</string>
|
||||||
|
<string name="boosted">Targ\'ib qilidi</string>
|
||||||
|
<string name="edited">Tahrirlangan</string>
|
||||||
|
<string name="edited_number">Tahrir № %1$s</string>
|
||||||
|
<string name="original">Asl</string>
|
||||||
|
<string name="quote">Iqtibos qilish</string>
|
||||||
|
<string name="fork">Nusxa olish</string>
|
||||||
|
<string name="propose_an_edit">Tahrir taklif qilish</string>
|
||||||
|
<string name="add">Qo\'shish</string>
|
||||||
|
<string name="replying_to">"Kimga javob yozilmoqda: "</string>
|
||||||
|
<string name="and">" va "</string>
|
||||||
|
<string name="in_channel">"bu kanalda: "</string>
|
||||||
|
<string name="profile_banner">Profil banneri</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -158,6 +158,7 @@
|
|||||||
<string name="record_a_message">录制消息</string>
|
<string name="record_a_message">录制消息</string>
|
||||||
<string name="record_a_message_title">录制消息</string>
|
<string name="record_a_message_title">录制消息</string>
|
||||||
<string name="record_a_message_description">点按以录制消息</string>
|
<string name="record_a_message_description">点按以录制消息</string>
|
||||||
|
<string name="re_record">重新录制</string>
|
||||||
<string name="recording_indicator_description">正在录制</string>
|
<string name="recording_indicator_description">正在录制</string>
|
||||||
<string name="recording_indicator_with_time">正在录制 %1$s</string>
|
<string name="recording_indicator_with_time">正在录制 %1$s</string>
|
||||||
<string name="uploading">上传中…</string>
|
<string name="uploading">上传中…</string>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
<string name="could_not_decrypt_the_message">Could not decrypt the message</string>
|
<string name="could_not_decrypt_the_message">Could not decrypt the message</string>
|
||||||
<string name="group_picture">Group Picture</string>
|
<string name="group_picture">Group Picture</string>
|
||||||
<string name="explicit_content">Explicit Content</string>
|
<string name="explicit_content">Explicit Content</string>
|
||||||
|
<string name="relay_notice">Relay Notice</string>
|
||||||
|
<string name="duplicated_post">Duplicated Post</string>
|
||||||
<string name="spam">Spam</string>
|
<string name="spam">Spam</string>
|
||||||
<string name="spam_description">The number of spamming events coming from this relay</string>
|
<string name="spam_description">The number of spamming events coming from this relay</string>
|
||||||
<string name="impersonation">Impersonation</string>
|
<string name="impersonation">Impersonation</string>
|
||||||
@@ -133,6 +135,7 @@
|
|||||||
<string name="relay_address">Relay Address</string>
|
<string name="relay_address">Relay Address</string>
|
||||||
<string name="posts">Posts</string>
|
<string name="posts">Posts</string>
|
||||||
<string name="bytes">Bytes</string>
|
<string name="bytes">Bytes</string>
|
||||||
|
<string name="error">Error</string>
|
||||||
<string name="errors">Errors</string>
|
<string name="errors">Errors</string>
|
||||||
<string name="errors_description">The number of connection errors in this session</string>
|
<string name="errors_description">The number of connection errors in this session</string>
|
||||||
<string name="home_feed">Home Feed</string>
|
<string name="home_feed">Home Feed</string>
|
||||||
@@ -391,6 +394,8 @@
|
|||||||
|
|
||||||
<string name="manual_zaps">Manual Zap Splits</string>
|
<string name="manual_zaps">Manual Zap Splits</string>
|
||||||
<string name="bookmarks">Bookmarks</string>
|
<string name="bookmarks">Bookmarks</string>
|
||||||
|
<string name="bookmarks_title">Default Bookmarks</string>
|
||||||
|
<string name="bookmarks_explainer">Your default Bookmarks that many clients support</string>
|
||||||
<string name="drafts">Drafts</string>
|
<string name="drafts">Drafts</string>
|
||||||
<string name="private_bookmarks">Private Bookmarks</string>
|
<string name="private_bookmarks">Private Bookmarks</string>
|
||||||
<string name="public_bookmarks">Public Bookmarks</string>
|
<string name="public_bookmarks">Public Bookmarks</string>
|
||||||
@@ -745,28 +750,64 @@
|
|||||||
<string name="read_from_relay_description">The amount in bytes that was received from this relay, including filters and events</string>
|
<string name="read_from_relay_description">The amount in bytes that was received from this relay, including filters and events</string>
|
||||||
<string name="an_error_occurred_trying_to_get_relay_information">An error occurred trying to get relay information from %1$s</string>
|
<string name="an_error_occurred_trying_to_get_relay_information">An error occurred trying to get relay information from %1$s</string>
|
||||||
<string name="owner">Owner</string>
|
<string name="owner">Owner</string>
|
||||||
|
<string name="self">Service Key</string>
|
||||||
|
<string name="running_software">Running %1$s</string>
|
||||||
|
<string name="running_software_version">Running %1$s (%2$s)</string>
|
||||||
<string name="version">Version</string>
|
<string name="version">Version</string>
|
||||||
<string name="software">Software</string>
|
<string name="software">Software</string>
|
||||||
<string name="contact">Contact</string>
|
<string name="contact">Contact</string>
|
||||||
<string name="supports">Supported NIPs</string>
|
<string name="supports">Supported NIPs</string>
|
||||||
|
<string name="supported_grasps">Supported Grasps</string>
|
||||||
<string name="admission_fees">Admission Fees</string>
|
<string name="admission_fees">Admission Fees</string>
|
||||||
|
<string name="admission">Admission</string>
|
||||||
|
<string name="subscription">Subscription</string>
|
||||||
|
<string name="publication">Publication</string>
|
||||||
|
<string name="payment_link">Payments %1$s</string>
|
||||||
<string name="payments_url">Payments url</string>
|
<string name="payments_url">Payments url</string>
|
||||||
|
<string name="target_audience">Target Audience</string>
|
||||||
|
<string name="policies_and_links">Policies & Links</string>
|
||||||
|
<string name="fees_and_payments">Fees & Payments</string>
|
||||||
<string name="limitations">Limitations</string>
|
<string name="limitations">Limitations</string>
|
||||||
<string name="countries">Countries</string>
|
<string name="countries">Countries</string>
|
||||||
<string name="languages">Languages</string>
|
<string name="languages">Languages</string>
|
||||||
<string name="tags">Tags</string>
|
<string name="tags">Tags</string>
|
||||||
|
<string name="topics">Topics</string>
|
||||||
|
<string name="all_countries">All Countries</string>
|
||||||
|
<string name="all_languages">All Languages</string>
|
||||||
<string name="posting_policy">Posting policy</string>
|
<string name="posting_policy">Posting policy</string>
|
||||||
|
<string name="privacy_policy">Privacy Policy</string>
|
||||||
|
<string name="terms_and_conditions">Terms & Conditions</string>
|
||||||
|
<string name="not_available_acronym">N/A</string>
|
||||||
<string name="relay_error_messages">Errors and Notices from this Relay</string>
|
<string name="relay_error_messages">Errors and Notices from this Relay</string>
|
||||||
<string name="message_length">Message length</string>
|
<string name="message_length">Message length</string>
|
||||||
<string name="subscriptions">Subscriptions</string>
|
<string name="subscriptions">Subscriptions</string>
|
||||||
<string name="filters">Filters</string>
|
<string name="filters">Filters</string>
|
||||||
<string name="subscription_id_length">Subscription id length</string>
|
<string name="subscription_id_length">Subscription id length</string>
|
||||||
<string name="minimum_prefix">Minimum prefix</string>
|
<string name="minimum_prefix">Min Prefix</string>
|
||||||
<string name="maximum_event_tags">Maximum event tags</string>
|
<string name="maximum_event_tags">Max Event Tags</string>
|
||||||
<string name="content_length">Content length</string>
|
<string name="content_length">Content length</string>
|
||||||
<string name="minimum_pow">Minimum PoW</string>
|
<string name="max_content_length">Max Content Length</string>
|
||||||
|
<string name="discards_older_than">Discards older than</string>
|
||||||
|
<string name="accepts_up_to">Accepts up to</string>
|
||||||
|
<string name="time_in_the_future">%1$s in the future</string>
|
||||||
|
<string name="time_in_the_past">%1$s ago</string>
|
||||||
|
<string name="amount_in_zeros">%1$s zeros</string>
|
||||||
|
<string name="amount_in_bits">%1$s bits</string>
|
||||||
|
<string name="event_retention">Event Retention</string>
|
||||||
|
<string name="content_size">Content Size</string>
|
||||||
|
<string name="connectivity">Connectivity</string>
|
||||||
|
<string name="access_control">Access Control</string>
|
||||||
|
<string name="minimum_pow">Min PoW Difficulty</string>
|
||||||
<string name="auth">Auth</string>
|
<string name="auth">Auth</string>
|
||||||
|
<string name="auth_required">Auth Required</string>
|
||||||
<string name="payment">Payment</string>
|
<string name="payment">Payment</string>
|
||||||
|
<string name="payment_required">Payment Required</string>
|
||||||
|
<string name="max_message_length">Max Message Length</string>
|
||||||
|
<string name="max_subs">Max Subscriptions</string>
|
||||||
|
<string name="max_filters_per_sub">Max Filters per Sub</string>
|
||||||
|
<string name="max_limit_events_returning">Max Limit (Events Returning)</string>
|
||||||
|
<string name="default_limit">Default Limit (Events Returning)</string>
|
||||||
|
<string name="max_subid_length">Max SubID Length</string>
|
||||||
<string name="cashu">Cashu Token</string>
|
<string name="cashu">Cashu Token</string>
|
||||||
<string name="cashu_redeem">Redeem</string>
|
<string name="cashu_redeem">Redeem</string>
|
||||||
<string name="cashu_redeem_to_zap">Send to Zap Wallet</string>
|
<string name="cashu_redeem_to_zap">Send to Zap Wallet</string>
|
||||||
|
|||||||
+1
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.account
|
package com.vitorpamplona.amethyst.commons.account
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
|||||||
+45
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.util
|
package com.vitorpamplona.amethyst.commons.util
|
||||||
|
|
||||||
|
import com.sun.org.apache.xalan.internal.lib.ExsltDatetime.time
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
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.
|
* Formats a Unix timestamp as a date string.
|
||||||
* For recent dates (< 1 day), returns the provided today string.
|
* For recent dates (< 1 day), returns the provided today string.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ torAndroid = "0.4.8.21.1"
|
|||||||
translate = "17.0.3"
|
translate = "17.0.3"
|
||||||
unifiedpush = "3.0.10"
|
unifiedpush = "3.0.10"
|
||||||
urlDetector = "0.1.23"
|
urlDetector = "0.1.23"
|
||||||
vico-charts = "2.3.6"
|
vico-charts = "2.4.0"
|
||||||
zelory = "3.0.1"
|
zelory = "3.0.1"
|
||||||
zoomable = "2.9.0"
|
zoomable = "2.9.0"
|
||||||
zxing = "3.5.4"
|
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" }
|
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
||||||
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
|
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" }
|
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||||
mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" }
|
mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" }
|
||||||
|
|||||||
+76
-90
@@ -20,9 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteConstraintException
|
import android.database.sqlite.SQLiteConstraintException
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
@@ -30,91 +28,91 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
|||||||
import junit.framework.TestCase
|
import junit.framework.TestCase
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
import junit.framework.TestCase.fail
|
import junit.framework.TestCase.fail
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class AddressableTest {
|
class AddressableTest : BaseDBTest() {
|
||||||
private lateinit var db: EventStore
|
|
||||||
|
|
||||||
val signer = NostrSignerSync()
|
val signer = NostrSignerSync()
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
db = EventStore(context, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testReplacingAddressables() {
|
fun testReplacingAddressables() =
|
||||||
val time = TimeUtils.now()
|
forEachDB { db ->
|
||||||
val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
|
val time = TimeUtils.now()
|
||||||
val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
|
val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
|
||||||
val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
|
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)
|
db.insert(version1)
|
||||||
fail("It should not allow inserting an older version")
|
|
||||||
} catch (e: Exception) {
|
db.assertQuery(version1, Filter(ids = listOf(version1.id)))
|
||||||
TestCase.assertTrue(e is SQLiteConstraintException)
|
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)))
|
@Test
|
||||||
db.assertQuery(version3, addressableQuery)
|
fun testBlockingOldAddressables() =
|
||||||
db.assertQuery(null, Filter(ids = listOf(version2.id)))
|
forEachDB { db ->
|
||||||
db.assertQuery(null, Filter(ids = listOf(version1.id)))
|
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
|
@Test
|
||||||
fun testTriggersIndexUsage() {
|
fun testTriggersIndexUsage() =
|
||||||
val explainer =
|
forEachDB { db ->
|
||||||
db.store.explainQuery(
|
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
|
SELECT * FROM event_headers
|
||||||
WHERE
|
WHERE
|
||||||
@@ -123,21 +121,9 @@ class AddressableTest {
|
|||||||
event_headers.d_tag = 'test-tag' AND
|
event_headers.d_tag = 'test-tag' AND
|
||||||
event_headers.created_at < 1766686500 AND
|
event_headers.created_at < 1766686500 AND
|
||||||
event_headers.kind >= 30000 AND event_headers.kind < 40000
|
event_headers.kind >= 30000 AND event_headers.kind < 40000
|
||||||
|
└── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||||
""".trimIndent(),
|
""".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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+84
@@ -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<String, EventStore>
|
||||||
|
|
||||||
|
fun DefaultIndexingStrategy.name(): String =
|
||||||
|
"""
|
||||||
|
indexEventsByCreatedAtAlone=$indexEventsByCreatedAtAlone
|
||||||
|
indexTagsByCreatedAtAlone=$indexTagsByCreatedAtAlone
|
||||||
|
indexTagsWithKindAndPubkey=$indexTagsWithKindAndPubkey
|
||||||
|
useAndIndexIdOnOrderBy=$useAndIndexIdOnOrderBy
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
|
||||||
|
val booleans = listOf(true, false)
|
||||||
|
|
||||||
|
dbs = mutableMapOf<String, EventStore>()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
-158
@@ -20,8 +20,6 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
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.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
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.nip01Core.tags.hashtags.isTaggedHash
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class BasicTest {
|
class BasicTest : BaseDBTest() {
|
||||||
private lateinit var db: SQLiteEventStore
|
|
||||||
|
|
||||||
val signer = NostrSignerSync()
|
val signer = NostrSignerSync()
|
||||||
|
|
||||||
companion object Companion {
|
companion object {
|
||||||
val profile =
|
val profile =
|
||||||
MetadataEvent(
|
MetadataEvent(
|
||||||
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
|
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
|
||||||
@@ -82,168 +76,166 @@ class BasicTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
db = SQLiteEventStore(context, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInsertDeleteEvent() {
|
fun testInsertDeleteEvent() =
|
||||||
val note = signer.sign(TextNoteEvent.build("test1"))
|
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)))
|
db.store.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<Event>(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event ->
|
|
||||||
assertEquals(comment.toJson(), event.toJson())
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun hashCodeTest() {
|
fun testEmptyFilter() =
|
||||||
val note1 =
|
forEachDB { db ->
|
||||||
signer.sign(
|
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
|
||||||
TextNoteEvent.build("test1") {
|
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
|
||||||
hashtag("AaAa")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val note2 =
|
|
||||||
signer.sign(
|
|
||||||
TextNoteEvent.build("test2") {
|
|
||||||
hashtag("AaAa")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val note3 =
|
|
||||||
signer.sign(
|
|
||||||
TextNoteEvent.build("test3") {
|
|
||||||
hashtag("BBBB")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
db.insertEvent(note1)
|
db.store.insertEvent(note1)
|
||||||
db.insertEvent(note2)
|
|
||||||
db.insertEvent(note3)
|
|
||||||
|
|
||||||
val list =
|
db.store.assertQuery(note1, Filter())
|
||||||
db.query<TextNoteEvent>(
|
|
||||||
Filter(
|
|
||||||
tags = mapOf("t" to listOf("AaAa")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(2, list.size)
|
db.store.insertEvent(note2)
|
||||||
list.forEach {
|
|
||||||
assertTrue(it.isTaggedHash("AaAa"))
|
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<Event>(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<TextNoteEvent>(
|
||||||
|
Filter(
|
||||||
|
tags = mapOf("t" to listOf("AaAa")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2, list.size)
|
||||||
|
list.forEach {
|
||||||
|
assertTrue(it.isTaggedHash("AaAa"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+333
-337
@@ -20,9 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteConstraintException
|
import android.database.sqlite.SQLiteConstraintException
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
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 com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import junit.framework.TestCase
|
import junit.framework.TestCase
|
||||||
import junit.framework.TestCase.fail
|
import junit.framework.TestCase.fail
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class DeletionTest {
|
class DeletionTest : BaseDBTest() {
|
||||||
private lateinit var db: EventStore
|
|
||||||
|
|
||||||
val signer = NostrSignerSync()
|
val signer = NostrSignerSync()
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
db = EventStore(context, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInsertDeleteEvent() {
|
fun testInsertDeleteEvent() =
|
||||||
val note1 = signer.sign(TextNoteEvent.build("test1"))
|
forEachDB { db ->
|
||||||
val note2 = signer.sign(TextNoteEvent.build("test2"))
|
val note1 = signer.sign(TextNoteEvent.build("test1"))
|
||||||
val note3 = signer.sign(TextNoteEvent.build("test3"))
|
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)
|
db.insert(note1)
|
||||||
fail("Should not be able to insert a deleted event")
|
db.insert(note2)
|
||||||
} catch (e: SQLiteConstraintException) {
|
db.insert(note3)
|
||||||
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
|
|
||||||
|
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
|
@Test
|
||||||
fun testInsertDeleteEventOfAddressable() {
|
fun testInsertDeleteEventOfAddressable() =
|
||||||
val time = TimeUtils.now()
|
forEachDB { db ->
|
||||||
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
|
val time = TimeUtils.now()
|
||||||
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
|
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
|
||||||
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
|
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)
|
db.insert(note1)
|
||||||
fail("Should not be able to insert a deleted event")
|
|
||||||
} catch (e: SQLiteConstraintException) {
|
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
|
||||||
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
|
|
||||||
|
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
|
@Test
|
||||||
fun testInsertDeleteEventOfAddressable2() {
|
fun testInsertDeleteEventOfAddressable2() =
|
||||||
val time = TimeUtils.now()
|
forEachDB { db ->
|
||||||
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
|
val time = TimeUtils.now()
|
||||||
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
|
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
|
||||||
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
|
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)
|
db.insert(note1)
|
||||||
fail("Should not be able to insert a deleted event")
|
db.insert(note2)
|
||||||
} catch (e: SQLiteConstraintException) {
|
db.insert(note3)
|
||||||
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
|
|
||||||
|
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
|
@Test
|
||||||
fun testInsertDeleteWrap() {
|
fun testInsertDeleteWrap() =
|
||||||
val me = NostrSignerSync()
|
forEachDB { db ->
|
||||||
val myFriend = NostrSignerSync()
|
val me = NostrSignerSync()
|
||||||
|
val myFriend = NostrSignerSync()
|
||||||
|
|
||||||
val note1 = me.sign(TextNoteEvent.build("test1"))
|
val note1 = me.sign(TextNoteEvent.build("test1"))
|
||||||
val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
|
val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
|
||||||
val wrap2 = GiftWrapEvent.create(note1, myFriend.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)
|
db.insert(wrap1)
|
||||||
fail("Should not be able to insert a deleted event")
|
db.insert(wrap2)
|
||||||
} catch (e: SQLiteConstraintException) {
|
|
||||||
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
|
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)))
|
@Test
|
||||||
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
|
fun testTriggersIndexUsage() =
|
||||||
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
|
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
|
@Test
|
||||||
fun testTriggersIndexUsage() {
|
fun testDeleteById() =
|
||||||
val sql =
|
forEachDB { db ->
|
||||||
"""
|
val sql =
|
||||||
SELECT 1 FROM event_tags
|
db.store.deletionModule
|
||||||
INNER JOIN event_headers
|
.deleteSQL(
|
||||||
ON event_headers.row_id = event_tags.event_header_row_id
|
pubkey = "key1",
|
||||||
WHERE
|
idValues = listOf("ca29c211f", "ca29c211d"),
|
||||||
event_tags.tag_hash IN (3221122, 223322) AND
|
addresses = emptyList(),
|
||||||
event_headers.kind = 5 AND
|
hasher = TagNameValueHasher(0),
|
||||||
event_headers.created_at >= 1766686500 AND
|
).first()
|
||||||
event_headers.pubkey_owner_hash = 22332323
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val explainer =
|
TestCase.assertEquals(
|
||||||
db.store.explainQuery(sql)
|
"""
|
||||||
|
DELETE FROM event_headers
|
||||||
TestCase.assertEquals(
|
WHERE
|
||||||
"""
|
id IN ("ca29c211f","ca29c211d") AND
|
||||||
${sql.replace("\n","\n ")}
|
pubkey_owner_hash = "1573573083296714675"
|
||||||
├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?)
|
├── SEARCH event_headers USING INDEX event_headers_id (id=?)
|
||||||
└── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
|
||||||
""".trimIndent(),
|
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
|
||||||
explainer,
|
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
|
||||||
)
|
""".trimIndent(),
|
||||||
}
|
db.store.explainQuery(sql.sql, sql.args),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDeleteById() {
|
fun testDeleteAddressable() =
|
||||||
val sql =
|
forEachDB { db ->
|
||||||
db.store.deletionModule
|
val sql =
|
||||||
.deleteSQL(
|
db.store.deletionModule
|
||||||
pubkey = "key1",
|
.deleteSQL(
|
||||||
idValues = listOf("ca29c211f", "ca29c211d"),
|
pubkey = "key1",
|
||||||
addresses = emptyList(),
|
idValues = emptyList(),
|
||||||
hasher = TagNameValueHasher(0),
|
addresses =
|
||||||
).first()
|
listOf(
|
||||||
|
Address(30000, "key1", "a"),
|
||||||
|
),
|
||||||
|
hasher = TagNameValueHasher(0),
|
||||||
|
).first()
|
||||||
|
|
||||||
TestCase.assertEquals(
|
TestCase.assertEquals(
|
||||||
"""
|
"""
|
||||||
DELETE FROM event_headers
|
DELETE FROM event_headers
|
||||||
WHERE
|
WHERE (
|
||||||
id IN ("ca29c211f","ca29c211d") AND
|
(kind = "30000" AND pubkey = "key1" AND d_tag = "a")
|
||||||
pubkey_owner_hash = "1573573083296714675"
|
) AND
|
||||||
├── SEARCH event_headers USING INDEX event_headers_id (id=?)
|
kind >= 30000 AND kind < 40000
|
||||||
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
|
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||||
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
|
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
|
||||||
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
|
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
|
||||||
""".trimIndent(),
|
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
|
||||||
db.store.explainQuery(sql.sql, sql.args),
|
""".trimIndent(),
|
||||||
)
|
db.store.explainQuery(sql.sql, sql.args),
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDeleteAddressable() {
|
fun testDeleteAddressablesSingleKind() =
|
||||||
val sql =
|
forEachDB { db ->
|
||||||
db.store.deletionModule
|
val sql =
|
||||||
.deleteSQL(
|
db.store.deletionModule
|
||||||
pubkey = "key1",
|
.deleteSQL(
|
||||||
idValues = emptyList(),
|
pubkey = "key1",
|
||||||
addresses =
|
idValues = emptyList(),
|
||||||
listOf(
|
addresses =
|
||||||
Address(30000, "key1", "a"),
|
listOf(
|
||||||
),
|
Address(30000, "key1", "a"),
|
||||||
hasher = TagNameValueHasher(0),
|
Address(30000, "key1", "b"),
|
||||||
).first()
|
Address(30000, "key1", "c"),
|
||||||
|
Address(30000, "key1", "d"),
|
||||||
|
),
|
||||||
|
hasher = TagNameValueHasher(0),
|
||||||
|
).first()
|
||||||
|
|
||||||
TestCase.assertEquals(
|
TestCase.assertEquals(
|
||||||
"""
|
"""
|
||||||
DELETE FROM event_headers
|
DELETE FROM event_headers
|
||||||
WHERE (
|
WHERE (
|
||||||
(kind = "30000" AND pubkey = "key1" AND d_tag = "a")
|
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d"))
|
||||||
) AND
|
) AND
|
||||||
kind >= 30000 AND kind < 40000
|
kind >= 30000 AND kind < 40000
|
||||||
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||||
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
|
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
|
||||||
├── SEARCH event_expirations 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=?)
|
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
db.store.explainQuery(sql.sql, sql.args),
|
db.store.explainQuery(sql.sql, sql.args),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDeleteAddressablesSingleKind() {
|
fun testDeleteAddressablesMultipleKinds() =
|
||||||
val sql =
|
forEachDB { db ->
|
||||||
db.store.deletionModule
|
val sql =
|
||||||
.deleteSQL(
|
db.store.deletionModule
|
||||||
pubkey = "key1",
|
.deleteSQL(
|
||||||
idValues = emptyList(),
|
pubkey = "key1",
|
||||||
addresses =
|
idValues = emptyList(),
|
||||||
listOf(
|
addresses =
|
||||||
Address(30000, "key1", "a"),
|
listOf(
|
||||||
Address(30000, "key1", "b"),
|
Address(30000, "key1", "a"),
|
||||||
Address(30000, "key1", "c"),
|
Address(30000, "key1", "b"),
|
||||||
Address(30000, "key1", "d"),
|
Address(30101, "key1", "c"),
|
||||||
),
|
Address(30101, "key1", "d"),
|
||||||
hasher = TagNameValueHasher(0),
|
Address(30001, "key2", "e"),
|
||||||
).first()
|
Address(30001, "key2", "f"),
|
||||||
|
),
|
||||||
|
hasher = TagNameValueHasher(0),
|
||||||
|
).first()
|
||||||
|
|
||||||
TestCase.assertEquals(
|
TestCase.assertEquals(
|
||||||
"""
|
"""
|
||||||
DELETE FROM event_headers
|
DELETE FROM event_headers
|
||||||
WHERE (
|
WHERE (
|
||||||
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d"))
|
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b"))
|
||||||
) AND
|
OR
|
||||||
kind >= 30000 AND kind < 40000
|
(kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d"))
|
||||||
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
) AND
|
||||||
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
|
kind >= 30000 AND kind < 40000
|
||||||
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
|
├── MULTI-INDEX OR
|
||||||
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
|
│ ├── INDEX 1
|
||||||
""".trimIndent(),
|
│ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||||
db.store.explainQuery(sql.sql, sql.args),
|
│ └── 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
|
@Test
|
||||||
fun testDeleteAddressablesMultipleKinds() {
|
fun testDeleteReplaceables() =
|
||||||
val sql =
|
forEachDB { db ->
|
||||||
db.store.deletionModule
|
val sql =
|
||||||
.deleteSQL(
|
db.store.deletionModule
|
||||||
pubkey = "key1",
|
.deleteSQL(
|
||||||
idValues = emptyList(),
|
pubkey = "key1",
|
||||||
addresses =
|
idValues = emptyList(),
|
||||||
listOf(
|
addresses =
|
||||||
Address(30000, "key1", "a"),
|
listOf(
|
||||||
Address(30000, "key1", "b"),
|
Address(10000, "key1", ""),
|
||||||
Address(30101, "key1", "c"),
|
Address(10000, "key1", ""),
|
||||||
Address(30101, "key1", "d"),
|
Address(10001, "key1", ""),
|
||||||
Address(30001, "key2", "e"),
|
Address(10001, "key1", ""),
|
||||||
Address(30001, "key2", "f"),
|
Address(10001, "key2", ""),
|
||||||
),
|
Address(10001, "key2", ""),
|
||||||
hasher = TagNameValueHasher(0),
|
),
|
||||||
).first()
|
hasher = TagNameValueHasher(0),
|
||||||
|
).first()
|
||||||
|
|
||||||
TestCase.assertEquals(
|
TestCase.assertEquals(
|
||||||
"""
|
"""
|
||||||
DELETE FROM event_headers
|
DELETE FROM event_headers
|
||||||
WHERE (
|
WHERE
|
||||||
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b"))
|
kind IN ("10000","10001") AND
|
||||||
OR
|
pubkey = "key1" AND
|
||||||
(kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d"))
|
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000))
|
||||||
) AND
|
├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?)
|
||||||
kind >= 30000 AND kind < 40000
|
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
|
||||||
├── MULTI-INDEX OR
|
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
|
||||||
│ ├── INDEX 1
|
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
|
||||||
│ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
""".trimIndent(),
|
||||||
│ └── INDEX 2
|
db.store.explainQuery(sql.sql, sql.args),
|
||||||
│ └── 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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-58
@@ -20,84 +20,69 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteConstraintException
|
import android.database.sqlite.SQLiteConstraintException
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import junit.framework.TestCase.fail
|
import junit.framework.TestCase.fail
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ExpirationTest {
|
class ExpirationTest : BaseDBTest() {
|
||||||
private lateinit var db: EventStore
|
|
||||||
|
|
||||||
val signer = NostrSignerSync()
|
val signer = NostrSignerSync()
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
db = EventStore(context, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDeletingExpiredEvents() {
|
fun testDeletingExpiredEvents() =
|
||||||
val time = TimeUtils.now()
|
forEachDB { db ->
|
||||||
|
val time = TimeUtils.now()
|
||||||
|
|
||||||
val noteSafe =
|
val noteSafe =
|
||||||
signer.sign(
|
signer.sign(
|
||||||
TextNoteEvent.build("test1", createdAt = time + 1) {
|
TextNoteEvent.build("test1", createdAt = time + 1) {
|
||||||
expiration(time + 100)
|
expiration(time + 100)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
db.insert(noteSafe)
|
db.insert(noteSafe)
|
||||||
|
|
||||||
val noteToExpire =
|
val noteToExpire =
|
||||||
signer.sign(
|
signer.sign(
|
||||||
TextNoteEvent.build("test1", createdAt = time + 1) {
|
TextNoteEvent.build("test1", createdAt = time + 1) {
|
||||||
expiration(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(null, Filter(ids = listOf(noteToExpire.id)))
|
||||||
db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id)))
|
db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInsertingExpiredEvents() {
|
fun testInsertingExpiredEvents() =
|
||||||
val time = TimeUtils.now()
|
forEachDB { db ->
|
||||||
|
val time = TimeUtils.now()
|
||||||
val note1 =
|
|
||||||
signer.sign(
|
val note1 =
|
||||||
TextNoteEvent.build("test1", createdAt = time - 12) {
|
signer.sign(
|
||||||
expiration(time - 10)
|
TextNoteEvent.build("test1", createdAt = time - 12) {
|
||||||
},
|
expiration(time - 10)
|
||||||
)
|
},
|
||||||
|
)
|
||||||
try {
|
|
||||||
db.insert(note1)
|
try {
|
||||||
fail("Should not be able to insert expired events")
|
db.insert(note1)
|
||||||
} catch (e: Exception) {
|
fail("Should not be able to insert expired events")
|
||||||
assertTrue(e is SQLiteConstraintException)
|
} catch (e: Exception) {
|
||||||
|
assertTrue(e is SQLiteConstraintException)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+165
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
|||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
import org.junit.Ignore
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.util.zip.GZIPInputStream
|
import java.util.zip.GZIPInputStream
|
||||||
@@ -82,6 +83,7 @@ class LargeDBTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@Ignore("Not testing")
|
||||||
fun insertDatabase() {
|
fun insertDatabase() {
|
||||||
events.forEach { event ->
|
events.forEach { event ->
|
||||||
try {
|
try {
|
||||||
|
|||||||
+909
-369
File diff suppressed because it is too large
Load Diff
+95
-112
@@ -20,9 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteConstraintException
|
import android.database.sqlite.SQLiteConstraintException
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
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
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
import junit.framework.TestCase.fail
|
import junit.framework.TestCase.fail
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ReplaceableTest {
|
class ReplaceableTest : BaseDBTest() {
|
||||||
private lateinit var db: EventStore
|
|
||||||
|
|
||||||
val signer = NostrSignerSync()
|
val signer = NostrSignerSync()
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
db = EventStore(context, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testReplacing() {
|
fun testReplacing() =
|
||||||
val time = TimeUtils.now()
|
forEachDB { db ->
|
||||||
val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time))
|
val time = TimeUtils.now()
|
||||||
val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1))
|
val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time))
|
||||||
val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2))
|
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)
|
db.insert(version1)
|
||||||
fail("It should not allow inserting an older version")
|
|
||||||
} catch (e: Exception) {
|
db.assertQuery(version1, Filter(ids = listOf(version1.id)))
|
||||||
TestCase.assertTrue(e is SQLiteConstraintException)
|
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)))
|
@Test
|
||||||
db.assertQuery(version3, addressableQuery)
|
fun testBlockingOldVersions() =
|
||||||
db.assertQuery(null, Filter(ids = listOf(version2.id)))
|
forEachDB { db ->
|
||||||
db.assertQuery(null, Filter(ids = listOf(version1.id)))
|
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
|
@Test
|
||||||
fun testTriggersIndexUsageKind0() {
|
fun testTriggersIndexUsageKind0() =
|
||||||
val explainer =
|
forEachDB { db ->
|
||||||
db.store.explainQuery(
|
val sql =
|
||||||
"""
|
"""
|
||||||
SELECT * FROM event_headers
|
SELECT * FROM event_headers
|
||||||
WHERE
|
WHERE
|
||||||
event_headers.kind = 0 AND
|
event_headers.kind = 0 AND
|
||||||
event_headers.pubkey = 'aa' AND
|
event_headers.pubkey = 'aa' AND
|
||||||
event_headers.created_at < 1766686500 AND
|
event_headers.created_at < 1766686500
|
||||||
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
|
""".trimIndent()
|
||||||
""".trimIndent(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(
|
val explainer = db.store.explainQuery(sql)
|
||||||
"""
|
|
||||||
SELECT * FROM event_headers
|
assertEquals(
|
||||||
WHERE
|
"""
|
||||||
event_headers.kind = 0 AND
|
SELECT * FROM event_headers
|
||||||
event_headers.pubkey = 'aa' AND
|
WHERE
|
||||||
event_headers.created_at < 1766686500 AND
|
event_headers.kind = 0 AND
|
||||||
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
|
event_headers.pubkey = 'aa' AND
|
||||||
└── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?)
|
event_headers.created_at < 1766686500
|
||||||
""".trimIndent(),
|
└── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at<?)
|
||||||
explainer,
|
""".trimIndent(),
|
||||||
)
|
explainer,
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testTriggersIndexUsageKind3() {
|
fun testTriggersIndexUsageKind3() =
|
||||||
val explainer =
|
forEachDB { db ->
|
||||||
db.store.explainQuery(
|
val sql =
|
||||||
"""
|
"""
|
||||||
SELECT * FROM event_headers
|
SELECT * FROM event_headers
|
||||||
WHERE
|
WHERE
|
||||||
event_headers.kind = 3 AND
|
event_headers.kind = 3 AND
|
||||||
event_headers.pubkey = 'aa' AND
|
event_headers.pubkey = 'aa' AND
|
||||||
event_headers.created_at < 1766686500 AND
|
event_headers.created_at < 1766686500
|
||||||
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
|
""".trimIndent()
|
||||||
""".trimIndent(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(
|
val explainer = db.store.explainQuery(sql)
|
||||||
"""
|
|
||||||
SELECT * FROM event_headers
|
assertEquals(
|
||||||
WHERE
|
"""
|
||||||
event_headers.kind = 3 AND
|
SELECT * FROM event_headers
|
||||||
event_headers.pubkey = 'aa' AND
|
WHERE
|
||||||
event_headers.created_at < 1766686500 AND
|
event_headers.kind = 3 AND
|
||||||
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
|
event_headers.pubkey = 'aa' AND
|
||||||
└── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?)
|
event_headers.created_at < 1766686500
|
||||||
""".trimIndent(),
|
└── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at<?)
|
||||||
explainer,
|
""".trimIndent(),
|
||||||
)
|
explainer,
|
||||||
}
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-89
@@ -20,9 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteConstraintException
|
import android.database.sqlite.SQLiteConstraintException
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
@@ -30,107 +28,94 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
|||||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import junit.framework.TestCase.fail
|
import junit.framework.TestCase.fail
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class RightToVanishTest {
|
class RightToVanishTest : BaseDBTest() {
|
||||||
private lateinit var db: EventStore
|
|
||||||
|
|
||||||
val signer = NostrSignerSync()
|
val signer = NostrSignerSync()
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
db = EventStore(context, null, relayUrl = "testUrl")
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInsertDeleteEvent() {
|
fun testInsertDeleteEvent() =
|
||||||
val time = TimeUtils.now()
|
forEachDB { db ->
|
||||||
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time))
|
val time = TimeUtils.now()
|
||||||
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1))
|
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time))
|
||||||
val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = time + 2))
|
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)
|
db.insert(note1)
|
||||||
fail("Should not be able to insert a deleted event")
|
db.insert(note2)
|
||||||
} catch (e: SQLiteConstraintException) {
|
db.insert(note3)
|
||||||
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(note1, Filter(ids = listOf(note1.id)))
|
||||||
db.assertQuery(null, Filter(ids = listOf(note1.id)))
|
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
|
||||||
db.assertQuery(null, Filter(ids = listOf(note2.id)))
|
db.assertQuery(note3, Filter(ids = listOf(note3.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
|
@Test
|
||||||
fun testInsertDeleteGiftWrap() {
|
fun testInsertDeleteGiftWrap() =
|
||||||
val time = TimeUtils.now()
|
forEachDB { db ->
|
||||||
|
val time = TimeUtils.now()
|
||||||
|
|
||||||
val me = NostrSignerSync()
|
val me = NostrSignerSync()
|
||||||
val myFriend = NostrSignerSync()
|
val myFriend = NostrSignerSync()
|
||||||
|
|
||||||
val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time))
|
val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time))
|
||||||
val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
|
val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
|
||||||
val wrap2 = GiftWrapEvent.create(note1, myFriend.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)
|
db.insert(wrap1)
|
||||||
fail("Should not be able to insert a deleted event")
|
db.insert(wrap2)
|
||||||
} 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(wrap1, Filter(ids = listOf(wrap1.id)))
|
||||||
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
|
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-33
@@ -20,20 +20,14 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
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.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class SearchTest {
|
class SearchTest : BaseDBTest() {
|
||||||
private lateinit var db: SQLiteEventStore
|
companion object {
|
||||||
|
|
||||||
companion object Companion {
|
|
||||||
val profile =
|
val profile =
|
||||||
MetadataEvent(
|
MetadataEvent(
|
||||||
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
|
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
|
||||||
@@ -74,35 +68,25 @@ class SearchTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
db = SQLiteEventStore(context, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
db.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testTagWithSearch() {
|
fun testTagWithSearch() =
|
||||||
db.insertEvent(comment)
|
forEachDB { db ->
|
||||||
db.insertEvent(profile)
|
db.store.insertEvent(comment)
|
||||||
|
db.store.insertEvent(profile)
|
||||||
|
|
||||||
db.assertQuery(null, Filter(search = "testing1"))
|
db.assertQuery(null, Filter(search = "testing1"))
|
||||||
db.assertQuery(comment, Filter(search = "testing"))
|
db.assertQuery(comment, Filter(search = "testing"))
|
||||||
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), 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(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(search = "testing"))
|
||||||
db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), 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(search = "testing"))
|
||||||
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
|
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-9
@@ -30,30 +30,45 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
|||||||
|
|
||||||
class DeletionRequestModule(
|
class DeletionRequestModule(
|
||||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||||
|
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||||
) : IModule {
|
) : 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
|
* Creates a trigger to reject events that have been
|
||||||
* deleted by ID or ATag including GiftWraps that
|
* deleted by ID or ATag including GiftWraps that
|
||||||
* must be checked against the p-tag (pubkey_owner_hash)
|
* must be checked against the p-tag (pubkey_owner_hash)
|
||||||
*/
|
*/
|
||||||
override fun create(db: SQLiteDatabase) {
|
override fun create(db: SQLiteDatabase) {
|
||||||
|
val sql = rejectDeletedEventsSQLTemplate().replace("\n", "\n ")
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"""
|
"""
|
||||||
CREATE TRIGGER reject_deleted_events
|
CREATE TRIGGER reject_deleted_events
|
||||||
BEFORE INSERT ON event_headers
|
BEFORE INSERT ON event_headers
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Check for ID-based deletion record
|
|
||||||
SELECT RAISE(ABORT, 'blocked: a deletion event exists')
|
SELECT RAISE(ABORT, 'blocked: a deletion event exists')
|
||||||
WHERE EXISTS (
|
WHERE EXISTS (
|
||||||
SELECT 1 FROM event_tags
|
$sql
|
||||||
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
|
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
|
|||||||
+46
-389
@@ -20,23 +20,16 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||||
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer
|
import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
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.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.nip59Giftwrap.wraps.GiftWrapEvent
|
||||||
import com.vitorpamplona.quartz.utils.EventFactory
|
|
||||||
|
|
||||||
class EventIndexesModule(
|
class EventIndexesModule(
|
||||||
val fts: FullTextSearchModule,
|
|
||||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||||
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||||
) : IModule {
|
) : IModule {
|
||||||
override fun create(db: SQLiteDatabase) {
|
override fun create(db: SQLiteDatabase) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
@@ -63,19 +56,53 @@ class EventIndexesModule(
|
|||||||
CREATE TABLE event_tags (
|
CREATE TABLE event_tags (
|
||||||
event_header_row_id INTEGER NOT NULL,
|
event_header_row_id INTEGER NOT NULL,
|
||||||
tag_hash 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
|
FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// queries by ID (load events)
|
||||||
db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)")
|
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 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
|
// Prevent updates to maintain immutability
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
@@ -117,9 +144,9 @@ class EventIndexesModule(
|
|||||||
val sqlInsertTags =
|
val sqlInsertTags =
|
||||||
"""
|
"""
|
||||||
INSERT OR ROLLBACK INTO event_tags
|
INSERT OR ROLLBACK INTO event_tags
|
||||||
(event_header_row_id, tag_hash)
|
(event_header_row_id, tag_hash, created_at, kind, pubkey_hash)
|
||||||
VALUES
|
VALUES
|
||||||
(?,?)
|
(?,?,?,?,?)
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun insert(
|
fun insert(
|
||||||
@@ -169,7 +196,7 @@ class EventIndexesModule(
|
|||||||
// rebalancing the tree every new insert
|
// rebalancing the tree every new insert
|
||||||
val indexableTags = ArrayList<Long>()
|
val indexableTags = ArrayList<Long>()
|
||||||
for (idx in event.tags.indices) {
|
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]))
|
indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,387 +204,17 @@ class EventIndexesModule(
|
|||||||
indexableTags.forEach {
|
indexableTags.forEach {
|
||||||
stmtTags.bindLong(1, headerId)
|
stmtTags.bindLong(1, headerId)
|
||||||
stmtTags.bindLong(2, it)
|
stmtTags.bindLong(2, it)
|
||||||
|
stmtTags.bindLong(3, event.createdAt)
|
||||||
|
stmtTags.bindLong(4, kindLong)
|
||||||
|
stmtTags.bindLong(5, pubkeyHash)
|
||||||
stmtTags.executeInsert()
|
stmtTags.executeInsert()
|
||||||
}
|
}
|
||||||
|
|
||||||
return headerId
|
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 <T : Event> query(
|
|
||||||
filter: Filter,
|
|
||||||
db: SQLiteDatabase,
|
|
||||||
): List<T> {
|
|
||||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
|
|
||||||
|
|
||||||
return if (rowIdSubQuery == null) {
|
|
||||||
db.runQuery(makeEverythingQuery())
|
|
||||||
} else {
|
|
||||||
db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T : Event> 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<Filter>,
|
|
||||||
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 <T : Event> query(
|
|
||||||
filters: List<Filter>,
|
|
||||||
db: SQLiteDatabase,
|
|
||||||
): List<T> {
|
|
||||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQuery(makeEverythingQuery())
|
|
||||||
return db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T : Event> query(
|
|
||||||
filters: List<Filter>,
|
|
||||||
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 <T : Event> SQLiteDatabase.runQuery(
|
|
||||||
sql: String,
|
|
||||||
args: List<String> = emptyList(),
|
|
||||||
): List<T> =
|
|
||||||
rawQuery(sql, args.toTypedArray()).use { cursor ->
|
|
||||||
ArrayList<T>(cursor.count).apply {
|
|
||||||
while (cursor.moveToNext()) {
|
|
||||||
add(cursor.toEvent())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <T : Event> SQLiteDatabase.runQuery(
|
|
||||||
sql: String,
|
|
||||||
args: List<String> = emptyList(),
|
|
||||||
onEach: (T) -> Unit,
|
|
||||||
) = rawQuery(sql, args.toTypedArray()).use { cursor ->
|
|
||||||
while (cursor.moveToNext()) {
|
|
||||||
onEach(cursor.toEvent())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T : Event> Cursor.toEvent() =
|
|
||||||
EventFactory.create<T>(
|
|
||||||
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 <T : Event> toEvent() =
|
|
||||||
EventFactory.create<T>(
|
|
||||||
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<Filter>,
|
|
||||||
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<String>,
|
|
||||||
) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args)
|
|
||||||
|
|
||||||
private fun SQLiteDatabase.runCount(
|
|
||||||
sql: String,
|
|
||||||
args: List<String> = 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<Filter>,
|
|
||||||
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<String> = emptyList(),
|
|
||||||
): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray())
|
|
||||||
|
|
||||||
// ---------------------------------
|
|
||||||
// Prepare unions of all the filters
|
|
||||||
// ---------------------------------
|
|
||||||
fun unionSubqueriesIfNeeded(
|
|
||||||
filters: List<Filter>,
|
|
||||||
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) {
|
override fun deleteAll(db: SQLiteDatabase) {
|
||||||
db.execSQL("DELETE FROM event_tags")
|
db.execSQL("DELETE FROM event_tags")
|
||||||
db.execSQL("DELETE FROM event_headers")
|
db.execSQL("DELETE FROM event_headers")
|
||||||
}
|
}
|
||||||
|
|
||||||
data class RowIdSubQuery(
|
|
||||||
val sql: String,
|
|
||||||
val args: List<String>,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -29,9 +29,9 @@ class EventStore(
|
|||||||
context: Context,
|
context: Context,
|
||||||
dbName: String? = "events.db",
|
dbName: String? = "events.db",
|
||||||
val relayUrl: String? = "wss://quartz.local",
|
val relayUrl: String? = "wss://quartz.local",
|
||||||
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||||
) : IEventStore {
|
) : IEventStore {
|
||||||
val store = SQLiteEventStore(context, dbName, relayUrl, tagIndexStrategy)
|
val store = SQLiteEventStore(context, dbName, relayUrl, indexStrategy)
|
||||||
|
|
||||||
override fun insert(event: Event) = store.insertEvent(event)
|
override fun insert(event: Event) = store.insertEvent(event)
|
||||||
|
|
||||||
|
|||||||
+64
-1
@@ -23,6 +23,64 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||||
|
|
||||||
interface IndexingStrategy {
|
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(
|
fun shouldIndex(
|
||||||
kind: Int,
|
kind: Int,
|
||||||
tag: Tag,
|
tag: Tag,
|
||||||
@@ -32,7 +90,12 @@ interface IndexingStrategy {
|
|||||||
/**
|
/**
|
||||||
* By default, we index all tags that have a single letter name and some value
|
* 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(
|
override fun shouldIndex(
|
||||||
kind: Int,
|
kind: Int,
|
||||||
tag: Tag,
|
tag: Tag,
|
||||||
|
|||||||
+710
@@ -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 <T : Event> query(
|
||||||
|
filter: Filter,
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
): List<T> = db.runQuery(toSql(filter, hasher(db)))
|
||||||
|
|
||||||
|
fun <T : Event> query(
|
||||||
|
filter: Filter,
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
onEach: (T) -> Unit,
|
||||||
|
) = db.runQuery(toSql(filter, hasher(db)), onEach)
|
||||||
|
|
||||||
|
fun <T : Event> query(
|
||||||
|
filters: List<Filter>,
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
): List<T> = db.runQuery(toSql(filters, hasher(db)))
|
||||||
|
|
||||||
|
fun <T : Event> query(
|
||||||
|
filters: List<Filter>,
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
onEach: (T) -> Unit,
|
||||||
|
) = db.runQuery(toSql(filters, hasher(db)), onEach)
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Raw methods for performance
|
||||||
|
// ---------------------------
|
||||||
|
fun rawQuery(
|
||||||
|
filter: Filter,
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
): List<RawEvent> = 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<Filter>,
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
): List<RawEvent> = db.runRawQuery(toSql(filters, hasher(db)))
|
||||||
|
|
||||||
|
fun rawQuery(
|
||||||
|
filters: List<Filter>,
|
||||||
|
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<Filter>,
|
||||||
|
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<Filter>,
|
||||||
|
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 <T : Event> SQLiteDatabase.runQuery(query: QuerySpec): List<T> =
|
||||||
|
rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
|
||||||
|
ArrayList<T>(cursor.count).apply {
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
add(cursor.toEvent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SQLiteDatabase.runRawQuery(query: QuerySpec): List<RawEvent> =
|
||||||
|
rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
|
||||||
|
ArrayList<RawEvent>(cursor.count).apply {
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
add(cursor.toRawEvent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <T : Event> 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 <T : Event> Cursor.toEvent() =
|
||||||
|
EventFactory.create<T>(
|
||||||
|
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<Filter>,
|
||||||
|
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<String>,
|
||||||
|
) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args)
|
||||||
|
|
||||||
|
private fun SQLiteDatabase.runCount(
|
||||||
|
sql: String,
|
||||||
|
args: List<String> = 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<Filter>,
|
||||||
|
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<String> = emptyList(),
|
||||||
|
): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray())
|
||||||
|
|
||||||
|
// ---------------------------------
|
||||||
|
// Prepare unions of all the filters
|
||||||
|
// ---------------------------------
|
||||||
|
fun unionSubqueriesIfNeeded(
|
||||||
|
filters: List<Filter>,
|
||||||
|
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<HexKey>? = null,
|
||||||
|
authors: List<HexKey>? = null,
|
||||||
|
kinds: List<Kind>? = null,
|
||||||
|
dTags: List<String>? = 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<HexKey>? = null,
|
||||||
|
authors: List<HexKey>? = null,
|
||||||
|
kinds: List<Kind>? = null,
|
||||||
|
dTags: List<String>? = 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<HexKey>? = null,
|
||||||
|
val authors: List<HexKey>? = null,
|
||||||
|
val kinds: List<Kind>? = null,
|
||||||
|
val dTags: List<String>? = null,
|
||||||
|
val nonDTagsIn: Map<String, List<String>>? = null,
|
||||||
|
val nonDTagsAll: Map<String, List<String>>? = 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<String> = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-2
@@ -48,8 +48,7 @@ class ReplaceableModule : IModule {
|
|||||||
WHERE
|
WHERE
|
||||||
event_headers.kind = NEW.kind AND
|
event_headers.kind = NEW.kind AND
|
||||||
event_headers.pubkey = NEW.pubkey AND
|
event_headers.pubkey = NEW.pubkey AND
|
||||||
event_headers.created_at < NEW.created_at AND
|
event_headers.created_at < NEW.created_at;
|
||||||
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
|
|
||||||
END;
|
END;
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
|
|||||||
+65
-11
@@ -27,10 +27,13 @@ import android.database.sqlite.SQLiteOpenHelper
|
|||||||
import androidx.core.database.sqlite.transaction
|
import androidx.core.database.sqlite.transaction
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
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.core.isEphemeral
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
||||||
|
import com.vitorpamplona.quartz.utils.EventFactory
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -38,7 +41,7 @@ class SQLiteEventStore(
|
|||||||
val context: Context,
|
val context: Context,
|
||||||
val dbName: String? = "events.db",
|
val dbName: String? = "events.db",
|
||||||
val relayUrl: String? = null,
|
val relayUrl: String? = null,
|
||||||
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||||
) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) {
|
) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) {
|
||||||
companion object {
|
companion object {
|
||||||
const val DATABASE_VERSION = 2
|
const val DATABASE_VERSION = 2
|
||||||
@@ -47,7 +50,7 @@ class SQLiteEventStore(
|
|||||||
val seedModule = SeedModule()
|
val seedModule = SeedModule()
|
||||||
|
|
||||||
val fullTextSearchModule = FullTextSearchModule()
|
val fullTextSearchModule = FullTextSearchModule()
|
||||||
val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule::hasher, tagIndexStrategy)
|
val eventIndexModule = EventIndexesModule(seedModule::hasher, indexStrategy)
|
||||||
|
|
||||||
val replaceableModule = ReplaceableModule()
|
val replaceableModule = ReplaceableModule()
|
||||||
val addressableModule = AddressableModule()
|
val addressableModule = AddressableModule()
|
||||||
@@ -57,6 +60,8 @@ class SQLiteEventStore(
|
|||||||
val expirationModule = ExpirationModule()
|
val expirationModule = ExpirationModule()
|
||||||
val rightToVanishModule = RightToVanishModule(seedModule::hasher)
|
val rightToVanishModule = RightToVanishModule(seedModule::hasher)
|
||||||
|
|
||||||
|
val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher, indexStrategy)
|
||||||
|
|
||||||
val modules =
|
val modules =
|
||||||
listOf(
|
listOf(
|
||||||
seedModule,
|
seedModule,
|
||||||
@@ -73,6 +78,9 @@ class SQLiteEventStore(
|
|||||||
override fun onConfigure(db: SQLiteDatabase) {
|
override fun onConfigure(db: SQLiteDatabase) {
|
||||||
super.onConfigure(db)
|
super.onConfigure(db)
|
||||||
|
|
||||||
|
// 32MB memory cache
|
||||||
|
db.execSQL("PRAGMA cache_size=-32000;")
|
||||||
|
|
||||||
// makes sure the FKs are sane
|
// makes sure the FKs are sane
|
||||||
db.setForeignKeyConstraintsEnabled(true)
|
db.setForeignKeyConstraintsEnabled(true)
|
||||||
|
|
||||||
@@ -82,7 +90,14 @@ class SQLiteEventStore(
|
|||||||
|
|
||||||
// The DB can be corrupted if the OS is shutdown before sync, which generally
|
// The DB can be corrupted if the OS is shutdown before sync, which generally
|
||||||
// doesn't happen on Android
|
// 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) {
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
@@ -171,33 +186,72 @@ class SQLiteEventStore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T : Event> query(filter: Filter): List<T> = eventIndexModule.query(filter, readableDatabase)
|
fun <T : Event> query(filter: Filter): List<T> = queryBuilder.query(filter, readableDatabase)
|
||||||
|
|
||||||
fun <T : Event> query(filters: List<Filter>): List<T> = eventIndexModule.query(filters, readableDatabase)
|
fun <T : Event> query(filters: List<Filter>): List<T> = queryBuilder.query(filters, readableDatabase)
|
||||||
|
|
||||||
fun <T : Event> query(
|
fun <T : Event> query(
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
onEach: (T) -> Unit,
|
onEach: (T) -> Unit,
|
||||||
) = eventIndexModule.query(filter, readableDatabase, onEach)
|
) = queryBuilder.query(filter, readableDatabase, onEach)
|
||||||
|
|
||||||
fun <T : Event> query(
|
fun <T : Event> query(
|
||||||
filters: List<Filter>,
|
filters: List<Filter>,
|
||||||
onEach: (T) -> Unit,
|
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<RawEvent> = queryBuilder.rawQuery(filter, readableDatabase)
|
||||||
|
|
||||||
fun count(filters: List<Filter>): Int = eventIndexModule.count(filters, readableDatabase)
|
fun rawQuery(filters: List<Filter>): List<RawEvent> = queryBuilder.rawQuery(filters, readableDatabase)
|
||||||
|
|
||||||
|
fun rawQuery(
|
||||||
|
filter: Filter,
|
||||||
|
onEach: (RawEvent) -> Unit,
|
||||||
|
) = queryBuilder.rawQuery(filter, readableDatabase, onEach)
|
||||||
|
|
||||||
|
fun rawQuery(
|
||||||
|
filters: List<Filter>,
|
||||||
|
onEach: (RawEvent) -> Unit,
|
||||||
|
) = queryBuilder.rawQuery(filters, readableDatabase, onEach)
|
||||||
|
|
||||||
|
fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(readableDatabase), readableDatabase)
|
||||||
|
|
||||||
|
fun planQuery(filters: List<Filter>) = queryBuilder.planQuery(filters, seedModule.hasher(readableDatabase), readableDatabase)
|
||||||
|
|
||||||
|
fun count(filter: Filter): Int = queryBuilder.count(filter, readableDatabase)
|
||||||
|
|
||||||
|
fun count(filters: List<Filter>): Int = queryBuilder.count(filters, readableDatabase)
|
||||||
|
|
||||||
fun delete(filter: Filter) {
|
fun delete(filter: Filter) {
|
||||||
eventIndexModule.delete(filter, writableDatabase)
|
queryBuilder.delete(filter, writableDatabase)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(filters: List<Filter>) {
|
fun delete(filters: List<Filter>) {
|
||||||
eventIndexModule.delete(filters, writableDatabase)
|
queryBuilder.delete(filters, writableDatabase)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id))
|
fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id))
|
||||||
|
|
||||||
fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase)
|
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 <T : Event> toEvent() =
|
||||||
|
EventFactory.create<T>(
|
||||||
|
id.intern(),
|
||||||
|
pubKey.intern(),
|
||||||
|
createdAt,
|
||||||
|
kind,
|
||||||
|
OptimizedJsonMapper.fromJsonToTagArray(jsonTags),
|
||||||
|
content,
|
||||||
|
sig,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+6
@@ -21,6 +21,10 @@
|
|||||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
|
package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
|
||||||
|
|
||||||
sealed class Condition {
|
sealed class Condition {
|
||||||
|
data class Raw(
|
||||||
|
val condition: String,
|
||||||
|
) : Condition()
|
||||||
|
|
||||||
data class Equals(
|
data class Equals(
|
||||||
val column: String,
|
val column: String,
|
||||||
val value: Any?,
|
val value: Any?,
|
||||||
@@ -81,4 +85,6 @@ sealed class Condition {
|
|||||||
data class Or(
|
data class Or(
|
||||||
val conditions: List<Condition>,
|
val conditions: List<Condition>,
|
||||||
) : Condition()
|
) : Condition()
|
||||||
|
|
||||||
|
class Empty : Condition()
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-4
@@ -38,13 +38,27 @@ class SqlSelectionBuilder(
|
|||||||
*/
|
*/
|
||||||
private fun buildCondition(cond: Condition): String =
|
private fun buildCondition(cond: Condition): String =
|
||||||
when (cond) {
|
when (cond) {
|
||||||
|
is Condition.Empty -> {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
is Condition.Raw -> {
|
||||||
|
cond.condition
|
||||||
|
}
|
||||||
is Condition.Equals -> {
|
is Condition.Equals -> {
|
||||||
selectionArgs.add(cond.value.toString())
|
if (cond.value == null) {
|
||||||
"${cond.column} = ?"
|
"${cond.column} IS NULL"
|
||||||
|
} else {
|
||||||
|
selectionArgs.add(cond.value.toString())
|
||||||
|
"${cond.column} = ?"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is Condition.NotEquals -> {
|
is Condition.NotEquals -> {
|
||||||
selectionArgs.add(cond.value.toString())
|
if (cond.value == null) {
|
||||||
"${cond.column} != ?"
|
"${cond.column} IS NULL"
|
||||||
|
} else {
|
||||||
|
selectionArgs.add(cond.value.toString())
|
||||||
|
"${cond.column} != ?"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is Condition.GreaterThan -> {
|
is Condition.GreaterThan -> {
|
||||||
selectionArgs.add(cond.value.toString())
|
selectionArgs.add(cond.value.toString())
|
||||||
|
|||||||
+13
-4
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
|
|||||||
class WhereClauseBuilder {
|
class WhereClauseBuilder {
|
||||||
private val conditions = mutableListOf<Condition>()
|
private val conditions = mutableListOf<Condition>()
|
||||||
|
|
||||||
|
fun raw(condition: String) = apply { conditions.add(Condition.Raw(condition)) }
|
||||||
|
|
||||||
fun equals(
|
fun equals(
|
||||||
column: String,
|
column: String,
|
||||||
value: Any?,
|
value: Any?,
|
||||||
@@ -86,7 +88,7 @@ class WhereClauseBuilder {
|
|||||||
fun and(block: WhereClauseBuilder.() -> Unit) =
|
fun and(block: WhereClauseBuilder.() -> Unit) =
|
||||||
apply {
|
apply {
|
||||||
val builder = WhereClauseBuilder().apply(block)
|
val builder = WhereClauseBuilder().apply(block)
|
||||||
val builtCondition = builder.build()
|
val builtCondition = builder.buildAnd()
|
||||||
if (builtCondition != null) {
|
if (builtCondition != null) {
|
||||||
conditions.add(builtCondition)
|
conditions.add(builtCondition)
|
||||||
}
|
}
|
||||||
@@ -95,22 +97,29 @@ class WhereClauseBuilder {
|
|||||||
fun or(block: WhereClauseBuilder.() -> Unit) =
|
fun or(block: WhereClauseBuilder.() -> Unit) =
|
||||||
apply {
|
apply {
|
||||||
val builder = WhereClauseBuilder().apply(block)
|
val builder = WhereClauseBuilder().apply(block)
|
||||||
val builtCondition = builder.build()
|
val builtCondition = builder.buildOr()
|
||||||
if (builtCondition != null) {
|
if (builtCondition != null) {
|
||||||
conditions.add(builtCondition)
|
conditions.add(builtCondition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun build(): Condition? =
|
fun buildAnd(): Condition? =
|
||||||
when (conditions.size) {
|
when (conditions.size) {
|
||||||
0 -> null
|
0 -> null
|
||||||
1 -> conditions.first()
|
1 -> conditions.first()
|
||||||
else -> Condition.And(conditions.toList())
|
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 {
|
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()
|
return SqlSelectionBuilder(condition).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -44,7 +44,13 @@ class AddressSerializer {
|
|||||||
return try {
|
return try {
|
||||||
val parts = addressId.split(":", limit = 3)
|
val parts = addressId.split(":", limit = 3)
|
||||||
if (parts.size > 2 && parts[1].length == 64 && Hex.isHex(parts[1])) {
|
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 {
|
} else {
|
||||||
if (addressId.startsWith("naddr1")) {
|
if (addressId.startsWith("naddr1")) {
|
||||||
val addr = Nip19Parser.uriToRoute(addressId)?.entity
|
val addr = Nip19Parser.uriToRoute(addressId)?.entity
|
||||||
@@ -60,7 +66,7 @@ class AddressSerializer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.e("AddressableId", "Error parsing: $addressId: ${t.message}", t)
|
Log.w("AddressableId", "Error parsing: $addressId: ${t.message}", t)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
|||||||
|
|
||||||
typealias Kind = Int
|
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.isEphemeral() = this >= 20_000 && this < 30_000
|
||||||
|
|
||||||
fun Kind.isReplaceable() = this == MetadataEvent.KIND || this == ContactListEvent.KIND || (this >= 10_000 && this < 20_000)
|
fun Kind.isReplaceable() = this == MetadataEvent.KIND || this == ContactListEvent.KIND || (this >= 10_000 && this < 20_000)
|
||||||
|
|||||||
+1
@@ -54,6 +54,7 @@ object JsonMapper {
|
|||||||
val jsonInstance =
|
val jsonInstance =
|
||||||
Json {
|
Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
}
|
}
|
||||||
|
|
||||||
inline fun <reified T> fromJson(json: String): T = jsonInstance.decodeFromString<T>(json)
|
inline fun <reified T> fromJson(json: String): T = jsonInstance.decodeFromString<T>(json)
|
||||||
|
|||||||
+3
-3
@@ -35,10 +35,10 @@ class RelayBasedFilter(
|
|||||||
fun List<RelayBasedFilter>.groupByRelay(): Map<NormalizedRelayUrl, List<Filter>> {
|
fun List<RelayBasedFilter>.groupByRelay(): Map<NormalizedRelayUrl, List<Filter>> {
|
||||||
val result = mutableMapOf<NormalizedRelayUrl, MutableList<Filter>>()
|
val result = mutableMapOf<NormalizedRelayUrl, MutableList<Filter>>()
|
||||||
for (relayBasedFilter in this) {
|
for (relayBasedFilter in this) {
|
||||||
if (relayBasedFilter.filter.isFilledFilter()) {
|
if (relayBasedFilter.filter.isEmpty()) {
|
||||||
result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter)
|
|
||||||
} else {
|
|
||||||
Log.e("FilterError", "Ignoring empty filter for ${relayBasedFilter.relay}")
|
Log.e("FilterError", "Ignoring empty filter for ${relayBasedFilter.relay}")
|
||||||
|
} else {
|
||||||
|
result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
+1
-1
@@ -128,7 +128,7 @@ open class BasicRelayClient(
|
|||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
// doesn't expose parsing errors to lib users as errors
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-21
@@ -21,8 +21,10 @@
|
|||||||
package com.vitorpamplona.quartz.nip01Core.relay.client.stats
|
package com.vitorpamplona.quartz.nip01Core.relay.client.stats
|
||||||
|
|
||||||
import androidx.collection.LruCache
|
import androidx.collection.LruCache
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
@Stable
|
||||||
class RelayStat(
|
class RelayStat(
|
||||||
var receivedBytes: Int = 0,
|
var receivedBytes: Int = 0,
|
||||||
var sentBytes: Int = 0,
|
var sentBytes: Int = 0,
|
||||||
@@ -31,12 +33,11 @@ class RelayStat(
|
|||||||
var pingInMs: Int = 0,
|
var pingInMs: Int = 0,
|
||||||
var compression: Boolean = false,
|
var compression: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val messages = LruCache<RelayDebugMessage, RelayDebugMessage>(100)
|
val messages = LruCache<IRelayDebugMessage, IRelayDebugMessage>(100)
|
||||||
|
|
||||||
fun newNotice(notice: String?) {
|
fun newNotice(notice: String?) {
|
||||||
val debugMessage =
|
val debugMessage =
|
||||||
RelayDebugMessage(
|
NoticeDebugMessage(
|
||||||
type = RelayDebugMessageType.NOTICE,
|
|
||||||
message = notice ?: "No error message provided",
|
message = notice ?: "No error message provided",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,8 +48,7 @@ class RelayStat(
|
|||||||
errorCounter++
|
errorCounter++
|
||||||
|
|
||||||
val debugMessage =
|
val debugMessage =
|
||||||
RelayDebugMessage(
|
ErrorDebugMessage(
|
||||||
type = RelayDebugMessageType.ERROR,
|
|
||||||
message = error ?: "No error message provided",
|
message = error ?: "No error message provided",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,27 +63,35 @@ class RelayStat(
|
|||||||
sentBytes += bytesUsedInMemory
|
sentBytes += bytesUsedInMemory
|
||||||
}
|
}
|
||||||
|
|
||||||
fun newSpam(spamDescriptor: String) {
|
fun newSpam(
|
||||||
|
link1: String,
|
||||||
|
link2: String,
|
||||||
|
) {
|
||||||
spamCounter++
|
spamCounter++
|
||||||
|
|
||||||
val debugMessage =
|
val debugMessage = SpamDebugMessage(link1, link2)
|
||||||
RelayDebugMessage(
|
|
||||||
type = RelayDebugMessageType.SPAM,
|
|
||||||
message = spamDescriptor,
|
|
||||||
)
|
|
||||||
|
|
||||||
messages.put(debugMessage, debugMessage)
|
messages.put(debugMessage, debugMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RelayDebugMessage(
|
@Stable
|
||||||
val type: RelayDebugMessageType,
|
sealed interface IRelayDebugMessage {
|
||||||
val message: String,
|
val time: Long
|
||||||
val time: Long = TimeUtils.now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class RelayDebugMessageType {
|
|
||||||
SPAM,
|
|
||||||
NOTICE,
|
|
||||||
ERROR,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
+41
-22
@@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
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.OptimizedJsonMapper
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
|
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
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).
|
* - authors: Optional list of author public keys (must be 64 characters).
|
||||||
* - kinds: Optional list of event kinds to include.
|
* - 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).
|
* - 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.
|
* - since: Optional timestamp for filtering events with publication time ≥ this value.
|
||||||
* - until: 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.
|
* - 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.
|
* follow Nostr requirements (64-char hex, onion addresses) and logs errors for invalid inputs.
|
||||||
*/
|
*/
|
||||||
class Filter(
|
class Filter(
|
||||||
val ids: List<String>? = null,
|
val ids: List<HexKey>? = null,
|
||||||
val authors: List<String>? = null,
|
val authors: List<HexKey>? = null,
|
||||||
val kinds: List<Int>? = null,
|
val kinds: List<Kind>? = null,
|
||||||
val tags: Map<String, List<String>>? = null,
|
val tags: Map<String, List<String>>? = null,
|
||||||
|
val tagsAll: Map<String, List<String>>? = null,
|
||||||
val since: Long? = null,
|
val since: Long? = null,
|
||||||
val until: Long? = null,
|
val until: Long? = null,
|
||||||
val limit: Int? = null,
|
val limit: Int? = null,
|
||||||
@@ -55,31 +59,33 @@ class Filter(
|
|||||||
) : OptimizedSerializable {
|
) : OptimizedSerializable {
|
||||||
fun toJson() = OptimizedJsonMapper.toJson(this)
|
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(
|
fun copy(
|
||||||
ids: List<String>? = this.ids,
|
ids: List<String>? = this.ids,
|
||||||
authors: List<String>? = this.authors,
|
authors: List<String>? = this.authors,
|
||||||
kinds: List<Int>? = this.kinds,
|
kinds: List<Int>? = this.kinds,
|
||||||
tags: Map<String, List<String>>? = this.tags,
|
tags: Map<String, List<String>>? = this.tags,
|
||||||
|
tagsAll: Map<String, List<String>>? = this.tagsAll,
|
||||||
since: Long? = this.since,
|
since: Long? = this.since,
|
||||||
until: Long? = this.until,
|
until: Long? = this.until,
|
||||||
limit: Int? = this.limit,
|
limit: Int? = this.limit,
|
||||||
search: String? = this.search,
|
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() =
|
fun isEmpty() =
|
||||||
(ids != null && ids.isNotEmpty()) ||
|
(ids == null || ids.isEmpty()) &&
|
||||||
(authors != null && authors.isNotEmpty()) ||
|
(authors == null || authors.isEmpty()) &&
|
||||||
(kinds != null && kinds.isNotEmpty()) ||
|
(kinds == null || kinds.isEmpty()) &&
|
||||||
(tags != null && tags.isNotEmpty() && tags.values.all { it.isNotEmpty() }) ||
|
(tags == null || tags.isEmpty() && tags.values.all { it.isNotEmpty() }) &&
|
||||||
(since != null) ||
|
(tagsAll == null || tagsAll.isEmpty() && tagsAll.values.all { it.isNotEmpty() }) &&
|
||||||
(until != null) ||
|
(since == null) &&
|
||||||
(limit != null) ||
|
(until == null) &&
|
||||||
(search != null && search.isNotEmpty())
|
(limit == null) &&
|
||||||
|
(search == null || search.isEmpty())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
ids?.forEach {
|
ids?.forEach {
|
||||||
@@ -89,14 +95,27 @@ class Filter(
|
|||||||
if (it.length != 64) Log.e("FilterError", "Invalid author length $it on ${toJson()}")
|
if (it.length != 64) Log.e("FilterError", "Invalid author length $it on ${toJson()}")
|
||||||
}
|
}
|
||||||
// tests common tags.
|
// tests common tags.
|
||||||
tags?.get("p")?.forEach {
|
if (tags != null) {
|
||||||
if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}")
|
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 (tagsAll != null) {
|
||||||
if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}")
|
tagsAll["p"]?.forEach {
|
||||||
}
|
if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}")
|
||||||
tags?.get("a")?.forEach {
|
}
|
||||||
if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $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()}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -29,6 +29,7 @@ object FilterMatcher {
|
|||||||
authors: List<String>? = null,
|
authors: List<String>? = null,
|
||||||
kinds: List<Int>? = null,
|
kinds: List<Int>? = null,
|
||||||
tags: Map<String, List<String>>? = null,
|
tags: Map<String, List<String>>? = null,
|
||||||
|
tagsAll: Map<String, List<String>>? = null,
|
||||||
since: Long? = null,
|
since: Long? = null,
|
||||||
until: Long? = null,
|
until: Long? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
@@ -36,7 +37,23 @@ object FilterMatcher {
|
|||||||
if (kinds?.contains(event.kind) == false) return false
|
if (kinds?.contains(event.kind) == false) return false
|
||||||
if (authors?.contains(event.pubKey) == false) return false
|
if (authors?.contains(event.pubKey) == false) return false
|
||||||
tags?.forEach { tag ->
|
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)) {
|
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+3
@@ -20,6 +20,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.relay.normalizer
|
package com.vitorpamplona.quartz.nip01Core.relay.normalizer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
|
||||||
|
@Stable
|
||||||
data class NormalizedRelayUrl(
|
data class NormalizedRelayUrl(
|
||||||
val url: String,
|
val url: String,
|
||||||
) : Comparable<NormalizedRelayUrl> {
|
) : Comparable<NormalizedRelayUrl> {
|
||||||
|
|||||||
+7
-7
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.quartz.nip11RelayInfo
|
package com.vitorpamplona.quartz.nip11RelayInfo
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import com.vitorpamplona.quartz.utils.text
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.builtins.ListSerializer
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
@@ -32,15 +33,14 @@ import kotlinx.serialization.json.JsonArray
|
|||||||
import kotlinx.serialization.json.JsonDecoder
|
import kotlinx.serialization.json.JsonDecoder
|
||||||
import kotlinx.serialization.json.JsonNull
|
import kotlinx.serialization.json.JsonNull
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.int
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
object FlexibleIntListSerializer : KSerializer<List<Int>?> {
|
object FlexibleIntListSerializer : KSerializer<List<String>?> {
|
||||||
private val listSerializer = ListSerializer(Int.serializer())
|
private val listSerializer = ListSerializer(String.serializer())
|
||||||
|
|
||||||
override val descriptor: SerialDescriptor = listSerializer.descriptor
|
override val descriptor: SerialDescriptor = listSerializer.descriptor
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): List<Int>? {
|
override fun deserialize(decoder: Decoder): List<String>? {
|
||||||
require(decoder is JsonDecoder) { "This serializer can only be used with Json format" }
|
require(decoder is JsonDecoder) { "This serializer can only be used with Json format" }
|
||||||
|
|
||||||
return when (val element = decoder.decodeJsonElement()) {
|
return when (val element = decoder.decodeJsonElement()) {
|
||||||
@@ -51,7 +51,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
|
|||||||
is JsonArray -> {
|
is JsonArray -> {
|
||||||
element.mapNotNull { arrayElement ->
|
element.mapNotNull { arrayElement ->
|
||||||
try {
|
try {
|
||||||
arrayElement.jsonPrimitive.int
|
arrayElement.jsonPrimitive.text
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Skip elements that aren't valid integers (strings, booleans, floats, etc.)
|
// Skip elements that aren't valid integers (strings, booleans, floats, etc.)
|
||||||
Log.w("FlexibleIntListSerializer", "Invalid element in array: $arrayElement", e)
|
Log.w("FlexibleIntListSerializer", "Invalid element in array: $arrayElement", e)
|
||||||
@@ -63,7 +63,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
|
|||||||
// Handle single integer format (malformed but found in the wild): 1
|
// Handle single integer format (malformed but found in the wild): 1
|
||||||
is JsonPrimitive if !element.isString -> {
|
is JsonPrimitive if !element.isString -> {
|
||||||
try {
|
try {
|
||||||
listOf(element.int)
|
listOf(element.text)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Can't parse as integer (e.g., float, boolean), treat as missing data
|
// Can't parse as integer (e.g., float, boolean), treat as missing data
|
||||||
Log.w("FlexibleIntListSerializer", "Invalid primitive: $element", e)
|
Log.w("FlexibleIntListSerializer", "Invalid primitive: $element", e)
|
||||||
@@ -79,7 +79,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
|
|||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
override fun serialize(
|
override fun serialize(
|
||||||
encoder: Encoder,
|
encoder: Encoder,
|
||||||
value: List<Int>?,
|
value: List<String>?,
|
||||||
) {
|
) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
encoder.encodeNull()
|
encoder.encodeNull()
|
||||||
|
|||||||
+53
-43
@@ -21,19 +21,22 @@
|
|||||||
package com.vitorpamplona.quartz.nip11RelayInfo
|
package com.vitorpamplona.quartz.nip11RelayInfo
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Stable
|
||||||
@Serializable
|
@Serializable
|
||||||
class Nip11RelayInformation(
|
class Nip11RelayInformation(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val icon: String? = null,
|
val icon: String? = null,
|
||||||
val pubkey: String? = null,
|
val pubkey: HexKey? = null,
|
||||||
|
val self: HexKey? = null,
|
||||||
val contact: String? = null,
|
val contact: String? = null,
|
||||||
@Serializable(with = FlexibleIntListSerializer::class)
|
@Serializable(with = FlexibleIntListSerializer::class)
|
||||||
val supported_nips: List<Int>? = null,
|
val supported_nips: List<String>? = null,
|
||||||
val supported_nip_extensions: List<String>? = null,
|
val supported_nip_extensions: List<String>? = null,
|
||||||
val software: String? = null,
|
val software: String? = null,
|
||||||
val version: String? = null,
|
val version: String? = null,
|
||||||
@@ -42,53 +45,60 @@ class Nip11RelayInformation(
|
|||||||
val language_tags: List<String>? = null,
|
val language_tags: List<String>? = null,
|
||||||
val tags: List<String>? = null,
|
val tags: List<String>? = null,
|
||||||
val posting_policy: String? = null,
|
val posting_policy: String? = null,
|
||||||
|
val privacy_policy: String? = null,
|
||||||
|
val terms_of_service: String? = null,
|
||||||
val payments_url: String? = null,
|
val payments_url: String? = null,
|
||||||
val retention: List<RelayInformationRetentionData>? = null,
|
val retention: List<RelayInformationRetentionData>? = null,
|
||||||
val fees: RelayInformationFees? = null,
|
val fees: RelayInformationFees? = null,
|
||||||
val nip50: List<String>? = null,
|
val nip50: List<String>? = null,
|
||||||
|
val supported_grasps: List<String>? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson<Nip11RelayInformation>(json)
|
fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson<Nip11RelayInformation>(json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
@Serializable
|
||||||
|
class RelayInformationFee(
|
||||||
|
val amount: Int? = null,
|
||||||
|
val unit: String? = null,
|
||||||
|
val period: Int? = null,
|
||||||
|
val kinds: List<Int>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
@Serializable
|
||||||
|
class RelayInformationFees(
|
||||||
|
val admission: List<RelayInformationFee>? = null,
|
||||||
|
val subscription: List<RelayInformationFee>? = null,
|
||||||
|
val publication: List<RelayInformationFee>? = 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<Int>? = 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<Int>? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
class RelayInformationFees(
|
|
||||||
val admission: List<RelayInformationFee>? = null,
|
|
||||||
val subscription: List<RelayInformationFee>? = null,
|
|
||||||
val publication: List<RelayInformationFee>? = 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<Int>? = null,
|
|
||||||
val time: Int? = null,
|
|
||||||
val count: Int? = null,
|
|
||||||
)
|
|
||||||
|
|||||||
+15
@@ -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(
|
suspend fun resign(
|
||||||
tags: TagArray,
|
tags: TagArray,
|
||||||
privateTags: TagArray,
|
privateTags: TagArray,
|
||||||
|
|||||||
+2
@@ -20,11 +20,13 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip94FileMetadata.tags
|
package com.vitorpamplona.quartz.nip94FileMetadata.tags
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
import com.vitorpamplona.quartz.utils.ensure
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@Stable
|
||||||
class DimensionTag(
|
class DimensionTag(
|
||||||
val width: Int,
|
val width: Int,
|
||||||
val height: Int,
|
val height: Int,
|
||||||
|
|||||||
+149
@@ -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))
|
||||||
|
}
|
||||||
+7
-7
@@ -74,7 +74,7 @@ class Nip11RelayInformationTest {
|
|||||||
assertEquals("purplepag.es", info.name)
|
assertEquals("purplepag.es", info.name)
|
||||||
assertEquals("Nostr's Purple Pages", info.description)
|
assertEquals("Nostr's Purple Pages", info.description)
|
||||||
assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey)
|
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("1.0.4", info.version)
|
||||||
assertEquals("git+https://github.com/hoytech/strfry.git", info.software)
|
assertEquals("git+https://github.com/hoytech/strfry.git", info.software)
|
||||||
}
|
}
|
||||||
@@ -88,7 +88,7 @@ class Nip11RelayInformationTest {
|
|||||||
assertEquals("Nostr's Purple Pages", info.description)
|
assertEquals("Nostr's Purple Pages", info.description)
|
||||||
assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey)
|
assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey)
|
||||||
// Single integer should be converted to a list with one element
|
// 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("1.0.4", info.version)
|
||||||
assertEquals("git+https://github.com/hoytech/strfry.git", info.software)
|
assertEquals("git+https://github.com/hoytech/strfry.git", info.software)
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,7 @@ class Nip11RelayInformationTest {
|
|||||||
assertNotNull(info)
|
assertNotNull(info)
|
||||||
assertEquals("test relay", info.name)
|
assertEquals("test relay", info.name)
|
||||||
// Should skip invalid elements and keep only valid integers
|
// 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
|
@Test
|
||||||
@@ -144,7 +144,7 @@ class Nip11RelayInformationTest {
|
|||||||
assertNotNull(info)
|
assertNotNull(info)
|
||||||
assertEquals("test relay", info.name)
|
assertEquals("test relay", info.name)
|
||||||
// All elements invalid, should result in empty list
|
// All elements invalid, should result in empty list
|
||||||
assertEquals(emptyList(), info.supported_nips)
|
assertEquals(listOf("one", "two", "three"), info.supported_nips)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -156,7 +156,7 @@ class Nip11RelayInformationTest {
|
|||||||
assertNotNull(info)
|
assertNotNull(info)
|
||||||
assertEquals("test relay", info.name)
|
assertEquals("test relay", info.name)
|
||||||
// Cannot parse float as integer, should be null
|
// Cannot parse float as integer, should be null
|
||||||
assertNull(info.supported_nips)
|
assertEquals(listOf("1.5"), info.supported_nips)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -168,7 +168,7 @@ class Nip11RelayInformationTest {
|
|||||||
assertNotNull(info)
|
assertNotNull(info)
|
||||||
assertEquals("test relay", info.name)
|
assertEquals("test relay", info.name)
|
||||||
// Cannot parse boolean as integer, should be null
|
// Cannot parse boolean as integer, should be null
|
||||||
assertNull(info.supported_nips)
|
assertEquals(listOf("true"), info.supported_nips)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -204,6 +204,6 @@ class Nip11RelayInformationTest {
|
|||||||
|
|
||||||
assertNotNull(info)
|
assertNotNull(info)
|
||||||
assertEquals("test relay", info.name)
|
assertEquals("test relay", info.name)
|
||||||
assertEquals((1..100).toList(), info.supported_nips)
|
assertEquals((1..100).map { it.toString() }, info.supported_nips)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -106,9 +106,9 @@ class NIP19ParserTest {
|
|||||||
val actual = Nip19Parser.uriToRoute("nostr:nprofile1qqsyvrp9u6p0mfur9dfdru3d853tx9mdjuhkphxuxgfwmryja7zsvhqpzamhxue69uhhv6t5daezumn0wd68yvfwvdhk6tcpz9mhxue69uhkummnw3ezuamfdejj7qgwwaehxw309ahx7uewd3hkctcscpyug")
|
val actual = Nip19Parser.uriToRoute("nostr:nprofile1qqsyvrp9u6p0mfur9dfdru3d853tx9mdjuhkphxuxgfwmryja7zsvhqpzamhxue69uhhv6t5daezumn0wd68yvfwvdhk6tcpz9mhxue69uhkummnw3ezuamfdejj7qgwwaehxw309ahx7uewd3hkctcscpyug")
|
||||||
|
|
||||||
assertNotNull(actual)
|
assertNotNull(actual)
|
||||||
assertTrue(actual?.entity is NProfile)
|
assertTrue(actual.entity is NProfile)
|
||||||
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", actual.entity.hex)
|
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()
|
@Test()
|
||||||
@@ -267,7 +267,7 @@ class NIP19ParserTest {
|
|||||||
"31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far",
|
"31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far",
|
||||||
result.aTag(),
|
result.aTag(),
|
||||||
)
|
)
|
||||||
assertEquals(true, result.relay?.isEmpty())
|
assertEquals(true, result.relay.isEmpty())
|
||||||
assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result.author)
|
assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result.author)
|
||||||
assertEquals(31337, result.kind)
|
assertEquals(31337, result.kind)
|
||||||
}
|
}
|
||||||
@@ -285,7 +285,7 @@ class NIP19ParserTest {
|
|||||||
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509",
|
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509",
|
||||||
result.aTag(),
|
result.aTag(),
|
||||||
)
|
)
|
||||||
assertEquals(true, result.relay?.isEmpty())
|
assertEquals(true, result.relay.isEmpty())
|
||||||
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
|
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
|
||||||
assertEquals(30023, result.kind)
|
assertEquals(30023, result.kind)
|
||||||
}
|
}
|
||||||
@@ -303,7 +303,7 @@ class NIP19ParserTest {
|
|||||||
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418",
|
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418",
|
||||||
result.aTag(),
|
result.aTag(),
|
||||||
)
|
)
|
||||||
assertEquals(true, result.relay?.isEmpty())
|
assertEquals(true, result.relay.isEmpty())
|
||||||
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
|
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
|
||||||
assertEquals(30023, result.kind)
|
assertEquals(30023, result.kind)
|
||||||
}
|
}
|
||||||
@@ -356,7 +356,7 @@ class NIP19ParserTest {
|
|||||||
assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result.hex)
|
assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result.hex)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
NormalizedRelayUrl("wss://nostr.mom/"),
|
NormalizedRelayUrl("wss://nostr.mom/"),
|
||||||
result.relay?.get(0),
|
result.relay[0],
|
||||||
)
|
)
|
||||||
assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result.author)
|
assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result.author)
|
||||||
assertEquals(1, result.kind)
|
assertEquals(1, result.kind)
|
||||||
@@ -403,7 +403,7 @@ class NIP19ParserTest {
|
|||||||
|
|
||||||
assertNotNull(result)
|
assertNotNull(result)
|
||||||
assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result.hex)
|
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("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author)
|
||||||
assertEquals(1, result.kind)
|
assertEquals(1, result.kind)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import kotlinx.cinterop.ExperimentalForeignApi
|
|||||||
import kotlinx.cinterop.refTo
|
import kotlinx.cinterop.refTo
|
||||||
import platform.Security.SecRandomCopyBytes
|
import platform.Security.SecRandomCopyBytes
|
||||||
import platform.Security.kSecRandomDefault
|
import platform.Security.kSecRandomDefault
|
||||||
import kotlin.random.Random.Default.nextBytes
|
|
||||||
|
|
||||||
actual class SecureRandom {
|
actual class SecureRandom {
|
||||||
actual fun nextInt(): Int {
|
actual fun nextInt(): Int {
|
||||||
|
|||||||
+4
@@ -63,6 +63,10 @@ class EventDeserializer : StdDeserializer<Event>(Event::class.java) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pubKey.isEmpty()) {
|
||||||
|
throw IllegalArgumentException("Event not found")
|
||||||
|
}
|
||||||
|
|
||||||
return EventFactory.create(id, pubKey, createdAt, kind, tags, content, sig)
|
return EventFactory.create(id, pubKey, createdAt, kind, tags, content, sig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -59,7 +59,6 @@ import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer
|
|||||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
|
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
|
||||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer
|
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer
|
||||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer
|
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer
|
||||||
import kotlinx.serialization.json.JsonNull.content
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class JacksonMapper {
|
class JacksonMapper {
|
||||||
|
|||||||
+10
-3
@@ -38,10 +38,16 @@ class FilterDeserializer : StdDeserializer<Filter>(Filter::class.java) {
|
|||||||
class ManualFilterDeserializer {
|
class ManualFilterDeserializer {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromJson(jsonObject: ObjectNode): Filter {
|
fun fromJson(jsonObject: ObjectNode): Filter {
|
||||||
val tags = mutableListOf<String>()
|
val tagsIn = mutableListOf<String>()
|
||||||
jsonObject.fieldNames().forEach {
|
jsonObject.fieldNames().forEach {
|
||||||
if (it.startsWith("#")) {
|
if (it.startsWith("#")) {
|
||||||
tags.add(it.substring(1))
|
tagsIn.add(it.substring(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val tagsAll = mutableListOf<String>()
|
||||||
|
jsonObject.fieldNames().forEach {
|
||||||
|
if (it.startsWith("&")) {
|
||||||
|
tagsAll.add(it.substring(1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +55,8 @@ class ManualFilterDeserializer {
|
|||||||
ids = jsonObject.get("ids").mapNotNull { it.asTextOrNull() },
|
ids = jsonObject.get("ids").mapNotNull { it.asTextOrNull() },
|
||||||
authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() },
|
authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() },
|
||||||
kinds = jsonObject.get("kinds").mapNotNull { it.asIntOrNull() },
|
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(),
|
since = jsonObject.get("since").asLongOrNull(),
|
||||||
until = jsonObject.get("until").asLongOrNull(),
|
until = jsonObject.get("until").asLongOrNull(),
|
||||||
limit = jsonObject.get("limit").asIntOrNull(),
|
limit = jsonObject.get("limit").asIntOrNull(),
|
||||||
|
|||||||
+10
@@ -66,6 +66,16 @@ class FilterSerializer : StdSerializer<Filter>(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.since?.run { gen.writeNumberField("since", this) }
|
||||||
filter.until?.run { gen.writeNumberField("until", this) }
|
filter.until?.run { gen.writeNumberField("until", this) }
|
||||||
filter.limit?.run { gen.writeNumberField("limit", this) }
|
filter.limit?.run { gen.writeNumberField("limit", this) }
|
||||||
|
|||||||
Reference in New Issue
Block a user