From 13e07eb2e8ee1d0149edf52905720f84542fa1a5 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 18 Dec 2025 12:45:18 +0100 Subject: [PATCH 01/44] Add VoiceReply Route Create VoiceReplyViewModel and VoiceReplyScreen Add String Resource(s) --- .../amethyst/ui/navigation/routes/Routes.kt | 10 + .../screen/loggedIn/home/VoiceReplyScreen.kt | 321 ++++++++++++++++++ .../loggedIn/home/VoiceReplyViewModel.kt | 242 +++++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 4 files changed, 574 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9c3ccf5a9..631edb320 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -285,6 +285,15 @@ sealed class Route { val draft: String? = null, ) : Route() + @Serializable + data class VoiceReply( + val replyToNoteId: String, + val recordingFilePath: String, + val mimeType: String, + val duration: Int, + val amplitudes: String, // JSON-encoded List + ) : Route() + @Serializable data class ManualZapSplitPayment( val paymentId: String, @@ -334,6 +343,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt new file mode 100644 index 000000000..05c9f88ac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -0,0 +1,321 @@ +/** + * 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.home + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox +import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VoiceReplyScreen( + replyToNoteId: String, + recordingFilePath: String, + mimeType: String, + duration: Int, + amplitudesJson: String, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val viewModel: VoiceReplyViewModel = viewModel() + viewModel.init(accountViewModel) + + LaunchedEffect(replyToNoteId, recordingFilePath) { + viewModel.load(replyToNoteId, recordingFilePath, mimeType, duration, amplitudesJson) + } + + BackHandler { + viewModel.cancel() + nav.popBack() + } + + Scaffold( + topBar = { + PostingTopBar( + isActive = viewModel::canSend, + onPost = { + viewModel.sendVoiceReply { + nav.popBack() + } + }, + onCancel = { + viewModel.cancel() + nav.popBack() + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad), + ) { + VoiceReplyScreenBody(viewModel, accountViewModel, nav) + } + } +} + +@Composable +private fun VoiceReplyScreenBody( + viewModel: VoiceReplyViewModel, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val scrollState = rememberScrollState() + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = Size10dp), + ) { + // Show the note being replied to + viewModel.replyToNote?.let { note -> + Spacer(modifier = StdVertSpacer) + NoteCompose( + baseNote = note, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = false, + makeItShort = true, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdVertSpacer) + } + + // Voice preview or upload progress + viewModel.voiceOrchestrator?.let { orchestrator -> + VoiceUploadingProgress(orchestrator) + } ?: run { + viewModel.getVoicePreviewMetadata()?.let { metadata -> + VoiceMessagePreview( + voiceMetadata = metadata, + localFile = viewModel.voiceLocalFile, + onRemove = { + viewModel.cancel() + nav.popBack() + }, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Server selection + ServerSelectionRow(viewModel, accountViewModel) + + Spacer(modifier = Modifier.weight(1f)) + + // Re-record button at bottom + ReRecordButton(viewModel) + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun ServerSelectionRow( + viewModel: VoiceReplyViewModel, + accountViewModel: AccountViewModel, +) { + val nip95description = stringRes(id = R.string.upload_server_relays_nip95) + val fileServersState = + accountViewModel.account.serverLists.liveServerList + .collectAsState() + val fileServers = fileServersState.value + + val fileServerOptions = + remember(fileServers) { + fileServers + .map { + if (it.type == ServerType.NIP95) { + TitleExplainer(it.name, nip95description) + } else { + TitleExplainer(it.name, it.baseUrl) + } + }.toImmutableList() + } + + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = + fileServers + .firstOrNull { it == (viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) } + ?.name + ?: fileServers.firstOrNull()?.name + ?: "", + options = fileServerOptions, + onSelect = { viewModel.voiceSelectedServer = fileServers[it] }, + ) + } +} + +@Composable +private fun ReRecordButton(viewModel: VoiceReplyViewModel) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + RecordAudioBox( + modifier = Modifier, + onRecordTaken = { recording -> + viewModel.selectRecording(recording) + }, + ) { isRecording, _ -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + tint = + if (isRecording) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + Text( + text = stringRes(id = R.string.re_record), + color = + if (isRecording) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } + } + } +} + +@Composable +private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { + val progressValue = orchestrator.progress.collectAsState().value + val progressStatusValue = orchestrator.progressState.collectAsState().value + + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.size(55.dp), + contentAlignment = Alignment.Center, + ) { + val animatedProgress = + animateFloatAsState( + targetValue = progressValue.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ).value + + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + + val txt = + when (progressStatusValue) { + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) + } + + Text( + txt, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt new file mode 100644 index 000000000..fe99743f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -0,0 +1,242 @@ +/** + * 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.home + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import java.io.File + +@Stable +class VoiceReplyViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + + var replyToNote: Note? by mutableStateOf(null) + + var voiceRecording: RecordingResult? by mutableStateOf(null) + var voiceLocalFile: File? by mutableStateOf(null) + var voiceMetadata: AudioMeta? by mutableStateOf(null) + var voiceSelectedServer: ServerName? by mutableStateOf(null) + var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null) + var isUploading: Boolean by mutableStateOf(false) + + fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + } + + fun load( + replyToNoteId: String, + recordingFilePath: String, + mimeType: String, + duration: Int, + amplitudesJson: String, + ) { + replyToNote = accountViewModel.getNoteIfExists(replyToNoteId) + + val amplitudes = + try { + Json.decodeFromString>(amplitudesJson) + } catch (e: Exception) { + Log.w("VoiceReplyViewModel", "Failed to parse amplitudes", e) + emptyList() + } + + val file = File(recordingFilePath) + voiceLocalFile = file + voiceRecording = + RecordingResult( + file = file, + mimeType = mimeType, + duration = duration, + amplitudes = amplitudes, + ) + } + + fun getVoicePreviewMetadata(): AudioMeta? = + voiceRecording?.let { recording -> + AudioMeta( + url = "", + mimeType = recording.mimeType, + duration = recording.duration, + waveform = recording.amplitudes, + ) + } + + fun selectRecording(recording: RecordingResult) { + deleteVoiceLocalFile() + voiceRecording = recording + voiceLocalFile = recording.file + voiceMetadata = null + } + + fun removeVoiceMessage() { + deleteVoiceLocalFile() + voiceRecording = null + voiceLocalFile = null + voiceMetadata = null + voiceSelectedServer = null + isUploading = false + voiceOrchestrator = null + } + + private fun deleteVoiceLocalFile() { + voiceLocalFile?.let { file -> + try { + if (file.exists()) { + file.delete() + Log.d("VoiceReplyViewModel", "Deleted voice file: ${file.absolutePath}") + } + } catch (e: Exception) { + Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) + } + } + } + + fun canSend(): Boolean = (voiceRecording != null || voiceMetadata != null) && !isUploading + + fun sendVoiceReply(onSuccess: () -> Unit) { + val note = replyToNote ?: return + val recording = voiceRecording ?: return + val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer + + viewModelScope.launch(Dispatchers.IO) { + uploadAndSend(note, recording, serverToUse, onSuccess) + } + } + + private suspend fun uploadAndSend( + note: Note, + recording: RecordingResult, + server: ServerName, + onSuccess: () -> Unit, + ) { + val appContext = Amethyst.instance.appContext + val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) + val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) + val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed) + val uploadVoiceExceptionMessage: (String) -> String = { detail -> + stringRes(appContext, R.string.upload_error_voice_message_exception, detail) + } + + isUploading = true + + try { + val uri = android.net.Uri.fromFile(recording.file) + val orchestrator = UploadOrchestrator() + voiceOrchestrator = orchestrator + + val result = + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = server, + account = accountViewModel.account, + context = appContext, + useH265 = false, + ) + + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + val audioMeta = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, + ) + + val hint = note.toEventHint() + if (hint != null) { + accountViewModel.account.signAndComputeBroadcast( + VoiceReplyEvent.build(audioMeta, hint), + ) + } + + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) + } + + deleteVoiceLocalFile() + voiceLocalFile = null + voiceRecording = null + voiceMetadata = audioMeta + + onSuccess() + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceNip95NotSupported) + } + } + } + is UploadingState.Error -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + } + } + } catch (e: Exception) { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName)) + } finally { + isUploading = false + voiceOrchestrator = null + } + } + + fun cancel() { + deleteVoiceLocalFile() + voiceRecording = null + voiceLocalFile = null + voiceMetadata = null + voiceSelectedServer = null + isUploading = false + voiceOrchestrator = null + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8db1152f4..7893b01aa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -170,6 +170,7 @@ Record a message Record a message Click and hold to record a message + Re-record Recording Recording %1$s Uploading… From 8eee85a05ed1dde20e33e18419e44e290d64cf5a Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 18 Dec 2025 12:59:11 +0100 Subject: [PATCH 02/44] Register Route in AppNavigation Modify ReplyViaVoiceReaction to Navigate --- .../amethyst/ui/navigation/AppNavigation.kt | 13 +++++++++++++ .../amethyst/ui/note/ReactionsRow.kt | 16 ++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b1557de3a..30d7e9a14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen @@ -289,6 +290,18 @@ fun AppNavigation( nav = nav, ) } + + composableFromBottomArgs { + VoiceReplyScreen( + replyToNoteId = it.replyToNoteId, + recordingFilePath = it.recordingFilePath, + mimeType = it.mimeType, + duration = it.duration, + amplitudesJson = it.amplitudes, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 716d0191f..7a1e53541 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -169,6 +169,7 @@ import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import kotlin.math.roundToInt import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -585,7 +586,7 @@ private fun ReplyReactionWithDialog( nav: INav, ) { if (baseNote.event is BaseVoiceEvent) { - ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel) + ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel, nav) } else { ReplyReaction(baseNote, grayTint, accountViewModel) { nav.nav { routeReplyTo(baseNote, accountViewModel.account) } @@ -599,15 +600,22 @@ fun ReplyViaVoiceReaction( baseNote: Note, grayTint: Color, accountViewModel: AccountViewModel, + nav: INav, showCounter: Boolean = true, iconSizeModifier: Modifier = Size19Modifier, ) { - val context = LocalContext.current - RecordAudioBox( modifier = iconSizeModifier, onRecordTaken = { audio -> - accountViewModel.sendVoiceReply(baseNote, audio, context) + nav.nav { + Route.VoiceReply( + replyToNoteId = baseNote.idHex, + recordingFilePath = audio.file.absolutePath, + mimeType = audio.mimeType, + duration = audio.duration, + amplitudes = Json.encodeToString(audio.amplitudes), + ) + } }, ) { _, _ -> VoiceReplyIcon(iconSizeModifier, grayTint) From 9b2ef76250d11deb6993f9444e111fa54bf22115 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 18 Dec 2025 18:21:31 +0100 Subject: [PATCH 03/44] TOOD: Move re-record higher up tp avoid google assistant on longpress Reply to a reply fails to upload Verify deletion of temp voice files Code review: - temp file cleanup on disposal - Made uploads cancellable - recording must exist; metadata-only no longer enables Send) - Removed duplication --- .../mediaServers/FileServerSelectionRow.kt | 67 +++++++ .../uploads/UploadProgressIndicator.kt | 101 ++++++++++ .../loggedIn/home/ShortNotePostScreen.kt | 103 +---------- .../screen/loggedIn/home/VoiceReplyScreen.kt | 172 +++++------------- .../loggedIn/home/VoiceReplyViewModel.kt | 165 ++++++++++------- 5 files changed, 322 insertions(+), 286 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt new file mode 100644 index 000000000..d7ecec0e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.mediaServers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun FileServerSelectionRow( + fileServers: List, + selectedServer: ServerName?, + onSelect: (ServerName) -> Unit, +) { + val nip95description = stringRes(id = R.string.upload_server_relays_nip95) + + val fileServerOptions = + remember(fileServers) { + fileServers + .map { + if (it.type == ServerType.NIP95) { + TitleExplainer(it.name, nip95description) + } else { + TitleExplainer(it.name, it.baseUrl) + } + }.toImmutableList() + } + + val placeholder = + fileServers + .firstOrNull { it == selectedServer } + ?.name + ?: fileServers.firstOrNull()?.name + ?: "" + + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = placeholder, + options = fileServerOptions, + onSelect = { index -> fileServers.getOrNull(index)?.let(onSelect) }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt new file mode 100644 index 000000000..2720dc877 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions.uploads + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun UploadProgressIndicator( + orchestrator: UploadOrchestrator, + modifier: Modifier = Modifier, +) { + val progressValue = orchestrator.progress.collectAsState().value + val progressStatusValue = orchestrator.progressState.collectAsState().value + + Box( + modifier = + modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.size(55.dp), + contentAlignment = Alignment.Center, + ) { + val animatedProgress = + animateFloatAsState( + targetValue = progressValue.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ).value + + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + + val txt = + when (progressStatusValue) { + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) + } + + Text( + txt, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index b347e68b3..504c723be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -24,10 +24,7 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -38,47 +35,37 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.components.TextSpinner -import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -111,7 +98,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -365,24 +351,11 @@ private fun NewPostScreenBody( // Show preview for both uploaded messages (voiceMetadata) and pending recordings (postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata -> - val nip95description = stringRes(id = R.string.upload_server_relays_nip95) val fileServersState = accountViewModel.account.serverLists.liveServerList .collectAsState() val fileServers = fileServersState.value - val fileServerOptions = - remember(fileServers) { - fileServers - .map { - if (it.type == ServerType.NIP95) { - TitleExplainer(it.name, nip95description) - } else { - TitleExplainer(it.name, it.baseUrl) - } - }.toImmutableList() - } - Column( modifier = Modifier @@ -391,7 +364,7 @@ private fun NewPostScreenBody( ) { // Display voice preview or uploading progress postViewModel.voiceOrchestrator?.let { orchestrator -> - VoiceUploadingProgress(orchestrator) + UploadProgressIndicator(orchestrator) } ?: run { VoiceMessagePreview( voiceMetadata = metadata, @@ -400,18 +373,11 @@ private fun NewPostScreenBody( ) } - SettingsRow(R.string.file_server, R.string.file_server_description) { - TextSpinner( - label = "", - placeholder = - fileServers - .firstOrNull { it == (postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) } - ?.name - ?: fileServers[0].name, - options = fileServerOptions, - onSelect = { postViewModel.voiceSelectedServer = fileServers[it] }, - ) - } + FileServerSelectionRow( + fileServers = fileServers, + selectedServer = postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer, + onSelect = { postViewModel.voiceSelectedServer = it }, + ) } } @@ -584,56 +550,3 @@ private fun AddPollButton( } } } - -@Composable -private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { - val progressValue = orchestrator.progress.collectAsState().value - val progressStatusValue = orchestrator.progressState.collectAsState().value - - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = androidx.compose.ui.Alignment.Center, - ) { - Box( - modifier = Modifier.size(55.dp), - contentAlignment = androidx.compose.ui.Alignment.Center, - ) { - val animatedProgress = - animateFloatAsState( - targetValue = progressValue.toFloat(), - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ).value - - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = - Size55Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), - strokeWidth = 5.dp, - ) - - val txt = - when (progressStatusValue) { - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) - } - - Text( - txt, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 10.sp, - textAlign = TextAlign.Center, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index 05c9f88ac..95e0227d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -21,10 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -33,49 +30,37 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox +import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.components.TextSpinner -import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier -import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -89,9 +74,9 @@ fun VoiceReplyScreen( nav: Nav, ) { val viewModel: VoiceReplyViewModel = viewModel() - viewModel.init(accountViewModel) - LaunchedEffect(replyToNoteId, recordingFilePath) { + LaunchedEffect(replyToNoteId, recordingFilePath, accountViewModel) { + viewModel.init(accountViewModel) viewModel.load(replyToNoteId, recordingFilePath, mimeType, duration, amplitudesJson) } @@ -100,6 +85,12 @@ fun VoiceReplyScreen( nav.popBack() } + DisposableEffect(Unit) { + onDispose { + viewModel.cancel() + } + } + Scaffold( topBar = { PostingTopBar( @@ -115,6 +106,9 @@ fun VoiceReplyScreen( }, ) }, + bottomBar = { + ReRecordButton(viewModel) + }, ) { pad -> Surface( modifier = @@ -160,7 +154,7 @@ private fun VoiceReplyScreenBody( // Voice preview or upload progress viewModel.voiceOrchestrator?.let { orchestrator -> - VoiceUploadingProgress(orchestrator) + UploadProgressIndicator(orchestrator) } ?: run { viewModel.getVoicePreviewMetadata()?.let { metadata -> VoiceMessagePreview( @@ -177,62 +171,47 @@ private fun VoiceReplyScreenBody( Spacer(modifier = Modifier.height(16.dp)) // Server selection - ServerSelectionRow(viewModel, accountViewModel) - - Spacer(modifier = Modifier.weight(1f)) - - // Re-record button at bottom - ReRecordButton(viewModel) - - Spacer(modifier = Modifier.height(16.dp)) - } -} - -@Composable -private fun ServerSelectionRow( - viewModel: VoiceReplyViewModel, - accountViewModel: AccountViewModel, -) { - val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - val fileServersState = - accountViewModel.account.serverLists.liveServerList - .collectAsState() - val fileServers = fileServersState.value - - val fileServerOptions = - remember(fileServers) { - fileServers - .map { - if (it.type == ServerType.NIP95) { - TitleExplainer(it.name, nip95description) - } else { - TitleExplainer(it.name, it.baseUrl) - } - }.toImmutableList() - } - - SettingsRow(R.string.file_server, R.string.file_server_description) { - TextSpinner( - label = "", - placeholder = - fileServers - .firstOrNull { it == (viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) } - ?.name - ?: fileServers.firstOrNull()?.name - ?: "", - options = fileServerOptions, - onSelect = { viewModel.voiceSelectedServer = fileServers[it] }, + val fileServers = + accountViewModel.account.serverLists.liveServerList + .collectAsState() + .value + FileServerSelectionRow( + fileServers = fileServers, + selectedServer = viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer, + onSelect = { viewModel.voiceSelectedServer = it }, ) + + Spacer(modifier = Modifier.height(80.dp)) } } @Composable private fun ReRecordButton(viewModel: VoiceReplyViewModel) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = Size10dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { + if (viewModel.isUploading) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + tint = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringRes(id = R.string.re_record), + color = MaterialTheme.colorScheme.onBackground, + ) + } + return + } + RecordAudioBox( modifier = Modifier, onRecordTaken = { recording -> @@ -266,56 +245,3 @@ private fun ReRecordButton(viewModel: VoiceReplyViewModel) { } } } - -@Composable -private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { - val progressValue = orchestrator.progress.collectAsState().value - val progressStatusValue = orchestrator.progressState.collectAsState().value - - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier.size(55.dp), - contentAlignment = Alignment.Center, - ) { - val animatedProgress = - animateFloatAsState( - targetValue = progressValue.toFloat(), - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ).value - - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = - Size55Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), - strokeWidth = 5.dp, - ) - - val txt = - when (progressStatusValue) { - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) - } - - Text( - txt, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 10.sp, - textAlign = TextAlign.Center, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index fe99743f2..f5010cb4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -41,8 +41,11 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.io.File @@ -59,6 +62,8 @@ class VoiceReplyViewModel : ViewModel() { var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null) var isUploading: Boolean by mutableStateOf(false) + private var uploadJob: Job? = null + fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM } @@ -109,6 +114,7 @@ class VoiceReplyViewModel : ViewModel() { } fun removeVoiceMessage() { + cancelUpload() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -118,6 +124,11 @@ class VoiceReplyViewModel : ViewModel() { voiceOrchestrator = null } + private fun cancelUpload() { + uploadJob?.cancel() + uploadJob = null + } + private fun deleteVoiceLocalFile() { voiceLocalFile?.let { file -> try { @@ -131,101 +142,118 @@ class VoiceReplyViewModel : ViewModel() { } } - fun canSend(): Boolean = (voiceRecording != null || voiceMetadata != null) && !isUploading + fun canSend(): Boolean = voiceRecording != null && !isUploading fun sendVoiceReply(onSuccess: () -> Unit) { val note = replyToNote ?: return val recording = voiceRecording ?: return val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer - viewModelScope.launch(Dispatchers.IO) { - uploadAndSend(note, recording, serverToUse, onSuccess) - } + cancelUpload() + uploadJob = + viewModelScope.launch { + isUploading = true + val orchestrator = UploadOrchestrator() + voiceOrchestrator = orchestrator + + try { + val result = + withContext(Dispatchers.IO) { + val uri = android.net.Uri.fromFile(recording.file) + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = serverToUse, + account = accountViewModel.account, + context = Amethyst.instance.appContext, + useH265 = false, + ) + } + + handleUploadResult(note, recording, serverToUse, result, onSuccess) + } catch (e: CancellationException) { + // User canceled, or ViewModel cleared. + } catch (e: Exception) { + val appContext = Amethyst.instance.appContext + val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) + val uploadVoiceExceptionMessage: (String) -> String = { detail -> + stringRes(appContext, R.string.upload_error_voice_message_exception, detail) + } + accountViewModel.toastManager.toast( + uploadErrorTitle, + uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName), + ) + } finally { + isUploading = false + voiceOrchestrator = null + uploadJob = null + } + } } - private suspend fun uploadAndSend( + private suspend fun handleUploadResult( note: Note, recording: RecordingResult, server: ServerName, + result: UploadingState, onSuccess: () -> Unit, ) { val appContext = Amethyst.instance.appContext val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed) - val uploadVoiceExceptionMessage: (String) -> String = { detail -> - stringRes(appContext, R.string.upload_error_voice_message_exception, detail) - } - isUploading = true - - try { - val uri = android.net.Uri.fromFile(recording.file) - val orchestrator = UploadOrchestrator() - voiceOrchestrator = orchestrator - - val result = - orchestrator.upload( - uri = uri, - mimeType = recording.mimeType, - alt = null, - contentWarningReason = null, - compressionQuality = CompressorQuality.UNCOMPRESSED, - server = server, - account = accountViewModel.account, - context = appContext, - useH265 = false, - ) - - when (result) { - is UploadingState.Finished -> { - when (val orchestratorResult = result.result) { - is UploadOrchestrator.OrchestratorResult.ServerResult -> { - val audioMeta = - AudioMeta( - url = orchestratorResult.url, - mimeType = recording.mimeType, - hash = orchestratorResult.fileHeader.hash, - duration = recording.duration, - waveform = recording.amplitudes, - ) - - val hint = note.toEventHint() - if (hint != null) { - accountViewModel.account.signAndComputeBroadcast( - VoiceReplyEvent.build(audioMeta, hint), - ) - } - - if (server.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(server) - } - - deleteVoiceLocalFile() - voiceLocalFile = null - voiceRecording = null - voiceMetadata = audioMeta - - onSuccess() + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + val hint = note.toEventHint() + if (hint == null) { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + return } - is UploadOrchestrator.OrchestratorResult.NIP95Result -> { - accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceNip95NotSupported) + + val audioMeta = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, + ) + + accountViewModel.account.signAndComputeBroadcast(VoiceReplyEvent.build(audioMeta, hint)) + + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) } + + deleteVoiceLocalFile() + voiceLocalFile = null + voiceRecording = null + voiceMetadata = audioMeta + + onSuccess() + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceNip95NotSupported) } } - is UploadingState.Error -> { - accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) - } } - } catch (e: Exception) { - accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName)) - } finally { - isUploading = false - voiceOrchestrator = null + is UploadingState.Error -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + } + else -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + } } } fun cancel() { + cancelUpload() deleteVoiceLocalFile() voiceRecording = null voiceLocalFile = null @@ -236,6 +264,7 @@ class VoiceReplyViewModel : ViewModel() { } override fun onCleared() { + cancel() super.onCleared() Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") } From 64fe4dfac656c0817d84a27b164371bd2d327912 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Dec 2025 11:31:32 +0000 Subject: [PATCH 04/44] Code review: - Removed DisposableEffect cleanup - Added cancelUpload() to selectRecording() --- .../ui/screen/loggedIn/home/VoiceReplyScreen.kt | 7 ------- .../ui/screen/loggedIn/home/VoiceReplyViewModel.kt | 14 ++------------ 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index 95e0227d6..feea98085 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -41,7 +41,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment @@ -85,12 +84,6 @@ fun VoiceReplyScreen( nav.popBack() } - DisposableEffect(Unit) { - onDispose { - viewModel.cancel() - } - } - Scaffold( topBar = { PostingTopBar( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index f5010cb4b..12c6be189 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -107,23 +107,13 @@ class VoiceReplyViewModel : ViewModel() { } fun selectRecording(recording: RecordingResult) { + cancelUpload() deleteVoiceLocalFile() voiceRecording = recording voiceLocalFile = recording.file voiceMetadata = null } - fun removeVoiceMessage() { - cancelUpload() - deleteVoiceLocalFile() - voiceRecording = null - voiceLocalFile = null - voiceMetadata = null - voiceSelectedServer = null - isUploading = false - voiceOrchestrator = null - } - private fun cancelUpload() { uploadJob?.cancel() uploadJob = null @@ -175,7 +165,7 @@ class VoiceReplyViewModel : ViewModel() { handleUploadResult(note, recording, serverToUse, result, onSuccess) } catch (e: CancellationException) { - // User canceled, or ViewModel cleared. + Log.w("VoiceReplyViewModel", "User canceled, or ViewModel cleared", e) } catch (e: Exception) { val appContext = Amethyst.instance.appContext val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) From 2422e77dd04cd4b96930a637939457d8624143bf Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Dec 2025 11:42:41 +0000 Subject: [PATCH 05/44] TODO: Reply to a reply fails to upload Verify deletion of temp voice files Code review: - move re-record button higher --- .../amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index feea98085..da1dd4b1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -184,7 +185,8 @@ private fun ReRecordButton(viewModel: VoiceReplyViewModel) { modifier = Modifier .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = Size10dp), + .navigationBarsPadding() + .padding(vertical = 16.dp, horizontal = Size10dp), horizontalAlignment = Alignment.CenterHorizontally, ) { if (viewModel.isUploading) { From 6785f124e927860e0d03d825c72b6f998f8c0827 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Dec 2025 13:07:56 +0000 Subject: [PATCH 06/44] bug fix: - Allow reply to reply by using BaseVoiceEvent --- .../main/java/com/vitorpamplona/amethyst/model/Account.kt | 3 ++- .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 4 ++-- .../ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 8 +++----- .../ui/screen/loggedIn/home/VoiceReplyViewModel.kt | 4 ++-- .../quartz/nipA0VoiceMessages/VoiceReplyEvent.kt | 4 ++-- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d32ca043b..c38e80510 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -202,6 +202,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log @@ -1002,7 +1003,7 @@ class Account( hash: String, duration: Int, waveform: List, - replyTo: EventHintBundle, + replyTo: EventHintBundle, ) { signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3e44b809e..8264d3cd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -134,7 +134,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -1154,7 +1154,7 @@ class AccountViewModel( context: Context, ) { if (isWriteable()) { - val hint = note.toEventHint() ?: return + val hint = note.toEventHint() ?: return launchSigner { val uploader = UploadOrchestrator() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 87b87b5fc..652cc425f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -121,6 +121,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log @@ -539,8 +540,8 @@ open class ShortNotePostViewModel : private suspend fun createTemplate(): EventTemplate? { // Check if this is a voice message voiceMetadata?.let { audioMeta -> - // Only create voice reply if original note is also a VoiceEvent - val originalVoiceHint = originalNote?.toEventHint() + // Only create voice reply if original note is also a voice message + val originalVoiceHint = originalNote?.toEventHint() return if (originalVoiceHint != null) { // Create voice reply event VoiceReplyEvent.build( @@ -1024,9 +1025,6 @@ open class ShortNotePostViewModel : onError(uploadErrorTitle, uploadVoiceFailed) voiceRecording = null } - else -> { - onError(uploadErrorTitle, uploadVoiceUnexpected) - } } } catch (e: Exception) { onError(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 12c6be189..faa5d677a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -200,7 +200,7 @@ class VoiceReplyViewModel : ViewModel() { is UploadingState.Finished -> { when (val orchestratorResult = result.result) { is UploadOrchestrator.OrchestratorResult.ServerResult -> { - val hint = note.toEventHint() + val hint = note.toEventHint() if (hint == null) { accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) return diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt index 2b064deb3..dfb3158d0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt @@ -65,12 +65,12 @@ class VoiceReplyEvent( hash: String, duration: Int, waveform: List, - replyingTo: EventHintBundle, + replyingTo: EventHintBundle, ) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo) fun build( voiceMessage: AudioMeta, - replyingTo: EventHintBundle, + replyingTo: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) { From 0a0365c26666b35e41fb639534dc1a705c4054be Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Dec 2025 16:28:39 +0000 Subject: [PATCH 07/44] translations --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 1 + amethyst/src/main/res/values-de-rDE/strings.xml | 1 + amethyst/src/main/res/values-pt-rBR/strings.xml | 1 + amethyst/src/main/res/values-sv-rSE/strings.xml | 1 + 4 files changed, 4 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 5cc85b46e..8177aed50 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1183,4 +1183,5 @@ Broadcast sady doporučení Smazat seznam Smazat sadu doporučení + Znovu nahrát diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 4dea3cb35..d5904f0be 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1188,4 +1188,5 @@ anz der Bedingungen ist erforderlich Empfehlungspaket veröffentlichen Liste löschen Empfehlungspaket löschen + Neu aufnehmen diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index c8278d893..6ad5d17cf 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1183,4 +1183,5 @@ Transmitir pacote de recomendações Excluir lista Excluir pacote de recomendações + Gravar novamente diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index a3393dcdf..d862ca5f1 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1182,4 +1182,5 @@ Sänd rekommendationspaket Ta bort lista Ta bort rekommendationspaket + Spela in igen From 6f8072d1bbf431b7bd35aa4e201c39c951824a56 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 24 Dec 2025 18:28:22 +0000 Subject: [PATCH 08/44] enhanced recording button --- .../ui/actions/uploads/RecordingIndicators.kt | 19 ++++++-- .../amethyst/ui/note/ReactionsRow.kt | 46 +++++++++++++++++-- .../vitorpamplona/amethyst/ui/theme/Shape.kt | 4 +- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index 9b81d710f..a4e3ecd65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -175,18 +175,27 @@ fun FloatingRecordingIndicator( modifier: Modifier = Modifier, isRecording: Boolean, elapsedSeconds: Int, + isCompact: Boolean = false, ) { if (!isRecording) return val recordingLabel = stringRes(id = R.string.recording_indicator_description) - val recordingWithTime = stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds)) + val recordingWithTime = + if (isCompact) { + formatSecondsToTime(elapsedSeconds) + } else { + stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds)) + } + val horizontalPadding = if (isCompact) 8.dp else 16.dp + val innerPadding = if (isCompact) 6.dp else 12.dp + val textSize = if (isCompact) 12.sp else 14.sp Box( modifier = modifier .fillMaxWidth() .height(48.dp) - .padding(horizontal = 16.dp) + .padding(horizontal = horizontalPadding) .background( color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(12.dp), @@ -195,7 +204,7 @@ fun FloatingRecordingIndicator( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp), + modifier = Modifier.padding(horizontal = innerPadding), ) { // Pulsing red dot val infiniteTransition = rememberInfiniteTransition(label = "recording_dot") @@ -223,8 +232,10 @@ fun FloatingRecordingIndicator( Text( text = recordingWithTime, color = Color.White, - fontSize = 14.sp, + fontSize = textSize, fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 7a1e53541..279a3414f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -47,6 +47,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -61,6 +63,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -109,6 +112,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox @@ -205,9 +209,12 @@ private fun InnerReactionRow( accountViewModel: AccountViewModel, nav: INav, ) { + val voiceRecordingState = remember(baseNote.idHex) { mutableStateOf(false) } + GenericInnerReactionRow( showReactionDetail = showReactionDetail, addPadding = addPadding, + weightTwo = if (voiceRecordingState.value) 2f else 1f, one = { WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) { RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel) @@ -219,6 +226,7 @@ private fun InnerReactionRow( MaterialTheme.colorScheme.placeholderText, accountViewModel, nav, + voiceRecordingState = voiceRecordingState, ) }, three = { @@ -289,6 +297,7 @@ fun ShareReaction( private fun GenericInnerReactionRow( showReactionDetail: Boolean, addPadding: Boolean, + weightTwo: Float = 1f, one: @Composable () -> Unit, two: @Composable () -> Unit, three: @Composable () -> Unit, @@ -310,7 +319,7 @@ private fun GenericInnerReactionRow( } } - Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(1f)) { two() } + Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(weightTwo)) { two() } Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(1f)) { three() } @@ -584,9 +593,16 @@ private fun ReplyReactionWithDialog( grayTint: Color, accountViewModel: AccountViewModel, nav: INav, + voiceRecordingState: MutableState? = null, ) { if (baseNote.event is BaseVoiceEvent) { - ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel, nav) + ReplyViaVoiceReaction( + baseNote, + grayTint, + accountViewModel, + nav, + voiceRecordingState = voiceRecordingState, + ) } else { ReplyReaction(baseNote, grayTint, accountViewModel) { nav.nav { routeReplyTo(baseNote, accountViewModel.account) } @@ -603,9 +619,10 @@ fun ReplyViaVoiceReaction( nav: INav, showCounter: Boolean = true, iconSizeModifier: Modifier = Size19Modifier, + voiceRecordingState: MutableState? = null, ) { RecordAudioBox( - modifier = iconSizeModifier, + modifier = Modifier, onRecordTaken = { audio -> nav.nav { Route.VoiceReply( @@ -617,8 +634,27 @@ fun ReplyViaVoiceReaction( ) } }, - ) { _, _ -> - VoiceReplyIcon(iconSizeModifier, grayTint) + ) { isRecording, elapsedSeconds -> + if (voiceRecordingState != null) { + SideEffect { + if (voiceRecordingState.value != isRecording) { + voiceRecordingState.value = isRecording + } + } + } + if (isRecording) { + FloatingRecordingIndicator( + modifier = + Modifier + .fillMaxWidth() + .height(40.dp), + isRecording = true, + elapsedSeconds = elapsedSeconds, + isCompact = true, + ) + } else { + VoiceReplyIcon(iconSizeModifier, grayTint) + } } if (showCounter) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 1fcd3daab..05bf255fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -190,8 +190,8 @@ val VideoReactionColumnPadding = Modifier.padding(bottom = 75.dp) val DividerThickness = 0.25.dp -val ReactionRowHeight = Modifier.padding(vertical = 7.dp).height(24.dp) -val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).height(24.dp).padding(horizontal = 10.dp) +val ReactionRowHeight = Modifier.padding(vertical = 7.dp).heightIn(min = 24.dp) +val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).heightIn(min = 24.dp).padding(horizontal = 10.dp) val ReactionRowHeightChat = Modifier.height(20.dp) val ReactionRowHeightChatMaxWidth = Modifier.height(25.dp).fillMaxWidth() val UserNameRowHeight = Modifier.fillMaxWidth() From ff48af5090a237ee02a724d894ace2892bc37143 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Dec 2025 15:25:27 -0500 Subject: [PATCH 09/44] Adds a pTagHash utility to the hasher --- .../quartz/nip01Core/store/sqlite/TagNameValueHasher.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt index 876f26d01..c8b73f1bf 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.bloom.MurmurHash3 /** @@ -32,6 +33,9 @@ class TagNameValueHasher( val hasher = MurmurHash3() // small performance improvements on inserting + val pTagHash by lazy { + hasher.hash128x64Half("p".encodeToByteArray(), seed) + } val eTagHash by lazy { hasher.hash128x64Half("e".encodeToByteArray(), seed) } @@ -56,4 +60,8 @@ class TagNameValueHasher( fun hashATag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), aTagHash) fun hashETag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), eTagHash) + + fun hashPTag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), pTagHash) + + fun hash(value: HexKey) = hasher.hash128x64Half(value.encodeToByteArray(), seed) } From 826308cbadcdc003cb8c9a702e78cfa6bec59b64 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Dec 2025 15:26:43 -0500 Subject: [PATCH 10/44] test addressable filters together with event-id based for deletions and updates --- .../quartz/nip01Core/store/sqlite/AddressableTest.kt | 8 ++++++++ .../quartz/nip01Core/store/sqlite/ReplaceableTest.kt | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt index 540833e51..9df1893e9 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -59,20 +59,25 @@ class AddressableTest { 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(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 @@ -82,6 +87,8 @@ class AddressableTest { 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))) @@ -101,6 +108,7 @@ class AddressableTest { } 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))) } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt index 0a85cfc75..5c694430e 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -56,20 +56,25 @@ class ReplaceableTest { 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(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(version3, 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 @@ -79,6 +84,8 @@ class ReplaceableTest { 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))) @@ -98,6 +105,7 @@ class ReplaceableTest { } 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))) } From f1298c3133da869b956bdfe1263bdfef9378a6a4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Dec 2025 15:27:38 -0500 Subject: [PATCH 11/44] Removes event id from the deletion module insert --- .../quartz/nip01Core/store/sqlite/DeletionRequestModule.kt | 1 - .../quartz/nip01Core/store/sqlite/SQLiteEventStore.kt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt index 51c81d6ee..60c5a8ac7 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -56,7 +56,6 @@ class DeletionRequestModule : IModule { fun insert( event: Event, - headerId: Long, db: SQLiteDatabase, ) { if (event is DeletionEvent) { diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 3c86d9271..38a1159ac 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -117,7 +117,7 @@ class SQLiteEventStore( db: SQLiteDatabase, ) { val headerId = eventIndexModule.insert(event, db) - deletionModule.insert(event, headerId, db) + deletionModule.insert(event, db) expirationModule.insert(event, headerId, db) fullTextSearchModule.insert(event, headerId, db) rightToVanishModule.insert(event, relayUrl, headerId, db) From 09eeebba94c2cc04d1ec130193eb7621f95286d4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Dec 2025 15:28:59 -0500 Subject: [PATCH 12/44] Keeps the hasher cache on the seed module --- .../quartz/nip01Core/store/sqlite/EventIndexesModule.kt | 6 +----- .../quartz/nip01Core/store/sqlite/SQLiteEventStore.kt | 3 ++- .../quartz/nip01Core/store/sqlite/SeedModule.kt | 4 ++++ 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index f8ceb93cc..0fbac80f6 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -33,13 +33,9 @@ import com.vitorpamplona.quartz.utils.EventFactory class EventIndexesModule( val fts: FullTextSearchModule, - val seedModule: SeedModule, + val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, val tagIndexStrategy: IndexingStrategy = IndexingStrategy(), ) : IModule { - private var hasherCache: TagNameValueHasher? = null - - fun hasher(db: SQLiteDatabase): TagNameValueHasher = hasherCache ?: TagNameValueHasher(seedModule.getSeed(db)).also { hasherCache = it } - override fun create(db: SQLiteDatabase) { db.execSQL( """ diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 38a1159ac..7af2d4186 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -44,8 +44,9 @@ class SQLiteEventStore( } val seedModule = SeedModule() + val fullTextSearchModule = FullTextSearchModule() - val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule, tagIndexStrategy) + val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule::hasher, tagIndexStrategy) val replaceableModule = ReplaceableModule() val addressableModule = AddressableModule() diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt index ece45f6f1..7788213b7 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt @@ -76,4 +76,8 @@ class SeedModule : IModule { } override fun deleteAll(db: SQLiteDatabase) {} + + private var hasherCache: TagNameValueHasher? = null + + fun hasher(db: SQLiteDatabase): TagNameValueHasher = hasherCache ?: TagNameValueHasher(getSeed(db)).also { hasherCache = it } } From 77e56f86a92eec34c4a90f60fa1d9af614449855 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Dec 2025 17:09:33 -0500 Subject: [PATCH 13/44] Using in memory databases for the test cases --- .../quartz/nip01Core/store/sqlite/AddressableTest.kt | 5 +---- .../quartz/nip01Core/store/sqlite/ExpirationTest.kt | 2 +- .../quartz/nip01Core/store/sqlite/LargeDBTests.kt | 5 +---- .../quartz/nip01Core/store/sqlite/ReplaceableTest.kt | 2 +- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt index 9df1893e9..1d99bcfe8 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -41,15 +41,12 @@ class AddressableTest { @Before fun setup() { val context = ApplicationProvider.getApplicationContext() - context.deleteDatabase("test.db") - db = EventStore(context, "test.db", relayUrl = "testUrl") + db = EventStore(context, null) } @After fun tearDown() { db.close() - val context = ApplicationProvider.getApplicationContext() - context.deleteDatabase("test.db") } @Test diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt index add6ce20b..6197a8967 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt @@ -42,7 +42,7 @@ class ExpirationTest { @Before fun setup() { val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null, relayUrl = "testUrl") + db = EventStore(context, null) } @After diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt index 53277c750..a16e2909b 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -56,15 +56,12 @@ class LargeDBTests { @Before fun setup() { val context = ApplicationProvider.getApplicationContext() - context.deleteDatabase("test_large.db") - db = EventStore(context, "largeDBTest.db") + db = EventStore(context, null) } @After fun tearDown() { db.close() - val context = ApplicationProvider.getApplicationContext() - context.deleteDatabase("test_large.db") } @Test diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt index 5c694430e..6c5339123 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -41,7 +41,7 @@ class ReplaceableTest { @Before fun setup() { val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null, relayUrl = "testUrl") + db = EventStore(context, null) } @After From 66ea8ceda051034be3ef0fc002646f1119586a29 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Dec 2025 17:10:04 -0500 Subject: [PATCH 14/44] Fixes replaceable test --- .../quartz/nip01Core/store/sqlite/ReplaceableTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt index 6c5339123..e127b6393 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -67,7 +67,7 @@ class ReplaceableTest { db.assertQuery(null, Filter(ids = listOf(version1.id))) db.assertQuery(version2, Filter(ids = listOf(version2.id))) - db.assertQuery(version3, addressableQuery) + db.assertQuery(version2, addressableQuery) db.insert(version3) From eeb0c11c6527f67b44644ec09f940f58df8b0294 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Dec 2025 17:10:34 -0500 Subject: [PATCH 15/44] No need for this create method of GiftWraps to be suspending --- .../quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index b408fd19b..de404e7a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag @@ -104,13 +104,13 @@ class GiftWrapEvent( const val KIND = 1059 const val ALT = "Encrypted event" - suspend fun create( + fun create( event: Event, recipientPubKey: HexKey, expirationDelta: Long? = null, createdAt: Long = TimeUtils.randomWithTwoDays(), ): GiftWrapEvent { - val signer = NostrSignerInternal(KeyPair()) // GiftWrap is always a random key + val signer = NostrSignerSync(KeyPair()) // GiftWrap is always a random key val tags = expirationDelta?.let { From 82f8bc62a0b4e2388030db30d5c4d71c3cf9842f Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 26 Dec 2025 07:51:43 +0200 Subject: [PATCH 16/44] feat: Add Compose Multiplatform Desktop support foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add desktopApp module with JVM entry point and sidebar navigation - Add Claude specs for AI-assisted development: - Agent definitions: nostr-protocol, kotlin-multiplatform, compose-ui, kotlin-coroutines - Skills: quartz-kmp conversion, compose-desktop patterns - Commands: desktop-run, nip, extract - Update Gradle configuration with Compose Multiplatform 1.7.1 plugin - Add coroutines and secp256k1 JVM dependencies to version catalog Next steps: - Convert Quartz library to full KMP (expect/actual for crypto) - Implement relay connections in desktop app - Share UI components between Android and Desktop 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/CLAUDE.md | 120 +++++++ .claude/agents/compose-ui.md | 243 +++++++++++++ .claude/agents/kotlin-coroutines.md | 284 +++++++++++++++ .claude/agents/kotlin-multiplatform.md | 161 +++++++++ .claude/agents/nostr-protocol.md | 102 ++++++ .claude/commands/desktop-run.md | 45 +++ .claude/commands/extract.md | 50 +++ .claude/commands/nip.md | 32 ++ .claude/skills/compose-desktop.md | 339 ++++++++++++++++++ .claude/skills/quartz-kmp.md | 165 +++++++++ build.gradle | 3 +- desktopApp/build.gradle.kts | 59 +++ .../vitorpamplona/amethyst/desktop/Main.kt | 306 ++++++++++++++++ desktopApp/src/jvmMain/resources/icon.png | Bin 0 -> 7016 bytes gradle/libs.versions.toml | 5 +- settings.gradle | 1 + 16 files changed, 1913 insertions(+), 2 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/agents/compose-ui.md create mode 100644 .claude/agents/kotlin-coroutines.md create mode 100644 .claude/agents/kotlin-multiplatform.md create mode 100644 .claude/agents/nostr-protocol.md create mode 100644 .claude/commands/desktop-run.md create mode 100644 .claude/commands/extract.md create mode 100644 .claude/commands/nip.md create mode 100644 .claude/skills/compose-desktop.md create mode 100644 .claude/skills/quartz-kmp.md create mode 100644 desktopApp/build.gradle.kts create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt create mode 100644 desktopApp/src/jvmMain/resources/icon.png diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..44e046f04 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,120 @@ +# Amethyst Desktop Fork + +## Project Overview + +Fork of [Amethyst](https://github.com/vitorpamplona/amethyst) adding Compose Multiplatform Desktop support. Quartz library converted to full KMP for code sharing between Android and Desktop JVM. + +## Architecture + +``` +amethyst/ +├── quartz/ # Nostr KMP library (converted to multiplatform) +│ └── src/ +│ ├── commonMain/ # Shared Nostr protocol +│ ├── androidMain/ # Android-specific (crypto, storage) +│ └── jvmMain/ # Desktop JVM-specific +├── desktopApp/ # Desktop JVM application +├── amethyst/ # Android app (existing) +├── ammolite/ # Support module +└── commons/ # Utilities +``` + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| **Core** | Quartz (Nostr KMP) | +| **UI** | Compose Multiplatform 1.7.x | +| **Async** | kotlinx.coroutines + Flow | +| **Network** | OkHttp (JVM) | +| **Serialization** | Jackson | +| **DI** | Manual / Koin | +| **Build** | Gradle 8.x, Kotlin 2.1.0 | + +## Agents + +Use these specialized agents for domain expertise: + +| Agent | Expertise | When to Use | +|-------|-----------|-------------| +| `nostr-protocol` | NIPs, events, relays, crypto | Protocol questions, NIP implementation | +| `kotlin-multiplatform` | KMP, source sets, expect/actual | Project structure, code sharing | +| `compose-ui` | Composables, Desktop features | UI components, navigation | +| `kotlin-coroutines` | Flows, async, concurrency | Data streams, async operations | + +## Commands + +- `/desktop-run` - Build and run desktop app +- `/extract ` - Move composable to shared code +- `/nip ` - Get NIP implementation guidance + +## Development Workflow + +```bash +# Run desktop app +./gradlew :desktopApp:run + +# Run Android app +./gradlew :amethyst:installDebug + +# Build Quartz for all targets +./gradlew :quartz:build + +# Run tests +./gradlew test + +# Format code +./gradlew spotlessApply +``` + +## Quartz KMP Structure + +The Quartz library uses expect/actual for platform-specific implementations: + +```kotlin +// commonMain - shared protocol logic +expect class CryptoProvider { + fun sign(message: ByteArray, privateKey: ByteArray): ByteArray + fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean +} + +// androidMain - uses secp256k1-kmp-jni-android +actual class CryptoProvider { /* Android implementation */ } + +// jvmMain - uses secp256k1-kmp-jni-jvm +actual class CryptoProvider { /* JVM implementation */ } +``` + +## Key Patterns + +### Platform Abstraction +```kotlin +// commonMain +expect fun openExternalUrl(url: String) + +// androidMain +actual fun openExternalUrl(url: String) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) +} + +// jvmMain (Desktop) +actual fun openExternalUrl(url: String) { + Desktop.getDesktop().browse(URI(url)) +} +``` + +### Navigation Shell +- **Desktop**: Sidebar + main content area +- **Android**: Bottom navigation + +## Git Workflow + +- Branch: `feat/desktop-` or `fix/desktop-` +- Commits: Conventional commits (`feat:`, `fix:`, etc.) +- Never use `--no-verify` + +## Resources + +- [Nostr NIPs](https://github.com/nostr-protocol/nips) +- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) +- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html) diff --git a/.claude/agents/compose-ui.md b/.claude/agents/compose-ui.md new file mode 100644 index 000000000..2a8b837d8 --- /dev/null +++ b/.claude/agents/compose-ui.md @@ -0,0 +1,243 @@ +# Compose Multiplatform UI Agent + +## Expertise Domain + +This agent specializes in Compose Multiplatform for building declarative UIs that share code across Android and Desktop JVM. + +## Platform Support + +| Platform | Status | Notes | +|----------|--------|-------| +| Android | Stable | Jetpack Compose | +| Desktop (JVM) | Stable | Windows, macOS, Linux | +| iOS | Beta | Future consideration | +| Web (Wasm) | Alpha | Future consideration | + +## Core Knowledge Areas + +### Shared Composables (commonMain) +```kotlin +@Composable +fun NoteCard( + note: Note, + onReply: () -> Unit, + onRepost: () -> Unit, + onZap: () -> Unit, + modifier: Modifier = Modifier +) { + Card(modifier = modifier) { + Column(Modifier.padding(16.dp)) { + AuthorRow(note.author) + Spacer(Modifier.height(8.dp)) + Text(note.content) + Spacer(Modifier.height(8.dp)) + ActionRow(onReply, onRepost, onZap) + } + } +} +``` + +### Desktop-Specific Features +```kotlin +// Window management +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + state = rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center) + ), + title = "Amethyst Desktop" + ) { + App() + } +} + +// Menu bar +MenuBar { + Menu("File") { + Item("New Note", onClick = { }, shortcut = KeyShortcut(Key.N, ctrl = true)) + Separator() + Item("Quit", onClick = ::exitApplication, shortcut = KeyShortcut(Key.Q, ctrl = true)) + } + Menu("View") { + Item("Feed", onClick = { navigateTo(Screen.Feed) }) + Item("Messages", onClick = { navigateTo(Screen.Messages) }) + } +} + +// System tray +Tray( + icon = painterResource("icon.png"), + menu = { + Item("Show", onClick = { windowVisible = true }) + Item("Exit", onClick = ::exitApplication) + } +) + +// Keyboard shortcuts +Modifier.onKeyEvent { event -> + when { + event.isCtrlPressed && event.key == Key.Enter -> { + sendNote() + true + } + event.key == Key.Escape -> { + closeDialog() + true + } + else -> false + } +} + +// Context menus +ContextMenuArea(items = { + listOf( + ContextMenuItem("Copy") { copyToClipboard(note.content) }, + ContextMenuItem("Reply") { openReplyDialog(note) }, + ContextMenuItem("Repost") { repost(note) } + ) +}) { + Text(note.content) +} +``` + +### Navigation Patterns +```kotlin +// Sidebar + Content pattern for Desktop +@Composable +fun DesktopLayout(currentScreen: Screen, onNavigate: (Screen) -> Unit) { + Row(Modifier.fillMaxSize()) { + // Sidebar + NavigationRail { + NavigationRailItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == Screen.Feed, + onClick = { onNavigate(Screen.Feed) } + ) + NavigationRailItem( + icon = { Icon(Icons.Default.Message, "Messages") }, + label = { Text("Messages") }, + selected = currentScreen == Screen.Messages, + onClick = { onNavigate(Screen.Messages) } + ) + NavigationRailItem( + icon = { Icon(Icons.Default.Person, "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == Screen.Profile, + onClick = { onNavigate(Screen.Profile) } + ) + } + + // Content + Box(Modifier.weight(1f)) { + when (currentScreen) { + Screen.Feed -> FeedScreen() + Screen.Messages -> MessagesScreen() + Screen.Profile -> ProfileScreen() + } + } + } +} +``` + +### State Management +```kotlin +// StateFlow for shared state +class FeedViewModel(private val repository: FeedRepository) { + private val _notes = MutableStateFlow>(emptyList()) + val notes: StateFlow> = _notes.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + fun loadFeed() { + viewModelScope.launch { + _isLoading.value = true + repository.getFeed() + .catch { /* handle error */ } + .collect { _notes.value = it } + _isLoading.value = false + } + } +} + +// In Composable +@Composable +fun FeedScreen(viewModel: FeedViewModel) { + val notes by viewModel.notes.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + Box(Modifier.fillMaxSize()) { + LazyColumn { + items(notes, key = { it.id }) { note -> + NoteCard(note) + } + } + if (isLoading) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + } +} +``` + +## Agent Capabilities + +1. **Composable Design** + - Create shareable UI components + - Implement Material3 design + - Handle responsive layouts + +2. **Desktop UI Patterns** + - Window management (size, position, multi-window) + - Menu bars and context menus + - System tray integration + - Keyboard shortcuts + - Native file dialogs + +3. **Navigation Architecture** + - Screen-based navigation + - Platform-specific shells (sidebar vs bottom nav) + - Deep linking patterns + +4. **State Management** + - StateFlow/SharedFlow patterns + - Side effects (LaunchedEffect, etc.) + - ViewModel integration + +## Android vs Desktop Differences + +| Aspect | Android | Desktop | +|--------|---------|---------| +| **Entry** | Activity | main() + Window | +| **Navigation** | Bottom nav / Drawer | Sidebar / MenuBar | +| **Input** | Touch | Mouse + Keyboard | +| **Windows** | Single | Multi-window | +| **Menus** | Overflow menu | MenuBar | +| **Files** | SAF / MediaStore | JFileChooser | +| **Notifications** | System notifications | Tray notifications | +| **Clipboard** | ClipboardManager | Toolkit.clipboard | + +## Scope Boundaries + +### In Scope +- Composable architecture and design +- UI component patterns +- Desktop-specific features (menus, tray, keyboard) +- Navigation patterns +- State → UI binding +- Theming and styling +- Responsive layouts + +### Out of Scope +- KMP project setup (use kotlin-multiplatform agent) +- Async data loading (use kotlin-coroutines agent) +- Nostr protocol details (use nostr-protocol agent) +- Business logic implementation + +## Key References +- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) +- [Desktop Tutorials](https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials) +- [Material3 Components](https://m3.material.io/components) diff --git a/.claude/agents/kotlin-coroutines.md b/.claude/agents/kotlin-coroutines.md new file mode 100644 index 000000000..2f56b1f39 --- /dev/null +++ b/.claude/agents/kotlin-coroutines.md @@ -0,0 +1,284 @@ +# Kotlin Coroutines Agent + +## Expertise Domain + +This agent specializes in Kotlin coroutines and the kotlinx.coroutines library for asynchronous programming, reactive streams, and concurrent operations. + +## Core Knowledge Areas + +### Coroutine Fundamentals +```kotlin +// Suspending functions +suspend fun fetchNote(id: String): Note { + return withContext(Dispatchers.IO) { + api.getNote(id) + } +} + +// Coroutine builders +fun main() = runBlocking { + // launch: fire-and-forget, returns Job + val job = launch { + delay(1000) + println("World") + } + + // async: returns Deferred + val deferred = async { + computeValue() + } + val result = deferred.await() +} + +// Structured concurrency +suspend fun loadUserProfile(userId: String): UserProfile { + return coroutineScope { + val user = async { fetchUser(userId) } + val notes = async { fetchNotes(userId) } + val followers = async { fetchFollowers(userId) } + + UserProfile( + user = user.await(), + notes = notes.await(), + followers = followers.await() + ) + } // All complete or all cancel together +} +``` + +### Dispatchers + +| Dispatcher | Use Case | Notes | +|------------|----------|-------| +| `Dispatchers.Main` | UI updates | Main thread (Android/Desktop) | +| `Dispatchers.IO` | Network, disk | Optimized for blocking I/O | +| `Dispatchers.Default` | CPU-intensive | Parallelism = CPU cores | +| `Dispatchers.Unconfined` | Testing only | Runs in caller's thread | + +### Flow (Cold Streams) +```kotlin +// Creating flows +fun observeNotes(): Flow> = flow { + while (true) { + val notes = repository.getNotes() + emit(notes) + delay(30_000) // Refresh every 30s + } +} + +// Operators +repository.observeNotes() + .map { notes -> notes.filter { it.isVisible } } + .distinctUntilChanged() + .debounce(300) + .catch { e -> + log.error("Failed to load notes", e) + emit(emptyList()) + } + .flowOn(Dispatchers.IO) + .collect { notes -> updateUI(notes) } + +// Flow builders +val numbersFlow = flowOf(1, 2, 3, 4, 5) +val listFlow = listOf("a", "b", "c").asFlow() +``` + +### StateFlow & SharedFlow (Hot Streams) +```kotlin +// StateFlow - always has a value, replays latest +class FeedViewModel { + private val _state = MutableStateFlow(FeedState()) + val state: StateFlow = _state.asStateFlow() + + fun updateFilter(filter: Filter) { + _state.update { current -> + current.copy(filter = filter) + } + } +} + +// SharedFlow - no initial value, configurable replay +class EventBus { + private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val events: SharedFlow = _events.asSharedFlow() + + suspend fun emit(event: AppEvent) { + _events.emit(event) + } +} +``` + +### Channels +```kotlin +// Producer-consumer pattern +val channel = Channel(Channel.BUFFERED) + +// Producer +launch { + for (event in eventSource) { + channel.send(event) + } + channel.close() +} + +// Consumer +launch { + for (event in channel) { + process(event) + } +} + +// channelFlow for complex producers +fun relayEvents(relay: Relay): Flow = channelFlow { + relay.connect() + relay.onEvent { event -> + trySend(event) + } + awaitClose { relay.disconnect() } +} +``` + +### Cancellation & Exception Handling +```kotlin +// Cooperative cancellation +suspend fun processNotes(notes: List) { + for (note in notes) { + ensureActive() // Throws if cancelled + process(note) + yield() // Suspend point for cancellation + } +} + +// Exception handling +val handler = CoroutineExceptionHandler { _, exception -> + log.error("Coroutine failed", exception) +} + +val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler) + +// supervisorScope: child failures don't cancel siblings +supervisorScope { + launch { task1() } // Can fail independently + launch { task2() } // Continues even if task1 fails +} +``` + +### Testing Coroutines +```kotlin +class FeedViewModelTest { + @Test + fun `loadFeed updates state with notes`() = runTest { + val repository = mockk() + coEvery { repository.getFeed() } returns flowOf(testNotes) + + val viewModel = FeedViewModel(repository) + viewModel.loadFeed() + + advanceUntilIdle() + + assertEquals(testNotes, viewModel.state.value.notes) + } +} + +// Inject test dispatcher +val testDispatcher = StandardTestDispatcher() +Dispatchers.setMain(testDispatcher) +``` + +## Nostr-Specific Patterns + +### Relay Connection Pool +```kotlin +class RelayPool(private val scope: CoroutineScope) { + private val relays = ConcurrentHashMap() + + fun connect(url: String) { + scope.launch { + val connection = RelayConnection(url) + relays[url] = connection + + supervisorScope { + launch { connection.receiveLoop() } + launch { connection.sendLoop() } + } + } + } + + fun observeEvents(): Flow = relays.values + .map { it.events } + .merge() + .distinctBy { it.id } +} +``` + +### Subscription Management +```kotlin +fun subscribe(filters: List): Flow = channelFlow { + val subscriptionId = UUID.randomUUID().toString() + + try { + relayPool.activeRelays.collect { relays -> + relays.forEach { relay -> + launch { + relay.subscribe(subscriptionId, filters) + .collect { send(it) } + } + } + } + } finally { + relayPool.unsubscribe(subscriptionId) + } +} +``` + +## Agent Capabilities + +1. **Async Architecture Design** + - Coroutine scope hierarchy + - Structured concurrency patterns + - Error propagation strategies + +2. **Flow Pipeline Design** + - Cold vs hot stream selection + - Operator chaining + - Backpressure handling + +3. **Concurrency Patterns** + - Parallel decomposition + - Rate limiting + - Resource pooling + +4. **Testing Strategies** + - runTest usage + - Dispatcher injection + - Flow testing with Turbine + +5. **Performance Optimization** + - Dispatcher selection + - Buffer sizing + - Cancellation efficiency + +## Scope Boundaries + +### In Scope +- kotlinx.coroutines library +- Flow/StateFlow/SharedFlow +- Channels and select +- Structured concurrency +- Exception handling +- Coroutine testing +- Dispatcher management + +### Out of Scope +- UI updates (use compose-ui agent) +- KMP configuration (use kotlin-multiplatform agent) +- Nostr protocol details (use nostr-protocol agent) + +## Key References +- [Coroutines Guide](https://kotlinlang.org/docs/coroutines-guide.html) +- [Flow Documentation](https://kotlinlang.org/docs/flow.html) +- [StateFlow/SharedFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/) diff --git a/.claude/agents/kotlin-multiplatform.md b/.claude/agents/kotlin-multiplatform.md new file mode 100644 index 000000000..2e148cf26 --- /dev/null +++ b/.claude/agents/kotlin-multiplatform.md @@ -0,0 +1,161 @@ +# Kotlin Multiplatform Agent + +## Expertise Domain + +This agent specializes in Kotlin Multiplatform (KMP) project architecture, enabling code sharing across Android, iOS, Desktop (JVM), and Web targets. + +## Core Knowledge Areas + +### Project Structure +``` +module/ +├── build.gradle.kts +└── src/ + ├── commonMain/ # Shared code (all targets) + ├── commonTest/ # Shared tests + ├── androidMain/ # Android-specific + ├── jvmMain/ # Desktop JVM-specific + └── iosMain/ # iOS-specific (future) +``` + +### Source Set Hierarchy +``` + commonMain + │ + ┌───────────┼───────────┐ + │ │ │ + jvmMain nativeMain jsMain + │ │ + ┌──────┴───┐ ┌───┴───┐ + │ │ │ │ +androidMain desktopMain iosMain +``` + +### expect/actual Mechanism +```kotlin +// commonMain - Declaration only +expect class PlatformContext + +expect fun getPlatform(): Platform + +expect class SecureStorage { + fun store(key: String, value: ByteArray) + fun retrieve(key: String): ByteArray? +} + +// androidMain - Android implementation +actual class PlatformContext(val context: Context) + +actual fun getPlatform(): Platform = Platform.Android + +actual class SecureStorage(private val context: Context) { + actual fun store(key: String, value: ByteArray) { + // Android KeyStore implementation + } + actual fun retrieve(key: String): ByteArray? = TODO() +} + +// jvmMain - Desktop implementation +actual class PlatformContext + +actual fun getPlatform(): Platform = Platform.Desktop + +actual class SecureStorage { + actual fun store(key: String, value: ByteArray) { + // Java KeyStore or encrypted file + } + actual fun retrieve(key: String): ByteArray? = TODO() +} +``` + +### Dependency Management +```kotlin +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + androidMain.dependencies { + implementation(libs.secp256k1.kmp.jni.android) + } + jvmMain.dependencies { + implementation(libs.secp256k1.kmp.jni.jvm) + } + } +} +``` + +## Agent Capabilities + +1. **Project Configuration** + - Set up KMP modules from scratch + - Configure targets (Android, JVM, iOS) + - Manage Gradle build scripts + - Version catalog setup + +2. **Code Sharing Strategy** + - Identify shareable vs platform-specific code + - Design expect/actual interfaces + - Create intermediate source sets + - Maximize code reuse (target: 70-80%) + +3. **Dependency Selection** + - Recommend KMP-compatible libraries + - Handle platform-specific variants + - Resolve version conflicts + +4. **Migration Guidance** + - Port Android code to commonMain + - Extract platform abstractions + - Refactor for multiplatform + +5. **Build & Tooling** + - Gradle configuration + - IDE setup (Android Studio) + - CI/CD for multiple targets + +## Platform-Specific Patterns + +| Concern | Android | Desktop JVM | +|---------|---------|-------------| +| **Crypto** | secp256k1-kmp-jni-android | secp256k1-kmp-jni-jvm | +| **Storage** | Android KeyStore | Java KeyStore / File | +| **Sodium** | lazysodium-android | lazysodium-java | +| **UI** | Jetpack Compose | Compose Desktop | +| **Context** | Context object | None needed | + +## Quartz KMP Conversion + +Current Quartz is Android-only. Conversion steps: + +1. **Add KMP plugin** to build.gradle.kts +2. **Define targets**: android(), jvm() +3. **Move shared code** to `src/commonMain/` +4. **Create expect declarations** for platform-specific APIs +5. **Implement actuals** in androidMain/jvmMain + +Key abstractions needed: +- `CryptoProvider` - secp256k1 signing/verification +- `SodiumProvider` - NIP-44 encryption +- `PlatformJson` - Jackson vs kotlinx.serialization + +## Scope Boundaries + +### In Scope +- KMP project structure and configuration +- Source set hierarchy design +- expect/actual declarations +- Gradle multiplatform plugin +- Cross-platform library selection +- Migration from Android-only + +### Out of Scope +- UI implementation details (use compose-ui agent) +- Coroutine patterns (use kotlin-coroutines agent) +- Nostr protocol specifics (use nostr-protocol agent) + +## Key References +- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html) +- [expect/actual](https://kotlinlang.org/docs/multiplatform-expect-actual.html) +- [Hierarchical Structure](https://kotlinlang.org/docs/multiplatform-hierarchy.html) diff --git a/.claude/agents/nostr-protocol.md b/.claude/agents/nostr-protocol.md new file mode 100644 index 000000000..06ac8de5d --- /dev/null +++ b/.claude/agents/nostr-protocol.md @@ -0,0 +1,102 @@ +# Nostr Protocol Agent + +## Expertise Domain + +This agent specializes in the Nostr decentralized social protocol, covering 94+ NIPs (Nostr Implementation Possibilities) that define the protocol specification. + +## Core Knowledge Areas + +### Protocol Fundamentals + +| Concept | Description | +|---------|-------------| +| **Events** | Signed JSON objects: id, pubkey, created_at, kind, tags, content, sig | +| **Relays** | WebSocket servers that store/forward events | +| **Keys** | secp256k1 keypairs, Schnorr signatures (BIP-340) | +| **Filters** | Subscription queries (kinds, authors, tags, since, until, limit) | + +### Event Structure +```kotlin +data class Event( + val id: String, // SHA256 of serialized event + val pubkey: String, // 32-byte hex public key + val created_at: Long, // Unix timestamp + val kind: Int, // Event type + val tags: List>, + val content: String, + val sig: String // 64-byte Schnorr signature +) +``` + +### NIP Categories + +| Category | NIPs | Scope | +|----------|------|-------| +| **Core Protocol** | 01, 02, 10, 11 | Basic events, follows, threads, relay info | +| **Messaging** | 04 (deprecated), 17, 44 | DMs, encrypted messaging | +| **Social** | 18, 25, 32, 38, 51, 52 | Reactions, reports, lists, communities | +| **Identity** | 05, 19, 39, 46, 55 | DNS, bech32, external identity, bunker, signer | +| **Media** | 23, 30, 54, 71, 94 | Long-form, audio, video, blobs | +| **Payments** | 47, 57, 60, 61 | Wallet Connect, zaps, cashu | +| **Relay** | 42, 65, 66 | Auth, relay lists, closed groups | + +### Cryptographic Operations +- **Signing**: Schnorr signatures on secp256k1 curve +- **Encryption**: NIP-44 (XChaCha20-Poly1305), NIP-04 (deprecated AES) +- **Key derivation**: BIP-32/39 compatible +- **Event ID**: SHA256 of `[0, pubkey, created_at, kind, tags, content]` + +## Agent Capabilities + +1. **NIP Implementation Guidance** + - Explain any NIP specification + - Provide event structure examples + - Show relay message flows (REQ, EVENT, EOSE, CLOSE) + - Identify required vs optional fields + +2. **Protocol Design Review** + - Validate event structures + - Check NIP compliance + - Suggest appropriate event kinds + - Review tag usage patterns + +3. **Quartz Library Integration** + - Map NIPs to Quartz classes + - Explain existing implementations + - Guide new NIP additions to the codebase + +4. **Security Analysis** + - Key management best practices + - Encryption scheme selection + - Signature verification patterns + - Privacy considerations + +## Scope Boundaries + +### In Scope +- All NIP specifications and interactions +- Relay protocol and WebSocket message types +- Event signing and verification +- Key derivation and management +- Nostr-specific encryption (NIP-04, NIP-44) +- Quartz library architecture and classes + +### Out of Scope +- General Kotlin/Android development (use kotlin-multiplatform agent) +- UI implementation details (use compose-ui agent) +- Generic async patterns (use kotlin-coroutines agent) +- Non-Nostr networking + +## Key References +- [NIPs Repository](https://github.com/nostr-protocol/nips) +- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) +- [NIP-44: Versioned Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md) +- Quartz source: `quartz/src/` + +## Example Queries + +- "How do I implement NIP-57 zap receipts?" +- "What's the correct tag structure for a reply?" +- "How does NIP-44 encryption work?" +- "Which event kind for a long-form article?" +- "How to verify a Schnorr signature in Quartz?" diff --git a/.claude/commands/desktop-run.md b/.claude/commands/desktop-run.md new file mode 100644 index 000000000..6b43f0d2b --- /dev/null +++ b/.claude/commands/desktop-run.md @@ -0,0 +1,45 @@ +--- +description: Build and run the desktop app +--- + +Build and run the Amethyst Desktop application: + +```bash +./gradlew :desktopApp:run +``` + +## Troubleshooting + +If the build fails, check: + +1. **JDK Version**: Requires JDK 17+ + ```bash + java -version + ``` + +2. **Compose Multiplatform Plugin**: Verify version in `gradle/libs.versions.toml` + +3. **Quartz Build**: Ensure Quartz compiles first + ```bash + ./gradlew :quartz:build + ``` + +4. **Desktop Dependencies**: Check `desktopApp/build.gradle.kts` has: + ```kotlin + implementation(compose.desktop.currentOs) + ``` + +## Creating Distributable + +```bash +# macOS +./gradlew :desktopApp:packageDmg + +# Windows +./gradlew :desktopApp:packageMsi + +# Linux +./gradlew :desktopApp:packageDeb +``` + +Outputs will be in `desktopApp/build/compose/binaries/` diff --git a/.claude/commands/extract.md b/.claude/commands/extract.md new file mode 100644 index 000000000..99c01eea7 --- /dev/null +++ b/.claude/commands/extract.md @@ -0,0 +1,50 @@ +--- +description: Extract a composable from amethyst to shared code +--- + +Extract the component `$ARGUMENTS` from the Android app to shared KMP code: + +## Process + +1. **Locate the component** in the amethyst module: + ```bash + find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*" + grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/ + ``` + +2. **Analyze dependencies**: + - Android-specific imports (Context, Intent, etc.) + - Platform APIs (Camera, MediaStore, etc.) + - Android Compose specifics vs standard Compose + +3. **Identify what can be shared**: + - Pure Composable functions → `shared-ui/commonMain/` + - Business logic → `quartz/commonMain/` + - Platform-specific → create expect/actual + +4. **Create shared version**: + - Move to appropriate shared module + - Replace Android imports with multiplatform alternatives + - Add expect declarations for platform-specific parts + +5. **Update references**: + - Change imports in amethyst module + - Add implementations in desktopApp if needed + +## Common Replacements + +| Android | Multiplatform | +|---------|---------------| +| `LocalContext.current` | expect/actual or parameter | +| `stringResource()` | `Res.string.*` | +| `painterResource()` | `painterResource(Res.drawable.*)` | +| `Toast.makeText()` | Custom snackbar/notification | +| `Intent` | expect/actual for navigation | + +## Example + +``` +/extract NoteCard +``` + +This will find NoteCard, analyze its dependencies, and guide you through extracting it to shared code. diff --git a/.claude/commands/nip.md b/.claude/commands/nip.md new file mode 100644 index 000000000..2c7631be0 --- /dev/null +++ b/.claude/commands/nip.md @@ -0,0 +1,32 @@ +--- +description: Get NIP specification and implementation guidance +--- + +Fetch and explain NIP-$ARGUMENTS from the Nostr protocol: + +1. **Get the specification** from https://github.com/nostr-protocol/nips/blob/master/$ARGUMENTS.md + +2. **Show key details**: + - Event kind(s) used + - Required and optional fields + - Tag structure + - Message flow between client and relay + +3. **Check implementation status** in Quartz: + ```bash + grep -r "NIP-$ARGUMENTS\|nip$ARGUMENTS\|kind.*=" quartz/src/ + ``` + +4. **Provide implementation guidance**: + - Which Quartz classes to use or create + - Event construction example + - Relay subscription filters + - Verification/validation logic + +## Example Usage + +``` +/nip 01 # Basic protocol +/nip 44 # Versioned encryption +/nip 57 # Zaps +``` diff --git a/.claude/skills/compose-desktop.md b/.claude/skills/compose-desktop.md new file mode 100644 index 000000000..7f9d3b6c9 --- /dev/null +++ b/.claude/skills/compose-desktop.md @@ -0,0 +1,339 @@ +# Compose Desktop Skill + +## Desktop Application Entry Point + +```kotlin +// desktopApp/src/jvmMain/kotlin/Main.kt +package com.vitorpamplona.amethyst.desktop + +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.* + +fun main() = application { + val windowState = rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center) + ) + + // System tray + Tray( + icon = painterResource("icon.png"), + tooltip = "Amethyst", + menu = { + Item("Show", onClick = { windowState.isMinimized = false }) + Separator() + Item("Exit", onClick = ::exitApplication) + } + ) + + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "Amethyst", + icon = painterResource("icon.png") + ) { + MenuBar { + Menu("File") { + Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { } + Separator() + Item("Settings", shortcut = KeyShortcut(Key.Comma, ctrl = true)) { } + Separator() + Item("Quit", shortcut = KeyShortcut(Key.Q, ctrl = true), onClick = ::exitApplication) + } + Menu("Edit") { + Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true)) { } + Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true)) { } + } + Menu("View") { + Item("Feed") { } + Item("Messages") { } + Item("Notifications") { } + } + Menu("Help") { + Item("About Amethyst") { } + } + } + + App() + } +} +``` + +## Desktop-Specific Components + +### File Dialog +```kotlin +@Composable +fun rememberFileDialog(): FileDialogState { + return remember { FileDialogState() } +} + +class FileDialogState { + var isOpen by mutableStateOf(false) + var result by mutableStateOf(null) + + fun open() { isOpen = true } + + @Composable + fun Dialog( + title: String = "Select File", + allowedExtensions: List = emptyList() + ) { + if (isOpen) { + DisposableEffect(Unit) { + val dialog = java.awt.FileDialog(null as java.awt.Frame?, title) + if (allowedExtensions.isNotEmpty()) { + dialog.setFilenameFilter { _, name -> + allowedExtensions.any { name.endsWith(it) } + } + } + dialog.isVisible = true + result = dialog.file?.let { File(dialog.directory, it) } + isOpen = false + onDispose { } + } + } + } +} +``` + +### Scroll Behavior +```kotlin +@Composable +fun DesktopScrollableColumn( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + val scrollState = rememberScrollState() + + Box(modifier) { + Column( + modifier = Modifier + .verticalScroll(scrollState) + .fillMaxSize() + ) { + content() + } + + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd), + adapter = rememberScrollbarAdapter(scrollState) + ) + } +} +``` + +### Keyboard Navigation +```kotlin +@Composable +fun KeyboardNavigableList( + items: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + onActivate: (Note) -> Unit +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + LazyColumn( + modifier = Modifier + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { event -> + when { + event.key == Key.DirectionDown && event.type == KeyEventType.KeyDown -> { + onSelect((selectedIndex + 1).coerceAtMost(items.lastIndex)) + true + } + event.key == Key.DirectionUp && event.type == KeyEventType.KeyDown -> { + onSelect((selectedIndex - 1).coerceAtLeast(0)) + true + } + event.key == Key.Enter && event.type == KeyEventType.KeyDown -> { + items.getOrNull(selectedIndex)?.let { onActivate(it) } + true + } + else -> false + } + } + ) { + itemsIndexed(items) { index, note -> + NoteCard( + note = note, + isSelected = index == selectedIndex, + modifier = Modifier.clickable { onSelect(index) } + ) + } + } +} +``` + +### Multi-Window Support +```kotlin +@Composable +fun ApplicationScope.NoteDetailWindow( + note: Note, + onClose: () -> Unit +) { + Window( + onCloseRequest = onClose, + title = "Note by ${note.author.name}", + state = rememberWindowState(width = 600.dp, height = 400.dp) + ) { + NoteDetailScreen(note) + } +} + +// Usage in main application +var openNotes by remember { mutableStateOf>(emptyList()) } + +openNotes.forEach { note -> + key(note.id) { + NoteDetailWindow( + note = note, + onClose = { openNotes = openNotes - note } + ) + } +} +``` + +### Tooltips +```kotlin +@Composable +fun TooltipButton( + tooltip: String, + onClick: () -> Unit, + content: @Composable () -> Unit +) { + TooltipArea( + tooltip = { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.inverseSurface + ) { + Text( + text = tooltip, + modifier = Modifier.padding(8.dp), + color = MaterialTheme.colorScheme.inverseOnSurface + ) + } + } + ) { + IconButton(onClick = onClick) { + content() + } + } +} +``` + +## Desktop Layout Pattern + +```kotlin +@Composable +fun DesktopAppLayout( + currentScreen: Screen, + onNavigate: (Screen) -> Unit, + content: @Composable () -> Unit +) { + Row(Modifier.fillMaxSize()) { + // Sidebar navigation + NavigationRail( + modifier = Modifier.width(72.dp), + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) { + Spacer(Modifier.height(16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == Screen.Feed, + onClick = { onNavigate(Screen.Feed) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == Screen.Messages, + onClick = { onNavigate(Screen.Messages) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == Screen.Notifications, + onClick = { onNavigate(Screen.Notifications) } + ) + + Spacer(Modifier.weight(1f)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == Screen.Settings, + onClick = { onNavigate(Screen.Settings) } + ) + } + + // Divider + VerticalDivider() + + // Main content + Box(Modifier.weight(1f).fillMaxHeight()) { + content() + } + } +} +``` + +## Build Configuration + +```kotlin +// desktopApp/build.gradle.kts +plugins { + kotlin("jvm") + id("org.jetbrains.compose") +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(project(":quartz")) +} + +compose.desktop { + application { + mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + + nativeDistributions { + targetFormats( + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb + ) + + packageName = "Amethyst" + packageVersion = "1.0.0" + + macOS { + bundleID = "com.vitorpamplona.amethyst.desktop" + iconFile.set(project.file("icons/icon.icns")) + } + + windows { + iconFile.set(project.file("icons/icon.ico")) + menuGroup = "Amethyst" + } + + linux { + iconFile.set(project.file("icons/icon.png")) + } + } + } +} +``` diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md new file mode 100644 index 000000000..49e5000ff --- /dev/null +++ b/.claude/skills/quartz-kmp.md @@ -0,0 +1,165 @@ +# Quartz KMP Conversion Skill + +When working with Quartz library conversion to Kotlin Multiplatform: + +## Current Structure (Android-only) +``` +quartz/ +├── build.gradle +└── src/ + ├── main/kotlin/ # All Nostr code here + ├── test/ + └── androidTest/ +``` + +## Target Structure (KMP) +``` +quartz/ +├── build.gradle.kts +└── src/ + ├── commonMain/kotlin/ # Shared protocol code + ├── commonTest/kotlin/ # Shared tests + ├── androidMain/kotlin/ # Android crypto, storage + ├── androidTest/kotlin/ + ├── jvmMain/kotlin/ # Desktop crypto, storage + └── jvmTest/kotlin/ +``` + +## Platform Abstractions Required + +### 1. Cryptography (expect/actual) +```kotlin +// commonMain +expect object Secp256k1 { + fun sign(data: ByteArray, privateKey: ByteArray): ByteArray + fun verify(data: ByteArray, signature: ByteArray, pubKey: ByteArray): Boolean + fun pubKeyCreate(privateKey: ByteArray): ByteArray +} + +// androidMain - uses secp256k1-kmp-jni-android +actual object Secp256k1 { + actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray { + return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey) + } + // ... +} + +// jvmMain - uses secp256k1-kmp-jni-jvm +actual object Secp256k1 { + actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray { + return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey) + } + // ... +} +``` + +### 2. NIP-44 Encryption (Sodium) +```kotlin +// commonMain +expect object Nip44 { + fun encrypt(plaintext: String, sharedSecret: ByteArray): String + fun decrypt(ciphertext: String, sharedSecret: ByteArray): String +} + +// androidMain - lazysodium-android +// jvmMain - lazysodium-java or libsodium-jni +``` + +### 3. Secure Random +```kotlin +// commonMain +expect fun secureRandomBytes(size: Int): ByteArray + +// androidMain +actual fun secureRandomBytes(size: Int): ByteArray { + return SecureRandom().let { random -> + ByteArray(size).also { random.nextBytes(it) } + } +} + +// jvmMain +actual fun secureRandomBytes(size: Int): ByteArray { + return java.security.SecureRandom().let { random -> + ByteArray(size).also { random.nextBytes(it) } + } +} +``` + +## Build Configuration + +```kotlin +// quartz/build.gradle.kts +plugins { + kotlin("multiplatform") + id("com.android.library") +} + +kotlin { + androidTarget { + compilations.all { + kotlinOptions.jvmTarget = "17" + } + } + + jvm("desktop") { + compilations.all { + kotlinOptions.jvmTarget = "17" + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.collections.immutable) + } + } + + val androidMain by getting { + dependencies { + implementation(libs.secp256k1.kmp.jni.android) + implementation(libs.lazysodium.android) + } + } + + val desktopMain by getting { + dependencies { + implementation(libs.secp256k1.kmp.jni.jvm) + // lazysodium-java or alternative + } + } + } +} + +android { + namespace = "com.vitorpamplona.quartz" + compileSdk = 35 + defaultConfig.minSdk = 26 +} +``` + +## Migration Steps + +1. **Convert build.gradle to build.gradle.kts** with KMP plugin +2. **Move pure Kotlin code** to `commonMain/` +3. **Identify platform dependencies** (crypto, JNA, Android APIs) +4. **Create expect declarations** for platform-specific APIs +5. **Implement actuals** in androidMain and jvmMain +6. **Update imports** in amethyst module +7. **Test on both platforms** + +## Files to Move to commonMain + +Most of Quartz can be shared: +- Event classes and parsing +- Filter definitions +- Relay message types +- NIP implementations (logic only) +- Utilities (hex encoding, bech32) + +## Files Needing expect/actual + +- `Secp256k1.kt` - Signature operations +- `Nip04.kt` - Legacy encryption (uses AES) +- `Nip44.kt` - Modern encryption (uses ChaCha) +- `KeyPair.kt` - Key generation diff --git a/build.gradle b/build.gradle index f27850355..5bf6769b7 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.diffplugSpotless) alias(libs.plugins.googleServices) apply false alias(libs.plugins.jetbrainsComposeCompiler) apply false + alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.serialization) @@ -64,4 +65,4 @@ tasks.register('installGitHook', Copy) { into { new File(rootProject.rootDir, '.git/hooks') } filePermissions { unix(0777) } } -tasks.getByPath(':amethyst:preBuild').dependsOn installGitHook \ No newline at end of file +tasks.getByPath(':amethyst:preBuild').dependsOn installGitHook diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts new file mode 100644 index 000000000..42722007e --- /dev/null +++ b/desktopApp/build.gradle.kts @@ -0,0 +1,59 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.jetbrainsKotlinJvm) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + + // Quartz Nostr library (will use JVM target) + implementation(project(":quartz")) + + // Coroutines + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.swing) + + // Networking + implementation(libs.okhttp) + + // JSON + implementation(libs.jackson.module.kotlin) + + // Collections + implementation(libs.kotlinx.collections.immutable) +} + +compose.desktop { + application { + mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + + packageName = "Amethyst" + packageVersion = "1.0.0" + description = "Nostr client for desktop" + vendor = "Amethyst Contributors" + + macOS { + bundleID = "com.vitorpamplona.amethyst.desktop" + iconFile.set(project.file("src/jvmMain/resources/icon.icns")) + } + + windows { + iconFile.set(project.file("src/jvmMain/resources/icon.ico")) + menuGroup = "Amethyst" + upgradeUuid = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + } + + linux { + iconFile.set(project.file("src/jvmMain/resources/icon.png")) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt new file mode 100644 index 000000000..843e6f659 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -0,0 +1,306 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyShortcut +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.MenuBar +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState + +enum class Screen { + Feed, + Search, + Messages, + Notifications, + Profile, + Settings +} + +fun main() = application { + val windowState = rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center) + ) + + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "Amethyst" + ) { + MenuBar { + Menu("File") { + Item( + "New Note", + shortcut = KeyShortcut(Key.N, ctrl = true), + onClick = { /* TODO: Open new note dialog */ } + ) + Separator() + Item( + "Settings", + shortcut = KeyShortcut(Key.Comma, ctrl = true), + onClick = { /* TODO: Open settings */ } + ) + Separator() + Item( + "Quit", + shortcut = KeyShortcut(Key.Q, ctrl = true), + onClick = ::exitApplication + ) + } + Menu("Edit") { + Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true), onClick = { }) + Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true), onClick = { }) + } + Menu("View") { + Item("Feed", onClick = { }) + Item("Messages", onClick = { }) + Item("Notifications", onClick = { }) + } + Menu("Help") { + Item("About Amethyst", onClick = { }) + Item("Keyboard Shortcuts", onClick = { }) + } + } + + App() + } +} + +@Composable +fun App() { + var currentScreen by remember { mutableStateOf(Screen.Feed) } + + MaterialTheme( + colorScheme = darkColorScheme() + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Row(Modifier.fillMaxSize()) { + // Sidebar Navigation + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) { + Spacer(Modifier.height(16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == Screen.Feed, + onClick = { currentScreen = Screen.Feed } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + label = { Text("Search") }, + selected = currentScreen == Screen.Search, + onClick = { currentScreen = Screen.Search } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == Screen.Messages, + onClick = { currentScreen = Screen.Messages } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == Screen.Notifications, + onClick = { currentScreen = Screen.Notifications } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == Screen.Profile, + onClick = { currentScreen = Screen.Profile } + ) + + Spacer(Modifier.weight(1f)) + + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == Screen.Settings, + onClick = { currentScreen = Screen.Settings } + ) + + Spacer(Modifier.height(16.dp)) + } + + VerticalDivider() + + // Main Content + Box( + modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp) + ) { + when (currentScreen) { + Screen.Feed -> FeedPlaceholder() + Screen.Search -> SearchPlaceholder() + Screen.Messages -> MessagesPlaceholder() + Screen.Notifications -> NotificationsPlaceholder() + Screen.Profile -> ProfilePlaceholder() + Screen.Settings -> SettingsPlaceholder() + } + } + } + } + } +} + +@Composable +fun FeedPlaceholder() { + Column { + Text( + "Feed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Your Nostr feed will appear here.\n\nConnect to relays and follow users to see notes.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun SearchPlaceholder() { + Column { + Text( + "Search", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Search for users, notes, and hashtags.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun MessagesPlaceholder() { + Column { + Text( + "Messages", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Your encrypted direct messages will appear here.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun NotificationsPlaceholder() { + Column { + Text( + "Notifications", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Mentions, replies, and reactions will appear here.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun ProfilePlaceholder() { + Column { + Text( + "Profile", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Login with your Nostr keys to see your profile.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun SettingsPlaceholder() { + Column { + Text( + "Settings", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Configure relays, appearance, and account settings.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/desktopApp/src/jvmMain/resources/icon.png b/desktopApp/src/jvmMain/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dbb5d7153df9e4fe370bfde124098a89b0582392 GIT binary patch literal 7016 zcmeHLc{o(<`ycyQv%HB=V^Fp+i?PjX$-XsZdr2s>988!QW31U?Y$*{%3MJ7-vQySb zQ7KD8Aw(Nl3Z?Wrqu$=SzTfNmUGH`Me*ZOd&7AYx_vd_``~E!lbI&=GY`4o&SYWdN z1OgGZwlZ@7k7V`-$_swe$4@wchxTx%Jxm8e2#n64c~SfT7&D9xz<^MS7X%VIXSFVXq*FGs+yt>ZRn>5gvUvn_XPINokS5+l!$KgLNEBEd0 z6N}3d<2#-$={(}Wd5B2PbHOjIP2K`fj^-O_7WJpgN=&Zv7H&}RZHt7P1Z3Nz6^c~2 zH81uzI$%ee%CwqHrUMM#`iHK1KFbTa`(#AS7-LU*m~;=rHx|_vFvAH)Cz#0DcX_fZ z@UiiqhkN;#xg3bDBbi~{J0TDb7RA)m&f3)U?}WfK&P1jgSl!wo+rHn?TxDaT{tR9oPGLRCVP-RJ!yrtx&oyLgN}tP2QMwXJLMd0^rV>4E%r55 z11!973Rqx!@H=rNn!g5tOd|psq5gER@*og>qfk15 zXll?H-bl2bo*oi~L1Hin5P=8`qcRDh2x_1Lo8lXX84yTfQ0PnwjS6FP5{R@QrU4ud z`eA>^=TFDuf6!9{zpDW1feaW;9X|ds1sN12{Wg9GOOxq1l#TS zkH?AP4>&RjY}L>@7(E>{28-3v(gl;(`Um6;Fap7fW0Rs$8k*R351E9s04WJzXWnKA$Zlg4nO(fkbH>|kMR${%h#xSz-bCc%ur z1i;x)7%UEr!eKO>&{{Yw3WwD}ps+a9Pk0)c;uZG4q1n3!rvGitttf%u{9)^+Z(GU{ z2>3SoHuR&c?9OD&LeiDVtJ z9sxln5w#ImO^hCbs0UUB3eeOc0%#oq2GIPKJ&@+b3?VQ8V{cGLP%E%L*R_Hve_KM8 zU*RD>0DFBv#Smx|;y)Ebe$N=m?izomtdIN;QS{dVKeZX~x^FhHeSzH&`J)|v7meL` z{vTi87vukN29WyqB>zg^-*Wwy>t8AGufV^n>$hD0N`Zd`{#{-FXL1Sr^FReq!P}q^ z@NsErxs3;W)Z!)DTAD$`+3yXZVO^kwpKj$E2!V*)Vt+U~XcyUk4e&9o@#cIl1vW`2 zBVFw~#UT(u5oivz2@ao24>*+F=2moRruNjm z%E3<$C(j{snYiAp!rooO(+?Cib1RWS+fO~g-$n$xZ=biYk~`9(eFb&81y;e&Z*2uF zajB1NR`ZmRLoC}or6ow~KK+{a;>PsX?yvbr>9QThEKiu&=^ct@EUP4|Bxt&5x~LdB z20J3X*K6uwua!dGE%;3L!zqq4tQdt9qtKVjtFOd4)$s>c29+>-jPn#iz?cCQNBZY4vdSA(eBFRL z_bu+a&$iwWy7PL=>7a_2FZ0Fyt#)@npDuEYsNo@4tc;jYfNxfsoic0DJDhmhT59H? zf5aAriRvobJ5T&266(Ep90NGVTD2yIlnU!!c}48i6Z%|C;&o2+o)H^os-K;Ie|D_V zB09~~w!3Xep>X08Vx^(wiMJWyyb*0nqLHZkpDgY{(FZ!r_HGm_**-O z`v_NHtcc5*x^O;|Q$;Cy@>$5u<1R9t77d|Ysr(h1ANjAl=B}*0rAKs>XX&@Z(n=Ecl^#}QHO$wTy3yz03sf6)MzrMlM`%+(=f4kf4+?LV9 z((Qbtd|vA#^m_K&+F)wt-o{F^_%rHyNMXh-Z_{mqZ4yocZlA;i7`9%V zE_`)oTHYD1pnG~89`%F^X5}OV_}=4>PIEi+K0}lYbe7dvq#Q=PNiTJd5l-;DUXRj# z6*I7v)zLxhtBhHD#lzhy>v-mMZoAv+vpe^9+KQM&j6fbl8`JLXj|@^Pujq%wd^s5O zq{$_br)I0+=r}ZqAf<_B!Dzo}u8vGZ`G*m^OljvPe=LrG zZ8vJ%sC9leV3j@}7(P1DY%F);Y2S)^YGe;*oK})Yzf$P*L+z`Mcb&^jF=;5v$fF*P zp*lti=z~_YX_UJp@`PVN^5)apiA{9GnD_jnt-H=OMDl98K)%jiADg(HzA;}(F`_HD zAdokeCwg40HC`8+ccA!W^hY{Rp~E&mz16E+j;5)WJsSAhF;dnQWRsCpHJ;9E;*$~> zp{B|@a_kg}4&;+0-VZUftp>7?mIu=t(K6X=q#ifqZUDE8h%` zN=NFa98R)#?<-23Ny7dwAE66^nJMp7?Zz9pfXZIDT0zH@c@sM7O$}W8wMMbbXx`cV z5SJQn2|VkCVYQRb{G&vp_>MP;4k~;Kt{;I*msHYXVs1bCbJ&o_!!_6`In{N;t2#7y zehM{A`Ie~Pv!bK5;wmaaJ^hC}j&nfX^f7!i0jGB~sdCcoDapo80Upy@<^pe3ME`6A znXEkFXwTpBC@0PG~=vtNHF?ha;)`8Ul~wrppHvGv?{d`PFpT+itLl#lzFkt5UDwP zF8O$D(8|g{_XHi-^`w0JwcYLdjpleOZ#N^(EuS>5UQ^+?HCNGYpHbRO8||4u(rAg= zkslgfEL6(W6z8@ou!OlEt$aM1go!FD5REW_%RljOWkrAX+OQVN?7E!vy!pAwa^)OU zTDmq)g{z^WjpO0TNfW4Drz5>AoGZ1#B}FILuwiG%!IH8Y5v}ma)oa6I$yu2$g{*D1 z!t%x!tlE*soQcPl?!MRyO?cQtlN6D6UX(4>F{>URB{rt%tj;SI+DP{qEHX*aH=nT} zGUB}QaxPT3)boXhp+mZN^0CX~h`jZby?F`{HB$lrVh^u;0SC>GfQ- zL$sDbXIO)w=uSc#P*)!^>6Ek>7!aIRm%W18k_m6&r5Fxs>ith@% z^Wls4@>Y75krmP6@`rMF_xLT1KhDfBGCABOSavCI%D*C zh|v8OUCA}kJtfB{Ueps?E|)})RJKS=iB;NrloVYXwvZdeY<|V0dIVkao{}2s`m3#-j=$MyPdbPt~}M^mW3pn#mfEpa&z!Rf_vtqs%K^B zCjUF)RaMt8eDw=g`8M6sXVeLX?Zj^>F z>0u$HbRSdh7_7O#zPdLVNd{)HizPkV3dOAVn%JaNoKFBR+1>c7{8djI9wcVqPt?` Y!Xv2regUZt-Yi3`&3BoVns~ Date: Fri, 26 Dec 2025 08:24:38 +0200 Subject: [PATCH 17/44] fix: Correct compose compiler plugin alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use jetbrainsComposeCompiler alias to match version catalog. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- desktopApp/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 42722007e..25522b131 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.composeMultiplatform) - alias(libs.plugins.composeCompiler) + alias(libs.plugins.jetbrainsComposeCompiler) } dependencies { From 8c8a4ab9e8b57880ea6f6246b118aea162a3b380 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 26 Dec 2025 14:02:44 +0200 Subject: [PATCH 18/44] feat: Add relay connections, login, and feed view to desktop app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DesktopHttpClient and DesktopRelayConnectionManager for relay WebSocket connections - Implement AccountManager with key generation and nsec/npub login support - Create LoginScreen with key import and new key generation UI - Build FeedScreen with live global feed subscription and note cards - Add ProfileScreen showing account info and logout - Update RelaySettingsScreen with connection status and relay management - Configure JDK 21 toolchain for desktop builds - Add shared UI analysis documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- desktopApp/build.gradle.kts | 11 + .../vitorpamplona/amethyst/desktop/Main.kt | 427 ++++++++++++++---- .../desktop/account/AccountManager.kt | 121 +++++ .../desktop/network/DesktopHttpClient.kt | 39 ++ .../network/DesktopRelayConnectionManager.kt | 151 +++++++ .../amethyst/desktop/ui/FeedScreen.kt | 279 ++++++++++++ .../amethyst/desktop/ui/LoginScreen.kt | 265 +++++++++++ docs/shared-ui-analysis.md | 219 +++++++++ 8 files changed, 1419 insertions(+), 93 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt create mode 100644 docs/shared-ui-analysis.md diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 25522b131..86b736734 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -6,6 +6,17 @@ plugins { alias(libs.plugins.jetbrainsComposeCompiler) } +sourceSets { + main { + kotlin.srcDir("src/jvmMain/kotlin") + resources.srcDir("src/jvmMain/resources") + } +} + +kotlin { + jvmToolchain(21) +} + dependencies { implementation(compose.desktop.currentOs) implementation(compose.material3) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 843e6f659..5f0277445 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -20,38 +20,56 @@ */ package com.vitorpamplona.amethyst.desktop +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyShortcut import androidx.compose.ui.unit.dp @@ -60,6 +78,12 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.network.RelayStatus +import com.vitorpamplona.amethyst.desktop.ui.FeedScreen +import com.vitorpamplona.amethyst.desktop.ui.LoginScreen enum class Screen { Feed, @@ -124,6 +148,17 @@ fun main() = application { @Composable fun App() { var currentScreen by remember { mutableStateOf(Screen.Feed) } + val relayManager = remember { DesktopRelayConnectionManager() } + val accountManager = remember { AccountManager() } + val accountState by accountManager.accountState.collectAsState() + + DisposableEffect(Unit) { + relayManager.addDefaultRelays() + relayManager.connect() + onDispose { + relayManager.disconnect() + } + } MaterialTheme( colorScheme = darkColorScheme() @@ -132,77 +167,21 @@ fun App() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - Row(Modifier.fillMaxSize()) { - // Sidebar Navigation - NavigationRail( - modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) { - Spacer(Modifier.height(16.dp)) - - NavigationRailItem( - icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, - label = { Text("Feed") }, - selected = currentScreen == Screen.Feed, - onClick = { currentScreen = Screen.Feed } + when (accountState) { + is AccountState.LoggedOut -> { + LoginScreen( + accountManager = accountManager, + onLoginSuccess = { currentScreen = Screen.Feed } ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, - label = { Text("Search") }, - selected = currentScreen == Screen.Search, - onClick = { currentScreen = Screen.Search } - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, - label = { Text("DMs") }, - selected = currentScreen == Screen.Messages, - onClick = { currentScreen = Screen.Messages } - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, - label = { Text("Alerts") }, - selected = currentScreen == Screen.Notifications, - onClick = { currentScreen = Screen.Notifications } - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, - label = { Text("Profile") }, - selected = currentScreen == Screen.Profile, - onClick = { currentScreen = Screen.Profile } - ) - - Spacer(Modifier.weight(1f)) - - HorizontalDivider(Modifier.padding(horizontal = 16.dp)) - - NavigationRailItem( - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - label = { Text("Settings") }, - selected = currentScreen == Screen.Settings, - onClick = { currentScreen = Screen.Settings } - ) - - Spacer(Modifier.height(16.dp)) } - - VerticalDivider() - - // Main Content - Box( - modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp) - ) { - when (currentScreen) { - Screen.Feed -> FeedPlaceholder() - Screen.Search -> SearchPlaceholder() - Screen.Messages -> MessagesPlaceholder() - Screen.Notifications -> NotificationsPlaceholder() - Screen.Profile -> ProfilePlaceholder() - Screen.Settings -> SettingsPlaceholder() - } + is AccountState.LoggedIn -> { + MainContent( + currentScreen = currentScreen, + onScreenChange = { currentScreen = it }, + relayManager = relayManager, + accountManager = accountManager, + account = accountState as AccountState.LoggedIn, + ) } } } @@ -210,18 +189,85 @@ fun App() { } @Composable -fun FeedPlaceholder() { - Column { - Text( - "Feed", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(Modifier.height(16.dp)) - Text( - "Your Nostr feed will appear here.\n\nConnect to relays and follow users to see notes.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) +fun MainContent( + currentScreen: Screen, + onScreenChange: (Screen) -> Unit, + relayManager: DesktopRelayConnectionManager, + accountManager: AccountManager, + account: AccountState.LoggedIn, +) { + Row(Modifier.fillMaxSize()) { + // Sidebar Navigation + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) { + Spacer(Modifier.height(16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == Screen.Feed, + onClick = { onScreenChange(Screen.Feed) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + label = { Text("Search") }, + selected = currentScreen == Screen.Search, + onClick = { onScreenChange(Screen.Search) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == Screen.Messages, + onClick = { onScreenChange(Screen.Messages) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == Screen.Notifications, + onClick = { onScreenChange(Screen.Notifications) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == Screen.Profile, + onClick = { onScreenChange(Screen.Profile) } + ) + + Spacer(Modifier.weight(1f)) + + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == Screen.Settings, + onClick = { onScreenChange(Screen.Settings) } + ) + + Spacer(Modifier.height(16.dp)) + } + + VerticalDivider() + + // Main Content + Box( + modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp) + ) { + when (currentScreen) { + Screen.Feed -> FeedScreen(relayManager) + Screen.Search -> SearchPlaceholder() + Screen.Messages -> MessagesPlaceholder() + Screen.Notifications -> NotificationsPlaceholder() + Screen.Profile -> ProfileScreen(account, accountManager) + Screen.Settings -> RelaySettingsScreen(relayManager) + } + } } } @@ -274,7 +320,7 @@ fun NotificationsPlaceholder() { } @Composable -fun ProfilePlaceholder() { +fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager) { Column { Text( "Profile", @@ -282,25 +328,220 @@ fun ProfilePlaceholder() { color = MaterialTheme.colorScheme.onBackground ) Spacer(Modifier.height(16.dp)) - Text( - "Login with your Nostr keys to see your profile.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (account.isReadOnly) { + Text( + "Read-only mode", + style = MaterialTheme.typography.labelMedium, + color = Color.Yellow + ) + Spacer(Modifier.height(8.dp)) + } + + Text( + "Public Key", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + account.npub, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Hex", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + account.pubKeyHex, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + Spacer(Modifier.height(24.dp)) + + OutlinedButton( + onClick = { accountManager.logout() }, + colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors( + contentColor = Color.Red + ) + ) { + Text("Logout") + } } } @Composable -fun SettingsPlaceholder() { - Column { +fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays by relayManager.connectedRelays.collectAsState() + var newRelayUrl by remember { mutableStateOf("") } + + Column(modifier = Modifier.fillMaxSize()) { Text( - "Settings", + "Relay Settings", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground ) + Spacer(Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "${connectedRelays.size} of ${relayStatuses.size} relays connected", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + IconButton(onClick = { relayManager.connect() }) { + Icon( + Icons.Default.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary + ) + } + } + Spacer(Modifier.height(16.dp)) - Text( - "Configure relays, appearance, and account settings.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = newRelayUrl, + onValueChange = { newRelayUrl = it }, + label = { Text("Add relay") }, + placeholder = { Text("wss://relay.example.com") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + Button( + onClick = { + if (newRelayUrl.isNotBlank()) { + relayManager.addRelay(newRelayUrl) + newRelayUrl = "" + } + }, + enabled = newRelayUrl.isNotBlank() + ) { + Text("Add") + } + } + + Spacer(Modifier.height(16.dp)) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f) + ) { + items(relayStatuses.values.toList()) { status -> + RelayStatusCard(status) { + relayManager.removeRelay(status.url) + } + } + } + + Spacer(Modifier.height(16.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { + Text("Reset to Defaults") + } + } + } +} + +@Composable +fun RelayStatusCard(status: RelayStatus, onRemove: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f) + ) { + val statusColor = when { + status.connected -> Color.Green + status.error != null -> Color.Red + else -> Color.Gray + } + + if (status.connected) { + Icon( + Icons.Default.Check, + contentDescription = "Connected", + tint = statusColor, + modifier = Modifier.size(20.dp) + ) + } else if (status.error != null) { + Icon( + Icons.Default.Close, + contentDescription = "Error", + tint = statusColor, + modifier = Modifier.size(20.dp) + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + + Column { + Text( + status.url.url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (status.connected && status.pingMs != null) { + Text( + "${status.pingMs}ms${if (status.compressed) " • compressed" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + status.error?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = Color.Red.copy(alpha = 0.8f) + ) + } + } + } + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = "Remove relay", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt new file mode 100644 index 000000000..91f544c25 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.account + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +sealed class AccountState { + data object LoggedOut : AccountState() + data class LoggedIn( + val signer: NostrSigner, + val pubKeyHex: String, + val npub: String, + val nsec: String?, + val isReadOnly: Boolean, + ) : AccountState() +} + +class AccountManager { + private val _accountState = MutableStateFlow(AccountState.LoggedOut) + val accountState: StateFlow = _accountState.asStateFlow() + + fun generateNewAccount(): AccountState.LoggedIn { + val keyPair = KeyPair() + val signer = NostrSignerInternal(keyPair) + + val state = AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) + _accountState.value = state + return state + } + + fun loginWithKey(keyInput: String): Result { + val trimmedInput = keyInput.trim() + + // Try as private key first (nsec or hex) + val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput) + if (privKeyHex != null) { + return try { + val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) + val signer = NostrSignerInternal(keyPair) + + val state = AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) + _accountState.value = state + Result.success(state) + } catch (e: Exception) { + Result.failure(IllegalArgumentException("Invalid private key format")) + } + } + + // Try as public key (npub or hex) - read-only mode + val pubKeyHex = decodePublicKeyAsHexOrNull(trimmedInput) + if (pubKeyHex != null) { + return try { + val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray()) + val signer = NostrSignerInternal(keyPair) + + val state = AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = null, + isReadOnly = true, + ) + _accountState.value = state + Result.success(state) + } catch (e: Exception) { + Result.failure(IllegalArgumentException("Invalid public key format")) + } + } + + return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, or hex format.")) + } + + fun logout() { + _accountState.value = AccountState.LoggedOut + } + + fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn + + fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt new file mode 100644 index 000000000..9046e2d1f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.network + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +object DesktopHttpClient { + private val client: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + } + + fun getHttpClient(url: NormalizedRelayUrl): OkHttpClient = client +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt new file mode 100644 index 000000000..a8583c4fd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -0,0 +1,151 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.network + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val pingMs: Int? = null, + val compressed: Boolean = false, + val error: String? = null, +) + +class DesktopRelayConnectionManager : IRelayClientListener { + private val websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient) + private val client = NostrClient(websocketBuilder) + + private val _relayStatuses = MutableStateFlow>(emptyMap()) + val relayStatuses: StateFlow> = _relayStatuses.asStateFlow() + + val connectedRelays: StateFlow> = client.connectedRelaysFlow() + val availableRelays: StateFlow> = client.availableRelaysFlow() + + companion object { + val DEFAULT_RELAYS = listOf( + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nos.lol", + "wss://relay.snort.social", + "wss://nostr.wine", + ) + } + + init { + client.subscribe(this) + } + + fun connect() { + client.connect() + } + + fun disconnect() { + client.disconnect() + } + + fun addRelay(url: String): NormalizedRelayUrl? { + val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null + updateRelayStatus(normalized) { it.copy(connected = false, error = null) } + return normalized + } + + fun removeRelay(url: NormalizedRelayUrl) { + _relayStatuses.value = _relayStatuses.value - url + } + + fun addDefaultRelays() { + DEFAULT_RELAYS.forEach { addRelay(it) } + } + + fun subscribe( + subId: String, + filters: List, + relays: Set = availableRelays.value, + listener: IRequestListener? = null, + ) { + val filterMap = relays.associateWith { filters } + client.openReqSubscription(subId, filterMap, listener) + } + + fun unsubscribe(subId: String) { + client.close(subId) + } + + fun send(event: Event, relays: Set = connectedRelays.value) { + client.send(event, relays) + } + + private fun updateRelayStatus( + url: NormalizedRelayUrl, + update: (RelayStatus) -> RelayStatus + ) { + _relayStatuses.value = _relayStatuses.value.toMutableMap().apply { + val current = this[url] ?: RelayStatus(url, connected = false) + this[url] = update(current) + } + } + + override fun onConnecting(relay: IRelayClient) { + updateRelayStatus(relay.url) { + it.copy(connected = false, error = null) + } + } + + override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) { + updateRelayStatus(relay.url) { + it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null) + } + } + + override fun onDisconnected(relay: IRelayClient) { + updateRelayStatus(relay.url) { + it.copy(connected = false) + } + } + + override fun onCannotConnect(relay: IRelayClient, errorMessage: String) { + updateRelayStatus(relay.url) { + it.copy(connected = false, error = errorMessage) + } + } + + override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { + // Events are handled by subscription listeners + } + + override fun onSent(relay: IRelayClient, cmdStr: String, cmd: Command, success: Boolean) { + // Command send tracking + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt new file mode 100644 index 000000000..139326a7d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -0,0 +1,279 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun FeedScreen(relayManager: DesktopRelayConnectionManager) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val events = remember { mutableStateListOf() } + val seenIds = remember { mutableSetOf() } + + DisposableEffect(connectedRelays) { + if (connectedRelays.isNotEmpty()) { + val subId = "global-feed-${System.currentTimeMillis()}" + val filters = listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + limit = 50, + ) + ) + + relayManager.subscribe( + subId = subId, + filters = filters, + relays = connectedRelays, + listener = object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List? + ) { + if (event.id !in seenIds) { + seenIds.add(event.id) + events.add(0, event) + if (events.size > 200) { + val removed = events.removeAt(events.size - 1) + seenIds.remove(removed.id) + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List? + ) { + // End of stored events + } + } + ) + + onDispose { + relayManager.unsubscribe(subId) + } + } else { + onDispose { } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Global Feed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val connected = connectedRelays.size + val color = when { + connected == 0 -> Color.Red + connected < 3 -> Color.Yellow + else -> Color.Green + } + Icon( + if (connected > 0) Icons.Default.Check else Icons.Default.Close, + contentDescription = null, + tint = color, + modifier = Modifier.size(16.dp) + ) + Text( + "$connected relay${if (connected != 1) "s" else ""}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + IconButton(onClick = { relayManager.connect() }) { + Icon( + Icons.Default.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + if (connectedRelays.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Connecting to relays...", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else if (events.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Loading notes...", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(events, key = { it.id }) { event -> + NoteCard(event) + } + } + } + } +} + +@Composable +fun NoteCard(event: Event) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Author npub (truncated) + val npub = try { + event.pubKey.hexToByteArrayOrNull()?.toNpub() ?: event.pubKey.take(16) + "..." + } catch (e: Exception) { + event.pubKey.take(16) + "..." + } + Text( + text = npub.take(20) + "...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1 + ) + + // Timestamp + val date = Date(event.createdAt * 1000) + val format = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) + Text( + text = format.format(date), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(Modifier.height(8.dp)) + + Text( + text = event.content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 10, + overflow = TextOverflow.Ellipsis + ) + + Spacer(Modifier.height(8.dp)) + + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) + + Spacer(Modifier.height(4.dp)) + + // Event ID (truncated) + Text( + text = "ID: ${event.id.take(12)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + } +} + +private fun String.hexToByteArrayOrNull(): ByteArray? { + return try { + if (length % 2 != 0) return null + ByteArray(length / 2) { i -> + substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } catch (e: Exception) { + null + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt new file mode 100644 index 000000000..2f9ba4fb1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -0,0 +1,265 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState + +@Composable +fun LoginScreen( + accountManager: AccountManager, + onLoginSuccess: () -> Unit, +) { + var keyInput by remember { mutableStateOf("") } + var showKey by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var showNewKeyDialog by remember { mutableStateOf(false) } + var generatedAccount by remember { mutableStateOf(null) } + + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + "Welcome to Amethyst", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "A Nostr client for desktop", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(48.dp)) + + Card( + modifier = Modifier.width(400.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Login with your Nostr key", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = keyInput, + onValueChange = { + keyInput = it + errorMessage = null + }, + label = { Text("nsec or npub") }, + placeholder = { Text("nsec1... or npub1...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (showKey) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { showKey = !showKey }) { + Icon( + if (showKey) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showKey) "Hide key" else "Show key", + ) + } + }, + isError = errorMessage != null, + supportingText = errorMessage?.let { + { Text(it, color = MaterialTheme.colorScheme.error) } + }, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "Use nsec for full access or npub for read-only", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + accountManager.loginWithKey(keyInput).fold( + onSuccess = { onLoginSuccess() }, + onFailure = { errorMessage = it.message }, + ) + }, + modifier = Modifier.weight(1f), + enabled = keyInput.isNotBlank(), + ) { + Text("Login") + } + + OutlinedButton( + onClick = { + generatedAccount = accountManager.generateNewAccount() + showNewKeyDialog = true + }, + modifier = Modifier.weight(1f), + ) { + Text("Generate New") + } + } + } + } + + if (showNewKeyDialog && generatedAccount != null) { + Spacer(Modifier.height(24.dp)) + NewKeyCard( + account = generatedAccount!!, + onContinue = { + showNewKeyDialog = false + onLoginSuccess() + }, + ) + } + } +} + +@Composable +fun NewKeyCard( + account: AccountState.LoggedIn, + onContinue: () -> Unit, +) { + Card( + modifier = Modifier.width(500.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + ) { + Text( + "IMPORTANT: Save your keys!", + style = MaterialTheme.typography.titleMedium, + color = Color.Red, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Your secret key (nsec) is the ONLY way to access your account. " + + "If you lose it, your account is gone forever. Save it somewhere safe!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Public Key (shareable):", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SelectableKeyText(account.npub) + + Spacer(Modifier.height(12.dp)) + + account.nsec?.let { nsec -> + Text( + "Secret Key (NEVER share this!):", + style = MaterialTheme.typography.labelMedium, + color = Color.Red, + ) + SelectableKeyText(nsec) + } + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + ) { + Text("I've saved my keys, continue") + } + } + } +} + +@Composable +fun SelectableKeyText(key: String) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Text( + text = key, + modifier = Modifier.padding(12.dp).fillMaxWidth(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/docs/shared-ui-analysis.md b/docs/shared-ui-analysis.md new file mode 100644 index 000000000..174d2d058 --- /dev/null +++ b/docs/shared-ui-analysis.md @@ -0,0 +1,219 @@ +# Shared UI Components Analysis + +Analysis of Android vs Desktop UI to identify reusable Compose Multiplatform components. + +## Executive Summary + +| Category | Android Files | Shareable As-Is | Needs Abstraction | +|----------|---------------|-----------------|-------------------| +| Formatters | 4 | 2 | 2 | +| Theme | 4 | 4 | 0 | +| Note Components | 35 | ~10 | ~25 | +| Total Potential | ~450+ | ~100+ | ~200+ | + +## Immediately Shareable Components + +These have **zero Android dependencies** and can be moved to a shared module today: + +### Formatters (Pure Kotlin) +``` +amethyst/ui/note/PubKeyFormatter.kt -> shared/ui/formatters/ +amethyst/ui/note/ZapFormatter.kt -> shared/ui/formatters/ +amethyst/ui/note/ZapFormatterNoDecimals.kt -> shared/ui/formatters/ +``` + +### Theme (Pure Compose) +``` +amethyst/ui/theme/Color.kt -> shared/ui/theme/ +amethyst/ui/theme/Shape.kt -> shared/ui/theme/ +amethyst/ui/theme/Type.kt -> shared/ui/theme/ +``` + +### Layout Primitives +``` +amethyst/ui/theme/ButtonBorder (padding values) +amethyst/ui/theme/Size* (dimension constants) +``` + +## Components Requiring Abstraction + +### 1. String Resources (R.string.*) + +**Problem:** Android uses `R.string.xxx` for localized strings. + +**Solution:** Create `StringProvider` interface: + +```kotlin +// commonMain +interface StringProvider { + fun get(key: String): String + fun get(key: String, vararg args: Any): String +} + +// androidMain +class AndroidStringProvider(private val context: Context) : StringProvider { + override fun get(key: String) = context.getString(keyToId(key)) +} + +// jvmMain +class DesktopStringProvider : StringProvider { + private val strings = mapOf( + "post_not_found" to "Post not found", + "now" to "now", + // ... + ) + override fun get(key: String) = strings[key] ?: key +} +``` + +**Affected files:** +- TimeAgoFormatter.kt +- BlankNote.kt +- All UI components with text + +### 2. AccountViewModel + +**Problem:** Most UI components depend on `AccountViewModel` for: +- User profile data +- Settings (image loading, NSFW, etc.) +- Actions (follow, mute, report) + +**Solution:** Create interface in shared module: + +```kotlin +// commonMain +interface IAccountState { + val userPubKey: String + val userNpub: String + val settings: IUserSettings +} + +interface IUserSettings { + val showImages: Boolean + val showSensitiveContent: Boolean +} +``` + +### 3. Navigation (INav) + +**Problem:** Android uses custom `INav` interface tied to Compose Navigation. + +**Solution:** Already abstracted! `INav` is an interface - just needs to be in shared module. + +### 4. Image Loading + +**Problem:** Uses Coil with Android-specific caching. + +**Solution:** Coil 3.x supports Compose Multiplatform. Create shared wrapper: + +```kotlin +// commonMain +@Composable +expect fun AsyncImage( + url: String, + contentDescription: String?, + modifier: Modifier, +) +``` + +## Recommended Shared Module Structure + +``` +shared-ui/ +├── src/ +│ ├── commonMain/kotlin/ +│ │ ├── formatters/ +│ │ │ ├── PubKeyFormatter.kt +│ │ │ ├── ZapFormatter.kt +│ │ │ └── TimeAgoFormatter.kt (abstracted) +│ │ ├── theme/ +│ │ │ ├── Color.kt +│ │ │ ├── Shape.kt +│ │ │ ├── Type.kt +│ │ │ └── Dimensions.kt +│ │ ├── components/ +│ │ │ ├── NoteCard.kt +│ │ │ ├── UserAvatar.kt +│ │ │ └── LoadingIndicator.kt +│ │ └── providers/ +│ │ ├── StringProvider.kt +│ │ └── ImageProvider.kt +│ ├── androidMain/kotlin/ +│ │ └── providers/ +│ │ └── AndroidStringProvider.kt +│ └── jvmMain/kotlin/ +│ └── providers/ +│ └── DesktopStringProvider.kt +└── build.gradle.kts +``` + +## Implementation Priority + +### Phase 1: Low-Hanging Fruit (1-2 days) +1. Create `shared-ui` module +2. Move pure Kotlin formatters (PubKeyFormatter, ZapFormatter) +3. Move theme colors and dimensions +4. Update both apps to use shared module + +### Phase 2: String Abstraction (2-3 days) +1. Create StringProvider interface +2. Implement for Android and Desktop +3. Migrate TimeAgoFormatter +4. Create composable wrappers for common strings + +### Phase 3: Core UI Components (1 week) +1. Abstract AccountViewModel to IAccountState +2. Move simple components (BlankNote, LoadingIndicator) +3. Create NoteCard shared component +4. Integrate with both apps + +### Phase 4: Complex Components (2+ weeks) +1. Image loading abstraction +2. RichTextViewer +3. ReactionsRow +4. Full NoteCompose migration + +## Desktop vs Android Comparison + +| Feature | Android | Desktop | Share Strategy | +|---------|---------|---------|----------------| +| Note display | NoteCompose.kt (51KB) | NoteCard in FeedScreen | Extract common layout | +| Time formatting | TimeAgoFormatter.kt | SimpleDateFormat inline | Abstract with interface | +| Key display | PubKeyFormatter.kt | Manual truncation | Use shared formatter | +| Colors | Color.kt | darkColorScheme() | Share Color.kt | +| Navigation | INav + Compose Nav | enum Screen | Abstract navigation | +| Images | Coil + OkHttp | Not implemented | Coil Multiplatform | + +## Current Desktop Implementation Gaps + +Desktop currently has basic implementations that could benefit from sharing: + +1. **NoteCard** - Has inline date formatting, could use TimeAgoFormatter +2. **Key display** - Manual truncation, should use PubKeyFormatter +3. **Theme** - Uses default darkColorScheme, could use shared theme +4. **No image loading** - Needs Coil integration + +## Quick Wins + +Immediate improvements to desktop using patterns from Android: + +```kotlin +// Instead of: +val npub = event.pubKey.take(16) + "..." + +// Use shared formatter: +val npub = event.pubKey.toDisplayHexKey() + +// Instead of: +val format = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) + +// Use shared formatter: +val timeAgo = timeAgoNoDot(event.createdAt, stringProvider) +``` + +## Next Steps + +1. **Create shared-ui module** in project structure +2. **Move formatters** as proof of concept +3. **Create StringProvider** abstraction +4. **Iterate** on more complex components From 59eeb1ff0ca3691ec90dd2b0b273b73e0a9d2b3d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 26 Dec 2025 14:10:02 +0200 Subject: [PATCH 19/44] fix: Use configured relays to initiate subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FeedScreen was waiting for connectedRelays to be non-empty before subscribing, but relays are only added to the NostrClient pool when subscriptions request them. This created a deadlock where no relays would ever connect. Now uses relayStatuses.keys (configured relays) to initiate the subscription, which triggers the NostrClient to add those relays to its pool and connect to them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 139326a7d..8eef7d1e6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -69,11 +69,16 @@ import java.util.Locale @Composable fun FeedScreen(relayManager: DesktopRelayConnectionManager) { val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() val events = remember { mutableStateListOf() } val seenIds = remember { mutableSetOf() } - DisposableEffect(connectedRelays) { - if (connectedRelays.isNotEmpty()) { + // Use relayStatuses.keys (configured relays) to initiate subscription, + // not connectedRelays (which is empty until subscriptions trigger connections) + val configuredRelays = relayStatuses.keys + + DisposableEffect(configuredRelays) { + if (configuredRelays.isNotEmpty()) { val subId = "global-feed-${System.currentTimeMillis()}" val filters = listOf( Filter( @@ -85,7 +90,7 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) { relayManager.subscribe( subId = subId, filters = filters, - relays = connectedRelays, + relays = configuredRelays, listener = object : IRequestListener { override fun onEvent( event: Event, From 720e4d2c87eb9ef75d48907d1ac2459b473408b0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 26 Dec 2025 14:29:10 -0500 Subject: [PATCH 20/44] Forces the use of the index on Addressables and Replaceables --- .../quartz/nip01Core/store/sqlite/AddressableModule.kt | 5 ++++- .../quartz/nip01Core/store/sqlite/ReplaceableModule.kt | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt index 27f625d66..735997518 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt @@ -36,6 +36,8 @@ class AddressableModule : IModule { // if a newer addressable is inserted the unique index // above will be triggered. Delete cascade will take // care of the event_tags table + // the duplicate: kind >= 30000 AND kind < 40000 + // helps SQLlite find the index above db.execSQL( """ CREATE TRIGGER delete_older_addressable_event @@ -48,7 +50,8 @@ class AddressableModule : IModule { event_headers.kind = NEW.kind AND event_headers.pubkey = NEW.pubkey AND event_headers.d_tag = NEW.d_tag AND - event_headers.created_at < NEW.created_at; + event_headers.created_at < NEW.created_at AND + event_headers.kind >= 30000 AND event_headers.kind < 40000; END; """.trimIndent(), ) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt index 04a2d3c7f..7aa87e594 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt @@ -28,7 +28,7 @@ class ReplaceableModule : IModule { """ CREATE UNIQUE INDEX replaceable_idx ON event_headers (kind, pubkey) - WHERE (kind >= 10000 AND kind < 20000) OR (kind IN (0, 3)) + WHERE (kind IN (0, 3)) OR (kind >= 10000 AND kind < 20000) """.trimIndent(), ) @@ -41,14 +41,15 @@ class ReplaceableModule : IModule { CREATE TRIGGER delete_older_replaceable_event BEFORE INSERT ON event_headers FOR EACH ROW - WHEN (NEW.kind >= 10000 AND NEW.kind < 20000) OR (NEW.kind IN (0, 3)) + WHEN (NEW.kind IN (0, 3)) OR (NEW.kind >= 10000 AND NEW.kind < 20000) BEGIN -- Delete older records if this is the newest DELETE FROM event_headers WHERE event_headers.kind = NEW.kind AND event_headers.pubkey = NEW.pubkey AND - event_headers.created_at < NEW.created_at; + event_headers.created_at < NEW.created_at AND + ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); END; """.trimIndent(), ) From ef5ed755be1e0f2fa115f3e1af72007661a7b1e3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 26 Dec 2025 14:31:18 -0500 Subject: [PATCH 21/44] Uses primary key to reuse rowId instead of building a separate index. --- .../quartz/nip01Core/store/sqlite/ExpirationModule.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt index 03f1992f8..2a8b1a94e 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt @@ -29,7 +29,7 @@ class ExpirationModule : IModule { db.execSQL( """ CREATE TABLE event_expirations ( - event_header_row_id INTEGER, + event_header_row_id INTEGER PRIMARY KEY NOT NULL, expiration INTEGER NOT NULL, FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE ) @@ -49,8 +49,6 @@ class ExpirationModule : IModule { END; """.trimIndent(), ) - - db.execSQL("CREATE UNIQUE INDEX events_exp_id ON event_expirations (event_header_row_id)") } override fun drop(db: SQLiteDatabase) { From 5dcac034ac267af0cb9f6365339a36a5f186ce20 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 26 Dec 2025 14:32:22 -0500 Subject: [PATCH 22/44] Uses event_header_row_id as primary key (with index) on event_vanish --- .../quartz/nip01Core/store/sqlite/RightToVanishModule.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt index db07539c8..eb5658b87 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt @@ -29,8 +29,8 @@ class RightToVanishModule : IModule { db.execSQL( """ CREATE TABLE event_vanish ( - event_header_row_id INTEGER, pubkey TEXT NOT NULL, + event_header_row_id INTEGER PRIMARY KEY NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE ) From 4455509e32ff1f1004e4d7b5fa6166ec31e4f99a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 26 Dec 2025 14:33:41 -0500 Subject: [PATCH 23/44] Converts pubkey to pubkey hash on vanish events. --- .../store/sqlite/RightToVanishModule.kt | 24 ++++++++++--------- .../store/sqlite/SQLiteEventStore.kt | 2 +- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt index eb5658b87..979574c10 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt @@ -24,20 +24,22 @@ import android.database.sqlite.SQLiteDatabase import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent -class RightToVanishModule : IModule { +class RightToVanishModule( + val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, +) : IModule { override fun create(db: SQLiteDatabase) { db.execSQL( """ CREATE TABLE event_vanish ( - pubkey TEXT NOT NULL, event_header_row_id INTEGER PRIMARY KEY NOT NULL, + pubkey_hash INTEGER NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE ) """.trimIndent(), ) - db.execSQL("CREATE UNIQUE INDEX event_vanish_key ON event_vanish (pubkey)") + db.execSQL("CREATE UNIQUE INDEX event_vanish_key ON event_vanish (pubkey_hash)") db.execSQL( """ @@ -48,8 +50,8 @@ class RightToVanishModule : IModule { -- Delete older records if this is the newest DELETE FROM event_vanish WHERE - event_vanish.created_at < NEW.created_at AND - event_vanish.pubkey = NEW.pubkey; + event_vanish.pubkey_hash = NEW.pubkey_hash AND + event_vanish.created_at < NEW.created_at; END; """.trimIndent(), ) @@ -61,8 +63,8 @@ class RightToVanishModule : IModule { FOR EACH ROW BEGIN DELETE FROM event_headers - WHERE created_at < NEW.created_at AND - pubkey = NEW.pubkey; + WHERE event_headers.created_at < NEW.created_at AND + event_headers.pubkey_owner_hash = NEW.pubkey_hash; END; """.trimIndent(), ) @@ -78,8 +80,8 @@ class RightToVanishModule : IModule { WHERE EXISTS ( SELECT 1 FROM event_vanish WHERE - event_vanish.created_at >= NEW.created_at AND - event_vanish.pubkey = NEW.pubkey + event_vanish.pubkey_hash = NEW.pubkey_owner_hash AND + event_vanish.created_at >= NEW.created_at ); END; """.trimIndent(), @@ -92,7 +94,7 @@ class RightToVanishModule : IModule { val insertRTV = """ - INSERT OR ROLLBACK INTO event_vanish (event_header_row_id, pubkey, created_at) + INSERT OR ROLLBACK INTO event_vanish (event_header_row_id, pubkey_hash, created_at) VALUES (?, ?, ?) """.trimIndent() @@ -105,7 +107,7 @@ class RightToVanishModule : IModule { if (event is RequestToVanishEvent && event.shouldVanishFrom(relayUrl)) { val stmt = db.compileStatement(insertRTV) stmt.bindLong(1, headerId) - stmt.bindString(2, event.pubKey) + stmt.bindLong(2, hasher(db).hash(event.pubKey)) stmt.bindLong(3, event.createdAt) stmt.executeInsert() } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 7af2d4186..dee109346 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -54,7 +54,7 @@ class SQLiteEventStore( val deletionModule = DeletionRequestModule() val expirationModule = ExpirationModule() - val rightToVanishModule = RightToVanishModule() + val rightToVanishModule = RightToVanishModule(seedModule::hasher) val modules = listOf( From 273d5feb944bda5fcce4b7ee10d16d983c0909ab Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 26 Dec 2025 15:43:51 -0500 Subject: [PATCH 24/44] - Adds support to delete GiftWraps by p-tag for Deletion and Vanish modules - Adds Query plans to the test cases - Reduces the amount of indexes that weren't used. --- .../nip01Core/store/sqlite/AddressableTest.kt | 31 ++ .../nip01Core/store/sqlite/DeletionTest.kt | 239 +++++++++ .../store/sqlite/QueryAssemblerTest.kt | 480 ++++++++++++++++++ .../nip01Core/store/sqlite/ReplaceableTest.kt | 57 +++ .../store/sqlite/RightToVanishTest.kt | 46 ++ .../store/sqlite/DeletionRequestModule.kt | 145 ++++-- .../store/sqlite/EventIndexesModule.kt | 253 +++++---- .../nip01Core/store/sqlite/QueryExplainer.kt | 94 ++++ .../store/sqlite/SQLiteEventStore.kt | 2 +- .../sqllite/EventDbQueryAssemblerTest.kt | 135 ----- 10 files changed, 1214 insertions(+), 268 deletions(-) create mode 100644 quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt delete mode 100644 quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqllite/EventDbQueryAssemblerTest.kt diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt index 1d99bcfe8..6214aecc6 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase +import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail import org.junit.After import org.junit.Before @@ -109,4 +110,34 @@ class AddressableTest { db.assertQuery(null, Filter(ids = listOf(version2.id))) db.assertQuery(null, Filter(ids = listOf(version1.id))) } + + @Test + fun testTriggersIndexUsage() { + val explainer = + db.store.explainQuery( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 30000 AND + event_headers.pubkey = 'aa' AND + event_headers.d_tag = 'test-tag' AND + event_headers.created_at < 1766686500 AND + event_headers.kind >= 30000 AND event_headers.kind < 40000 + """.trimIndent(), + ) + + assertEquals( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 30000 AND + event_headers.pubkey = 'aa' AND + event_headers.d_tag = 'test-tag' AND + event_headers.created_at < 1766686500 AND + event_headers.kind >= 30000 AND event_headers.kind < 40000 + └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + """.trimIndent(), + explainer, + ) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt index 02789a115..1f7f771b3 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -23,12 +23,15 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import android.content.Context import android.database.sqlite.SQLiteConstraintException import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase import junit.framework.TestCase.fail import org.junit.After import org.junit.Assert.assertEquals @@ -170,4 +173,240 @@ class DeletionTest { db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(null, Filter(ids = listOf(note3.id))) } + + @Test + fun testInsertDeleteWrap() { + val me = NostrSignerSync() + val myFriend = NostrSignerSync() + + val note1 = me.sign(TextNoteEvent.build("test1")) + val wrap1 = GiftWrapEvent.create(note1, me.pubKey) + val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) + + db.insert(wrap1) + db.insert(wrap2) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1))) + + db.insert(randomDeletionToWrap) + + db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id))) + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val deletion = me.sign(DeletionEvent.build(listOf(wrap1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + // trying to insert again should fail. + try { + db.insert(wrap1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + } + + @Test + fun testTriggersIndexUsage() { + val sql = + """ + SELECT 1 FROM event_tags + INNER JOIN event_headers + ON event_headers.row_id = event_tags.event_header_row_id + WHERE + event_tags.tag_hash IN (3221122, 223322) AND + event_headers.kind = 5 AND + event_headers.created_at >= 1766686500 AND + event_headers.pubkey_owner_hash = 22332323 + """.trimIndent() + + val explainer = + db.store.explainQuery(sql) + + TestCase.assertEquals( + """ + ${sql.replace("\n","\n ")} + ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + └── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + """.trimIndent(), + explainer, + ) + } + + @Test + fun testDeleteById() { + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = listOf("ca29c211f", "ca29c211d"), + addresses = emptyList(), + hasher = TagNameValueHasher(0), + ).first() + + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE + id IN ("ca29c211f","ca29c211d") AND + pubkey_owner_hash = "1573573083296714675" + ├── SEARCH event_headers USING INDEX event_headers_id (id=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } + + @Test + fun testDeleteAddressable() { + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + ), + hasher = TagNameValueHasher(0), + ).first() + + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag = "a") + ) AND + kind >= 30000 AND kind < 40000 + ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } + + @Test + fun testDeleteAddressablesSingleKind() { + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + Address(30000, "key1", "b"), + Address(30000, "key1", "c"), + Address(30000, "key1", "d"), + ), + hasher = TagNameValueHasher(0), + ).first() + + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d")) + ) AND + kind >= 30000 AND kind < 40000 + ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } + + @Test + fun testDeleteAddressablesMultipleKinds() { + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + Address(30000, "key1", "b"), + Address(30101, "key1", "c"), + Address(30101, "key1", "d"), + Address(30001, "key2", "e"), + Address(30001, "key2", "f"), + ), + hasher = TagNameValueHasher(0), + ).first() + + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b")) + OR + (kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d")) + ) AND + kind >= 30000 AND kind < 40000 + ├── MULTI-INDEX OR + │ ├── INDEX 1 + │ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + │ └── INDEX 2 + │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } + + @Test + fun testDeleteReplaceables() { + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(10000, "key1", ""), + Address(10000, "key1", ""), + Address(10001, "key1", ""), + Address(10001, "key1", ""), + Address(10001, "key2", ""), + Address(10001, "key2", ""), + ), + hasher = TagNameValueHasher(0), + ).first() + + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE + kind IN ("10000","10001") AND + pubkey = "key1" AND + ((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) + ├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt new file mode 100644 index 000000000..a92b74aff --- /dev/null +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -0,0 +1,480 @@ +/** + * 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 com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import junit.framework.TestCase +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class QueryAssemblerTest { + val hasher = TagNameValueHasher(0L) + val builder = EventIndexesModule(FullTextSearchModule(), { hasher }) + + val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" + val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" + + private lateinit var db: EventStore + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null) + } + + @After + fun tearDown() { + db.close() + } + + fun explain(f: Filter) = builder.planQuery(f, hasher, db.store.readableDatabase) + + fun explain(f: List) = builder.planQuery(f, hasher, db.store.readableDatabase) + + @Test + fun testEmpty() { + Assert.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id + └── SCAN event_headers USING INDEX query_by_created_at_id + """.trimIndent(), + explain(Filter()), + ) + } + + @Test + fun testCheckDeletionEventExists() { + val query = + explain( + Filter( + kinds = listOf(5), + authors = listOf(key1), + tags = mapOf("e" to listOf(key2)), + since = 1750889190, + ), + ) + + println(query) + + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_headers.created_at >= "1750889190") AND (event_tags.tag_hash = "2657743813502222172") AND (event_headers.kind = "5") AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + query, + ) + } + + @Test + fun testLimit() { + val explainer = explain(Filter(limit = 10)) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + explainer, + ) + } + + @Test + fun testLimits() { + val explainer = explain(listOf(Filter(limit = 10), Filter(limit = 30))) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ │ └── SCAN (subquery-1) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-3) + │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ └── SCAN (subquery-3) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + explainer, + ) + } + + @Test + fun testAllFeatures() { + val sql = + explain( + listOf( + Filter(limit = 10), + Filter( + authors = listOf(key1), + kinds = listOf(1, 1111), + search = "keywords", + limit = 100, + ), + Filter(kinds = listOf(20), search = "cats", limit = 30), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 100) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ │ └── SCAN (subquery-1) + │ ├── UNION USING TEMP B-TREE + │ │ ├── CO-ROUTINE (subquery-3) + │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ │ └── USE TEMP B-TREE FOR ORDER BY + │ │ └── SCAN (subquery-3) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-5) + │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ └── USE TEMP B-TREE FOR ORDER BY + │ └── SCAN (subquery-5) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testKind() { + val sql = + explain( + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + limit = 30, + ), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.kind = "3" ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testKindAndDTag() { + val sql = + explain( + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = mapOf("d" to listOf("")), + limit = 30, + ), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind = "3") AND (event_headers.d_tag = "") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testJustTags() { + val sql = + explain( + listOf( + Filter( + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, + ), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE event_tags.tag_hash = "-4551135004136952885" ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testTagsAndKinds() { + val sql = + explain( + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, + ), + ), + ) + + println(sql) + + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_headers.kind = "3") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testTagsAndAuthors() { + val sql = + explain( + listOf( + Filter( + authors = listOf(key1), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, + ), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testTwoTags() { + val sql = + explain( + listOf( + Filter( + kinds = listOf(1), + tags = + mapOf( + "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + "t" to listOf("hashtag"), + ), + limit = 30, + ), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_tags as event_tagst ON event_tagst.event_header_row_id = event_tags.event_header_row_id INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tagst.tag_hash = "-6379614208644810021") AND (event_headers.kind = "1") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ ├── SEARCH event_tagst USING COVERING INDEX query_by_tags_hash (tag_hash=? AND event_header_row_id=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testIdQuery() { + val sql = explain(Filter(ids = listOf(key1))) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── SEARCH event_headers USING COVERING INDEX event_headers_id (id=?) + └── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + """.trimIndent(), + sql, + ) + } + + @Test + fun testAuthors() { + val sql = explain(Filter(authors = listOf(key1, key2), kinds = listOf(1, 30023), limit = 300)) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind IN ("1", "30023")) AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 300 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=? AND pubkey=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testAuthorsAndSearch() { + val sql = explain(Filter(authors = listOf(key1, key2, key3), search = "keywords")) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) AND (event_fts MATCH "keywords") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testKindAndSearch() { + val sql = explain(Filter(kinds = listOf(1, 1111, 10000), search = "keywords")) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111", "10000")) AND (event_fts MATCH "keywords") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } +} diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt index e127b6393..97c8aa75d 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase +import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail import org.junit.After import org.junit.Before @@ -109,4 +110,60 @@ class ReplaceableTest { db.assertQuery(null, Filter(ids = listOf(version2.id))) db.assertQuery(null, Filter(ids = listOf(version1.id))) } + + @Test + fun testTriggersIndexUsageKind0() { + val explainer = + db.store.explainQuery( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 0 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 AND + ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); + """.trimIndent(), + ) + + assertEquals( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 0 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 AND + ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); + └── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?) + """.trimIndent(), + explainer, + ) + } + + @Test + fun testTriggersIndexUsageKind3() { + val explainer = + db.store.explainQuery( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 3 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 AND + ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); + """.trimIndent(), + ) + + assertEquals( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 3 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 AND + ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); + └── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?) + """.trimIndent(), + explainer, + ) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt index 578d6aeb7..1a7f7aad5 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt @@ -26,6 +26,7 @@ import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase.fail @@ -87,4 +88,49 @@ class RightToVanishTest { db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(note3, Filter(ids = listOf(note3.id))) } + + @Test + fun testInsertDeleteGiftWrap() { + val time = TimeUtils.now() + + val me = NostrSignerSync() + val myFriend = NostrSignerSync() + + val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time)) + val wrap1 = GiftWrapEvent.create(note1, me.pubKey) + val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) + + db.insert(wrap1) + db.insert(wrap2) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) + + db.insert(randomVanishToWrap) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val vanish = me.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) + + db.insert(vanish) + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + // trying to insert again should fail. + try { + db.insert(wrap1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt index 60c5a8ac7..32e567fd1 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -21,12 +21,22 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.isAddressable +import com.vitorpamplona.quartz.nip01Core.core.isReplaceable import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -class DeletionRequestModule : IModule { +class DeletionRequestModule( + val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, +) : IModule { + /** + * Creates a trigger to reject events that have been + * deleted by ID or ATag including GiftWraps that + * must be checked against the p-tag (pubkey_owner_hash) + */ override fun create(db: SQLiteDatabase) { - // rejects deleted events. db.execSQL( """ CREATE TRIGGER reject_deleted_events @@ -36,14 +46,14 @@ class DeletionRequestModule : IModule { -- Check for ID-based deletion record SELECT RAISE(ABORT, 'blocked: a deletion event exists') WHERE EXISTS ( - SELECT 1 FROM event_headers - INNER JOIN event_tags + SELECT 1 FROM event_tags + INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE - event_headers.created_at >= NEW.created_at AND + event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND event_headers.kind = 5 AND - event_headers.pubkey = NEW.pubkey AND - event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) + event_headers.pubkey_owner_hash = NEW.pubkey_owner_hash AND + event_headers.created_at >= NEW.created_at ); END; """.trimIndent(), @@ -60,31 +70,106 @@ class DeletionRequestModule : IModule { ) { if (event is DeletionEvent) { val idValues = event.deleteEventIds() - val idParams = idValues.joinToString(",") { "?" } - val addresses = event.deleteAddresses() - val addressParams = addresses.joinToString(",") { "(?, ?)" } - val addressValues = addresses.flatMap { listOf(it.kind, it.dTag) } - val whereClause = - if (idValues.isNotEmpty() && addresses.isNotEmpty()) { - "(id IN ($idParams) OR (kind, d_tag) IN ($addressParams)) AND pubkey = ?" - } else if (idValues.isNotEmpty()) { - "id IN ($idParams) AND pubkey = ?" - } else if (addresses.isNotEmpty()) { - "(kind, d_tag) IN ($addressParams) AND pubkey = ?" - } else { - return - } - val whereParams = idValues.plus(addressValues).plus(event.pubKey).toTypedArray() - - db.execSQL( - """ - DELETE FROM event_headers - WHERE $whereClause; - """.trimIndent(), - whereParams, - ) + deleteSQL(event.pubKey, idValues, addresses, hasher(db)).forEach { delete -> + db.execSQL(delete.sql, delete.args) + } } } + + /** + * Creates a Delete statement that correctly deletes by id, + * by address and by replaceable (no d-tag) using each index + * appropriately, including GiftWraps where the owner is the + * p-tag (via event_header.pubkey_owner_hash) + */ + fun deleteSQL( + pubkey: HexKey, + idValues: List, + addresses: List
, + hasher: TagNameValueHasher, + ): List { + val owner = hasher.hash(pubkey) + val idParams = idValues.joinToString(",") { "?" } + + // aligns each type of param with the need to filter d-tag + // and thus each index type + val addressablesByKind = addresses.filter { it.kind.isAddressable() && it.pubKeyHex == pubkey }.groupBy { it.kind } + val replaceablesByKind = addresses.filter { it.kind.isReplaceable() && it.pubKeyHex == pubkey }.groupBy { it.kind } + + val addressableParams = + addressablesByKind.keys.joinToString("\n OR\n ") { + val tagList = addressablesByKind[it] + if (tagList == null) { + "" + } else if (tagList.size == 1) { + "(kind = ? AND pubkey = ? AND d_tag = ?)" + } else { + "(kind = ? AND pubkey = ? AND d_tag IN (${tagList.joinToString(",") { "?" }}))" + } + } + + val addressableValues = + addressablesByKind.flatMap { + listOf(it.key.toLong(), pubkey) + it.value.map { it.dTag } + } + + val replaceableKindsParam = replaceablesByKind.keys.joinToString(",") { "?" } + val replaceableKindsValues = replaceablesByKind.keys.map { it.toLong() } + + val deleteById = + if (idValues.isNotEmpty()) { + SqlArgs( + """ + DELETE FROM event_headers + WHERE + id IN ($idParams) AND + pubkey_owner_hash = ? + """.trimIndent(), + idValues.plus(owner).toTypedArray(), + ) + } else { + null + } + + val deleteByAddress = + if (addressableValues.isNotEmpty()) { + SqlArgs( + """ + DELETE FROM event_headers + WHERE ( + $addressableParams + ) AND + kind >= 30000 AND kind < 40000 + """.trimIndent(), + addressableValues.toTypedArray(), + ) + } else { + null + } + + val deleteByReplaceable = + if (replaceableKindsParam.isNotEmpty()) { + SqlArgs( + """ + DELETE FROM event_headers + WHERE + kind IN ($replaceableKindsParam) AND + pubkey = ? AND + ((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) + """.trimIndent(), + replaceableKindsValues.plus(pubkey).toTypedArray(), + ) + } else { + null + } + + return listOfNotNull(deleteById, deleteByAddress, deleteByReplaceable) + } + + class SqlArgs( + val sql: String, + val args: Array, + ) } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index 0fbac80f6..b0fbad682 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.EventFactory class EventIndexesModule( @@ -46,11 +47,12 @@ class EventIndexesModule( created_at INTEGER NOT NULL, kind INTEGER NOT NULL, d_tag TEXT, - etag_hash INTEGER NOT NULL, - atag_hash INTEGER, tags TEXT NOT NULL, content TEXT NOT NULL, - sig TEXT NOT NULL + sig TEXT NOT NULL, + pubkey_owner_hash INTEGER NOT NULL, + etag_hash INTEGER, + atag_hash INTEGER ) """.trimIndent(), ) @@ -65,10 +67,12 @@ class EventIndexesModule( """.trimIndent(), ) - db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)") - db.execSQL("CREATE INDEX query_by_kind_pubkey_idx ON event_headers (created_at desc, kind, pubkey, d_tag)") - db.execSQL("CREATE INDEX query_by_id_idx ON event_headers (created_at desc, id)") - db.execSQL("CREATE INDEX query_by_tags_idx ON event_tags (tag_hash)") + 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)") + + 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)") // Prevent updates to maintain immutability db.execSQL( @@ -102,9 +106,9 @@ class EventIndexesModule( val sqlInsertHeader = """ INSERT INTO event_headers - (id, pubkey, created_at, kind, tags, content, sig, d_tag, etag_hash, atag_hash) + (id, pubkey, created_at, kind, tags, content, sig, d_tag, pubkey_owner_hash, etag_hash, atag_hash) VALUES - (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent() val sqlInsertTags = @@ -121,22 +125,37 @@ class EventIndexesModule( ): Long { val hasher = hasher(db) val stmt = db.compileStatement(sqlInsertHeader) + + val kindLong = event.kind.toLong() + val pubkeyHash = hasher.hash(event.pubKey) + + val eventOwnerHash = + if (event is GiftWrapEvent) { + event.recipientPubKey()?.let { hasher.hash(it) } ?: pubkeyHash + } else { + pubkeyHash + } + + val eTagHash = hasher.hashETag(event.id) + stmt.bindString(1, event.id) stmt.bindString(2, event.pubKey) stmt.bindLong(3, event.createdAt) - stmt.bindLong(4, event.kind.toLong()) + stmt.bindLong(4, kindLong) stmt.bindString(5, OptimizedJsonMapper.toJson(event.tags)) stmt.bindString(6, event.content) stmt.bindString(7, event.sig) if (event is AddressableEvent) { val dTag = event.dTag() stmt.bindString(8, dTag) - stmt.bindLong(9, hasher.hashETag(event.id)) - stmt.bindLong(10, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag))) + stmt.bindLong(9, eventOwnerHash) + stmt.bindLong(10, eTagHash) + stmt.bindLong(11, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag))) } else { stmt.bindNull(8) - stmt.bindLong(9, hasher.hashETag(event.id)) - stmt.bindNull(10) + stmt.bindLong(9, eventOwnerHash) + stmt.bindLong(10, eTagHash) + stmt.bindNull(11) } val headerId = stmt.executeInsert() @@ -167,10 +186,17 @@ class EventIndexesModule( fun planQuery( filter: Filter, hasher: TagNameValueHasher, + db: SQLiteDatabase, ): String { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher) ?: return makeEverythingQuery() + val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher) - return makeQueryIn(rowIdSubQuery.sql) + return if (rowIdSubQuery == null) { + val query = makeEverythingQuery() + db.explainQuery(query) + } else { + val query = makeQueryIn(rowIdSubQuery.sql) + db.explainQuery(query, rowIdSubQuery.args.toTypedArray()) + } } fun query( @@ -192,32 +218,31 @@ class EventIndexesModule( onEach: (T) -> Unit, ) { val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) - db.runQueryEmitting(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach) } fun planQuery( filters: List, hasher: TagNameValueHasher, + db: SQLiteDatabase, ): String { - val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher) } - if (rowIdSubQueries.isEmpty()) return makeEverythingQuery() - val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } - return makeQueryIn(unions) + val rowIdSubQuery = unionSubqueriesIfNeeded(filters, hasher) + + return if (rowIdSubQuery == null) { + val query = makeEverythingQuery() + db.explainQuery(query) + } else { + val query = makeQueryIn(rowIdSubQuery.sql) + db.explainQuery(query, rowIdSubQuery.args.toTypedArray()) + } } fun query( filters: List, db: SQLiteDatabase, ): List { - val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) } - - if (rowIdSubQueries.isEmpty()) return db.runQuery(makeEverythingQuery()) - - val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } - val args = rowIdSubQueries.flatMap { it.args } - - return db.runQuery(makeQueryIn(unions), args) + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQuery(makeEverythingQuery()) + return db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args) } fun query( @@ -225,14 +250,9 @@ class EventIndexesModule( db: SQLiteDatabase, onEach: (T) -> Unit, ) { - val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) } + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) - if (rowIdSubQueries.isEmpty()) return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) - - val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } - val args = rowIdSubQueries.flatMap { it.args } - - db.runQueryEmitting(makeQueryIn(unions), args, onEach) + db.runQueryEmitting(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" @@ -240,7 +260,9 @@ class EventIndexesModule( private fun makeQueryIn(rowIdQuery: String) = """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ($rowIdQuery) AS filtered + INNER JOIN ( + $rowIdQuery + ) AS filtered ON event_headers.row_id = filtered.row_id ORDER BY created_at DESC, id """.trimIndent() @@ -316,14 +338,9 @@ class EventIndexesModule( filters: List, db: SQLiteDatabase, ): Int { - val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) } + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything() - if (rowIdSubQueries.isEmpty()) return db.countEverything() - - val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } - val args = rowIdSubQueries.flatMap { it.args } - - return db.countIn(unions, args) + return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args) } private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers") @@ -357,14 +374,9 @@ class EventIndexesModule( filters: List, db: SQLiteDatabase, ): Int { - val rowIdSubqueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) } + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0 - if (rowIdSubqueries.isEmpty()) return 0 - - val unions = rowIdSubqueries.joinToString(" UNION ") { it.sql } - val args = rowIdSubqueries.flatMap { it.args } - - return db.runDelete(unions, args) + return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args) } private fun SQLiteDatabase.runDelete( @@ -372,6 +384,30 @@ class EventIndexesModule( args: List = emptyList(), ): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray()) + // --------------------------------- + // Prepare unions of all the filters + // --------------------------------- + fun unionSubqueriesIfNeeded( + filters: List, + hasher: TagNameValueHasher, + ): RowIdSubQuery? { + val inner = + filters.mapNotNull { filter -> + prepareRowIDSubQueries(filter, hasher) + } + + if (inner.isEmpty()) return null + + return if (inner.size == 1) { + inner.first() + } else { + RowIdSubQuery( + sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" }, + args = inner.flatMap { it.args }, + ) + } + } + // ---------------------------- // Inner row id selections // ---------------------------- @@ -381,92 +417,105 @@ class EventIndexesModule( ): 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 && ids.isNotEmpty()) || + (ids != null) || (authors != null && authors.isNotEmpty()) || (kinds != null && kinds.isNotEmpty()) || + (tags != null && tags.containsKey("d")) || (since != null) || (until != null) || - (tags != null && tags.containsKey("d")) + (limit != null) } - val hasSearch = (filter.search != null && filter.search.isNotBlank()) + var defaultTagKey: String? = null val projection = buildString { - val joins = mutableListOf() + // always do tags if there are any + if (nonDTags.isNotEmpty()) { + append("SELECT event_tags.event_header_row_id as row_id FROM event_tags ") - if (hasHeaders) { - append("SELECT event_headers.row_id as row_id FROM event_headers") - - if (hasSearch) { - joins.add("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_headers.row_id") - } - - filter.tags?.forEach { (tagName, _) -> - if (tagName != "d") { - joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = event_headers.row_id") + // 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 } } - } else if (hasSearch) { - append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}") - filter.tags?.forEach { (tagName, _) -> - if (tagName != "d") { - joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") - } + 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 { - // has only tags - filter.tags?.forEach { (tagName, _) -> - if (tagName != "d") { - if (isEmpty()) { - append("SELECT tag$tagName.event_header_row_id as row_id FROM event_tags as tag$tagName") - } else { - joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = tag${tagName.takeLast(1)}.event_header_row_id") - } - } - } - - if (isEmpty()) { - // only limit is present - append("SELECT event_headers.row_id as row_id FROM event_headers") - } - } - - if (joins.isNotEmpty()) { - append(" ${joins.joinToString(" ")}") + // 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) } - filter.kinds?.let { equalsOrIn("event_headers.kind", it) } - filter.authors?.let { equalsOrIn("event_headers.pubkey", 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) - } else { - equalsOrIn( - "tag$tagName.tag_hash", - tagValues.map { - hasher.hash(tagName, it) - }, - ) } } - filter.search?.let { match(fts.tableName, it) } + // 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 created_at DESC, id ASC LIMIT ${filter.limit}" + "${clause.conditions} ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT ${filter.limit}" } else { clause.conditions } @@ -479,7 +528,7 @@ class EventIndexesModule( db.execSQL("DELETE FROM event_headers") } - class RowIdSubQuery( + data class RowIdSubQuery( val sql: String, val args: List, ) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt new file mode 100644 index 000000000..68bea3398 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt @@ -0,0 +1,94 @@ +/** + * 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.sqlite.SQLiteDatabase + +fun SQLiteEventStore.explainQuery( + sql: String, + args: Array = emptyArray(), +) = readableDatabase.explainQuery(sql, args.map { it.toString() }.toTypedArray()) + +fun SQLiteDatabase.explainQuery( + sql: String, + args: Array = emptyArray(), +): String = + rawQuery("EXPLAIN QUERY PLAN $sql", args).use { cursor -> + val treeIndex = mutableMapOf() + val rootNodes = mutableListOf() + + while (cursor.moveToNext()) { + val id = cursor.getInt(0) + val parentId = cursor.getInt(1) + val detail = cursor.getString(3) + + val line = PlanNode(detail) + + treeIndex[id] = line + + val parent = treeIndex[parentId] + if (parent != null) { + parent.children.add(line) + } else { + rootNodes.add(line) + } + } + + buildString { + appendLine(populateArgs(sql, args)) + for (idx in rootNodes.indices) { + printNode(rootNodes[idx], "", idx == rootNodes.size - 1, idx > 0) + } + } + } + +private fun populateArgs( + sql: String, + args: Array = emptyArray(), +): String { + var result = sql + args.forEach { + result = result.replaceFirst("?", "\"$it\"") + } + return result +} + +fun StringBuilder.printNode( + node: PlanNode, + prefix: String, + isLast: Boolean, + newLine: Boolean, +) { + if (newLine) append('\n') + append(prefix) + append(if (isLast) "└── " else "├── ") + append(node.detail) + + val newPrefix = prefix + if (isLast) " " else "│ " + for (i in node.children.indices) { + printNode(node.children[i], newPrefix, i == node.children.size - 1, true) + } +} + +data class PlanNode( + val detail: String, + val children: MutableList = mutableListOf(), +) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index dee109346..77473dce4 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -52,7 +52,7 @@ class SQLiteEventStore( val addressableModule = AddressableModule() val ephemeralModule = EphemeralModule() - val deletionModule = DeletionRequestModule() + val deletionModule = DeletionRequestModule(seedModule::hasher) val expirationModule = ExpirationModule() val rightToVanishModule = RightToVanishModule(seedModule::hasher) diff --git a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqllite/EventDbQueryAssemblerTest.kt b/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqllite/EventDbQueryAssemblerTest.kt deleted file mode 100644 index 8575333c5..000000000 --- a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqllite/EventDbQueryAssemblerTest.kt +++ /dev/null @@ -1,135 +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.quartz.nip01Core.store.sqlite - -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import junit.framework.TestCase -import kotlin.test.Test - -class EventDbQueryAssemblerTest { - val builder = EventIndexesModule(FullTextSearchModule(), SeedModule()) - val hasher = TagNameValueHasher(0L) - - val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" - val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" - val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" - - @Test - fun testEmpty() { - val sql = builder.planQuery(Filter(), hasher) - TestCase.assertEquals( - "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id", - sql, - ) - } - - @Test - fun testLimit() { - val sql = builder.planQuery(Filter(limit = 10), hasher) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY created_at DESC, id ASC LIMIT 10) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } - - @Test - fun testAllFearures() { - val sql = - builder.planQuery( - listOf( - Filter(limit = 10), - Filter(authors = listOf(key1), kinds = listOf(1, 1111), search = "keywords", limit = 100), - Filter(kinds = listOf(20), search = "cats", limit = 30), - ), - hasher, - ) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY created_at DESC, id ASC LIMIT 10 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?)) AND (event_headers.pubkey = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 100 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 30) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } - - @Test - fun testIdQuery() { - val sql = builder.planQuery(Filter(ids = listOf(key1)), hasher) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.id = ?) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } - - @Test - fun testAuthors() { - val sql = builder.planQuery(Filter(authors = listOf(key1, key2)), hasher) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.pubkey IN (?, ?)) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } - - @Test - fun testAuthorsAndSearch() { - val sql = builder.planQuery(Filter(authors = listOf(key1, key2, key3), search = "keywords"), hasher) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.pubkey IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } - - @Test - fun testKindAndSearch() { - val sql = builder.planQuery(Filter(kinds = listOf(1, 1111, 10000), search = "keywords"), hasher) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } -} From 131252f19d51d35ca84d1fa4a09e18923e2715ac Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 27 Dec 2025 07:52:45 +0200 Subject: [PATCH 25/44] feat: Convert commons module to Kotlin Multiplatform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite build.gradle.kts for KMP with Android + JVM targets - Restructure source sets: commonMain, jvmAndroid, androidMain, jvmMain - Replace android.util.LruCache with androidx.collection.LruCache (KMP-ready) - Replace android.util.Patterns with local regex constants - Move shared code to commonMain (icons, hashtags, robohash, compose, etc.) - Move JVM-shared code to jvmAndroid (richtext, base64Image detection) - Keep Android-specific code in androidMain (blurhash, bitmap handling) - Remove @Preview annotations from shared code (Android-only feature) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/CLAUDE.md | 68 ++++- .claude/agents/compose-ui.md | 280 +++++++----------- .claude/agents/kotlin-coroutines.md | 277 ++++++----------- .claude/agents/kotlin-multiplatform.md | 210 ++++++------- .claude/agents/nostr-protocol.md | 198 ++++++++----- .gitignore | 3 + commons/build.gradle | 69 ----- commons/build.gradle.kts | 119 ++++++++ .../{main => androidMain}/AndroidManifest.xml | 0 .../commons/base64Image/Base64ImageBitmap.kt | 37 +++ .../amethyst/commons/blurhash/Base83.kt | 0 .../amethyst/commons/blurhash/BitmapUtils.kt | 0 .../commons/blurhash/BlurHashDecoder.kt | 0 .../commons/blurhash/BlurHashDecoderOld.kt | 0 .../commons/blurhash/BlurHashEncoder.kt | 0 .../amethyst/commons/blurhash/CosineCache.kt | 0 .../amethyst/commons/blurhash/SRGB.kt | 0 .../commons/compose/AsyncCachedState.kt | 10 +- .../amethyst/commons/compose/CachedState.kt | 10 +- .../compose/TextFieldValueExtensions.kt | 0 .../amethyst/commons/emojicoder/EmojiCoder.kt | 0 .../amethyst/commons/hashtags/Amethyst.kt | 2 - .../amethyst/commons/hashtags/Btc.kt | 2 - .../amethyst/commons/hashtags/Cashu.kt | 2 - .../amethyst/commons/hashtags/Coffee.kt | 2 - .../commons/hashtags/CustomHashTagIcons.kt | 0 .../amethyst/commons/hashtags/Flowerstr.kt | 2 - .../amethyst/commons/hashtags/Footstr.kt | 2 - .../amethyst/commons/hashtags/Gamestr.kt | 2 - .../amethyst/commons/hashtags/Grownostr.kt | 2 - .../amethyst/commons/hashtags/Lightning.kt | 2 - .../amethyst/commons/hashtags/Mate.kt | 2 - .../amethyst/commons/hashtags/Nostr.kt | 2 - .../amethyst/commons/hashtags/Plebs.kt | 2 - .../amethyst/commons/hashtags/Skull.kt | 2 - .../amethyst/commons/hashtags/Tunestr.kt | 2 - .../amethyst/commons/hashtags/Weed.kt | 2 - .../amethyst/commons/hashtags/Zap.kt | 2 - .../amethyst/commons/icons/Following.kt | 2 - .../amethyst/commons/icons/Like.kt | 2 - .../amethyst/commons/icons/Liked.kt | 2 - .../amethyst/commons/icons/Reply.kt | 2 - .../amethyst/commons/icons/Repost.kt | 2 - .../amethyst/commons/icons/Reposted.kt | 2 - .../amethyst/commons/icons/Search.kt | 2 - .../amethyst/commons/icons/Share.kt | 2 - .../amethyst/commons/icons/Zap.kt | 2 - .../amethyst/commons/icons/ZapSplit.kt | 2 - .../commons/preview/MetaTagsParser.kt | 0 .../commons/robohash/CachedRobohash.kt | 2 +- .../commons/robohash/RobohashAssembler.kt | 2 - .../commons/robohash/parts/Accessory0Seven.kt | 2 - .../commons/robohash/parts/Accessory1Nose.kt | 2 - .../robohash/parts/Accessory2HornRed.kt | 2 - .../robohash/parts/Accessory3Button.kt | 2 - .../robohash/parts/Accessory4Satellite.kt | 2 - .../robohash/parts/Accessory5Mustache.kt | 2 - .../commons/robohash/parts/Accessory6Hat.kt | 2 - .../robohash/parts/Accessory7Antenna.kt | 2 - .../commons/robohash/parts/Accessory8Brush.kt | 2 - .../commons/robohash/parts/Accessory9Horn.kt | 2 - .../commons/robohash/parts/Body0Trooper.kt | 2 - .../commons/robohash/parts/Body1Thin.kt | 2 - .../commons/robohash/parts/Body2Thinnest.kt | 2 - .../commons/robohash/parts/Body3Front.kt | 2 - .../commons/robohash/parts/Body4Round.kt | 2 - .../commons/robohash/parts/Body5Neck.kt | 2 - .../commons/robohash/parts/Body6Ironman.kt | 2 - .../robohash/parts/Body7Neckthinner.kt | 2 - .../commons/robohash/parts/Body8Big.kt | 2 - .../commons/robohash/parts/Body9Huge.kt | 2 - .../commons/robohash/parts/Eyes0Squint.kt | 2 - .../commons/robohash/parts/Eyes1Round.kt | 2 - .../commons/robohash/parts/Eyes2Single.kt | 2 - .../commons/robohash/parts/Eyes3Scott.kt | 2 - .../robohash/parts/Eyes4Roundsingle.kt | 2 - .../commons/robohash/parts/Eyes5Roundsmall.kt | 2 - .../commons/robohash/parts/Eyes6Walle.kt | 2 - .../commons/robohash/parts/Eyes7Bar.kt | 2 - .../commons/robohash/parts/Eyes8Smallbar.kt | 2 - .../commons/robohash/parts/Eyes9Shield.kt | 2 - .../commons/robohash/parts/Face0C3po.kt | 2 - .../commons/robohash/parts/Face1Rock.kt | 2 - .../commons/robohash/parts/Face2Long.kt | 2 - .../commons/robohash/parts/Face3Oval.kt | 2 - .../commons/robohash/parts/Face4Cylinder.kt | 2 - .../commons/robohash/parts/Face5Baloon.kt | 2 - .../commons/robohash/parts/Face6Triangle.kt | 2 - .../commons/robohash/parts/Face7Bent.kt | 2 - .../robohash/parts/Face8TriangleInv.kt | 2 - .../commons/robohash/parts/Face9Square.kt | 2 - .../commons/robohash/parts/Mouth0Horz.kt | 2 - .../commons/robohash/parts/Mouth1Cylinder.kt | 2 - .../commons/robohash/parts/Mouth2Teeth.kt | 2 - .../commons/robohash/parts/Mouth3Grid.kt | 2 - .../commons/robohash/parts/Mouth4Vert.kt | 2 - .../commons/robohash/parts/Mouth5Midopen.kt | 2 - .../commons/robohash/parts/Mouth6Cell.kt | 2 - .../commons/robohash/parts/Mouth7Happy.kt | 2 - .../commons/robohash/parts/Mouth8Buttons.kt | 2 - .../commons/robohash/parts/Mouth9Closed.kt | 2 - .../commons/base64Image/Base64Image.kt | 32 +- .../ExpandableTextCutOffCalculator.kt | 0 .../commons/richtext/GalleryParser.kt | 0 .../commons/richtext/MediaContentModels.kt | 0 .../amethyst/commons/richtext/Patterns.kt | 45 +++ .../commons/richtext/RichTextParser.kt | 1 - .../richtext/RichTextParserSegments.kt | 0 108 files changed, 697 insertions(+), 818 deletions(-) delete mode 100644 commons/build.gradle create mode 100644 commons/build.gradle.kts rename commons/src/{main => androidMain}/AndroidManifest.xml (100%) create mode 100644 commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt (100%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt (91%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/compose/CachedState.kt (91%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt (100%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt (100%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt (100%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Following.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Like.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Liked.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Reply.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Repost.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Reposted.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Search.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Share.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Zap.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt (100%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt (98%) rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt (58%) rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt (100%) rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt (100%) rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt (100%) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt (99%) rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt (100%) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 44e046f04..9ecd75d86 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -8,17 +8,26 @@ Fork of [Amethyst](https://github.com/vitorpamplona/amethyst) adding Compose Mul ``` amethyst/ -├── quartz/ # Nostr KMP library (converted to multiplatform) +├── quartz/ # Nostr KMP library (protocol only, no UI) │ └── src/ -│ ├── commonMain/ # Shared Nostr protocol +│ ├── commonMain/ # Shared Nostr protocol, data models │ ├── androidMain/ # Android-specific (crypto, storage) │ └── jvmMain/ # Desktop JVM-specific -├── desktopApp/ # Desktop JVM application -├── amethyst/ # Android app (existing) -├── ammolite/ # Support module -└── commons/ # Utilities +├── commons/ # Shared UI components (convert to KMP) +│ └── src/ +│ ├── commonMain/ # Shared composables, icons, state +│ ├── androidMain/ # Android-specific UI utilities +│ └── jvmMain/ # Desktop-specific UI utilities +├── desktopApp/ # Desktop JVM application (layouts, navigation) +├── amethyst/ # Android app (layouts, navigation) +└── ammolite/ # Support module ``` +**Sharing Philosophy:** +- `quartz/` = Business logic, protocol, data (no UI) +- `commons/` = Shared UI components, icons, composables +- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation + ## Tech Stack | Layer | Technology | @@ -48,7 +57,52 @@ Use these specialized agents for domain expertise: - `/extract ` - Move composable to shared code - `/nip ` - Get NIP implementation guidance -## Development Workflow +## Feature Workflow + +When picking up a new task or feature, follow this process: + +### Step 1: Analyze Android Implementation + +Start by examining the existing Android Amethyst codebase: +1. Find the relevant feature/component in `amethyst/` module +2. Understand the current implementation patterns +3. Identify dependencies and integrations + +### Step 2: Create Implementation Plan + +Before coding, create a plan that categorizes work into three buckets: + +| Category | Description | Location | +|----------|-------------|----------| +| **Android-Specific** | Platform APIs, navigation, layouts | `amethyst/`, `androidMain/` | +| **Reusable (Shared)** | Business logic, UI components, state | `quartz/commonMain/`, `commons/` (convert to KMP) | +| **Desktop-Specific** | Desktop navigation, layouts, platform APIs | `desktopApp/`, `jvmMain/` | + +### Step 3: Code Sharing Strategy + +**Share:** +- Business logic and data models → `quartz/commonMain/` +- Major UI components (cards, lists, dialogs) → `commons/` (convert to KMP as needed) +- State management and ViewModels → shared +- Icons and visual assets → `commons/commonMain/` + +**Keep Platform-Native:** +- Navigation patterns (sidebar vs bottom nav) +- Screen layouts and scaffolding +- Platform-specific interactions (gestures, keyboard shortcuts) +- System integrations (notifications, file pickers) + +### Step 4: Extract Shared Components + +When extracting UI components: +1. Identify reusable composables in Android code +2. Use `/extract ` to move to `commons/commonMain/` +3. Create expect/actual declarations for platform-specific behavior +4. Update both Android and Desktop to use shared component + +**Note:** `quartz/` is protocol-only (no composables). Shared UI goes in `commons/` after converting it to KMP. + +## Build Commands ```bash # Run desktop app diff --git a/.claude/agents/compose-ui.md b/.claude/agents/compose-ui.md index 2a8b837d8..d51b2eca8 100644 --- a/.claude/agents/compose-ui.md +++ b/.claude/agents/compose-ui.md @@ -1,73 +1,60 @@ +--- +name: compose-ui +description: Automatically invoked when working with Compose Multiplatform UI code, @Composable functions, desktop Window/MenuBar/Tray, navigation patterns, or UI components in desktopApp/ or shared UI modules. +tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch +model: sonnet +--- + # Compose Multiplatform UI Agent -## Expertise Domain +You are a Compose Multiplatform UI expert specializing in shared composables and desktop-specific features. -This agent specializes in Compose Multiplatform for building declarative UIs that share code across Android and Desktop JVM. +## Auto-Trigger Contexts -## Platform Support +Activate when user works with: +- `@Composable` functions +- `desktopApp/` module files +- `Window`, `MenuBar`, `Tray` components +- Navigation patterns (NavigationRail, screens) +- Material3 theming +- Keyboard shortcuts, context menus -| Platform | Status | Notes | -|----------|--------|-------| -| Android | Stable | Jetpack Compose | -| Desktop (JVM) | Stable | Windows, macOS, Linux | -| iOS | Beta | Future consideration | -| Web (Wasm) | Alpha | Future consideration | +## Core Knowledge -## Core Knowledge Areas - -### Shared Composables (commonMain) +### Desktop Entry Point ```kotlin -@Composable -fun NoteCard( - note: Note, - onReply: () -> Unit, - onRepost: () -> Unit, - onZap: () -> Unit, - modifier: Modifier = Modifier -) { - Card(modifier = modifier) { - Column(Modifier.padding(16.dp)) { - AuthorRow(note.author) - Spacer(Modifier.height(8.dp)) - Text(note.content) - Spacer(Modifier.height(8.dp)) - ActionRow(onReply, onRepost, onZap) +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + state = rememberWindowState(width = 1200.dp, height = 800.dp), + title = "Amethyst Desktop" + ) { + MenuBar { + Menu("File") { + Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { } + Item("Quit", onClick = ::exitApplication) + } } + App() } } ``` ### Desktop-Specific Features -```kotlin -// Window management -fun main() = application { - Window( - onCloseRequest = ::exitApplication, - state = rememberWindowState( - width = 1200.dp, - height = 800.dp, - position = WindowPosition.Aligned(Alignment.Center) - ), - title = "Amethyst Desktop" - ) { - App() - } -} -// Menu bar +**Menu Bar** +```kotlin MenuBar { Menu("File") { - Item("New Note", onClick = { }, shortcut = KeyShortcut(Key.N, ctrl = true)) + Item("New", shortcut = KeyShortcut(Key.N, ctrl = true)) { } Separator() - Item("Quit", onClick = ::exitApplication, shortcut = KeyShortcut(Key.Q, ctrl = true)) - } - Menu("View") { - Item("Feed", onClick = { navigateTo(Screen.Feed) }) - Item("Messages", onClick = { navigateTo(Screen.Messages) }) + Item("Quit", onClick = ::exitApplication) } } +``` -// System tray +**System Tray** +```kotlin Tray( icon = painterResource("icon.png"), menu = { @@ -75,169 +62,108 @@ Tray( Item("Exit", onClick = ::exitApplication) } ) +``` -// Keyboard shortcuts -Modifier.onKeyEvent { event -> - when { - event.isCtrlPressed && event.key == Key.Enter -> { - sendNote() - true - } - event.key == Key.Escape -> { - closeDialog() - true - } - else -> false - } -} - -// Context menus +**Context Menus** +```kotlin ContextMenuArea(items = { listOf( - ContextMenuItem("Copy") { copyToClipboard(note.content) }, - ContextMenuItem("Reply") { openReplyDialog(note) }, - ContextMenuItem("Repost") { repost(note) } + ContextMenuItem("Copy") { copyToClipboard(text) }, + ContextMenuItem("Reply") { openReply() } ) }) { - Text(note.content) + Text(content) } ``` -### Navigation Patterns +**Keyboard Shortcuts** +```kotlin +Modifier.onKeyEvent { event -> + when { + event.isCtrlPressed && event.key == Key.Enter -> { send(); true } + event.key == Key.Escape -> { close(); true } + else -> false + } +} +``` + +### Navigation Pattern ```kotlin -// Sidebar + Content pattern for Desktop @Composable fun DesktopLayout(currentScreen: Screen, onNavigate: (Screen) -> Unit) { Row(Modifier.fillMaxSize()) { - // Sidebar NavigationRail { NavigationRailItem( icon = { Icon(Icons.Default.Home, "Feed") }, - label = { Text("Feed") }, selected = currentScreen == Screen.Feed, onClick = { onNavigate(Screen.Feed) } ) - NavigationRailItem( - icon = { Icon(Icons.Default.Message, "Messages") }, - label = { Text("Messages") }, - selected = currentScreen == Screen.Messages, - onClick = { onNavigate(Screen.Messages) } - ) - NavigationRailItem( - icon = { Icon(Icons.Default.Person, "Profile") }, - label = { Text("Profile") }, - selected = currentScreen == Screen.Profile, - onClick = { onNavigate(Screen.Profile) } - ) + // More items... } - - // Content Box(Modifier.weight(1f)) { when (currentScreen) { Screen.Feed -> FeedScreen() Screen.Messages -> MessagesScreen() - Screen.Profile -> ProfileScreen() } } } } ``` -### State Management -```kotlin -// StateFlow for shared state -class FeedViewModel(private val repository: FeedRepository) { - private val _notes = MutableStateFlow>(emptyList()) - val notes: StateFlow> = _notes.asStateFlow() - - private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow = _isLoading.asStateFlow() - - fun loadFeed() { - viewModelScope.launch { - _isLoading.value = true - repository.getFeed() - .catch { /* handle error */ } - .collect { _notes.value = it } - _isLoading.value = false - } - } -} - -// In Composable -@Composable -fun FeedScreen(viewModel: FeedViewModel) { - val notes by viewModel.notes.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() - - Box(Modifier.fillMaxSize()) { - LazyColumn { - items(notes, key = { it.id }) { note -> - NoteCard(note) - } - } - if (isLoading) { - CircularProgressIndicator(Modifier.align(Alignment.Center)) - } - } -} -``` - -## Agent Capabilities - -1. **Composable Design** - - Create shareable UI components - - Implement Material3 design - - Handle responsive layouts - -2. **Desktop UI Patterns** - - Window management (size, position, multi-window) - - Menu bars and context menus - - System tray integration - - Keyboard shortcuts - - Native file dialogs - -3. **Navigation Architecture** - - Screen-based navigation - - Platform-specific shells (sidebar vs bottom nav) - - Deep linking patterns - -4. **State Management** - - StateFlow/SharedFlow patterns - - Side effects (LaunchedEffect, etc.) - - ViewModel integration - -## Android vs Desktop Differences +### Platform Differences | Aspect | Android | Desktop | |--------|---------|---------| | **Entry** | Activity | main() + Window | -| **Navigation** | Bottom nav / Drawer | Sidebar / MenuBar | +| **Navigation** | Bottom nav | Sidebar / MenuBar | | **Input** | Touch | Mouse + Keyboard | | **Windows** | Single | Multi-window | -| **Menus** | Overflow menu | MenuBar | -| **Files** | SAF / MediaStore | JFileChooser | -| **Notifications** | System notifications | Tray notifications | -| **Clipboard** | ClipboardManager | Toolkit.clipboard | +| **Menus** | Overflow | MenuBar | -## Scope Boundaries +## Workflow -### In Scope -- Composable architecture and design -- UI component patterns -- Desktop-specific features (menus, tray, keyboard) -- Navigation patterns -- State → UI binding -- Theming and styling -- Responsive layouts +### 1. Assess Task +- Shared composable or desktop-specific? +- Navigation change or component work? +- State management needs? -### Out of Scope -- KMP project setup (use kotlin-multiplatform agent) -- Async data loading (use kotlin-coroutines agent) -- Nostr protocol details (use nostr-protocol agent) -- Business logic implementation +### 2. Investigate +```bash +# Find existing composables +grep -r "@Composable" desktopApp/src/ +# Check navigation structure +grep -r "Screen\|navigate" desktopApp/src/ +``` -## Key References -- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) -- [Desktop Tutorials](https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials) -- [Material3 Components](https://m3.material.io/components) +### 3. Implement +- Shared composables go in shared UI module +- Desktop-specific (Window, MenuBar) in desktopApp +- Use Material3 components +- Handle keyboard/mouse input for desktop + +### 4. Verify +```bash +./gradlew :desktopApp:run +``` + +## State Management +```kotlin +class FeedViewModel { + private val _state = MutableStateFlow(FeedState()) + val state: StateFlow = _state.asStateFlow() +} + +@Composable +fun FeedScreen(viewModel: FeedViewModel) { + val state by viewModel.state.collectAsState() + // UI based on state +} +``` + +## Constraints + +- Prefer shared composables in commonMain when possible +- Desktop-specific features only in desktopApp +- Follow Material3 design guidelines +- Support keyboard navigation for accessibility +- Test on macOS, Windows, Linux when possible diff --git a/.claude/agents/kotlin-coroutines.md b/.claude/agents/kotlin-coroutines.md index 2f56b1f39..ed1679972 100644 --- a/.claude/agents/kotlin-coroutines.md +++ b/.claude/agents/kotlin-coroutines.md @@ -1,206 +1,121 @@ +--- +name: kotlin-coroutines +description: Automatically invoked when working with coroutines, Flow, StateFlow, SharedFlow, suspend functions, CoroutineScope, channels, or async patterns in Kotlin code. +tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch +model: sonnet +--- + # Kotlin Coroutines Agent -## Expertise Domain +You are a Kotlin Coroutines expert specializing in async patterns, Flow, and structured concurrency. -This agent specializes in Kotlin coroutines and the kotlinx.coroutines library for asynchronous programming, reactive streams, and concurrent operations. +## Auto-Trigger Contexts -## Core Knowledge Areas +Activate when user works with: +- `suspend` functions +- `Flow`, `StateFlow`, `SharedFlow` +- `CoroutineScope`, `launch`, `async` +- `Channel`, `channelFlow` +- Dispatchers configuration +- Exception handling in coroutines -### Coroutine Fundamentals +## Core Knowledge + +### Coroutine Builders ```kotlin -// Suspending functions -suspend fun fetchNote(id: String): Note { - return withContext(Dispatchers.IO) { - api.getNote(id) - } -} +// launch: fire-and-forget +val job = launch { delay(1000); println("Done") } -// Coroutine builders -fun main() = runBlocking { - // launch: fire-and-forget, returns Job - val job = launch { - delay(1000) - println("World") - } - - // async: returns Deferred - val deferred = async { - computeValue() - } - val result = deferred.await() -} +// async: returns result +val deferred = async { computeValue() } +val result = deferred.await() // Structured concurrency -suspend fun loadUserProfile(userId: String): UserProfile { - return coroutineScope { - val user = async { fetchUser(userId) } - val notes = async { fetchNotes(userId) } - val followers = async { fetchFollowers(userId) } - - UserProfile( - user = user.await(), - notes = notes.await(), - followers = followers.await() - ) - } // All complete or all cancel together +suspend fun loadProfile(userId: String) = coroutineScope { + val user = async { fetchUser(userId) } + val notes = async { fetchNotes(userId) } + Profile(user.await(), notes.await()) } ``` ### Dispatchers -| Dispatcher | Use Case | Notes | -|------------|----------|-------| -| `Dispatchers.Main` | UI updates | Main thread (Android/Desktop) | -| `Dispatchers.IO` | Network, disk | Optimized for blocking I/O | -| `Dispatchers.Default` | CPU-intensive | Parallelism = CPU cores | -| `Dispatchers.Unconfined` | Testing only | Runs in caller's thread | +| Dispatcher | Use Case | +|------------|----------| +| `Dispatchers.Main` | UI updates | +| `Dispatchers.IO` | Network, disk I/O | +| `Dispatchers.Default` | CPU-intensive | ### Flow (Cold Streams) ```kotlin -// Creating flows fun observeNotes(): Flow> = flow { while (true) { - val notes = repository.getNotes() - emit(notes) - delay(30_000) // Refresh every 30s + emit(repository.getNotes()) + delay(30_000) } } // Operators repository.observeNotes() - .map { notes -> notes.filter { it.isVisible } } + .map { it.filter { note -> note.isVisible } } .distinctUntilChanged() - .debounce(300) - .catch { e -> - log.error("Failed to load notes", e) - emit(emptyList()) - } + .catch { emit(emptyList()) } .flowOn(Dispatchers.IO) - .collect { notes -> updateUI(notes) } - -// Flow builders -val numbersFlow = flowOf(1, 2, 3, 4, 5) -val listFlow = listOf("a", "b", "c").asFlow() + .collect { updateUI(it) } ``` -### StateFlow & SharedFlow (Hot Streams) +### StateFlow (Hot, Always Has Value) ```kotlin -// StateFlow - always has a value, replays latest class FeedViewModel { private val _state = MutableStateFlow(FeedState()) val state: StateFlow = _state.asStateFlow() fun updateFilter(filter: Filter) { - _state.update { current -> - current.copy(filter = filter) - } + _state.update { it.copy(filter = filter) } } } +``` -// SharedFlow - no initial value, configurable replay -class EventBus { - private val _events = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 64, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val events: SharedFlow = _events.asSharedFlow() - - suspend fun emit(event: AppEvent) { - _events.emit(event) - } -} +### SharedFlow (Hot, Configurable Replay) +```kotlin +private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST +) +val events: SharedFlow = _events.asSharedFlow() ``` ### Channels ```kotlin -// Producer-consumer pattern -val channel = Channel(Channel.BUFFERED) - -// Producer -launch { - for (event in eventSource) { - channel.send(event) - } - channel.close() -} - -// Consumer -launch { - for (event in channel) { - process(event) - } -} - -// channelFlow for complex producers fun relayEvents(relay: Relay): Flow = channelFlow { relay.connect() - relay.onEvent { event -> - trySend(event) - } + relay.onEvent { event -> trySend(event) } awaitClose { relay.disconnect() } } ``` -### Cancellation & Exception Handling +### Exception Handling ```kotlin -// Cooperative cancellation -suspend fun processNotes(notes: List) { - for (note in notes) { - ensureActive() // Throws if cancelled - process(note) - yield() // Suspend point for cancellation - } +val handler = CoroutineExceptionHandler { _, e -> + log.error("Coroutine failed", e) } - -// Exception handling -val handler = CoroutineExceptionHandler { _, exception -> - log.error("Coroutine failed", exception) -} - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler) // supervisorScope: child failures don't cancel siblings supervisorScope { launch { task1() } // Can fail independently - launch { task2() } // Continues even if task1 fails + launch { task2() } // Continues if task1 fails } ``` -### Testing Coroutines -```kotlin -class FeedViewModelTest { - @Test - fun `loadFeed updates state with notes`() = runTest { - val repository = mockk() - coEvery { repository.getFeed() } returns flowOf(testNotes) - - val viewModel = FeedViewModel(repository) - viewModel.loadFeed() - - advanceUntilIdle() - - assertEquals(testNotes, viewModel.state.value.notes) - } -} - -// Inject test dispatcher -val testDispatcher = StandardTestDispatcher() -Dispatchers.setMain(testDispatcher) -``` - ## Nostr-Specific Patterns ### Relay Connection Pool ```kotlin class RelayPool(private val scope: CoroutineScope) { - private val relays = ConcurrentHashMap() - fun connect(url: String) { scope.launch { - val connection = RelayConnection(url) - relays[url] = connection - supervisorScope { launch { connection.receiveLoop() } launch { connection.sendLoop() } @@ -218,67 +133,55 @@ class RelayPool(private val scope: CoroutineScope) { ### Subscription Management ```kotlin fun subscribe(filters: List): Flow = channelFlow { - val subscriptionId = UUID.randomUUID().toString() - + val subId = UUID.randomUUID().toString() try { relayPool.activeRelays.collect { relays -> relays.forEach { relay -> - launch { - relay.subscribe(subscriptionId, filters) - .collect { send(it) } - } + launch { relay.subscribe(subId, filters).collect { send(it) } } } } } finally { - relayPool.unsubscribe(subscriptionId) + relayPool.unsubscribe(subId) } } ``` -## Agent Capabilities +## Workflow -1. **Async Architecture Design** - - Coroutine scope hierarchy - - Structured concurrency patterns - - Error propagation strategies +### 1. Assess Task +- Cold stream (Flow) or hot stream (StateFlow/SharedFlow)? +- Need structured concurrency? +- Error handling strategy? -2. **Flow Pipeline Design** - - Cold vs hot stream selection - - Operator chaining - - Backpressure handling +### 2. Investigate +```bash +# Find coroutine usage +grep -r "suspend \|launch\|async\|Flow<" quartz/src/ +# Check existing patterns +grep -r "CoroutineScope\|StateFlow" quartz/src/ +``` -3. **Concurrency Patterns** - - Parallel decomposition - - Rate limiting - - Resource pooling +### 3. Implement +- Use appropriate dispatcher +- Implement proper cancellation +- Handle exceptions at right level +- Use structured concurrency -4. **Testing Strategies** - - runTest usage - - Dispatcher injection - - Flow testing with Turbine +### 4. Test +```kotlin +@Test +fun `test async operation`() = runTest { + val result = viewModel.loadData() + advanceUntilIdle() + assertEquals(expected, result) +} +``` -5. **Performance Optimization** - - Dispatcher selection - - Buffer sizing - - Cancellation efficiency +## Constraints -## Scope Boundaries - -### In Scope -- kotlinx.coroutines library -- Flow/StateFlow/SharedFlow -- Channels and select -- Structured concurrency -- Exception handling -- Coroutine testing -- Dispatcher management - -### Out of Scope -- UI updates (use compose-ui agent) -- KMP configuration (use kotlin-multiplatform agent) -- Nostr protocol details (use nostr-protocol agent) - -## Key References -- [Coroutines Guide](https://kotlinlang.org/docs/coroutines-guide.html) -- [Flow Documentation](https://kotlinlang.org/docs/flow.html) -- [StateFlow/SharedFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/) +- Always use structured concurrency +- Never use `GlobalScope` +- Handle cancellation cooperatively (`ensureActive()`, `yield()`) +- Use `SupervisorJob` when children should fail independently +- Prefer Flow over callbacks +- Use `flowOn` to switch dispatchers, not `withContext` in flow diff --git a/.claude/agents/kotlin-multiplatform.md b/.claude/agents/kotlin-multiplatform.md index 2e148cf26..419a7c740 100644 --- a/.claude/agents/kotlin-multiplatform.md +++ b/.claude/agents/kotlin-multiplatform.md @@ -1,121 +1,54 @@ +--- +name: kotlin-multiplatform +description: Automatically invoked when working with KMP project structure, build.gradle.kts files, expect/actual declarations, source sets (commonMain, androidMain, jvmMain), or multiplatform migration tasks. Expert in code sharing across Android and Desktop JVM. +tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch +model: sonnet +--- + # Kotlin Multiplatform Agent -## Expertise Domain +You are a Kotlin Multiplatform expert specializing in KMP architecture for Android and Desktop JVM targets. -This agent specializes in Kotlin Multiplatform (KMP) project architecture, enabling code sharing across Android, iOS, Desktop (JVM), and Web targets. +## Auto-Trigger Contexts -## Core Knowledge Areas +Activate when user works with: +- `build.gradle.kts` files with multiplatform plugin +- Files in `commonMain/`, `androidMain/`, `jvmMain/` source sets +- `expect` or `actual` declarations +- Cross-platform library selection +- Migration from Android-only to multiplatform -### Project Structure -``` -module/ -├── build.gradle.kts -└── src/ - ├── commonMain/ # Shared code (all targets) - ├── commonTest/ # Shared tests - ├── androidMain/ # Android-specific - ├── jvmMain/ # Desktop JVM-specific - └── iosMain/ # iOS-specific (future) -``` +## Core Knowledge ### Source Set Hierarchy ``` - commonMain - │ - ┌───────────┼───────────┐ - │ │ │ - jvmMain nativeMain jsMain - │ │ - ┌──────┴───┐ ┌───┴───┐ - │ │ │ │ + commonMain + │ + ┌───────────┼───────────┐ + │ │ │ + jvmMain nativeMain jsMain + │ │ + ┌──────┴───┐ ┌───┴───┐ + │ │ │ │ androidMain desktopMain iosMain ``` -### expect/actual Mechanism +### expect/actual Pattern ```kotlin -// commonMain - Declaration only +// commonMain - Declaration expect class PlatformContext - expect fun getPlatform(): Platform -expect class SecureStorage { - fun store(key: String, value: ByteArray) - fun retrieve(key: String): ByteArray? -} - -// androidMain - Android implementation +// androidMain actual class PlatformContext(val context: Context) - actual fun getPlatform(): Platform = Platform.Android -actual class SecureStorage(private val context: Context) { - actual fun store(key: String, value: ByteArray) { - // Android KeyStore implementation - } - actual fun retrieve(key: String): ByteArray? = TODO() -} - -// jvmMain - Desktop implementation +// jvmMain (Desktop) actual class PlatformContext - actual fun getPlatform(): Platform = Platform.Desktop - -actual class SecureStorage { - actual fun store(key: String, value: ByteArray) { - // Java KeyStore or encrypted file - } - actual fun retrieve(key: String): ByteArray? = TODO() -} ``` -### Dependency Management -```kotlin -kotlin { - sourceSets { - commonMain.dependencies { - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.serialization.json) - } - androidMain.dependencies { - implementation(libs.secp256k1.kmp.jni.android) - } - jvmMain.dependencies { - implementation(libs.secp256k1.kmp.jni.jvm) - } - } -} -``` - -## Agent Capabilities - -1. **Project Configuration** - - Set up KMP modules from scratch - - Configure targets (Android, JVM, iOS) - - Manage Gradle build scripts - - Version catalog setup - -2. **Code Sharing Strategy** - - Identify shareable vs platform-specific code - - Design expect/actual interfaces - - Create intermediate source sets - - Maximize code reuse (target: 70-80%) - -3. **Dependency Selection** - - Recommend KMP-compatible libraries - - Handle platform-specific variants - - Resolve version conflicts - -4. **Migration Guidance** - - Port Android code to commonMain - - Extract platform abstractions - - Refactor for multiplatform - -5. **Build & Tooling** - - Gradle configuration - - IDE setup (Android Studio) - - CI/CD for multiple targets - -## Platform-Specific Patterns +### Platform Dependencies | Concern | Android | Desktop JVM | |---------|---------|-------------| @@ -123,39 +56,72 @@ kotlin { | **Storage** | Android KeyStore | Java KeyStore / File | | **Sodium** | lazysodium-android | lazysodium-java | | **UI** | Jetpack Compose | Compose Desktop | -| **Context** | Context object | None needed | -## Quartz KMP Conversion +## Workflow -Current Quartz is Android-only. Conversion steps: +### 1. Assess Task +- Identify if task involves shared vs platform-specific code +- Check existing source set structure +- Understand dependency requirements -1. **Add KMP plugin** to build.gradle.kts -2. **Define targets**: android(), jvm() -3. **Move shared code** to `src/commonMain/` -4. **Create expect declarations** for platform-specific APIs -5. **Implement actuals** in androidMain/jvmMain +### 2. Investigate +```bash +# Check existing structure +ls -la quartz/src/ +# Find expect declarations +grep -r "expect " quartz/src/commonMain/ +# Find actual implementations +grep -r "actual " quartz/src/androidMain/ quartz/src/jvmMain/ +``` + +### 3. Implement +- Place shared code in `commonMain` +- Create `expect` declarations for platform APIs +- Implement `actual` in each target source set +- Update `build.gradle.kts` dependencies per source set + +### 4. Verify +```bash +./gradlew :quartz:build +./gradlew :desktopApp:compileKotlinJvm +``` + +## Quartz KMP Structure + +``` +quartz/src/ +├── commonMain/kotlin/ # Shared Nostr protocol +├── commonTest/kotlin/ # Shared tests +├── androidMain/kotlin/ # Android crypto, storage +└── jvmMain/kotlin/ # Desktop crypto, storage +``` + +## Key Abstractions for This Project -Key abstractions needed: - `CryptoProvider` - secp256k1 signing/verification - `SodiumProvider` - NIP-44 encryption -- `PlatformJson` - Jackson vs kotlinx.serialization +- `SecureStorage` - Key storage +- `PlatformContext` - Platform-specific context -## Scope Boundaries +## Constraints -### In Scope -- KMP project structure and configuration -- Source set hierarchy design -- expect/actual declarations -- Gradle multiplatform plugin -- Cross-platform library selection -- Migration from Android-only +- Maximize code in `commonMain` (target 70-80%) +- Use KMP-compatible libraries only in commonMain +- Platform implementations must have identical signatures +- Run tests on all targets before completing -### Out of Scope -- UI implementation details (use compose-ui agent) -- Coroutine patterns (use kotlin-coroutines agent) -- Nostr protocol specifics (use nostr-protocol agent) +## Resources -## Key References -- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html) -- [expect/actual](https://kotlinlang.org/docs/multiplatform-expect-actual.html) -- [Hierarchical Structure](https://kotlinlang.org/docs/multiplatform-hierarchy.html) +Reference these GitHub repositories for KMP patterns and libraries: + +| Repository | Focus | Key Examples | +|------------|-------|--------------| +| [joreilly](https://github.com/joreilly) | KMP samples | PeopleInSpace, Confetti, GeminiKMP | +| [touchlab](https://github.com/touchlab) | KMP tooling | Kermit (logging), Stately (state), SKIE | +| [cashapp](https://github.com/cashapp) | KMP libraries | SQLDelight, Turbine, Molecule | + +**Useful libraries from these sources:** +- `SQLDelight` - Type-safe SQL for all platforms +- `Kermit` - Multiplatform logging +- `Turbine` - Testing Kotlin Flows +- `Molecule` - Build UI state with Compose diff --git a/.claude/agents/nostr-protocol.md b/.claude/agents/nostr-protocol.md index 06ac8de5d..ea6073f66 100644 --- a/.claude/agents/nostr-protocol.md +++ b/.claude/agents/nostr-protocol.md @@ -1,19 +1,25 @@ +--- +name: nostr-protocol +description: Automatically invoked when working with Nostr events, NIPs, relay communication, cryptographic operations (signing, encryption), or Quartz library code for protocol implementation. +tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch +model: sonnet +--- + # Nostr Protocol Agent -## Expertise Domain +You are a Nostr Protocol expert specializing in NIPs, events, relays, and cryptographic operations. -This agent specializes in the Nostr decentralized social protocol, covering 94+ NIPs (Nostr Implementation Possibilities) that define the protocol specification. +## Auto-Trigger Contexts -## Core Knowledge Areas +Activate when user works with: +- Event classes (kind, tags, content, sig) +- NIP implementations +- Relay WebSocket communication +- Cryptographic operations (secp256k1, NIP-44) +- Quartz library protocol code +- Filters and subscriptions -### Protocol Fundamentals - -| Concept | Description | -|---------|-------------| -| **Events** | Signed JSON objects: id, pubkey, created_at, kind, tags, content, sig | -| **Relays** | WebSocket servers that store/forward events | -| **Keys** | secp256k1 keypairs, Schnorr signatures (BIP-340) | -| **Filters** | Subscription queries (kinds, authors, tags, since, until, limit) | +## Core Knowledge ### Event Structure ```kotlin @@ -26,77 +32,137 @@ data class Event( val content: String, val sig: String // 64-byte Schnorr signature ) + +// Event ID = SHA256([0, pubkey, created_at, kind, tags, content]) ``` ### NIP Categories | Category | NIPs | Scope | |----------|------|-------| -| **Core Protocol** | 01, 02, 10, 11 | Basic events, follows, threads, relay info | -| **Messaging** | 04 (deprecated), 17, 44 | DMs, encrypted messaging | -| **Social** | 18, 25, 32, 38, 51, 52 | Reactions, reports, lists, communities | -| **Identity** | 05, 19, 39, 46, 55 | DNS, bech32, external identity, bunker, signer | -| **Media** | 23, 30, 54, 71, 94 | Long-form, audio, video, blobs | -| **Payments** | 47, 57, 60, 61 | Wallet Connect, zaps, cashu | -| **Relay** | 42, 65, 66 | Auth, relay lists, closed groups | +| **Core** | 01, 02, 10, 11 | Basic events, follows, threads, relay info | +| **Messaging** | 04, 17, 44 | DMs, encrypted messaging | +| **Social** | 18, 25, 32, 51 | Reactions, reports, lists | +| **Identity** | 05, 19, 39, 46 | DNS, bech32, bunker, signer | +| **Media** | 23, 30, 54, 94 | Long-form, audio, video, blobs | +| **Payments** | 47, 57, 60 | Wallet Connect, zaps, cashu | + +### Relay Messages +``` +Client -> Relay: + ["REQ", , ...] # Subscribe + ["EVENT", ] # Publish + ["CLOSE", ] # Unsubscribe + +Relay -> Client: + ["EVENT", , ] # Event received + ["EOSE", ] # End of stored events + ["OK", , , ] # Publish result + ["NOTICE", ] # Info message +``` ### Cryptographic Operations -- **Signing**: Schnorr signatures on secp256k1 curve -- **Encryption**: NIP-44 (XChaCha20-Poly1305), NIP-04 (deprecated AES) -- **Key derivation**: BIP-32/39 compatible -- **Event ID**: SHA256 of `[0, pubkey, created_at, kind, tags, content]` -## Agent Capabilities +**Signing (Schnorr/BIP-340)** +```kotlin +val signature = Secp256k1.sign( + data = eventHash, + privateKey = privateKeyBytes +) +``` -1. **NIP Implementation Guidance** - - Explain any NIP specification - - Provide event structure examples - - Show relay message flows (REQ, EVENT, EOSE, CLOSE) - - Identify required vs optional fields +**NIP-44 Encryption (Modern)** +```kotlin +val ciphertext = Nip44.encrypt( + plaintext = message, + sharedSecret = computeSharedSecret(myPrivKey, theirPubKey) +) +``` -2. **Protocol Design Review** - - Validate event structures - - Check NIP compliance - - Suggest appropriate event kinds - - Review tag usage patterns +**NIP-04 Encryption (Deprecated)** +```kotlin +// AES-256-CBC - only for legacy compatibility +val ciphertext = Nip04.encrypt(message, sharedSecret) +``` -3. **Quartz Library Integration** - - Map NIPs to Quartz classes - - Explain existing implementations - - Guide new NIP additions to the codebase +### Common Event Kinds -4. **Security Analysis** - - Key management best practices - - Encryption scheme selection - - Signature verification patterns - - Privacy considerations +| Kind | NIP | Description | +|------|-----|-------------| +| 0 | 01 | Metadata (profile) | +| 1 | 01 | Short text note | +| 3 | 02 | Follows list | +| 4 | 04 | Encrypted DM (deprecated) | +| 7 | 25 | Reaction | +| 1984 | 32 | Report | +| 30023 | 23 | Long-form content | -## Scope Boundaries +### Tag Patterns +```kotlin +// Reply threading (NIP-10) +tags = listOf( + listOf("e", rootEventId, relayUrl, "root"), + listOf("e", replyToId, relayUrl, "reply"), + listOf("p", authorPubkey) +) -### In Scope -- All NIP specifications and interactions -- Relay protocol and WebSocket message types -- Event signing and verification -- Key derivation and management -- Nostr-specific encryption (NIP-04, NIP-44) -- Quartz library architecture and classes +// Mentions +tags = listOf( + listOf("p", mentionedPubkey), + listOf("t", "hashtag") +) +``` -### Out of Scope -- General Kotlin/Android development (use kotlin-multiplatform agent) -- UI implementation details (use compose-ui agent) -- Generic async patterns (use kotlin-coroutines agent) -- Non-Nostr networking +## Workflow -## Key References -- [NIPs Repository](https://github.com/nostr-protocol/nips) -- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) -- [NIP-44: Versioned Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md) -- Quartz source: `quartz/src/` +### 1. Assess Task +- Which NIP(s) are involved? +- Event creation or parsing? +- Relay communication? +- Crypto operations needed? -## Example Queries +### 2. Investigate +```bash +# Check existing NIP implementations +grep -r "kind.*=" quartz/src/ +# Find event classes +grep -r "class.*Event" quartz/src/ +# Check crypto usage +grep -r "Secp256k1\|Nip44\|sign\|verify" quartz/src/ +``` -- "How do I implement NIP-57 zap receipts?" -- "What's the correct tag structure for a reply?" -- "How does NIP-44 encryption work?" -- "Which event kind for a long-form article?" -- "How to verify a Schnorr signature in Quartz?" +### 3. Reference NIP Spec +```bash +# Fetch NIP specification +curl https://raw.githubusercontent.com/nostr-protocol/nips/master/XX.md +``` + +### 4. Implement +- Follow NIP spec exactly +- Use existing Quartz patterns +- Validate event structure +- Test signature verification + +### 5. Verify +```bash +./gradlew :quartz:test +``` + +## Quartz Library Structure +``` +quartz/src/commonMain/kotlin/ +├── events/ # Event types per NIP +├── encoders/ # Bech32, hex encoding +├── crypto/ # Signing, encryption +├── relay/ # WebSocket communication +└── filters/ # Subscription filters +``` + +## Constraints + +- Always follow NIP specifications exactly +- Use NIP-44 for new encryption (not NIP-04) +- Validate all incoming events (sig, id) +- Never log private keys or decrypted content +- Use existing Quartz classes when available +- Test with real relay responses when possible diff --git a/.gitignore b/.gitignore index 72ba3e1b4..b4fc788e4 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,6 @@ lint/generated/ lint/outputs/ lint/tmp/ # lint/reports/ + +# Local task tracking +TASKS.md diff --git a/commons/build.gradle b/commons/build.gradle deleted file mode 100644 index b37d1acc4..000000000 --- a/commons/build.gradle +++ /dev/null @@ -1,69 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.androidLibrary) - alias(libs.plugins.jetbrainsKotlinAndroid) - alias(libs.plugins.jetbrainsComposeCompiler) -} - -android { - namespace = 'com.vitorpamplona.amethyst.commons' - compileSdk = libs.versions.android.compileSdk.get().toInteger() - - defaultConfig { - minSdk = libs.versions.android.minSdk.get().toInteger() - targetSdk = libs.versions.android.targetSdk.get().toInteger() - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles "consumer-rules.pro" - } - - buildTypes { - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - create("benchmark") { - initWith(getByName("release")) - signingConfig = signingConfigs.debug - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 - } - - buildFeatures { - compose = true - } -} - -kotlin { - compilerOptions { - jvmTarget = JvmTarget.JVM_21 - freeCompilerArgs.add("-Xstring-concat=inline") - } -} - -composeCompiler { - reportsDestination = layout.buildDirectory.dir("compose_compiler") -} - -dependencies { - implementation project(path: ':quartz') - // Import @Immutable and @Stable - implementation platform(libs.androidx.compose.bom) - implementation libs.androidx.ui - implementation libs.androidx.compose.foundation - - debugImplementation libs.androidx.ui.tooling - implementation libs.androidx.ui.tooling.preview - - // immutable collections to avoid recomposition - api libs.kotlinx.collections.immutable - - testImplementation libs.junit - androidTestImplementation platform(libs.androidx.compose.bom) - androidTestImplementation libs.androidx.junit - androidTestImplementation libs.androidx.espresso.core -} \ No newline at end of file diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts new file mode 100644 index 000000000..0c642c3c8 --- /dev/null +++ b/commons/build.gradle.kts @@ -0,0 +1,119 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsComposeCompiler) + alias(libs.plugins.composeMultiplatform) +} + +android { + namespace = "com.vitorpamplona.amethyst.commons" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xstring-concat=inline") + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + sourceSets { + commonMain { + dependencies { + implementation(project(":quartz")) + + // Compose Multiplatform + implementation(compose.ui) + implementation(compose.foundation) + implementation(compose.runtime) + + // LruCache (KMP-ready) + implementation(libs.androidx.collection) + + // Immutable collections + api(libs.kotlinx.collections.immutable) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + // Shared JVM code for both Android and Desktop + val jvmAndroid = create("jvmAndroid") { + dependsOn(commonMain.get()) + dependencies { + // URL detection (JVM library, works on both) + implementation(libs.url.detector) + } + } + + jvmMain { + dependsOn(jvmAndroid) + dependencies { + // Desktop-specific Compose + implementation(compose.desktop.currentOs) + implementation(compose.uiTooling) + } + } + + androidMain { + dependsOn(jvmAndroid) + dependencies { + // Android-specific Compose tooling + implementation(libs.androidx.ui.tooling.preview) + } + } + + androidUnitTest { + dependencies { + implementation(libs.junit) + } + } + + androidInstrumentedTest { + dependencies { + implementation(libs.androidx.junit) + implementation(libs.androidx.espresso.core) + } + } + } +} diff --git a/commons/src/main/AndroidManifest.xml b/commons/src/androidMain/AndroidManifest.xml similarity index 100% rename from commons/src/main/AndroidManifest.xml rename to commons/src/androidMain/AndroidManifest.xml diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt new file mode 100644 index 000000000..975331bc3 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt @@ -0,0 +1,37 @@ +/** + * 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.commons.base64Image + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.util.Base64 + +fun Base64Image.toBitmap(content: String): Bitmap { + val matcher = pattern.matcher(content) + + if (matcher.find()) { + val base64String = matcher.group(2) + val byteArray = Base64.getDecoder().decode(base64String) + return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) + } + + throw Exception("Unable to convert base64 to image $content") +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt similarity index 91% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt index 733dfdac2..8fdaf3fd2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.compose -import android.util.LruCache +import androidx.collection.LruCache import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.produceState @Composable -fun produceCachedStateAsync( +fun produceCachedStateAsync( cache: AsyncCachedState, key: K, ): State = @@ -39,7 +39,7 @@ fun produceCachedStateAsync( } @Composable -fun produceCachedStateAsync( +fun produceCachedStateAsync( cache: AsyncCachedState, key: String, updateValue: K, @@ -52,13 +52,13 @@ fun produceCachedStateAsync( } } -interface AsyncCachedState { +interface AsyncCachedState { fun cached(k: K): V? suspend fun update(k: K): V? } -abstract class GenericBaseCacheAsync( +abstract class GenericBaseCacheAsync( capacity: Int, ) : AsyncCachedState { private val cache = LruCache(capacity) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt similarity index 91% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt index ba955f4d6..28cbeb6f9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.compose -import android.util.LruCache +import androidx.collection.LruCache import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.produceState @Composable -fun produceCachedState( +fun produceCachedState( cache: CachedState, key: K, ): State = @@ -39,7 +39,7 @@ fun produceCachedState( } @Composable -fun produceCachedState( +fun produceCachedState( cache: CachedState, key: String, updateValue: K, @@ -52,13 +52,13 @@ fun produceCachedState( } } -interface CachedState { +interface CachedState { fun cached(k: K): V? suspend fun update(k: K): V? } -abstract class GenericBaseCache( +abstract class GenericBaseCache( capacity: Int, ) : CachedState { private val cache = LruCache(capacity) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt index 0a7ee0dd0..c47beb329 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt @@ -32,10 +32,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsAmethystPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt index 5a4fc5ba2..1bb9aef79 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt @@ -33,10 +33,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsBtcPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt index de72f6eb4..96be5b124 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsCashuPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt index f59def11e..6c2543cd2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsCoffeePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt index 7cb20efa8..d8a9ba332 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsFlowerstrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt index 6b907ede5..70111c982 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsFootstrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt index fec5833a7..9a07e47ae 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt @@ -28,10 +28,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsGamestrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt index dc6628974..afec134fe 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsGrownostrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt index 281a1688d..3b884cc17 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsLightningPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt index 5b27fd5bd..037b8a7ec 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsMatePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt index b88317911..24160dde7 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsNostrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt index 7ce7b8ae6..d1a17353f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsPlebsPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt index 6a4d95899..8e0a23216 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsSkullPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt index 9add8ed4b..2d307df19 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsTunestrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt index 4a40e3d16..668f43b67 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsWeedPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt index b98538cf0..eec30fc12 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsZapPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt index 2eb7b112d..fe0b9cfc8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt @@ -29,10 +29,8 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Following, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt index afeccd3ae..994b6db33 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Like, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt index b509ebc10..8e89cc135 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Liked, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt index 4db2c8b12..31349a88b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Reply, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt index a599d032f..ffbb79b22 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Repost, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt index 6e11930eb..2cad31d4a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Reposted, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt index e996be38a..e967053d2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Search, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt index 5107eca3f..49923f688 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Share, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt index acf40c45c..d5169185f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt @@ -31,9 +31,7 @@ import androidx.compose.ui.graphics.vector.DefaultFillType import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview -@Preview @Composable private fun VectorPreview() { Image(Zap, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt index 84ef32ff7..91a111e19 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.DefaultFillType import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(ZapSplit, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt index 8811c0fb0..d3c7a5c22 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.robohash -import android.util.LruCache +import androidx.collection.LruCache import androidx.compose.ui.graphics.vector.ImageVector object CachedRobohash { diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index 99e81fb85..982196e72 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -32,7 +32,6 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.robohash.parts.accessory0Seven import com.vitorpamplona.amethyst.commons.robohash.parts.accessory1Nose @@ -107,7 +106,6 @@ val MediumGray = SolidColor(Color(0xFFd0d2d3)) val DefaultSize = 55.dp const val VIEWPORT_SIZE = 300f -@Preview @Composable fun RobohashPreview() { val assembler = RobohashAssembler() diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt index 3f97d864f..e58577de5 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory0SevenPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt index 2793862ab..b467f1c61 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory1NosePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt index 3f6f9813d..75ae3d3e6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightBrown @@ -36,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.MediumGray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory2HornRedPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt index bee5d0446..d52a26aaa 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory3ButtonPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt index cf10cd4e3..d414a5302 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory4SatellitePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt index 9967abd02..ec6cad933 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory5MustachePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt index ea3809b9c..debad6a99 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory6Hat() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt index 9bf7fd570..4802982ef 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory7Antenna() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt index 3b94fcf84..869baec5f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory8Brush() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt index a9baa4597..c77558585 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.MediumGray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory9Horn() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt index e84bd23ed..f11ab8e24 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body0TropperPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt index 6d5502289..b20f22d08 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body1ThinPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt index ead54ed93..520bc1566 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body2ThinnestPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt index aa95e8108..646f5e332 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body3FrontPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt index 1ec10ab8b..9a5115f7f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body4RoundPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt index ff939af00..11bb4cd4d 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body5NeckPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt index 62c505821..373a62121 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body6IronManPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt index 58bef8c43..f6cf8f407 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body7NeckThinnerPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt index d12739adb..ab93ec93b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body8BigPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt index 841aef162..98810d09d 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body9HugePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt index 52486c701..6e46a40db 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes0Squint() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt index 0535fad99..10ea055cf 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.DarkYellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes1Round() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt index fcf23af08..0e38faffd 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes2Single() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt index f8ffd01ca..2e6aa7410 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes3Scott() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt index 5d3d55312..fe7d0b3f6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes4RoundSingle() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt index 4c30a3240..cfc62a1f2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes5RoundSmall() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt index 11e1bf901..b30766bf9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightYellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes6WallE() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt index 7bd1093ad..9ea12638c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes7Bar() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt index 3064d1665..a93424325 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightYellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes8SmallBar() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt index 20ab0a875..900be2d90 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes9Shield() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt index 4d18e3453..b353e9588 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face0C3po() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt index 1fbf5f73d..7ebd9acd8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face1Rock() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt index 6c5a51cab..c7979de54 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face2Long() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt index bc3979d89..39e25f75c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face3Oval() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt index 8707de72c..6f58c5a1a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face4Cylinder() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt index bc16e370f..e6d72fb92 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face5Baloon() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt index b592cd4ed..591425eb9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face6Triangle() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt index b9e888caa..29de82282 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face7Bent() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt index d0c5bf806..f7492afd6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face8TriangleInv() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt index cfd5b8f7b..240edc78c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face9Square() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt index fb0de441a..f7e6002f3 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth0Horz() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt index 85925bc21..2f7917243 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth1Cylinder() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt index 44cb1951f..b74b0e6af 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt @@ -27,14 +27,12 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.DarkYellow import com.vitorpamplona.amethyst.commons.robohash.OrangeOne import com.vitorpamplona.amethyst.commons.robohash.OrangeTwo import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth2Teeth() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt index 6069be799..dec4f1651 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.DarkYellow import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth3Grid() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt index bf827cdde..443a5b183 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth4Vert() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt index 080db5980..4af586f96 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth5MidOpen() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt index bece6f091..f3dfe65ba 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth6Cell() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt index 30983dc1f..b2620d5a3 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.OrangeThree import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth7Happy() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt index 9981f4391..c93675884 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt @@ -27,14 +27,12 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.OrangeThree import com.vitorpamplona.amethyst.commons.robohash.OrangeTwo import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth8Buttons() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt index 2890d7376..e792b0adc 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth9Closed() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt similarity index 58% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt index cc26f5549..ac7bef89c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt @@ -20,33 +20,17 @@ */ package com.vitorpamplona.amethyst.commons.base64Image -import android.R.attr.data -import android.graphics.Bitmap -import android.graphics.BitmapFactory import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import java.util.Base64 import java.util.regex.Pattern -class Base64Image { - companion object { - val pattern = Pattern.compile("data:image/(${RichTextParser.imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") +object Base64Image { + val pattern: Pattern = + Pattern.compile( + "data:image/(${RichTextParser.imageExtensions.joinToString(separator = "|")});base64,([a-zA-Z0-9+/]+={0,2})", + ) - fun isBase64(content: String): Boolean { - val matcher = pattern.matcher(content) - return matcher.find() - } - - fun toBitmap(content: String): Bitmap { - val matcher = pattern.matcher(data.toString()) - - if (matcher.find()) { - val base64String = matcher.group(2) - - val byteArray = Base64.getDecoder().decode(base64String) - return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) - } - - throw Exception("Unable to convert base64 to image $content") - } + fun isBase64(content: String): Boolean { + val matcher = pattern.matcher(content) + return matcher.find() } } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt new file mode 100644 index 000000000..d0ef9308b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt @@ -0,0 +1,45 @@ +/** + * 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.commons.richtext + +import java.util.regex.Pattern + +/** + * Pattern constants for email and phone validation. + * These replace android.util.Patterns for KMP compatibility. + */ +object Patterns { + /** + * Email address pattern from RFC 5322. + */ + val EMAIL_ADDRESS: Pattern = + Pattern.compile( + "[a-zA-Z0-9+._%-]+@[a-zA-Z0-9][a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", + ) + + /** + * Phone number pattern - matches common phone formats. + */ + val PHONE: Pattern = + Pattern.compile( + "^[+]?[(]?[0-9]{1,4}[)]?[-\\s./0-9]*\$", + ) +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 14bc34a4f..243a07957 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import android.util.Patterns import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.amethyst.commons.base64Image.Base64Image diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt From e5a064d0c353a2065297281f0ac068f100698068 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 27 Dec 2025 12:48:32 +0200 Subject: [PATCH 26/44] feat: Add expect/actual for blurhash and base64Image in commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create PlatformImage expect/actual abstracting Bitmap/BufferedImage - Move pure blurhash algorithms (Base83, SRGB, CosineCache, BlurHashEncoder) to commonMain - Rewrite BlurHashDecoder to return PlatformImage instead of Bitmap - Add PlatformImage.toBlurhash() extension in commonMain - Add platform-specific Bitmap.toBlurhash() and BufferedImage.toBlurhash() - Add base64Image toPlatformImage() for both Android and JVM 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commons/base64Image/Base64ImageBitmap.kt | 8 ++ .../amethyst/commons/blurhash/BitmapUtils.kt | 42 ++--------- .../commons/blurhash/PlatformImage.android.kt | 65 ++++++++++++++++ .../amethyst/commons/blurhash/Base83.kt | 0 .../commons/blurhash/BlurHashDecoder.kt | 7 +- .../commons/blurhash/BlurHashEncoder.kt | 0 .../commons/blurhash/BlurHashEncoderExt.kt | 64 ++++++++++++++++ .../amethyst/commons/blurhash/CosineCache.kt | 5 +- .../commons/blurhash/PlatformImage.kt | 65 ++++++++++++++++ .../amethyst/commons/blurhash/SRGB.kt | 0 .../base64Image/Base64ImagePlatform.jvm.kt | 45 +++++++++++ .../commons/blurhash/BitmapUtils.jvm.kt | 29 +++++++ .../commons/blurhash/PlatformImage.jvm.kt | 75 +++++++++++++++++++ 13 files changed, 362 insertions(+), 43 deletions(-) create mode 100644 commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt rename commons/src/{androidMain => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt (100%) rename commons/src/{androidMain => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt (97%) rename commons/src/{androidMain => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt (100%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt rename commons/src/{androidMain => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt (96%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.kt rename commons/src/{androidMain => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt (100%) create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImagePlatform.jvm.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.jvm.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.jvm.kt diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt index 975331bc3..9340c5d08 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.base64Image import android.graphics.Bitmap import android.graphics.BitmapFactory +import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage import java.util.Base64 fun Base64Image.toBitmap(content: String): Bitmap { @@ -35,3 +37,9 @@ fun Base64Image.toBitmap(content: String): Bitmap { throw Exception("Unable to convert base64 to image $content") } + +/** + * Converts a base64 image data URI to a PlatformImage. + * Delegates to toBitmap and wraps the result. + */ +fun Base64Image.toPlatformImage(content: String): PlatformImage = toBitmap(content).toPlatformImage() diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt index 7a3bd652c..bf40e816b 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt @@ -21,41 +21,9 @@ package com.vitorpamplona.amethyst.commons.blurhash import android.graphics.Bitmap -import kotlin.math.roundToInt -fun Bitmap.toBlurhash(): String { - val aspectRatio = this.width.toFloat() / this.height.toFloat() - - if (this.width > 100 && this.height > 100) { - return Bitmap.createScaledBitmap(this, 100, (100 / aspectRatio).toInt(), false).toBlurhash() - } - - val intArray = IntArray(width * height) - this.getPixels(intArray, 0, width, 0, 0, width, height) - - val numX = - if (aspectRatio > 1) { - 9 - } else if (aspectRatio < 1) { - (9 * aspectRatio).roundToInt() - } else { - 4 - } - - val numY = - if (aspectRatio > 1) { - (9 * (1 / aspectRatio)).roundToInt() - } else if (aspectRatio < 1) { - 9 - } else { - 4 - } - - return BlurHashEncoder().encode( - intArray, - width, - height, - numX, - numY, - ) -} +/** + * Encodes this Android Bitmap to a blurhash string. + * Delegates to PlatformImage.toBlurhash() for the actual encoding. + */ +fun Bitmap.toBlurhash(): String = this.toPlatformImage().toBlurhash() diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt new file mode 100644 index 000000000..b864da631 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt @@ -0,0 +1,65 @@ +/** + * 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.commons.blurhash + +import android.graphics.Bitmap + +actual class PlatformImage( + val bitmap: Bitmap, +) { + actual val width: Int get() = bitmap.width + actual val height: Int get() = bitmap.height + + actual fun getPixels( + pixels: IntArray, + offset: Int, + stride: Int, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bitmap.getPixels(pixels, offset, stride, x, y, width, height) + } + + actual fun scale( + width: Int, + height: Int, + ): PlatformImage = PlatformImage(Bitmap.createScaledBitmap(bitmap, width, height, false)) + + actual companion object { + actual fun create( + pixels: IntArray, + width: Int, + height: Int, + ): PlatformImage = PlatformImage(Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)) + } +} + +/** + * Extension to convert Android Bitmap to PlatformImage. + */ +fun Bitmap.toPlatformImage(): PlatformImage = PlatformImage(this) + +/** + * Extension to get the underlying Android Bitmap. + */ +fun PlatformImage.toAndroidBitmap(): Bitmap = this.bitmap diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt similarity index 100% rename from commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt similarity index 97% rename from commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt index baf540fd3..140f3c86c 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.blurhash -import android.graphics.Bitmap import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.linearToSrgb import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.srgbToLinear import kotlin.math.pow @@ -66,7 +65,7 @@ object BlurHashDecoder { } /** - * Decode a blur hash into a new bitmap. + * Decode a blur hash into a new PlatformImage. * * @param useCache use in memory cache for the calculated math, reused by images with same size. * if the cache does not exist yet it will be created and populated with new calculations. By @@ -76,7 +75,7 @@ object BlurHashDecoder { blurHash: String?, width: Int, useCache: Boolean = true, - ): Bitmap? { + ): PlatformImage? { if (blurHash == null || blurHash.length < 6) { return null } @@ -87,7 +86,7 @@ object BlurHashDecoder { val height = (width * (1 / (numCompX.toFloat() / numCompY.toFloat()))).roundToInt() val colors = computeColors(numCompX, numCompY, blurHash) val imageArray = composeImageArray(width, height, numCompX, numCompY, colors, useCache) - return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + return PlatformImage.create(imageArray, width, height) } private fun decodeDc(colorEnc: Int): FloatArray { diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt similarity index 100% rename from commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt new file mode 100644 index 000000000..f476afb6a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt @@ -0,0 +1,64 @@ +/** + * 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.commons.blurhash + +import kotlin.math.roundToInt + +/** + * Encodes this PlatformImage to a blurhash string. + * The image will be scaled down if larger than 100x100 for performance. + */ +fun PlatformImage.toBlurhash(): String { + val aspectRatio = this.width.toFloat() / this.height.toFloat() + + if (this.width > 100 && this.height > 100) { + return this.scale(100, (100 / aspectRatio).toInt()).toBlurhash() + } + + val intArray = IntArray(width * height) + this.getPixels(intArray, 0, width, 0, 0, width, height) + + val numX = + if (aspectRatio > 1) { + 9 + } else if (aspectRatio < 1) { + (9 * aspectRatio).roundToInt() + } else { + 4 + } + + val numY = + if (aspectRatio > 1) { + (9 * (1 / aspectRatio)).roundToInt() + } else if (aspectRatio < 1) { + 9 + } else { + 4 + } + + return BlurHashEncoder().encode( + intArray, + width, + height, + numX, + numY, + ) +} diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt similarity index 96% rename from commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt index 7248cf5a1..a3d609915 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.blurhash import androidx.collection.LruCache +import kotlin.math.PI import kotlin.math.cos object CosineCache { @@ -54,7 +55,7 @@ object CosineCache { DoubleArray(height * numCompY) { val y = it / numCompY val j = it % numCompY - cos(Math.PI * y * j / height) + cos(PI * y * j / height) }.also { cacheCosinesY.put(height * numCompY, it) } @@ -73,7 +74,7 @@ object CosineCache { DoubleArray(width * numCompX) { val x = it / numCompX val i = it % numCompX - cos(Math.PI * x * i / width) + cos(PI * x * i / width) }.also { cacheCosinesX.put(width * numCompX, it) } } else -> cacheCosinesX[width * numCompX]!! diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.kt new file mode 100644 index 000000000..2e745ca8e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.kt @@ -0,0 +1,65 @@ +/** + * 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.commons.blurhash + +/** + * Platform-agnostic image wrapper for blurhash encoding/decoding. + * + * On Android: wraps android.graphics.Bitmap + * On Desktop JVM: wraps java.awt.image.BufferedImage + */ +expect class PlatformImage { + val width: Int + val height: Int + + /** + * Read ARGB pixels into the provided array. + * Pixels are in ARGB format (0xAARRGGBB). + */ + fun getPixels( + pixels: IntArray, + offset: Int, + stride: Int, + x: Int, + y: Int, + width: Int, + height: Int, + ) + + /** + * Create a scaled copy of this image. + */ + fun scale( + width: Int, + height: Int, + ): PlatformImage + + companion object { + /** + * Create a new image from ARGB pixel array. + */ + fun create( + pixels: IntArray, + width: Int, + height: Int, + ): PlatformImage + } +} diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt similarity index 100% rename from commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImagePlatform.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImagePlatform.jvm.kt new file mode 100644 index 000000000..6c523a33e --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImagePlatform.jvm.kt @@ -0,0 +1,45 @@ +/** + * 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.commons.base64Image + +import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage +import java.io.ByteArrayInputStream +import java.util.Base64 +import javax.imageio.ImageIO + +/** + * Converts a base64 image data URI to a PlatformImage (BufferedImage wrapper). + */ +fun Base64Image.toPlatformImage(content: String): PlatformImage { + val matcher = pattern.matcher(content) + + if (matcher.find()) { + val base64String = matcher.group(2) + val byteArray = Base64.getDecoder().decode(base64String) + val bufferedImage = + ImageIO.read(ByteArrayInputStream(byteArray)) + ?: throw Exception("Unable to decode base64 image: $content") + return bufferedImage.toPlatformImage() + } + + throw Exception("Unable to convert base64 to image $content") +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.jvm.kt new file mode 100644 index 000000000..7cb20fc93 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.jvm.kt @@ -0,0 +1,29 @@ +/** + * 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.commons.blurhash + +import java.awt.image.BufferedImage + +/** + * Encodes this BufferedImage to a blurhash string. + * Delegates to PlatformImage.toBlurhash() for the actual encoding. + */ +fun BufferedImage.toBlurhash(): String = this.toPlatformImage().toBlurhash() diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.jvm.kt new file mode 100644 index 000000000..8d4c990f6 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.jvm.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.blurhash + +import java.awt.Image +import java.awt.image.BufferedImage + +actual class PlatformImage( + val image: BufferedImage, +) { + actual val width: Int get() = image.width + actual val height: Int get() = image.height + + actual fun getPixels( + pixels: IntArray, + offset: Int, + stride: Int, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + image.getRGB(x, y, width, height, pixels, offset, stride) + } + + actual fun scale( + width: Int, + height: Int, + ): PlatformImage { + val scaled = image.getScaledInstance(width, height, Image.SCALE_FAST) + val buffered = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + buffered.graphics.drawImage(scaled, 0, 0, null) + return PlatformImage(buffered) + } + + actual companion object { + actual fun create( + pixels: IntArray, + width: Int, + height: Int, + ): PlatformImage { + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + image.setRGB(0, 0, width, height, pixels, 0, width) + return PlatformImage(image) + } + } +} + +/** + * Extension to convert BufferedImage to PlatformImage. + */ +fun BufferedImage.toPlatformImage(): PlatformImage = PlatformImage(this) + +/** + * Extension to get the underlying BufferedImage. + */ +fun PlatformImage.toBufferedImage(): BufferedImage = this.image From 201acc8140ebd077e13248fdf5ddd6303ad54aae Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 27 Dec 2025 15:12:04 +0200 Subject: [PATCH 27/44] feat: Extract shared UI components to commons for Android/Desktop reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract reusable components from desktopApp to commons module: Utilities (commonMain): - NumberFormatters: countToHumanReadable, countToHumanReadableBytes - PubKeyFormatter: toShortDisplay, toDisplayHexKey for key formatting Utilities (jvmAndroid): - TimeAgoFormatter: Human-readable time formatting for Nostr timestamps - ZapFormatter: BigDecimal amount formatting with G/M/k suffixes UI Components (commonMain): - AppScreen enum for shared navigation - LoadingState, EmptyState, ErrorState with refresh/retry support - FeedHeader with relay status indicator - NoteCard for displaying Nostr notes - ActionButtons (AddButton, RemoveButton) - RobohashImage for avatar display - PlaceholderScreens (Search, Messages, Notifications) - RelayStatusColors and shared color definitions UI Components (jvmAndroid): - LoginCard with key input field - NewKeyWarningCard for new key backup warnings - KeyInputField and SelectableKeyText - ProfileInfoCard for account display - RelayStatusCard for relay management Core (jvmAndroid): - AccountManager with login/logout/key generation - RelayConnectionManager base class - RelayStatus and DefaultRelays 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- commons/build.gradle.kts | 2 + .../amethyst/commons/navigation/AppScreen.kt | 55 ++++ .../commons/ui/components/ActionButtons.kt | 92 +++++++ .../commons/ui/components/LoadingState.kt | 158 +++++++++++ .../commons/ui/components/RobohashImage.kt | 110 ++++++++ .../amethyst/commons/ui/feed/FeedHeader.kt | 116 ++++++++ .../commons/ui/screens/PlaceholderScreens.kt | 89 +++++++ .../amethyst/commons/ui/theme/Colors.kt | 84 ++++++ .../amethyst/commons/util/NumberFormatters.kt | 92 +++++++ .../amethyst/commons/util/PubKeyFormatter.kt | 45 ++++ .../commons}/account/AccountManager.kt | 50 ++-- .../commons/network/RelayConnectionManager.kt | 140 ++++++++++ .../amethyst/commons/network/RelayStatus.kt | 48 ++++ .../amethyst/commons/ui/auth/KeyInputField.kt | 107 ++++++++ .../amethyst/commons/ui/auth/LoginCard.kt | 132 ++++++++++ .../commons/ui/auth/NewKeyWarningCard.kt | 112 ++++++++ .../amethyst/commons/ui/note/NoteCard.kt | 188 +++++++++++++ .../commons/ui/profile/ProfileInfoCard.kt | 89 +++++++ .../commons/ui/relay/RelayStatusCard.kt | 131 ++++++++++ .../amethyst/commons/util/EventExtensions.kt | 44 ++++ .../amethyst/commons/util/TimeAgoFormatter.kt | 118 +++++++++ .../amethyst/commons/util/ZapFormatter.kt | 82 ++++++ desktopApp/build.gradle.kts | 3 + .../vitorpamplona/amethyst/desktop/Main.kt | 247 +++--------------- .../network/DesktopRelayConnectionManager.kt | 133 +--------- .../amethyst/desktop/ui/FeedScreen.kt | 180 ++----------- .../amethyst/desktop/ui/LoginScreen.kt | 202 ++------------ .../quartz/nip01Core/core/HexKey.kt | 3 + 28 files changed, 2145 insertions(+), 707 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt rename {desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons}/account/AccountManager.kt (78%) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 0c642c3c8..e66de925e 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -62,6 +62,8 @@ kotlin { implementation(compose.ui) implementation(compose.foundation) implementation(compose.runtime) + implementation(compose.material3) + implementation(compose.materialIconsExtended) // LruCache (KMP-ready) implementation(libs.androidx.collection) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt new file mode 100644 index 000000000..c6b005d0b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.navigation + +/** + * Main application screens shared between Desktop and Android. + * Each platform implements its own navigation using these identifiers. + */ +enum class AppScreen( + val label: String, + val route: String, +) { + Feed("Feed", "feed"), + Search("Search", "search"), + Messages("Messages", "messages"), + Notifications("Notifications", "notifications"), + Profile("Profile", "profile"), + Settings("Settings", "settings"), +} + +/** + * Primary navigation destinations (shown in bottom bar on mobile, sidebar on desktop). + */ +val primaryScreens = listOf( + AppScreen.Feed, + AppScreen.Search, + AppScreen.Messages, + AppScreen.Notifications, + AppScreen.Profile, +) + +/** + * Secondary navigation destinations (settings, etc.) + */ +val secondaryScreens = listOf( + AppScreen.Settings, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt new file mode 100644 index 000000000..93ab2de84 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * Standard button shape used for action buttons. + */ +val ActionButtonShape = RoundedCornerShape(20.dp) + +/** + * Standard content padding for action buttons. + */ +val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp) + +/** + * An "Add" action button with consistent styling. + * + * @param onClick Action to perform when clicked + * @param modifier Modifier for the button + * @param text Button text (default: "Add") + * @param enabled Whether the button is enabled + */ +@Composable +fun AddButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Add", + enabled: Boolean = true, +) { + OutlinedButton( + modifier = modifier, + enabled = enabled, + onClick = onClick, + shape = ActionButtonShape, + contentPadding = ActionButtonPadding, + ) { + Text(text = text, textAlign = TextAlign.Center) + } +} + +/** + * A "Remove" action button with consistent styling. + * + * @param onClick Action to perform when clicked + * @param modifier Modifier for the button + * @param text Button text (default: "Remove") + * @param enabled Whether the button is enabled + */ +@Composable +fun RemoveButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Remove", + enabled: Boolean = true, +) { + OutlinedButton( + modifier = modifier, + onClick = onClick, + shape = ActionButtonShape, + enabled = enabled, + contentPadding = ActionButtonPadding, + ) { + Text(text = text) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt new file mode 100644 index 000000000..b549466dd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt @@ -0,0 +1,158 @@ +/** + * 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.commons.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A centered loading state with a progress indicator and message. + * Can be used by both Desktop and Android apps. + */ +@Composable +fun LoadingState( + message: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + message, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * A centered empty state with title, optional description, and optional refresh action. + */ +@Composable +fun EmptyState( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + onRefresh: (() -> Unit)? = null, + refreshLabel: String = "Refresh", +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (description != null) { + Spacer(Modifier.height(8.dp)) + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + if (onRefresh != null) { + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = onRefresh) { + Text(refreshLabel) + } + } + } +} + +/** + * A centered error state with message and optional retry action. + */ +@Composable +fun ErrorState( + message: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, + retryLabel: String = "Try Again", +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + if (onRetry != null) { + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(retryLabel) + } + } + } +} + +/** + * Empty feed state - a common pattern for feed views. + */ +@Composable +fun FeedEmptyState( + modifier: Modifier = Modifier, + title: String = "Feed is empty", + onRefresh: (() -> Unit)? = null, +) { + EmptyState( + title = title, + modifier = modifier, + onRefresh = onRefresh, + ) +} + +/** + * Feed error state - a common pattern for feed views. + */ +@Composable +fun FeedErrorState( + errorMessage: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, +) { + ErrorState( + message = "Error loading feed: $errorMessage", + modifier = modifier, + onRetry = onRetry, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt new file mode 100644 index 000000000..f5dfce736 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash + +/** + * Determines if the current color scheme is light. + * Uses the luminance of the background color. + */ +@Composable +private fun isLightTheme(): Boolean { + val background = MaterialTheme.colorScheme.background + // Simple luminance check: if any RGB component > 0.5, consider it light + return (background.red + background.green + background.blue) / 3 > 0.5f +} + +/** + * Displays a robohash image based on a seed string (typically a public key). + * Falls back to a generic face icon if robohash is disabled. + * + * @param robot The seed string for generating the robohash (e.g., pubkey hex) + * @param modifier Modifier for the image + * @param contentDescription Accessibility description + * @param loadRobohash Whether to load the robohash or show a placeholder + */ +@Composable +fun RobohashImage( + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + loadRobohash: Boolean = true, +) { + if (loadRobohash) { + Image( + imageVector = CachedRobohash.get(robot, isLightTheme()), + contentDescription = contentDescription, + modifier = modifier, + ) + } else { + Image( + imageVector = Icons.Default.Face, + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = modifier, + ) + } +} + +/** + * Displays a robohash image with configurable alignment and content scale. + */ +@Composable +fun RobohashImage( + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + colorFilter: ColorFilter? = null, + loadRobohash: Boolean = true, +) { + if (loadRobohash) { + Image( + painter = rememberVectorPainter(CachedRobohash.get(robot, isLightTheme())), + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + ) + } else { + Image( + imageVector = Icons.Default.Face, + contentDescription = contentDescription, + colorFilter = colorFilter ?: ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt new file mode 100644 index 000000000..7884b9ba2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feed + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.theme.RelayStatusColors + +/** + * Header component for feed screens with title and relay connection status. + * + * @param title The feed title + * @param connectedRelayCount Number of connected relays + * @param onRefresh Callback when refresh button is clicked + * @param modifier Modifier for the header row + */ +@Composable +fun FeedHeader( + title: String, + connectedRelayCount: Int, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + RelayStatusIndicator( + connectedCount = connectedRelayCount, + onRefresh = onRefresh, + ) + } +} + +/** + * Compact relay connection status indicator with refresh button. + */ +@Composable +fun RelayStatusIndicator( + connectedCount: Int, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val statusColor = when { + connectedCount == 0 -> RelayStatusColors.Disconnected + connectedCount < 3 -> RelayStatusColors.Connecting + else -> RelayStatusColors.Connected + } + + Icon( + imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(16.dp) + ) + + Text( + "$connectedCount relay${if (connectedCount != 1) "s" else ""}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + + IconButton(onClick = onRefresh) { + Icon( + Icons.Default.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt new file mode 100644 index 000000000..92820ee25 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Generic placeholder screen with title and description. + */ +@Composable +fun PlaceholderScreen( + title: String, + description: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + description, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Search screen placeholder. + */ +@Composable +fun SearchPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Search", + description = "Search for users, notes, and hashtags.", + modifier = modifier, + ) +} + +/** + * Messages/DMs screen placeholder. + */ +@Composable +fun MessagesPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Messages", + description = "Your encrypted direct messages will appear here.", + modifier = modifier, + ) +} + +/** + * Notifications screen placeholder. + */ +@Composable +fun NotificationsPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Notifications", + description = "Mentions, replies, and reactions will appear here.", + modifier = modifier, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt new file mode 100644 index 000000000..5ff1b209a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary brand colors +val Primary50 = Color(red = 127, green = 103, blue = 190) +val Primary60 = Color(red = 154, green = 130, blue = 219) +val Primary70 = Color(red = 182, green = 157, blue = 248) +val Primary80 = Color(red = 208, green = 188, blue = 255) +val DefaultPrimary = Color(red = 208, green = 188, blue = 255) +val LightPurple = Color(red = 187, green = 134, blue = 252) + +// Material Design colors +val Purple200 = Color(0xFFBB86FC) +val Purple500 = Color(0xFF6200EE) +val Purple700 = Color(0xFF3700B3) +val Teal200 = Color(0xFF03DAC5) + +// Bitcoin colors +val BitcoinOrange = Color(0xFFF7931A) +val BitcoinDark = Color(0xFFF7931A) +val BitcoinLight = Color(0xFFB66605) + +// Status colors +val RoyalBlue = Color(0xFF4169E1) +val Following = Color(0xFF03DAC5) +val FollowsFollow = Color.Yellow +val Nip05Verified = Color.Blue + +// NIP-05 email colors +val Nip05EmailColor = Color(0xFFb198ec) +val Nip05EmailColorDark = Color(0xFF6e5490) +val Nip05EmailColorLight = Color(0xFFa770f3) + +// Feedback colors +val DarkerGreen = Color.Green.copy(alpha = 0.32f) +val LightRedColor = Color(0xFFC62828) +val LighterRedColor = Color(0xFFFF0E0E) + +// Warning colors +val LightWarningColor = Color(0xFFffcc00) +val DarkWarningColor = Color(0xFFF8DE22) + +// Surface variant colors +val LightRedColorOnSecondSurface = Color(0xFFC62828) +val DarkRedColorOnSecondSurface = Color(0xFFF34747) +val LightWarningColorOnSecondSurface = Color(0xFFC09B14) +val DarkWarningColorOnSecondSurface = Color(0xFFE1C419) + +// Success colors +val LightAllGoodColor = Color(0xFF339900) +val DarkAllGoodColor = Color(0xFF99cc33) + +// Fundraiser colors +val LightFundraiserProgressColor = Color(0xFF3DB601) +val DarkFundraiserProgressColor = Color(0xFF61A229) + +// Relay status colors +object RelayStatusColors { + val Connected = Color.Green + val Connecting = Color.Yellow + val Disconnected = Color.Red + val Unknown = Color.Gray +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt new file mode 100644 index 000000000..f64005f08 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import kotlin.math.roundToInt + +/** + * Formats a count to human-readable format with suffix (K, M, G). + * Example: 1500 -> "1K items", 2500000 -> "2M items" + */ +fun countToHumanReadable(counter: Int, suffix: String): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K $suffix" + else -> "$counter $suffix" + } + +/** + * Formats a count to human-readable format with suffix (K, M, G). + */ +fun countToHumanReadable(counter: Long, suffix: String): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K $suffix" + else -> "$counter $suffix" + } + +/** + * Formats a count to human-readable format without suffix. + * Example: 1500 -> "1K", 2500000 -> "2M" + */ +fun countToHumanReadable(counter: Int): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K" + else -> "$counter" + } + +/** + * Formats a count to human-readable format without suffix. + */ +fun countToHumanReadable(counter: Long): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K" + else -> "$counter" + } + +/** + * Formats byte count to human-readable format (KB, MB, GB). + * Example: 1500 -> "1 KB", 2500000 -> "2 MB" + */ +fun countToHumanReadableBytes(counter: Int): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()} GB" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()} MB" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()} KB" + else -> "$counter B" + } + +/** + * Formats byte count to human-readable format (KB, MB, GB). + */ +fun countToHumanReadableBytes(counter: Long): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()} GB" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()} MB" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()} KB" + else -> "$counter B" + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt new file mode 100644 index 000000000..40520d4ab --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Shortens a hex key or npub for display. + * Example: "npub1abcdefghijklmnop..." -> "npub1abc…mnop" + * + * @return Shortened string with ellipsis in the middle, or original if <= 16 chars + */ +fun String.toShortDisplay(): String { + if (length <= 16) return this + return replaceRange(8, length - 8, "…") +} + +/** + * Converts a ByteArray to a shortened hex display. + */ +fun ByteArray.toHexShortDisplay(): String = toHexKey().toShortDisplay() + +/** + * Shortens a HexKey for display. + */ +fun HexKey.toDisplayHexKey(): String = this.toShortDisplay() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt similarity index 78% rename from desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt index 91f544c25..6fb4df3e3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.desktop.account +package com.vitorpamplona.amethyst.commons.account import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.asStateFlow sealed class AccountState { data object LoggedOut : AccountState() + data class LoggedIn( val signer: NostrSigner, val pubKeyHex: String, @@ -52,13 +53,14 @@ class AccountManager { val keyPair = KeyPair() val signer = NostrSignerInternal(keyPair) - val state = AccountState.LoggedIn( - signer = signer, - pubKeyHex = keyPair.pubKey.toHexKey(), - npub = keyPair.pubKey.toNpub(), - nsec = keyPair.privKey?.toNsec(), - isReadOnly = false, - ) + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) _accountState.value = state return state } @@ -73,13 +75,14 @@ class AccountManager { val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) val signer = NostrSignerInternal(keyPair) - val state = AccountState.LoggedIn( - signer = signer, - pubKeyHex = keyPair.pubKey.toHexKey(), - npub = keyPair.pubKey.toNpub(), - nsec = keyPair.privKey?.toNsec(), - isReadOnly = false, - ) + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) _accountState.value = state Result.success(state) } catch (e: Exception) { @@ -94,13 +97,14 @@ class AccountManager { val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray()) val signer = NostrSignerInternal(keyPair) - val state = AccountState.LoggedIn( - signer = signer, - pubKeyHex = keyPair.pubKey.toHexKey(), - npub = keyPair.pubKey.toNpub(), - nsec = null, - isReadOnly = true, - ) + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = null, + isReadOnly = true, + ) _accountState.value = state Result.success(state) } catch (e: Exception) { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt new file mode 100644 index 000000000..c14c1365f --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.network + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages Nostr relay connections, subscriptions, and status tracking. + * Can be used by both Android and Desktop apps. + * + * @param websocketBuilder Platform-specific websocket builder (e.g., OkHttp-based) + */ +open class RelayConnectionManager( + websocketBuilder: WebsocketBuilder, +) : IRelayClientListener { + private val client = NostrClient(websocketBuilder) + + private val _relayStatuses = MutableStateFlow>(emptyMap()) + val relayStatuses: StateFlow> = _relayStatuses.asStateFlow() + + val connectedRelays: StateFlow> = client.connectedRelaysFlow() + val availableRelays: StateFlow> = client.availableRelaysFlow() + + init { + client.subscribe(this) + } + + fun connect() { + client.connect() + } + + fun disconnect() { + client.disconnect() + } + + fun addRelay(url: String): NormalizedRelayUrl? { + val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null + updateRelayStatus(normalized) { it.copy(connected = false, error = null) } + return normalized + } + + fun removeRelay(url: NormalizedRelayUrl) { + _relayStatuses.value = _relayStatuses.value - url + } + + fun addDefaultRelays() { + DefaultRelays.RELAYS.forEach { addRelay(it) } + } + + fun subscribe( + subId: String, + filters: List, + relays: Set = availableRelays.value, + listener: IRequestListener? = null, + ) { + val filterMap = relays.associateWith { filters } + client.openReqSubscription(subId, filterMap, listener) + } + + fun unsubscribe(subId: String) { + client.close(subId) + } + + fun send(event: Event, relays: Set = connectedRelays.value) { + client.send(event, relays) + } + + private fun updateRelayStatus( + url: NormalizedRelayUrl, + update: (RelayStatus) -> RelayStatus, + ) { + _relayStatuses.value = _relayStatuses.value.toMutableMap().apply { + val current = this[url] ?: RelayStatus(url, connected = false) + this[url] = update(current) + } + } + + override fun onConnecting(relay: IRelayClient) { + updateRelayStatus(relay.url) { + it.copy(connected = false, error = null) + } + } + + override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) { + updateRelayStatus(relay.url) { + it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null) + } + } + + override fun onDisconnected(relay: IRelayClient) { + updateRelayStatus(relay.url) { + it.copy(connected = false) + } + } + + override fun onCannotConnect(relay: IRelayClient, errorMessage: String) { + updateRelayStatus(relay.url) { + it.copy(connected = false, error = errorMessage) + } + } + + override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { + // Events are handled by subscription listeners + } + + override fun onSent(relay: IRelayClient, cmdStr: String, cmd: Command, success: Boolean) { + // Command send tracking + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt new file mode 100644 index 000000000..decd6428f --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.network + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Represents the connection status of a Nostr relay. + * Used by both Android and Desktop apps. + */ +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val pingMs: Int? = null, + val compressed: Boolean = false, + val error: String? = null, +) + +/** + * Default relay URLs for Nostr connectivity. + */ +object DefaultRelays { + val RELAYS = listOf( + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nos.lol", + "wss://relay.snort.social", + "wss://nostr.wine", + ) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt new file mode 100644 index 000000000..e93045d37 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.auth + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +/** + * Text field for entering Nostr keys (nsec or npub) with visibility toggle. + */ +@Composable +fun KeyInputField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String = "nsec or npub", + placeholder: String = "nsec1... or npub1...", + errorMessage: String? = null, +) { + var showKey by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + placeholder = { Text(placeholder) }, + modifier = modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (showKey) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { showKey = !showKey }) { + Icon( + if (showKey) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showKey) "Hide key" else "Show key", + ) + } + }, + isError = errorMessage != null, + supportingText = errorMessage?.let { + { Text(it, color = MaterialTheme.colorScheme.error) } + }, + ) +} + +/** + * Card displaying a Nostr key that users can select/copy. + */ +@Composable +fun SelectableKeyText( + key: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Text( + text = key, + modifier = Modifier.padding(12.dp).fillMaxWidth(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt new file mode 100644 index 000000000..951089a5b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Login card with Nostr key input field and action buttons. + * + * @param onLogin Callback when login is attempted with the key input + * @param onGenerateNew Callback when "Generate New" is clicked + * @param modifier Modifier for the card + * @param cardWidth Width of the card (default 400.dp) + * @param title Card title + * @param subtitle Subtitle/hint text + */ +@Composable +fun LoginCard( + onLogin: (String) -> Result, + onGenerateNew: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = 400.dp, + title: String = "Login with your Nostr key", + subtitle: String = "Use nsec for full access or npub for read-only", +) { + var keyInput by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(null) } + + Card( + modifier = modifier.width(cardWidth), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + KeyInputField( + value = keyInput, + onValueChange = { + keyInput = it + errorMessage = null + }, + errorMessage = errorMessage, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + onLogin(keyInput).fold( + onSuccess = { /* handled by caller */ }, + onFailure = { errorMessage = it.message }, + ) + }, + modifier = Modifier.weight(1f), + enabled = keyInput.isNotBlank(), + ) { + Text("Login") + } + + OutlinedButton( + onClick = onGenerateNew, + modifier = Modifier.weight(1f), + ) { + Text("Generate New") + } + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt new file mode 100644 index 000000000..a5926a37e --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.auth + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Warning card displayed after generating a new Nostr key pair. + * Reminds users to save their keys and shows both public and secret keys. + * + * @param npub The public key in npub format + * @param nsec The secret key in nsec format (nullable for read-only accounts) + * @param onContinue Callback when user acknowledges they've saved their keys + * @param modifier Modifier for the card + * @param cardWidth Width of the card (default 500.dp) + */ +@Composable +fun NewKeyWarningCard( + npub: String, + nsec: String?, + onContinue: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = 500.dp, +) { + Card( + modifier = modifier.width(cardWidth), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + ) { + Text( + "IMPORTANT: Save your keys!", + style = MaterialTheme.typography.titleMedium, + color = Color.Red, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Your secret key (nsec) is the ONLY way to access your account. " + + "If you lose it, your account is gone forever. Save it somewhere safe!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Public Key (shareable):", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SelectableKeyText(npub) + + Spacer(Modifier.height(12.dp)) + + nsec?.let { secretKey -> + Text( + "Secret Key (NEVER share this!):", + style = MaterialTheme.typography.labelMedium, + color = Color.Red, + ) + SelectableKeyText(secretKey) + } + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + ) { + Text("I've saved my keys, continue") + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt new file mode 100644 index 000000000..a82e4faf8 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt @@ -0,0 +1,188 @@ +/** + * 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.commons.ui.note + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.util.toTimeAgo + +/** + * Data class for displaying a note card. + */ +data class NoteDisplayData( + val id: String, + val pubKeyDisplay: String, + val content: String, + val createdAt: Long, +) + +/** + * Reusable note card composable that displays a Nostr note. + * Can be used by both Desktop and Android apps. + */ +@Composable +fun NoteCard( + note: NoteDisplayData, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + val richTextParser = remember { RichTextParser() } + val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + onClick = onClick ?: {}, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Author (truncated) + Text( + text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + + // Timestamp + Text( + text = note.createdAt.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(8.dp)) + + RichTextContent( + content = note.content, + urls = urls, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) + + Spacer(Modifier.height(4.dp)) + + // Event ID (truncated) + Text( + text = "ID: ${note.id.take(12)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } +} + +/** + * Renders text content with highlighted URLs. + * Uses RichTextParser from commons to detect and highlight links. + */ +@Composable +fun RichTextContent( + content: String, + urls: Set, + modifier: Modifier = Modifier, + maxLines: Int = 10, +) { + if (urls.isEmpty()) { + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } else { + val annotatedText = + buildAnnotatedString { + var lastIndex = 0 + val sortedUrls = urls.sortedBy { content.indexOf(it) } + + for (url in sortedUrls) { + val startIndex = content.indexOf(url, lastIndex) + if (startIndex == -1) continue + + // Add text before URL + if (startIndex > lastIndex) { + append(content.substring(lastIndex, startIndex)) + } + + // Add URL with styling + withStyle( + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + ) { + append(url) + } + + lastIndex = startIndex + url.length + } + + // Add remaining text + if (lastIndex < content.length) { + append(content.substring(lastIndex)) + } + } + + Text( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt new file mode 100644 index 000000000..2a6b01d67 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.profile + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Card displaying Nostr account key information. + * Shows public key (npub), hex format, and optional read-only indicator. + */ +@Composable +fun ProfileInfoCard( + npub: String, + pubKeyHex: String, + isReadOnly: Boolean, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (isReadOnly) { + Text( + "Read-only mode", + style = MaterialTheme.typography.labelMedium, + color = Color.Yellow + ) + Spacer(Modifier.height(8.dp)) + } + + Text( + "Public Key", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + npub, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Hex", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + pubKeyHex, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt new file mode 100644 index 000000000..a523f2e05 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.relay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.network.RelayStatus + +/** + * Card displaying the status of a Nostr relay connection. + * Shows connection status, ping time, compression, and errors. + */ +@Composable +fun RelayStatusCard( + status: RelayStatus, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f) + ) { + val statusColor = when { + status.connected -> Color.Green + status.error != null -> Color.Red + else -> Color.Gray + } + + if (status.connected) { + Icon( + Icons.Default.Check, + contentDescription = "Connected", + tint = statusColor, + modifier = Modifier.size(20.dp) + ) + } else if (status.error != null) { + Icon( + Icons.Default.Close, + contentDescription = "Error", + tint = statusColor, + modifier = Modifier.size(20.dp) + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + + Column { + Text( + status.url.url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (status.connected && status.pingMs != null) { + Text( + "${status.pingMs}ms${if (status.compressed) " • compressed" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + status.error?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = Color.Red.copy(alpha = 0.8f) + ) + } + } + } + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = "Remove relay", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt new file mode 100644 index 000000000..8f8b8d078 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import com.vitorpamplona.amethyst.commons.ui.note.NoteDisplayData +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip19Bech32.toNpub + +/** + * Extension to convert Event to NoteDisplayData for the shared NoteCard. + */ +fun Event.toNoteDisplayData(): NoteDisplayData { + val npub = try { + pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." + } catch (e: Exception) { + pubKey.take(16) + "..." + } + + return NoteDisplayData( + id = id, + pubKeyDisplay = npub, + content = content, + createdAt = createdAt + ) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt new file mode 100644 index 000000000..1b2fb8ea0 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import com.vitorpamplona.quartz.utils.TimeUtils +import java.text.SimpleDateFormat +import java.util.Locale + +private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" +private const val MONTH_DATE_FORMAT = "MMM dd" + +private var locale = Locale.getDefault() +private var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) +private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + +private fun updateFormattersIfNeeded() { + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) + monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + } +} + +/** + * Formats a Unix timestamp (seconds) as a human-readable time ago string. + * Returns strings like " • 5m", " • 2h", " • Dec 12" + * + * @param time Unix timestamp in seconds, or null + * @param withDot Whether to prefix with " • " (default true) + * @return Formatted time ago string + */ +fun timeAgo( + time: Long?, + withDot: Boolean = true, +): String { + if (time == null) return " " + if (time == 0L) return if (withDot) " • never" else "never" + + val timeDifference = TimeUtils.now() - time + val prefix = if (withDot) " • " else "" + + return when { + timeDifference > TimeUtils.ONE_YEAR -> { + updateFormattersIfNeeded() + prefix + yearFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_MONTH -> { + updateFormattersIfNeeded() + prefix + monthFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_DAY -> { + prefix + (timeDifference / TimeUtils.ONE_DAY).toString() + "d" + } + timeDifference > TimeUtils.ONE_HOUR -> { + prefix + (timeDifference / TimeUtils.ONE_HOUR).toString() + "h" + } + timeDifference > TimeUtils.ONE_MINUTE -> { + prefix + (timeDifference / TimeUtils.ONE_MINUTE).toString() + "m" + } + else -> { + prefix + "now" + } + } +} + +/** + * Formats a Unix timestamp as a date string. + * For recent dates (< 1 day), returns the provided today string. + * + * @param time Unix timestamp in seconds + * @param never String to show for time == 0 + * @param today String to show for today's date + */ +fun dateFormatter( + time: Long?, + never: String = "never", + today: String = "today", +): String { + if (time == null) return " " + if (time == 0L) return " $never" + + val timeDifference = TimeUtils.now() - time + + return when { + timeDifference > TimeUtils.ONE_YEAR -> { + updateFormattersIfNeeded() + yearFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_DAY -> { + updateFormattersIfNeeded() + monthFormatter.format(time * 1000) + } + else -> today + } +} + +/** + * Extension function for Long timestamps. + */ +fun Long.toTimeAgo(withDot: Boolean = true): String = timeAgo(this, withDot) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt new file mode 100644 index 000000000..d28deafef --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat + +private val TenGiga = BigDecimal(10_000_000_000) +private val OneGiga = BigDecimal(1_000_000_000) +private val TenMega = BigDecimal(10_000_000) +private val OneMega = BigDecimal(1_000_000) +private val TenKilo = BigDecimal(10_000) +private val OneKilo = BigDecimal(1_000) + +private val dfGBig = ThreadLocal.withInitial { DecimalFormat("#.#G") } +private val dfGSmall = ThreadLocal.withInitial { DecimalFormat("#.0G") } +private val dfMBig = ThreadLocal.withInitial { DecimalFormat("#.#M") } +private val dfMSmall = ThreadLocal.withInitial { DecimalFormat("#.0M") } +private val dfK = ThreadLocal.withInitial { DecimalFormat("#.#k") } +private val dfN = ThreadLocal.withInitial { DecimalFormat("#") } + +/** + * Formats a BigDecimal amount to human-readable format with G/M/K suffixes. + * Returns empty string for null or very small amounts. + * + * Examples: + * - 1500 -> "1.5k" + * - 2500000 -> "2.5M" + * - 10000000000 -> "10G" + */ +fun showAmount(amount: BigDecimal?): String { + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" + + return when { + amount >= TenGiga -> dfGBig.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneGiga -> dfGSmall.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= TenMega -> dfMBig.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfMSmall.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= TenKilo -> dfK.get()!!.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.get()!!.format(amount) + } +} + +/** + * Formats a BigDecimal amount to human-readable format. + * Returns "0" for null or very small amounts instead of empty string. + */ +fun showAmountWithZero(amount: BigDecimal?): String { + if (amount == null) return "0" + if (amount.abs() < BigDecimal(0.01)) return "0" + return showAmount(amount) +} + +/** + * Extension function to format Long as zap amount. + */ +fun Long.toZapAmount(): String = showAmount(BigDecimal(this)) + +/** + * Extension function to format Int as zap amount. + */ +fun Int.toZapAmount(): String = showAmount(BigDecimal(this)) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 86b736734..5bd8c6c60 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -25,6 +25,9 @@ dependencies { // Quartz Nostr library (will use JVM target) implementation(project(":quartz")) + // Commons library + implementation(project(":commons")) + // Coroutines implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.swing) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 5f0277445..1027609e4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -30,13 +30,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Notifications @@ -47,7 +44,6 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -78,22 +74,18 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState -import com.vitorpamplona.amethyst.desktop.account.AccountManager -import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.commons.account.AccountManager +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.navigation.AppScreen +import com.vitorpamplona.amethyst.commons.ui.profile.ProfileInfoCard +import com.vitorpamplona.amethyst.commons.ui.relay.RelayStatusCard +import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder +import com.vitorpamplona.amethyst.commons.ui.screens.NotificationsPlaceholder +import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager -import com.vitorpamplona.amethyst.desktop.network.RelayStatus import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen -enum class Screen { - Feed, - Search, - Messages, - Notifications, - Profile, - Settings -} - fun main() = application { val windowState = rememberWindowState( width = 1200.dp, @@ -147,7 +139,7 @@ fun main() = application { @Composable fun App() { - var currentScreen by remember { mutableStateOf(Screen.Feed) } + var currentScreen by remember { mutableStateOf(AppScreen.Feed) } val relayManager = remember { DesktopRelayConnectionManager() } val accountManager = remember { AccountManager() } val accountState by accountManager.accountState.collectAsState() @@ -171,7 +163,7 @@ fun App() { is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { currentScreen = Screen.Feed } + onLoginSuccess = { currentScreen = AppScreen.Feed } ) } is AccountState.LoggedIn -> { @@ -190,8 +182,8 @@ fun App() { @Composable fun MainContent( - currentScreen: Screen, - onScreenChange: (Screen) -> Unit, + currentScreen: AppScreen, + onScreenChange: (AppScreen) -> Unit, relayManager: DesktopRelayConnectionManager, accountManager: AccountManager, account: AccountState.LoggedIn, @@ -207,36 +199,36 @@ fun MainContent( NavigationRailItem( icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, label = { Text("Feed") }, - selected = currentScreen == Screen.Feed, - onClick = { onScreenChange(Screen.Feed) } + selected = currentScreen == AppScreen.Feed, + onClick = { onScreenChange(AppScreen.Feed) } ) NavigationRailItem( icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, label = { Text("Search") }, - selected = currentScreen == Screen.Search, - onClick = { onScreenChange(Screen.Search) } + selected = currentScreen == AppScreen.Search, + onClick = { onScreenChange(AppScreen.Search) } ) NavigationRailItem( icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, label = { Text("DMs") }, - selected = currentScreen == Screen.Messages, - onClick = { onScreenChange(Screen.Messages) } + selected = currentScreen == AppScreen.Messages, + onClick = { onScreenChange(AppScreen.Messages) } ) NavigationRailItem( icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, label = { Text("Alerts") }, - selected = currentScreen == Screen.Notifications, - onClick = { onScreenChange(Screen.Notifications) } + selected = currentScreen == AppScreen.Notifications, + onClick = { onScreenChange(AppScreen.Notifications) } ) NavigationRailItem( icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, label = { Text("Profile") }, - selected = currentScreen == Screen.Profile, - onClick = { onScreenChange(Screen.Profile) } + selected = currentScreen == AppScreen.Profile, + onClick = { onScreenChange(AppScreen.Profile) } ) Spacer(Modifier.weight(1f)) @@ -246,8 +238,8 @@ fun MainContent( NavigationRailItem( icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, label = { Text("Settings") }, - selected = currentScreen == Screen.Settings, - onClick = { onScreenChange(Screen.Settings) } + selected = currentScreen == AppScreen.Settings, + onClick = { onScreenChange(AppScreen.Settings) } ) Spacer(Modifier.height(16.dp)) @@ -260,64 +252,17 @@ fun MainContent( modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp) ) { when (currentScreen) { - Screen.Feed -> FeedScreen(relayManager) - Screen.Search -> SearchPlaceholder() - Screen.Messages -> MessagesPlaceholder() - Screen.Notifications -> NotificationsPlaceholder() - Screen.Profile -> ProfileScreen(account, accountManager) - Screen.Settings -> RelaySettingsScreen(relayManager) + AppScreen.Feed -> FeedScreen(relayManager) + AppScreen.Search -> SearchPlaceholder() + AppScreen.Messages -> MessagesPlaceholder() + AppScreen.Notifications -> NotificationsPlaceholder() + AppScreen.Profile -> ProfileScreen(account, accountManager) + AppScreen.Settings -> RelaySettingsScreen(relayManager) } } } } -@Composable -fun SearchPlaceholder() { - Column { - Text( - "Search", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(Modifier.height(16.dp)) - Text( - "Search for users, notes, and hashtags.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@Composable -fun MessagesPlaceholder() { - Column { - Text( - "Messages", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(Modifier.height(16.dp)) - Text( - "Your encrypted direct messages will appear here.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@Composable -fun NotificationsPlaceholder() { - Column { - Text( - "Notifications", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(Modifier.height(16.dp)) - Text( - "Mentions, replies, and reactions will appear here.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} @Composable fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager) { @@ -329,47 +274,11 @@ fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager ) Spacer(Modifier.height(16.dp)) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - if (account.isReadOnly) { - Text( - "Read-only mode", - style = MaterialTheme.typography.labelMedium, - color = Color.Yellow - ) - Spacer(Modifier.height(8.dp)) - } - - Text( - "Public Key", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - account.npub, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(Modifier.height(16.dp)) - - Text( - "Hex", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - account.pubKeyHex, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface - ) - } - } + ProfileInfoCard( + npub = account.npub, + pubKeyHex = account.pubKeyHex, + isReadOnly = account.isReadOnly, + ) Spacer(Modifier.height(24.dp)) @@ -451,9 +360,10 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { modifier = Modifier.weight(1f) ) { items(relayStatuses.values.toList()) { status -> - RelayStatusCard(status) { - relayManager.removeRelay(status.url) - } + RelayStatusCard( + status = status, + onRemove = { relayManager.removeRelay(status.url) } + ) } } @@ -466,82 +376,3 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { } } } - -@Composable -fun RelayStatusCard(status: RelayStatus, onRemove: () -> Unit) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.weight(1f) - ) { - val statusColor = when { - status.connected -> Color.Green - status.error != null -> Color.Red - else -> Color.Gray - } - - if (status.connected) { - Icon( - Icons.Default.Check, - contentDescription = "Connected", - tint = statusColor, - modifier = Modifier.size(20.dp) - ) - } else if (status.error != null) { - Icon( - Icons.Default.Close, - contentDescription = "Error", - tint = statusColor, - modifier = Modifier.size(20.dp) - ) - } else { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) - } - - Column { - Text( - status.url.url, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - if (status.connected && status.pingMs != null) { - Text( - "${status.pingMs}ms${if (status.compressed) " • compressed" else ""}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - status.error?.let { error -> - Text( - error, - style = MaterialTheme.typography.bodySmall, - color = Color.Red.copy(alpha = 0.8f) - ) - } - } - } - - IconButton(onClick = onRemove) { - Icon( - Icons.Default.Close, - contentDescription = "Remove relay", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt index a8583c4fd..c143b4d7b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -20,132 +20,13 @@ */ package com.vitorpamplona.amethyst.desktop.network -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message -import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.amethyst.commons.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -data class RelayStatus( - val url: NormalizedRelayUrl, - val connected: Boolean, - val pingMs: Int? = null, - val compressed: Boolean = false, - val error: String? = null, +/** + * Desktop-specific relay connection manager that configures OkHttp for websockets. + * Delegates to the shared RelayConnectionManager from commons. + */ +class DesktopRelayConnectionManager : RelayConnectionManager( + websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient) ) - -class DesktopRelayConnectionManager : IRelayClientListener { - private val websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient) - private val client = NostrClient(websocketBuilder) - - private val _relayStatuses = MutableStateFlow>(emptyMap()) - val relayStatuses: StateFlow> = _relayStatuses.asStateFlow() - - val connectedRelays: StateFlow> = client.connectedRelaysFlow() - val availableRelays: StateFlow> = client.availableRelaysFlow() - - companion object { - val DEFAULT_RELAYS = listOf( - "wss://relay.damus.io", - "wss://relay.nostr.band", - "wss://nos.lol", - "wss://relay.snort.social", - "wss://nostr.wine", - ) - } - - init { - client.subscribe(this) - } - - fun connect() { - client.connect() - } - - fun disconnect() { - client.disconnect() - } - - fun addRelay(url: String): NormalizedRelayUrl? { - val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null - updateRelayStatus(normalized) { it.copy(connected = false, error = null) } - return normalized - } - - fun removeRelay(url: NormalizedRelayUrl) { - _relayStatuses.value = _relayStatuses.value - url - } - - fun addDefaultRelays() { - DEFAULT_RELAYS.forEach { addRelay(it) } - } - - fun subscribe( - subId: String, - filters: List, - relays: Set = availableRelays.value, - listener: IRequestListener? = null, - ) { - val filterMap = relays.associateWith { filters } - client.openReqSubscription(subId, filterMap, listener) - } - - fun unsubscribe(subId: String) { - client.close(subId) - } - - fun send(event: Event, relays: Set = connectedRelays.value) { - client.send(event, relays) - } - - private fun updateRelayStatus( - url: NormalizedRelayUrl, - update: (RelayStatus) -> RelayStatus - ) { - _relayStatuses.value = _relayStatuses.value.toMutableMap().apply { - val current = this[url] ?: RelayStatus(url, connected = false) - this[url] = update(current) - } - } - - override fun onConnecting(relay: IRelayClient) { - updateRelayStatus(relay.url) { - it.copy(connected = false, error = null) - } - } - - override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) { - updateRelayStatus(relay.url) { - it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null) - } - } - - override fun onDisconnected(relay: IRelayClient) { - updateRelayStatus(relay.url) { - it.copy(connected = false) - } - } - - override fun onCannotConnect(relay: IRelayClient, errorMessage: String) { - updateRelayStatus(relay.url) { - it.copy(connected = false, error = errorMessage) - } - } - - override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { - // Events are handled by subscription listeners - } - - override fun onSent(relay: IRelayClient, cmdStr: String, cmd: Command, success: Boolean) { - // Command send tracking - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 8eef7d1e6..5ebb75994 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -22,49 +22,29 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader +import com.vitorpamplona.amethyst.commons.ui.note.NoteCard +import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip19Bech32.toNpub -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Composable fun FeedScreen(relayManager: DesktopRelayConnectionManager) { @@ -126,159 +106,29 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) { } Column(modifier = Modifier.fillMaxSize()) { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "Global Feed", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val connected = connectedRelays.size - val color = when { - connected == 0 -> Color.Red - connected < 3 -> Color.Yellow - else -> Color.Green - } - Icon( - if (connected > 0) Icons.Default.Check else Icons.Default.Close, - contentDescription = null, - tint = color, - modifier = Modifier.size(16.dp) - ) - Text( - "$connected relay${if (connected != 1) "s" else ""}", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - IconButton(onClick = { relayManager.connect() }) { - Icon( - Icons.Default.Refresh, - contentDescription = "Reconnect", - tint = MaterialTheme.colorScheme.primary - ) - } - } - } + FeedHeader( + title = "Global Feed", + connectedRelayCount = connectedRelays.size, + onRefresh = { relayManager.connect() } + ) Spacer(Modifier.height(16.dp)) if (connectedRelays.isEmpty()) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - CircularProgressIndicator() - Spacer(Modifier.height(16.dp)) - Text( - "Connecting to relays...", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + LoadingState("Connecting to relays...") } else if (events.isEmpty()) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - CircularProgressIndicator() - Spacer(Modifier.height(16.dp)) - Text( - "Loading notes...", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + LoadingState("Loading notes...") } else { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(events, key = { it.id }) { event -> - NoteCard(event) + // Use NoteCard from commons + NoteCard( + note = event.toNoteDisplayData() + ) } } } } } - -@Composable -fun NoteCard(event: Event) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column(modifier = Modifier.padding(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // Author npub (truncated) - val npub = try { - event.pubKey.hexToByteArrayOrNull()?.toNpub() ?: event.pubKey.take(16) + "..." - } catch (e: Exception) { - event.pubKey.take(16) + "..." - } - Text( - text = npub.take(20) + "...", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1 - ) - - // Timestamp - val date = Date(event.createdAt * 1000) - val format = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) - Text( - text = format.format(date), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Spacer(Modifier.height(8.dp)) - - Text( - text = event.content, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 10, - overflow = TextOverflow.Ellipsis - ) - - Spacer(Modifier.height(8.dp)) - - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) - - Spacer(Modifier.height(4.dp)) - - // Event ID (truncated) - Text( - text = "ID: ${event.id.take(12)}...", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - } - } -} - -private fun String.hexToByteArrayOrNull(): ByteArray? { - return try { - if (length % 2 != 0) return null - ByteArray(length / 2) { i -> - substring(i * 2, i * 2 + 2).toInt(16).toByte() - } - } catch (e: Exception) { - null - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index 2f9ba4fb1..28632b99b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -22,24 +22,11 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -48,21 +35,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.desktop.account.AccountManager -import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.commons.account.AccountManager +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.ui.auth.LoginCard +import com.vitorpamplona.amethyst.commons.ui.auth.NewKeyWarningCard @Composable fun LoginScreen( accountManager: AccountManager, onLoginSuccess: () -> Unit, ) { - var keyInput by remember { mutableStateOf("") } - var showKey by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } var showNewKeyDialog by remember { mutableStateOf(false) } var generatedAccount by remember { mutableStateOf(null) } @@ -87,97 +70,23 @@ fun LoginScreen( Spacer(Modifier.height(48.dp)) - Card( - modifier = Modifier.width(400.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "Login with your Nostr key", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = keyInput, - onValueChange = { - keyInput = it - errorMessage = null - }, - label = { Text("nsec or npub") }, - placeholder = { Text("nsec1... or npub1...") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - visualTransformation = if (showKey) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailingIcon = { - IconButton(onClick = { showKey = !showKey }) { - Icon( - if (showKey) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showKey) "Hide key" else "Show key", - ) - } - }, - isError = errorMessage != null, - supportingText = errorMessage?.let { - { Text(it, color = MaterialTheme.colorScheme.error) } - }, - ) - - Spacer(Modifier.height(8.dp)) - - Text( - "Use nsec for full access or npub for read-only", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { - accountManager.loginWithKey(keyInput).fold( - onSuccess = { onLoginSuccess() }, - onFailure = { errorMessage = it.message }, - ) - }, - modifier = Modifier.weight(1f), - enabled = keyInput.isNotBlank(), - ) { - Text("Login") - } - - OutlinedButton( - onClick = { - generatedAccount = accountManager.generateNewAccount() - showNewKeyDialog = true - }, - modifier = Modifier.weight(1f), - ) { - Text("Generate New") - } + LoginCard( + onLogin = { keyInput -> + accountManager.loginWithKey(keyInput).map { + onLoginSuccess() } - } - } + }, + onGenerateNew = { + generatedAccount = accountManager.generateNewAccount() + showNewKeyDialog = true + }, + ) if (showNewKeyDialog && generatedAccount != null) { Spacer(Modifier.height(24.dp)) - NewKeyCard( - account = generatedAccount!!, + NewKeyWarningCard( + npub = generatedAccount!!.npub, + nsec = generatedAccount!!.nsec, onContinue = { showNewKeyDialog = false onLoginSuccess() @@ -186,80 +95,3 @@ fun LoginScreen( } } } - -@Composable -fun NewKeyCard( - account: AccountState.LoggedIn, - onContinue: () -> Unit, -) { - Card( - modifier = Modifier.width(500.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), - ), - ) { - Column( - modifier = Modifier.padding(24.dp), - ) { - Text( - "IMPORTANT: Save your keys!", - style = MaterialTheme.typography.titleMedium, - color = Color.Red, - ) - - Spacer(Modifier.height(16.dp)) - - Text( - "Your secret key (nsec) is the ONLY way to access your account. " + - "If you lose it, your account is gone forever. Save it somewhere safe!", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - - Spacer(Modifier.height(16.dp)) - - Text( - "Public Key (shareable):", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - SelectableKeyText(account.npub) - - Spacer(Modifier.height(12.dp)) - - account.nsec?.let { nsec -> - Text( - "Secret Key (NEVER share this!):", - style = MaterialTheme.typography.labelMedium, - color = Color.Red, - ) - SelectableKeyText(nsec) - } - - Spacer(Modifier.height(24.dp)) - - Button( - onClick = onContinue, - modifier = Modifier.fillMaxWidth(), - ) { - Text("I've saved my keys, continue") - } - } - } -} - -@Composable -fun SelectableKeyText(key: String) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) { - Text( - text = key, - modifier = Modifier.padding(12.dp).fillMaxWidth(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - ) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt index eae01d726..c7ffb0656 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt @@ -29,6 +29,9 @@ fun ByteArray.toHexKey(): HexKey = Hex.encode(this) fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this) +fun HexKey.hexToByteArrayOrNull(): ByteArray? = + if (Hex.isHex(this)) Hex.decode(this) else null + const val PUBKEY_LENGTH = 64 const val EVENT_ID_LENGTH = 64 From f9582bb007797b07aad3665052757e9451d9e92d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 27 Dec 2025 12:11:31 -0500 Subject: [PATCH 28/44] Increases cache size to up to 40MB --- .../quartz/nip01Core/store/sqlite/SQLiteEventStore.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 77473dce4..a999c6f84 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -82,6 +82,9 @@ class SQLiteEventStore( // The DB can be corrupted if the OS is shutdown before sync, which generally // doesn't happen on Android db.execSQL("PRAGMA synchronous = OFF") + + // Cache to keep larger parts of the index in memory + db.execSQL("PRAGMA cache_size=10000;"); // Cache 10,000 pages } override fun onCreate(db: SQLiteDatabase) { From f0b6fa622c156747219a9328ea85ab8eedd41eed Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 27 Dec 2025 12:11:58 -0500 Subject: [PATCH 29/44] adds vacuum and analyse utility functions --- .../nip01Core/store/sqlite/SQLiteEventStore.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index a999c6f84..20bb2bfea 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -116,6 +116,18 @@ class SQLiteEventStore( modules.reversed().forEach { it.deleteAll(db) } } + fun vacuum() { + // 1. ANALYZE: Collects statistics about tables and indices + // to help the query planner optimize queries. + writableDatabase.execSQL("VACUUM") + } + + fun analyse() { + // 2. VACUUM: Rebuilds the database file, reclaiming unused space + // and reducing fragmentation. + writableDatabase.execSQL("ANALYZE") + } + private fun innerInsertEvent( event: Event, db: SQLiteDatabase, From f07070d65ee98d820bf92af7975743162b0b89f0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 27 Dec 2025 12:12:12 -0500 Subject: [PATCH 30/44] Fixes test name --- .../quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt index a92b74aff..2a3983a8d 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -264,7 +264,7 @@ class QueryAssemblerTest { } @Test - fun testJustTags() { + fun testFollowersOf() { val sql = explain( listOf( From d435231f553ed1963283ff1f309dab3ae8c3e7b8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 27 Dec 2025 12:38:22 -0500 Subject: [PATCH 31/44] Setting cache doesn't seem to do anything :( --- .../quartz/nip01Core/store/sqlite/SQLiteEventStore.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 20bb2bfea..e947d234f 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -82,9 +82,6 @@ class SQLiteEventStore( // The DB can be corrupted if the OS is shutdown before sync, which generally // doesn't happen on Android db.execSQL("PRAGMA synchronous = OFF") - - // Cache to keep larger parts of the index in memory - db.execSQL("PRAGMA cache_size=10000;"); // Cache 10,000 pages } override fun onCreate(db: SQLiteDatabase) { From 068ba3b717759667aaa7eed279d76cd8ee509b46 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 27 Dec 2025 12:40:23 -0500 Subject: [PATCH 32/44] Vacuum and Analyze are blocking, so moving to a coroutine. --- .../nip01Core/store/sqlite/SQLiteEventStore.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index e947d234f..c6b202262 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventIndexesModule.IndexingStrategy import com.vitorpamplona.quartz.nip40Expiration.isExpired +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class SQLiteEventStore( val context: Context, @@ -113,16 +115,20 @@ class SQLiteEventStore( modules.reversed().forEach { it.deleteAll(db) } } - fun vacuum() { + suspend fun vacuum() { // 1. ANALYZE: Collects statistics about tables and indices // to help the query planner optimize queries. - writableDatabase.execSQL("VACUUM") + withContext(Dispatchers.IO) { + writableDatabase.execSQL("VACUUM") + } } - fun analyse() { + suspend fun analyse() { // 2. VACUUM: Rebuilds the database file, reclaiming unused space // and reducing fragmentation. - writableDatabase.execSQL("ANALYZE") + withContext(Dispatchers.IO) { + writableDatabase.execSQL("ANALYZE") + } } private fun innerInsertEvent( From 55774b0292839b490ab69b740c9e22733a9b7d88 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 27 Dec 2025 13:13:46 -0500 Subject: [PATCH 33/44] sorting tags before inserting speeds up imports by ~20% --- .../store/sqlite/EventIndexesModule.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index b0fbad682..22f9af3ab 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -70,6 +70,8 @@ class EventIndexesModule( 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)") 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)") @@ -162,13 +164,20 @@ class EventIndexesModule( val stmtTags = db.compileStatement(sqlInsertTags) - event.tags.forEach { tag -> - if (tagIndexStrategy.shouldIndex(event.kind, tag)) { - stmtTags.bindLong(1, headerId) - stmtTags.bindLong(2, hasher.hash(tag[0], tag[1])) - stmtTags.executeInsert() + // sorting helps SQLLite by avoiding + // rebalancing the tree every new insert + val indexableTags = ArrayList() + for (idx in event.tags.indices) { + if (tagIndexStrategy.shouldIndex(event.kind, event.tags[idx])) { + indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1])) } } + indexableTags.sort() + indexableTags.forEach { + stmtTags.bindLong(1, headerId) + stmtTags.bindLong(2, it) + stmtTags.executeInsert() + } return headerId } From 71d9f25557f099304ba915f7a869c2c6d9d3f902 Mon Sep 17 00:00:00 2001 From: Dumitru Date: Sat, 27 Dec 2025 20:41:17 +0100 Subject: [PATCH 34/44] Quartz Feature Parity Table on Multiplatform --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 46f7773a0..679c159b1 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,28 @@ When your app goes to the background, you can use NostrClient's `connect` and `d methods to stop all communication to relays. Add the `connect` to your `onResume` and `disconnect` to `onPause` methods. +### Feature Parity Table + +| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes | +| :--- | :--- | :---: | :---: | :--- | +| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ❌ No | Core Nostr signing/verification is missing on iOS. | +| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ❌ No | AEAD and stream ciphers are unimplemented. | +| | AES Encryption (CBC & GCM) | ✅ Full | ❌ No | `AESCBC` and `AESGCM` are stubs on iOS. | +| | Hashing (SHA-256, etc.) | ✅ Full | ❌ No | `DigestInstance` is unimplemented. | +| | MAC (HmacSHA256, etc.) | ✅ Full | ❌ No | `MacInstance` is unimplemented. | +| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ❌ No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. | +| | GZip Compression | ✅ Full | ❌ No | `GZip` implementation is missing. | +| | BitSet | ✅ Full | ❌ No | `BitSet` utility is unimplemented. | +| | LargeCache | ✅ Full | ❌ No | `LargeCache` methods (get, keys, size, etc.) are stubs. | +| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ❌ No | `ServerInfoParser` is unimplemented. | +| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. | +| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. | +| **Utilities** | URL Encoding / Decoding | ✅ Full | ❌ No | `UrlEncoder` and `URLs.ios.kt` are unimplemented. | +| | Unicode Normalization | ✅ Full | ❌ No | `UnicodeNormalizer` is a stub. | +| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. | +| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. | + + ## Contributing Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst) From 390da161295b4a7cf8af6c02b692550db4222ec1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 27 Dec 2025 17:55:30 -0500 Subject: [PATCH 35/44] Creating a IndexingStrategy interface --- .../store/sqlite/EventIndexesModule.kt | 12 +----- .../nip01Core/store/sqlite/EventStore.kt | 3 +- .../store/sqlite/IndexingStrategy.kt | 40 +++++++++++++++++++ .../store/sqlite/SQLiteEventStore.kt | 3 +- 4 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index 22f9af3ab..4154ca6f9 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.utils.EventFactory class EventIndexesModule( val fts: FullTextSearchModule, val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, - val tagIndexStrategy: IndexingStrategy = IndexingStrategy(), + val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IModule { override fun create(db: SQLiteDatabase) { db.execSQL( @@ -182,16 +182,6 @@ class EventIndexesModule( return headerId } - /** - * By default, we index all tags that have a single letter name and some value - */ - class IndexingStrategy { - fun shouldIndex( - kind: Int, - tag: Tag, - ) = tag.size >= 2 && tag[0].length == 1 - } - fun planQuery( filter: Filter, hasher: TagNameValueHasher, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index 1a9a3e57b..a4c85102e 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -24,13 +24,12 @@ import android.content.Context import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore -import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventIndexesModule.IndexingStrategy class EventStore( context: Context, dbName: String? = "events.db", val relayUrl: String? = "wss://quartz.local", - val tagIndexStrategy: IndexingStrategy = IndexingStrategy(), + val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IEventStore { val store = SQLiteEventStore(context, dbName, relayUrl, tagIndexStrategy) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt new file mode 100644 index 000000000..4e50b4225 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt @@ -0,0 +1,40 @@ +/** + * 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.Tag + +interface IndexingStrategy { + fun shouldIndex( + kind: Int, + tag: Tag, + ): Boolean +} + +/** + * By default, we index all tags that have a single letter name and some value + */ +class DefaultIndexingStrategy : IndexingStrategy { + override fun shouldIndex( + kind: Int, + tag: Tag, + ) = tag.size >= 2 && tag[0].length == 1 +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index c6b202262..c1e72a3bb 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isEphemeral import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore -import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventIndexesModule.IndexingStrategy import com.vitorpamplona.quartz.nip40Expiration.isExpired import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -39,7 +38,7 @@ class SQLiteEventStore( val context: Context, val dbName: String? = "events.db", val relayUrl: String? = null, - val tagIndexStrategy: IndexingStrategy = IndexingStrategy(), + val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) { companion object { const val DATABASE_VERSION = 2 From 57a22384b22fe3df131cdf7aa323e4132305fdd5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 27 Dec 2025 17:57:32 -0500 Subject: [PATCH 36/44] Refactoring --- .../store/sqlite/EventIndexesModule.kt | 127 +++++++++++------- 1 file changed, 78 insertions(+), 49 deletions(-) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index 4154ca6f9..10fcf8301 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -25,8 +25,9 @@ import android.database.sqlite.SQLiteDatabase import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper -import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -216,8 +217,13 @@ class EventIndexesModule( db: SQLiteDatabase, onEach: (T) -> Unit, ) { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) - db.runQueryEmitting(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach) + 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( @@ -249,9 +255,13 @@ class EventIndexesModule( db: SQLiteDatabase, onEach: (T) -> Unit, ) { - val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) - db.runQueryEmitting(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args, onEach) + 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" @@ -271,56 +281,66 @@ class EventIndexesModule( args: List = emptyList(), ): List = rawQuery(sql, args.toTypedArray()).use { cursor -> - parseResults(cursor) + ArrayList(cursor.count).apply { + while (cursor.moveToNext()) { + add(cursor.toEvent()) + } + } } - private fun parseResults(cursor: Cursor): List { - val events = ArrayList(cursor.count) - - while (cursor.moveToNext()) { - events.add( - EventFactory.create( - cursor.getString(0).intern(), - cursor.getString(1).intern(), - cursor.getLong(2), - cursor.getInt(3), - OptimizedJsonMapper.fromJsonToTagArray(cursor.getString(4)), - cursor.getString(5), - cursor.getString(6), - ), - ) - } - - return events - } - - private fun SQLiteDatabase.runQueryEmitting( + private inline fun SQLiteDatabase.runQuery( sql: String, args: List = emptyList(), onEach: (T) -> Unit, ) = rawQuery(sql, args.toTypedArray()).use { cursor -> - emitResults(cursor, onEach) - } - - private fun emitResults( - cursor: Cursor, - onEach: (T) -> Unit, - ) { while (cursor.moveToNext()) { - onEach( - EventFactory.create( - cursor.getString(0).intern(), - cursor.getString(1).intern(), - cursor.getLong(2), - cursor.getInt(3), - OptimizedJsonMapper.fromJsonToTagArray(cursor.getString(4)), - cursor.getString(5), - cursor.getString(6), - ), - ) + onEach(cursor.toEvent()) } } + private fun Cursor.toEvent() = + EventFactory.create( + getString(0).intern(), + getString(1).intern(), + getLong(2), + getInt(3), + OptimizedJsonMapper.fromJsonToTagArray(getString(4)), + getString(5), + getString(6), + ) + + class RawEvent( + val id: HexKey, + val pubKey: HexKey, + val createdAt: Long, + val kind: Kind, + val jsonTags: String, + val content: String, + val sig: HexKey, + ) { + fun toEvent() = + EventFactory.create( + id.intern(), + pubKey.intern(), + createdAt, + kind, + OptimizedJsonMapper.fromJsonToTagArray(jsonTags), + content, + sig, + ) + } + + private fun Cursor.toRawEvent() = + RawEvent( + getString(0), + getString(1), + getLong(2), + getInt(3), + getString(4), + getString(5), + getString(6), + ) + // -------------- // Counts // ------------- @@ -328,9 +348,13 @@ class EventIndexesModule( filter: Filter, db: SQLiteDatabase, ): Int { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return db.countEverything() + val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) - return db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args) + return if (rowIdSubQuery == null) { + db.countEverything() + } else { + db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args) + } } fun count( @@ -365,8 +389,13 @@ class EventIndexesModule( filter: Filter, db: SQLiteDatabase, ): Int { - val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return 0 - return db.runDelete(rowIdQuery.sql, rowIdQuery.args) + val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db)) + + return if (rowIdQuery == null) { + 0 + } else { + db.runDelete(rowIdQuery.sql, rowIdQuery.args) + } } fun delete( From b23ff488edf2a988d73377536255ee39eca411a3 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 28 Dec 2025 07:03:57 +0200 Subject: [PATCH 37/44] refactor: Polish shared commons components and fix formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Improve RelayConnectionManager with better connection handling - Refine UI components (LoginCard, ProfileInfoCard, RelayStatusCard) - Update KeyInputField with improved layout - Fix formatting in FeedHeader and AppScreen - Add proper number formatting utilities - Remove .claude config from version control 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../amethyst/service/images/Base64Fetcher.kt | 3 +- .../service/images/BlurHashFetcher.kt | 5 ++- .../amethyst/commons/navigation/AppScreen.kt | 24 ++++++----- .../commons/ui/components/ActionButtons.kt | 2 +- .../commons/ui/components/RobohashImage.kt | 2 +- .../amethyst/commons/ui/feed/FeedHeader.kt | 26 ++++++------ .../commons/ui/screens/PlaceholderScreens.kt | 6 +-- .../amethyst/commons/ui/theme/Colors.kt | 2 +- .../amethyst/commons/util/NumberFormatters.kt | 12 ++++-- .../amethyst/commons/util/PubKeyFormatter.kt | 2 +- .../commons/network/RelayConnectionManager.kt | 40 ++++++++++++++----- .../amethyst/commons/network/RelayStatus.kt | 17 ++++---- .../amethyst/commons/ui/auth/KeyInputField.kt | 27 +++++++------ .../amethyst/commons/ui/auth/LoginCard.kt | 9 +++-- .../commons/ui/auth/NewKeyWarningCard.kt | 9 +++-- .../commons/ui/profile/ProfileInfoCard.kt | 19 ++++----- .../commons/ui/relay/RelayStatusCard.kt | 38 +++++++++--------- .../amethyst/commons/util/EventExtensions.kt | 15 +++---- .../amethyst/commons/util/TimeAgoFormatter.kt | 2 +- .../amethyst/commons/util/ZapFormatter.kt | 2 +- .../quartz/nip01Core/core/HexKey.kt | 3 +- 21 files changed, 152 insertions(+), 113 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt index 50b1621bf..bbab146da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt @@ -31,6 +31,7 @@ import coil3.fetch.ImageFetchResult import coil3.key.Keyer import coil3.request.Options import com.vitorpamplona.amethyst.commons.base64Image.Base64Image +import com.vitorpamplona.amethyst.commons.base64Image.toBitmap import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -42,7 +43,7 @@ class Base64Fetcher( override suspend fun fetch(): FetchResult? = runCatching { ImageFetchResult( - image = Base64Image.Companion.toBitmap(data.toString()).asImage(true), + image = Base64Image.toBitmap(data.toString()).asImage(true), isSampled = false, dataSource = DataSource.MEMORY, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt index c755f8d14..f128bcd02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt @@ -30,6 +30,7 @@ import coil3.fetch.ImageFetchResult import coil3.key.Keyer import coil3.request.Options import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder +import com.vitorpamplona.amethyst.commons.blurhash.toAndroidBitmap data class BlurhashWrapper( val blurhash: String, @@ -43,10 +44,10 @@ class BlurHashFetcher( override suspend fun fetch(): FetchResult? { val hash = data.blurhash - val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null + val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null return ImageFetchResult( - image = bitmap.asImage(true), + image = platformImage.toAndroidBitmap().asImage(true), isSampled = false, dataSource = DataSource.MEMORY, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt index c6b005d0b..e3502dfc2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -39,17 +39,19 @@ enum class AppScreen( /** * Primary navigation destinations (shown in bottom bar on mobile, sidebar on desktop). */ -val primaryScreens = listOf( - AppScreen.Feed, - AppScreen.Search, - AppScreen.Messages, - AppScreen.Notifications, - AppScreen.Profile, -) +val primaryScreens = + listOf( + AppScreen.Feed, + AppScreen.Search, + AppScreen.Messages, + AppScreen.Notifications, + AppScreen.Profile, + ) /** * Secondary navigation destinations (settings, etc.) */ -val secondaryScreens = listOf( - AppScreen.Settings, -) +val secondaryScreens = + listOf( + AppScreen.Settings, + ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt index 93ab2de84..ea87b8ed1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt index f5dfce736..82e0123bf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt index 7884b9ba2..36efd264d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -35,7 +35,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.ui.theme.RelayStatusColors @@ -57,12 +56,12 @@ fun FeedHeader( Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( title, style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) RelayStatusIndicator( @@ -84,32 +83,33 @@ fun RelayStatusIndicator( Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - val statusColor = when { - connectedCount == 0 -> RelayStatusColors.Disconnected - connectedCount < 3 -> RelayStatusColors.Connecting - else -> RelayStatusColors.Connected - } + val statusColor = + when { + connectedCount == 0 -> RelayStatusColors.Disconnected + connectedCount < 3 -> RelayStatusColors.Connecting + else -> RelayStatusColors.Connected + } Icon( imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close, contentDescription = null, tint = statusColor, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(16.dp), ) Text( "$connectedCount relay${if (connectedCount != 1) "s" else ""}", color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall + style = MaterialTheme.typography.bodySmall, ) IconButton(onClick = onRefresh) { Icon( Icons.Default.Refresh, contentDescription = "Reconnect", - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt index 92820ee25..4048a5c02 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -42,12 +42,12 @@ fun PlaceholderScreen( Text( title, style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.height(16.dp)) Text( description, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt index 5ff1b209a..edca6b6c3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt index f64005f08..abb8d1d47 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -26,7 +26,10 @@ import kotlin.math.roundToInt * Formats a count to human-readable format with suffix (K, M, G). * Example: 1500 -> "1K items", 2500000 -> "2M items" */ -fun countToHumanReadable(counter: Int, suffix: String): String = +fun countToHumanReadable( + counter: Int, + suffix: String, +): String = when { counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix" counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix" @@ -37,7 +40,10 @@ fun countToHumanReadable(counter: Int, suffix: String): String = /** * Formats a count to human-readable format with suffix (K, M, G). */ -fun countToHumanReadable(counter: Long, suffix: String): String = +fun countToHumanReadable( + counter: Long, + suffix: String, +): String = when { counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix" counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix" diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt index 40520d4ab..250e17ea1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt index c14c1365f..45f9571c2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -92,7 +92,10 @@ open class RelayConnectionManager( client.close(subId) } - fun send(event: Event, relays: Set = connectedRelays.value) { + fun send( + event: Event, + relays: Set = connectedRelays.value, + ) { client.send(event, relays) } @@ -100,10 +103,11 @@ open class RelayConnectionManager( url: NormalizedRelayUrl, update: (RelayStatus) -> RelayStatus, ) { - _relayStatuses.value = _relayStatuses.value.toMutableMap().apply { - val current = this[url] ?: RelayStatus(url, connected = false) - this[url] = update(current) - } + _relayStatuses.value = + _relayStatuses.value.toMutableMap().apply { + val current = this[url] ?: RelayStatus(url, connected = false) + this[url] = update(current) + } } override fun onConnecting(relay: IRelayClient) { @@ -112,7 +116,11 @@ open class RelayConnectionManager( } } - override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) { + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { updateRelayStatus(relay.url) { it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null) } @@ -124,17 +132,29 @@ open class RelayConnectionManager( } } - override fun onCannotConnect(relay: IRelayClient, errorMessage: String) { + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { updateRelayStatus(relay.url) { it.copy(connected = false, error = errorMessage) } } - override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { // Events are handled by subscription listeners } - override fun onSent(relay: IRelayClient, cmdStr: String, cmd: Command, success: Boolean) { + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { // Command send tracking } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt index decd6428f..f446c7eba 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -38,11 +38,12 @@ data class RelayStatus( * Default relay URLs for Nostr connectivity. */ object DefaultRelays { - val RELAYS = listOf( - "wss://relay.damus.io", - "wss://relay.nostr.band", - "wss://nos.lol", - "wss://relay.snort.social", - "wss://nostr.wine", - ) + val RELAYS = + listOf( + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nos.lol", + "wss://relay.snort.social", + "wss://nostr.wine", + ) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt index e93045d37..e27629c9f 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -63,11 +63,12 @@ fun KeyInputField( placeholder = { Text(placeholder) }, modifier = modifier.fillMaxWidth(), singleLine = true, - visualTransformation = if (showKey) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, + visualTransformation = + if (showKey) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, trailingIcon = { IconButton(onClick = { showKey = !showKey }) { Icon( @@ -77,9 +78,10 @@ fun KeyInputField( } }, isError = errorMessage != null, - supportingText = errorMessage?.let { - { Text(it, color = MaterialTheme.colorScheme.error) } - }, + supportingText = + errorMessage?.let { + { Text(it, color = MaterialTheme.colorScheme.error) } + }, ) } @@ -93,9 +95,10 @@ fun SelectableKeyText( ) { Card( modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), ) { Text( text = key, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt index 951089a5b..a80912b91 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -68,9 +68,10 @@ fun LoginCard( Card( modifier = modifier.width(cardWidth), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), ) { Column( modifier = Modifier.padding(24.dp), diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt index a5926a37e..4fa976aa9 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -57,9 +57,10 @@ fun NewKeyWarningCard( ) { Card( modifier = modifier.width(cardWidth), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), - ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + ), ) { Column( modifier = Modifier.padding(24.dp), diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt index 2a6b01d67..53bf73bbc 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -47,16 +47,17 @@ fun ProfileInfoCard( ) { Card( modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), ) { Column(modifier = Modifier.padding(16.dp)) { if (isReadOnly) { Text( "Read-only mode", style = MaterialTheme.typography.labelMedium, - color = Color.Yellow + color = Color.Yellow, ) Spacer(Modifier.height(8.dp)) } @@ -64,12 +65,12 @@ fun ProfileInfoCard( Text( "Public Key", style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( npub, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(Modifier.height(16.dp)) @@ -77,12 +78,12 @@ fun ProfileInfoCard( Text( "Hex", style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( pubKeyHex, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt index a523f2e05..c82c5102b 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -55,44 +55,46 @@ fun RelayStatusCard( ) { Card( modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), ) { Row( modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { - val statusColor = when { - status.connected -> Color.Green - status.error != null -> Color.Red - else -> Color.Gray - } + val statusColor = + when { + status.connected -> Color.Green + status.error != null -> Color.Red + else -> Color.Gray + } if (status.connected) { Icon( Icons.Default.Check, contentDescription = "Connected", tint = statusColor, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(20.dp), ) } else if (status.error != null) { Icon( Icons.Default.Close, contentDescription = "Error", tint = statusColor, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(20.dp), ) } else { CircularProgressIndicator( modifier = Modifier.size(20.dp), - strokeWidth = 2.dp + strokeWidth = 2.dp, ) } @@ -100,20 +102,20 @@ fun RelayStatusCard( Text( status.url.url, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) if (status.connected && status.pingMs != null) { Text( "${status.pingMs}ms${if (status.compressed) " • compressed" else ""}", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } status.error?.let { error -> Text( error, style = MaterialTheme.typography.bodySmall, - color = Color.Red.copy(alpha = 0.8f) + color = Color.Red.copy(alpha = 0.8f), ) } } @@ -123,7 +125,7 @@ fun RelayStatusCard( Icon( Icons.Default.Close, contentDescription = "Remove relay", - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt index 8f8b8d078..1f0de406e 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -29,16 +29,17 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub * Extension to convert Event to NoteDisplayData for the shared NoteCard. */ fun Event.toNoteDisplayData(): NoteDisplayData { - val npub = try { - pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." - } catch (e: Exception) { - pubKey.take(16) + "..." - } + val npub = + try { + pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." + } catch (e: Exception) { + pubKey.take(16) + "..." + } return NoteDisplayData( id = id, pubKeyDisplay = npub, content = content, - createdAt = createdAt + createdAt = createdAt, ) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt index 1b2fb8ea0..88ef514b9 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt index d28deafef..7e0e3c6cb 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt index c7ffb0656..e59fd24a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt @@ -29,8 +29,7 @@ fun ByteArray.toHexKey(): HexKey = Hex.encode(this) fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this) -fun HexKey.hexToByteArrayOrNull(): ByteArray? = - if (Hex.isHex(this)) Hex.decode(this) else null +fun HexKey.hexToByteArrayOrNull(): ByteArray? = if (Hex.isHex(this)) Hex.decode(this) else null const val PUBKEY_LENGTH = 64 From e407e03e9067a910b23c3b7ec80c837eff55315a Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 28 Dec 2025 07:21:36 +0200 Subject: [PATCH 38/44] update readme --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 46f7773a0..7a620308b 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,11 @@ Information shared on Nostr can be re-broadcasted to other servers and should be # Development Overview -This repository is split between Amethyst and Quartz: -- Amethyst is a native Android app made with Kotlin and Jetpack Compose. -- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients. +This repository is split between Amethyst, Quartz, Commons, and DesktopApp: +- **Amethyst** - Native Android app with Kotlin and Jetpack Compose +- **Quartz** - Nostr-commons KMP library for protocol classes shared across platforms +- **Commons** - Kotlin Multiplatform module with shared UI components (icons, robohash, blurhash, composables) +- **DesktopApp** - Compose Multiplatform Desktop application reusing commons and quartz The app architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays, and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph. @@ -187,11 +189,17 @@ git clone https://github.com/vitorpamplona/amethyst.git Use an Android Studio build action to install and run the app on your device or a simulator. ## Building -Build the app: + +Build the Android app: ```bash ./gradlew assembleDebug ``` +Build and run the Desktop app (requires Java 21+): +```bash +./gradlew :desktopApp:run +``` + ## Testing ```bash ./gradlew test From 0dcd480d4369de4e86786f32f6481f454b394029 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 28 Dec 2025 07:35:48 +0200 Subject: [PATCH 39/44] leftover changes --- .../vitorpamplona/amethyst/desktop/Main.kt | 153 +++++++++--------- .../desktop/network/DesktopHttpClient.kt | 5 +- .../network/DesktopRelayConnectionManager.kt | 9 +- .../amethyst/desktop/ui/FeedScreen.kt | 62 +++---- .../amethyst/desktop/ui/LoginScreen.kt | 2 +- 5 files changed, 119 insertions(+), 112 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 1027609e4..7e91eb32d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -42,8 +42,6 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -86,56 +84,58 @@ import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen -fun main() = application { - val windowState = rememberWindowState( - width = 1200.dp, - height = 800.dp, - position = WindowPosition.Aligned(Alignment.Center) - ) +fun main() = + application { + val windowState = + rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center), + ) - Window( - onCloseRequest = ::exitApplication, - state = windowState, - title = "Amethyst" - ) { - MenuBar { - Menu("File") { - Item( - "New Note", - shortcut = KeyShortcut(Key.N, ctrl = true), - onClick = { /* TODO: Open new note dialog */ } - ) - Separator() - Item( - "Settings", - shortcut = KeyShortcut(Key.Comma, ctrl = true), - onClick = { /* TODO: Open settings */ } - ) - Separator() - Item( - "Quit", - shortcut = KeyShortcut(Key.Q, ctrl = true), - onClick = ::exitApplication - ) - } - Menu("Edit") { - Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true), onClick = { }) - Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true), onClick = { }) - } - Menu("View") { - Item("Feed", onClick = { }) - Item("Messages", onClick = { }) - Item("Notifications", onClick = { }) - } - Menu("Help") { - Item("About Amethyst", onClick = { }) - Item("Keyboard Shortcuts", onClick = { }) + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "Amethyst", + ) { + MenuBar { + Menu("File") { + Item( + "New Note", + shortcut = KeyShortcut(Key.N, ctrl = true), + onClick = { /* TODO: Open new note dialog */ }, + ) + Separator() + Item( + "Settings", + shortcut = KeyShortcut(Key.Comma, ctrl = true), + onClick = { /* TODO: Open settings */ }, + ) + Separator() + Item( + "Quit", + shortcut = KeyShortcut(Key.Q, ctrl = true), + onClick = ::exitApplication, + ) + } + Menu("Edit") { + Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true), onClick = { }) + Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true), onClick = { }) + } + Menu("View") { + Item("Feed", onClick = { }) + Item("Messages", onClick = { }) + Item("Notifications", onClick = { }) + } + Menu("Help") { + Item("About Amethyst", onClick = { }) + Item("Keyboard Shortcuts", onClick = { }) + } } + + App() } - - App() } -} @Composable fun App() { @@ -153,17 +153,17 @@ fun App() { } MaterialTheme( - colorScheme = darkColorScheme() + colorScheme = darkColorScheme(), ) { Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background + color = MaterialTheme.colorScheme.background, ) { when (accountState) { is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { currentScreen = AppScreen.Feed } + onLoginSuccess = { currentScreen = AppScreen.Feed }, ) } is AccountState.LoggedIn -> { @@ -192,7 +192,7 @@ fun MainContent( // Sidebar Navigation NavigationRail( modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant + containerColor = MaterialTheme.colorScheme.surfaceVariant, ) { Spacer(Modifier.height(16.dp)) @@ -200,35 +200,35 @@ fun MainContent( icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, label = { Text("Feed") }, selected = currentScreen == AppScreen.Feed, - onClick = { onScreenChange(AppScreen.Feed) } + onClick = { onScreenChange(AppScreen.Feed) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, label = { Text("Search") }, selected = currentScreen == AppScreen.Search, - onClick = { onScreenChange(AppScreen.Search) } + onClick = { onScreenChange(AppScreen.Search) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, label = { Text("DMs") }, selected = currentScreen == AppScreen.Messages, - onClick = { onScreenChange(AppScreen.Messages) } + onClick = { onScreenChange(AppScreen.Messages) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, label = { Text("Alerts") }, selected = currentScreen == AppScreen.Notifications, - onClick = { onScreenChange(AppScreen.Notifications) } + onClick = { onScreenChange(AppScreen.Notifications) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, label = { Text("Profile") }, selected = currentScreen == AppScreen.Profile, - onClick = { onScreenChange(AppScreen.Profile) } + onClick = { onScreenChange(AppScreen.Profile) }, ) Spacer(Modifier.weight(1f)) @@ -239,7 +239,7 @@ fun MainContent( icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, label = { Text("Settings") }, selected = currentScreen == AppScreen.Settings, - onClick = { onScreenChange(AppScreen.Settings) } + onClick = { onScreenChange(AppScreen.Settings) }, ) Spacer(Modifier.height(16.dp)) @@ -249,7 +249,7 @@ fun MainContent( // Main Content Box( - modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp) + modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), ) { when (currentScreen) { AppScreen.Feed -> FeedScreen(relayManager) @@ -263,14 +263,16 @@ fun MainContent( } } - @Composable -fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager) { +fun ProfileScreen( + account: AccountState.LoggedIn, + accountManager: AccountManager, +) { Column { Text( "Profile", style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.height(16.dp)) @@ -284,9 +286,10 @@ fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager OutlinedButton( onClick = { accountManager.logout() }, - colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors( - contentColor = Color.Red - ) + colors = + androidx.compose.material3.ButtonDefaults.outlinedButtonColors( + contentColor = Color.Red, + ), ) { Text("Logout") } @@ -303,24 +306,24 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { Text( "Relay Settings", style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( "${connectedRelays.size} of ${relayStatuses.size} relays connected", color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) IconButton(onClick = { relayManager.connect() }) { Icon( Icons.Default.Refresh, contentDescription = "Reconnect", - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) } } @@ -330,7 +333,7 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( value = newRelayUrl, @@ -338,7 +341,7 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { label = { Text("Add relay") }, placeholder = { Text("wss://relay.example.com") }, modifier = Modifier.weight(1f), - singleLine = true + singleLine = true, ) Button( onClick = { @@ -347,7 +350,7 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { newRelayUrl = "" } }, - enabled = newRelayUrl.isNotBlank() + enabled = newRelayUrl.isNotBlank(), ) { Text("Add") } @@ -357,12 +360,12 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { items(relayStatuses.values.toList()) { status -> RelayStatusCard( status = status, - onRemove = { relayManager.removeRelay(status.url) } + onRemove = { relayManager.removeRelay(status.url) }, ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt index 9046e2d1f..c94df538f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -26,7 +26,8 @@ import java.util.concurrent.TimeUnit object DesktopHttpClient { private val client: OkHttpClient by lazy { - OkHttpClient.Builder() + OkHttpClient + .Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt index c143b4d7b..34dd8db54 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc * Desktop-specific relay connection manager that configures OkHttp for websockets. * Delegates to the shared RelayConnectionManager from commons. */ -class DesktopRelayConnectionManager : RelayConnectionManager( - websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient) -) +class DesktopRelayConnectionManager : + RelayConnectionManager( + websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient), + ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 5ebb75994..9eb70e313 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -60,41 +60,43 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) { DisposableEffect(configuredRelays) { if (configuredRelays.isNotEmpty()) { val subId = "global-feed-${System.currentTimeMillis()}" - val filters = listOf( - Filter( - kinds = listOf(TextNoteEvent.KIND), - limit = 50, + val filters = + listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + limit = 50, + ), ) - ) relayManager.subscribe( subId = subId, filters = filters, relays = configuredRelays, - listener = object : IRequestListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List? - ) { - if (event.id !in seenIds) { - seenIds.add(event.id) - events.add(0, event) - if (events.size > 200) { - val removed = events.removeAt(events.size - 1) - seenIds.remove(removed.id) + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.id !in seenIds) { + seenIds.add(event.id) + events.add(0, event) + if (events.size > 200) { + val removed = events.removeAt(events.size - 1) + seenIds.remove(removed.id) + } } } - } - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List? - ) { - // End of stored events - } - } + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // End of stored events + } + }, ) onDispose { @@ -109,7 +111,7 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) { FeedHeader( title = "Global Feed", connectedRelayCount = connectedRelays.size, - onRefresh = { relayManager.connect() } + onRefresh = { relayManager.connect() }, ) Spacer(Modifier.height(16.dp)) @@ -120,12 +122,12 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) { LoadingState("Loading notes...") } else { LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(events, key = { it.id }) { event -> // Use NoteCard from commons NoteCard( - note = event.toNoteDisplayData() + note = event.toNoteDisplayData(), ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index 28632b99b..68761246e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 From d2164118bc5cf995a5a6e5a5b50a904eacc02653 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 28 Dec 2025 15:59:11 +0000 Subject: [PATCH 40/44] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 2 +- amethyst/src/main/res/values-de-rDE/strings.xml | 2 +- amethyst/src/main/res/values-pt-rBR/strings.xml | 2 +- amethyst/src/main/res/values-sv-rSE/strings.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 8177aed50..c32931f87 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -158,6 +158,7 @@ Nahrajte zprávu Nahrajte zprávu Stiskněte a podržte pro nahrání zprávy + Znovu nahrát Nahrávání Nahrávání %1$s Nahrávání… @@ -1183,5 +1184,4 @@ Broadcast sady doporučení Smazat seznam Smazat sadu doporučení - Znovu nahrát diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index d5904f0be..2073348b2 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -160,6 +160,7 @@ erie gespeichert Eine Nachricht aufnehmen Eine Nachricht aufnehmen Zum Aufnehmen einer Nachricht gedrückt halten + Neu aufnehmen Aufnahme Aufnahme %1$s Hochladen… @@ -1188,5 +1189,4 @@ anz der Bedingungen ist erforderlich Empfehlungspaket veröffentlichen Liste löschen Empfehlungspaket löschen - Neu aufnehmen diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 6ad5d17cf..6cbc364be 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -158,6 +158,7 @@ Gravar uma mensagem Gravar uma mensagem Clique e segure para gravar uma mensagem + Gravar novamente Gravando Gravando %1$s Enviando… @@ -1183,5 +1184,4 @@ Transmitir pacote de recomendações Excluir lista Excluir pacote de recomendações - Gravar novamente diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index d862ca5f1..6939e8824 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -158,6 +158,7 @@ Spela in ett meddelande Spela in ett meddelande Tryck och håll in för att spela in ett meddelande + Spela in igen Spelar in Spelar in %1$s Laddar upp… @@ -1182,5 +1183,4 @@ Sänd rekommendationspaket Ta bort lista Ta bort rekommendationspaket - Spela in igen From ff0466872ac6877ad19e200a958a668cabb5dbc5 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Mon, 29 Dec 2025 06:26:25 -0300 Subject: [PATCH 41/44] Create PaymentTargetEvent --- .../experimental/nipA3/PaymentTarget.kt | 26 ++++++++ .../experimental/nipA3/PaymentTargetTag.kt | 47 ++++++++++++++ .../experimental/nipA3/PaymentTargetsEvent.kt | 62 +++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + 4 files changed, 137 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt new file mode 100644 index 000000000..f0b4a6131 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt @@ -0,0 +1,26 @@ +/** + * 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.experimental.nipA3 + +class PaymentTarget( + val type: String, + val authority: String, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt new file mode 100644 index 000000000..422b8e494 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt @@ -0,0 +1,47 @@ +/** + * 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.experimental.nipA3 + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class PaymentTargetTag { + companion object { + const val TAG_NAME = "payto" + + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun notMatch(tag: Array) = !(tag.has(0) && tag[0] == TAG_NAME) + + fun parse(tag: Array): PaymentTarget? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val paymentTarget = PaymentTarget(tag[1], tag[2]) + + return paymentTarget + } + + fun assemble(paymentTarget: PaymentTarget) = arrayOf(TAG_NAME, paymentTarget.type, paymentTarget.authority) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt new file mode 100644 index 000000000..204e57f87 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt @@ -0,0 +1,62 @@ +/** + * 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.experimental.nipA3 + +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +class PaymentTargetsEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 10133 + + suspend fun updatePaymentTargets( + earlierVersion: PaymentTargetsEvent, + targets: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PaymentTargetsEvent { + val tags = + earlierVersion.tags + .filter(PaymentTargetTag::notMatch) + .plus(targets.map { PaymentTargetTag.assemble(it) }) + .toTypedArray() + + return signer.sign(createdAt, KIND, tags, earlierVersion.content) + } + + fun createPaymentTargets(targets: List) = targets.map { PaymentTargetTag.assemble(it) }.toTypedArray() + + suspend fun create( + targets: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PaymentTargetsEvent = signer.sign(createdAt, KIND, createPaymentTargets(targets), "") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 43e304b18..ef11c9d3f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent @@ -272,6 +273,7 @@ class EventFactory { NIP90UserDiscoveryRequestEvent.KIND -> NIP90UserDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig) NIP90UserDiscoveryResponseEvent.KIND -> NIP90UserDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig) + PaymentTargetsEvent.KIND -> PaymentTargetsEvent(id, pubKey, createdAt, tags, content, sig) PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig) PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) From 31d7e135cb047590ca41d78adfe8a0dc40df416c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 29 Dec 2025 14:23:48 -0500 Subject: [PATCH 42/44] Updates kotlin, compose, multiplatform, activity, serialization, media3, mockk, secp256, tor, androidxCamera, stdlib --- .../amethyst/model/UiSettingsFlow.kt | 34 +++--- .../amethyst/ui/tor/TorSettingsFlow.kt | 2 +- gradle/libs.versions.toml | 24 ++--- .../quartz/nip03Timestamp/ots/Merkle.kt | 2 +- .../quartz/nip03Timestamp/ots/Timestamp.kt | 4 +- .../quartz/nip10Notes/tags/Positional.kt | 57 ++++------ .../quartz/nip17Dm/base/BaseDMGroupEvent.kt | 2 +- .../quartz/nip17Dm/base/NIP17Group.kt | 2 +- .../admin/ChannelMetadataEvent.kt | 2 +- .../jackson/EventDeserializerTest.kt | 2 +- .../quartz/nip19Bech32/NIP19ParserTest.kt | 102 +++++++++--------- .../nip46RemoteSigner/BunkerRequestTest.kt | 3 +- 12 files changed, 111 insertions(+), 125 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index e51f89b14..d1d4303a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -36,23 +37,24 @@ class UiSettingsFlow( val featureSet: MutableStateFlow = MutableStateFlow(FeatureSetType.SIMPLIFIED), val gallerySet: MutableStateFlow = MutableStateFlow(ProfileGalleryType.CLASSIC), ) { + val listOfFlows: List> = + listOf>( + theme, + preferredLanguage, + automaticallyShowImages, + automaticallyStartPlayback, + automaticallyShowUrlPreview, + automaticallyHideNavigationBars, + automaticallyShowProfilePictures, + dontShowPushNotificationSelector, + dontAskForNotificationPermissions, + featureSet, + gallerySet, + ) + // emits at every change in any of the propertyes. - val propertyWatchFlow = - combine( - listOf( - theme, - preferredLanguage, - automaticallyShowImages, - automaticallyStartPlayback, - automaticallyShowUrlPreview, - automaticallyHideNavigationBars, - automaticallyShowProfilePictures, - dontShowPushNotificationSelector, - dontAskForNotificationPermissions, - featureSet, - gallerySet, - ), - ) { flows -> + val propertyWatchFlow: Flow = + combine(listOfFlows) { flows: Array -> UiSettings( flows[0] as ThemeType, flows[1] as String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt index 5c7c0a361..3b46a3272 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt @@ -40,7 +40,7 @@ class TorSettingsFlow( ) { // emits at every change in any of the properties. val propertyWatchFlow = - combine( + combine( listOf( torType, externalSocksPort, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b28210259..f2c9693c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanistAdaptive = "0.37.3" -composeMultiplatform = "1.7.1" -activityCompose = "1.12.1" +composeMultiplatform = "1.9.3" +activityCompose = "1.12.2" agp = "8.13.2" android-compileSdk = "36" android-minSdk = "26" @@ -14,40 +14,40 @@ benchmark = "1.4.1" benchmarkJunit4 = "1.4.1" biometricKtx = "1.2.0-alpha05" coil = "3.3.0" -composeBom = "2025.12.00" +composeBom = "2025.12.01" composeRuntimeAnnotation = "1.10.0" coreKtx = "1.17.0" datastore = "1.2.0" espressoCore = "3.7.0" -firebaseBom = "34.6.0" +firebaseBom = "34.7.0" fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.20.1" jna = "5.18.1" jtorctl = "0.4.5.7" junit = "4.13.2" -kotlin = "2.2.21" +kotlin = "2.3.0" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" kotlinxSerialization = "1.9.0" -kotlinxSerializationPlugin = "2.2.21" +kotlinxSerializationPlugin = "2.3.0" languageId = "17.0.6" lazysodiumAndroid = "5.2.0" lazysodiumJava = "5.2.0" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" -media3 = "1.8.0" -mockk = "1.14.6" +media3 = "1.9.0" +mockk = "1.14.7" kotlinx-coroutines-test = "1.10.2" navigationCompose = "2.9.6" okhttp = "5.3.2" runner = "1.7.0" rfc3986 = "0.1.2" -secp256k1KmpJniAndroid = "0.21.0" +secp256k1KmpJniAndroid = "0.22.0" securityCryptoKtx = "1.1.0" spotless = "8.1.0" -torAndroid = "0.4.8.21" +torAndroid = "0.4.8.21.1" translate = "17.0.3" unifiedpush = "3.0.10" urlDetector = "0.1.23" @@ -57,9 +57,9 @@ zoomable = "2.9.0" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" -androidxCamera = "1.5.1" +androidxCamera = "1.5.2" androidxCollection = "1.5.0" -kotlinStdlib = "2.2.21" +kotlinStdlib = "2.3.0" kotlinTest = "2.2.20" core = "1.7.0" mavenPublish = "0.35.0" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Merkle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Merkle.kt index 4d96a22d7..378b42f40 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Merkle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Merkle.kt @@ -47,7 +47,7 @@ object Merkle { // left.ops[OpAppend(right.msg)] = right_prepend_stamp // leftAppendStamp = left.ops.add(OpAppend(right.msg)) // Timestamp leftPrependStamp = left.add(new OpAppend(right.msg)); - left.ops.put(OpAppend(right.digest), rightPrependStamp!!) + left.ops[OpAppend(right.digest)] = rightPrependStamp // return rightPrependStamp.ops.add(unaryOpCls()) val res = rightPrependStamp.add(OpSHA256()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.kt index b44e59a73..92c611a8f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.kt @@ -453,12 +453,12 @@ class Timestamp( fun allTips(): MutableSet { val set: MutableSet = HashSet() - if (this.ops.size == 0) { + if (this.ops.isEmpty()) { set.add(this.digest) } for (entry in this.ops.entries) { - val ts: Timestamp = entry.value!! + val ts: Timestamp = entry.value // Op op = entry.getKey(); val subSet = ts.allTips() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt index 91884e339..0f68990b8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip10Notes.tags import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag /** * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the @@ -32,47 +33,25 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag * The tag to the root of the reply chain goes first. The tag to the reply event being responded * to goes last. The order for any other tag does not matter, so keep the relative order. */ -fun List.positionalMarkedTags( - tagName: String, - root: String?, - replyingTo: String?, - forkedFrom: String?, -) = sortedWith { o1, o2 -> - when { - o1 == o2 -> 0 - o1 == root -> -1 // root goes first - o2 == root -> 1 // root goes first - o1 == replyingTo -> 1 // reply event being responded to goes last - o2 == replyingTo -> -1 // reply event being responded to goes last - else -> 0 // keep the relative order for any other tag - } -}.map { - when (it) { - root -> arrayOf(tagName, it, "", MarkedETag.MARKER.ROOT) - replyingTo -> arrayOf(tagName, it, "", MarkedETag.MARKER.REPLY) - forkedFrom -> arrayOf(tagName, it, "", MarkedETag.MARKER.FORK) - else -> arrayOf(tagName, it) - } -} - fun List.positionalMarkedTags( root: ETag?, replyingTo: ETag?, forkedFrom: ETag?, -) = sortedWith { o1, o2 -> - when { - o1.eventId == o2.eventId -> 0 - o1.eventId == root?.eventId -> -1 // root goes first - o2.eventId == root?.eventId -> 1 // root goes first - o1.eventId == replyingTo?.eventId -> 1 // reply event being responded to goes last - o2.eventId == replyingTo?.eventId -> -1 // reply event being responded to goes last - else -> 0 // keep the relative order for any other tag +): List = + sortedWith { o1, o2 -> + when { + o1.eventId == o2.eventId -> 0 + o1.eventId == root?.eventId -> -1 // root goes first + o2.eventId == root?.eventId -> 1 // root goes first + o1.eventId == replyingTo?.eventId -> 1 // reply event being responded to goes last + o2.eventId == replyingTo?.eventId -> -1 // reply event being responded to goes last + else -> 0 // keep the relative order for any other tag + } + }.map { + when (it.eventId) { + root?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.ROOT, it.author) + replyingTo?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.REPLY, it.author) + forkedFrom?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.FORK, it.author) + else -> it + } } -}.map { - when (it.eventId) { - root?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.ROOT, it.author) - replyingTo?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.REPLY, it.author) - forkedFrom?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.FORK, it.author) - else -> it - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt index b47e875ce..0e2d3da7c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt @@ -69,7 +69,7 @@ open class BaseDMGroupEvent( return result } - override fun isIncluded(pubKey: HexKey) = pubKey == this.pubKey || tags.any(PTag::isTagged, pubKey) + override fun isIncluded(user: HexKey) = pubKey == this.pubKey || tags.any(PTag::isTagged, pubKey) override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt index 0beca6658..6f3a49d68 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip17Dm.base import com.vitorpamplona.quartz.nip01Core.core.HexKey interface NIP17Group { - fun isIncluded(pubKey: HexKey): Boolean + fun isIncluded(user: HexKey): Boolean fun groupMembers(): Set } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt index 02ead09a9..9f6ffd5f9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt @@ -70,7 +70,7 @@ class ChannelMetadataEvent( if (content.isEmpty() || !content.startsWith("{") || isEncrypted()) { ChannelDataNorm() } else { - ChannelData.parse(content).normalize() ?: ChannelDataNorm() + ChannelData.parse(content).normalize() } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializerTest.kt index b082372ba..d061ebd8f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializerTest.kt @@ -57,7 +57,7 @@ class EventDeserializerTest { val templateJson = """{"kind":1,"content":"This is an unsigned event.","created_at":1234,"tags":[]}""" val template = EventTemplate.fromJson(templateJson) - val signedEvent = signer.signerSync.sign(template)!! + val signedEvent = signer.signerSync.sign(template) assertTrue(signedEvent.id.isNotEmpty()) assertTrue(signedEvent.sig.isNotEmpty()) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt index 63198581c..58eb50846 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt @@ -107,8 +107,8 @@ class NIP19ParserTest { assertNotNull(actual) assertTrue(actual?.entity is NProfile) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", (actual?.entity as? NProfile)?.hex) - assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), (actual?.entity as? NProfile)?.relay?.first()) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", actual.entity.hex) + assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), actual.entity.relay?.first()) } @Test() @@ -116,7 +116,7 @@ class NIP19ParserTest { val actual = Nip19Parser.uriToRoute("nostr:nprofile1qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgwwaehxw309ahx7uewd3hkctcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uqzq9thu3vem5gvsc6f3l3uyz7c92h6lq56t9wws0zulzkrgc6nrvym5jfztf") assertTrue(actual?.entity is NProfile) - assertEquals("1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", (actual?.entity as? NProfile)?.hex) + assertEquals("1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", actual.entity.hex) } @Test() @@ -124,7 +124,7 @@ class NIP19ParserTest { val actual = Nip19Parser.uriToRoute("nostr:nevent1qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgwwaehxw309ahx7uewd3hkctcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq36amnwvaz7tmjv4kxz7fwvd5xjcmpvahhqmr9vfejucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshsz8thwden5te0dehhxarj9ekh2arfdeuhwctvd3jhgtnrdakj7qg3waehxw309ucngvpwvcmh5tnfduhszythwden5te0dehhxarj9emkjmn99uq3jamnwvaz7tmhv4kxxmmdv5hxummnw3ezuamfdejj7qpqvsup5xk3e2quedxjvn2gjppc0lqny5dmnr2ypc9tftwmdxta0yjqrd6n50") assertTrue(actual?.entity is NEvent) - assertEquals("64381a1ad1ca81ccb4d264d48904387fc13251bb98d440e0ab4addb6997d7924", (actual?.entity as? NEvent)?.hex) + assertEquals("64381a1ad1ca81ccb4d264d48904387fc13251bb98d440e0ab4addb6997d7924", actual.entity.hex) } @Test() @@ -132,7 +132,7 @@ class NIP19ParserTest { val actual = Nip19Parser.uriToRoute("nostr:naddr1qqyxzmt9w358jum5qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypd7v3r24z33cydnk3fmlrd0exe5dlej3506zxs05q4puerp765mzqcyqqq8scsq6mk7u") assertTrue(actual?.entity is NAddress) - assertEquals("30818:5be6446aa8a31c11b3b453bf8dafc9b346ff328d1fa11a0fa02a1e6461f6a9b1:amethyst", (actual?.entity as? NAddress)?.aTag()) + assertEquals("30818:5be6446aa8a31c11b3b453bf8dafc9b346ff328d1fa11a0fa02a1e6461f6a9b1:amethyst", actual.entity.aTag()) } @Test @@ -141,9 +141,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute( "nostr:naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", ) + assertNotNull(result) assertEquals( "30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:", - (result?.entity as? NAddress)?.aTag(), + (result.entity as? NAddress)?.aTag(), ) } @@ -153,9 +154,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute( "nostr:naddr1qqqqzxthwden5te0wfjkccte9ejxjanfdejjuanfv3jk7tczyrv4428upmlcujyf2fy4hqrynywj07ukakr99ufvmmw95n5tttj5qqcyqqqgt0qeresua", ) + assertNotNull(result) assertEquals( "34236:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:", - (result?.entity as? NAddress)?.aTag(), + (result.entity as? NAddress)?.aTag(), ) } @@ -165,9 +167,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute( "nostr:naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", ) + assertNotNull(result) assertEquals( "30023:d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c:guide-wireguard", - (result?.entity as? NAddress)?.aTag(), + (result.entity as? NAddress)?.aTag(), ) } @@ -177,12 +180,13 @@ class NIP19ParserTest { Nip19Parser.uriToRoute( "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", ) - assertTrue(result?.entity is NAddress) + assertNotNull(result) + assertTrue(result.entity is NAddress) assertEquals( "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", - (result?.entity as? NAddress)?.aTag(), + result.entity.aTag(), ) - assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), (result?.entity as? NAddress)?.relay?.get(0)) + assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result.entity.relay[0]) } @Test @@ -261,11 +265,11 @@ class NIP19ParserTest { assertNotNull(result) assertEquals( "31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", - result?.aTag(), + result.aTag(), ) - assertEquals(true, result?.relay?.isEmpty()) - assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) - assertEquals(31337, result?.kind) + assertEquals(true, result.relay?.isEmpty()) + assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result.author) + assertEquals(31337, result.kind) } @Test @@ -279,11 +283,11 @@ class NIP19ParserTest { assertNotNull(result) assertEquals( "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", - result?.aTag(), + result.aTag(), ) - assertEquals(true, result?.relay?.isEmpty()) - assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023, result?.kind) + assertEquals(true, result.relay?.isEmpty()) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author) + assertEquals(30023, result.kind) } @Test @@ -297,11 +301,11 @@ class NIP19ParserTest { assertNotNull(result) assertEquals( "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", - result?.aTag(), + result.aTag(), ) - assertEquals(true, result?.relay?.isEmpty()) - assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023, result?.kind) + assertEquals(true, result.relay?.isEmpty()) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author) + assertEquals(30023, result.kind) } @Test @@ -310,10 +314,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute("nostr:nevent1qqsdw6xpk28tjnrajz4xhy2jqg0md8ywxj6997rsutjzxs0207tedjspz4mhxue69uhhyetvv9ujumn0wd68ytnzvuhsygx2crjrydvqdksffurc0fdsfc566pxtrg78afw0v8kursecwdqg9vpsgqqqqqqsnknas6")?.entity as? NEvent assertNotNull(result) - assertEquals("d768c1b28eb94c7d90aa6b9152021fb69c8e34b452f870e2e42341ea7f9796ca", result?.hex) - assertEquals("wss://relay.nostr.bg/", result?.relay?.firstOrNull()?.url) - assertEquals("cac0e43235806da094f0787a5b04e29ad04cb1a3c7ea5cf61edc1c338734082b", result?.author) - assertEquals(1, result?.kind) + assertEquals("d768c1b28eb94c7d90aa6b9152021fb69c8e34b452f870e2e42341ea7f9796ca", result.hex) + assertEquals("wss://relay.nostr.bg/", result.relay.firstOrNull()?.url) + assertEquals("cac0e43235806da094f0787a5b04e29ad04cb1a3c7ea5cf61edc1c338734082b", result.author) + assertEquals(1, result.kind) } @Test @@ -322,10 +326,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy")?.entity as? NEvent assertNotNull(result) - assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result?.hex) - assertEquals(true, result?.relay?.isEmpty()) - assertEquals(null, result?.author) - assertEquals(null, result?.kind) + assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result.hex) + assertEquals(true, result.relay.isEmpty()) + assertEquals(null, result.author) + assertEquals(null, result.kind) } @Test @@ -334,10 +338,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute("nostr:nevent1qqsfvaa2w3nkw472lt2ezr6x5x347k8hht398vp7hrl6wrdjldry86sprfmhxue69uhhyetvv9ujuam9wd6x2unwvf6xxtnrdaks5myyah")?.entity as? NEvent assertNotNull(result) - assertEquals("9677aa74676757cafad5910f46a1a35f58f7bae253b03eb8ffa70db2fb4643ea", result?.hex) - assertEquals("wss://relay.westernbtc.com/", result?.relay?.firstOrNull()?.url) - assertEquals(null, result?.author) - assertEquals(null, result?.kind) + assertEquals("9677aa74676757cafad5910f46a1a35f58f7bae253b03eb8ffa70db2fb4643ea", result.hex) + assertEquals("wss://relay.westernbtc.com/", result.relay.firstOrNull()?.url) + assertEquals(null, result.author) + assertEquals(null, result.kind) } @Test @@ -349,13 +353,13 @@ class NIP19ParserTest { )?.entity as? NEvent assertNotNull(result) - assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) + assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result.hex) assertEquals( NormalizedRelayUrl("wss://nostr.mom/"), - result?.relay?.get(0), + result.relay?.get(0), ) - assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) - assertEquals(1, result?.kind) + assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result.author) + assertEquals(1, result.kind) } @Test @@ -367,10 +371,10 @@ class NIP19ParserTest { )?.entity as? NEvent assertNotNull(result) - assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) - assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result.relay.get(0)) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1, result?.kind) + assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result.hex) + assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result.relay[0]) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author) + assertEquals(1, result.kind) } @Test @@ -382,10 +386,10 @@ class NIP19ParserTest { )?.entity as? NEvent assertNotNull(result) - assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex) + assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result.hex) assertEquals( "wss://nos.lol/,wss://nostr.oxtr.dev/,wss://relay.nostr.bg/,wss://nostr.einundzwanzig.space/,wss://relay.nostr.band/,wss://relay.damus.io/", - result?.relay?.joinToString(",") { it.url }, + result.relay.joinToString(",") { it.url }, ) } @@ -398,10 +402,10 @@ class NIP19ParserTest { )?.entity as? NEvent assertNotNull(result) - assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) - assertEquals("wss://relay.damus.io/", result?.relay?.get(0)?.url) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1, result?.kind) + assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result.hex) + assertEquals("wss://relay.damus.io/", result.relay.get(0)?.url) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author) + assertEquals(1, result.kind) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt index fe6c4f7a9..32d82c516 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue class BunkerRequestTest { @@ -31,6 +32,6 @@ class BunkerRequestTest { val bunkerRequest = OptimizedJsonMapper.fromJsonTo(requestJson) assertTrue(bunkerRequest is BunkerRequestSign) - assertTrue((bunkerRequest as BunkerRequestSign).event.kind == 1) + assertEquals(1, bunkerRequest.event.kind) } } From d586c39008edc39ca93bdb571f6977245a1512e0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 29 Dec 2025 16:21:19 -0500 Subject: [PATCH 43/44] Getting rid of some idea files in the repo --- .gitignore | 3 + .idea/AndroidProjectSystem.xml | 6 -- .idea/deviceManager.xml | 13 ---- .idea/inspectionProfiles/Project_Default.xml | 77 -------------------- .idea/kotlinNotebook.xml | 6 -- .idea/kotlinc.xml | 2 +- 6 files changed, 4 insertions(+), 103 deletions(-) delete mode 100644 .idea/AndroidProjectSystem.xml delete mode 100644 .idea/deviceManager.xml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/kotlinNotebook.xml diff --git a/.gitignore b/.gitignore index b4fc788e4..e1e4e4ec7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ /.idea/kotlinNotebook.xml /.idea/ChatHistory_schema_v3.xml /.idea/markdown.xml +/.idea/AndroidProjectSystem.xml +/.idea/deviceManager.xml +/.idea/inspectionProfiles/ .DS_Store /build /captures diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml deleted file mode 100644 index 4a53bee8c..000000000 --- a/.idea/AndroidProjectSystem.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml deleted file mode 100644 index 91f95584d..000000000 --- a/.idea/deviceManager.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index bb270b80a..000000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/kotlinNotebook.xml b/.idea/kotlinNotebook.xml deleted file mode 100644 index 665e38dae..000000000 --- a/.idea/kotlinNotebook.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index bdfaa5e06..69cc381c0 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -7,6 +7,6 @@ - \ No newline at end of file From e9d07b393955dbfff176047dc50389e0e446dd66 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 29 Dec 2025 17:37:43 -0500 Subject: [PATCH 44/44] Aligns Android studio with gradle files --- .idea/kotlinc.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 69cc381c0..26a6b8f2a 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -4,7 +4,11 @@ - + +