From 74e44f1256355d082cbbc1a01df46d3c2c91c541 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 15:46:18 +0000 Subject: [PATCH] feat: add CodeSnippetEvent (NIP-C0) rendering to NoteCompose and ThreadFeedView - Create CodeSnippet.kt with RenderCodeSnippetEvent (feed card view) and RenderCodeSnippetHeaderForThread (full thread view) - Feed card shows: filename/language badge, description, 5-line code preview in a monospace box with rounded border - Thread header shows: full code in scrollable monospace block plus metadata (license, runtime, deps) - Add is CodeSnippetEvent branch to NoteCompose.kt RenderNoteRow when-statement - Add is CodeSnippetEvent branch to ThreadFeedView.kt header when-statement https://claude.ai/code/session_01X6jjsMLGBUmcaWXHJKmrEn --- .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/types/CodeSnippet.kt | 240 ++++++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 3 + 3 files changed, 249 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CodeSnippet.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index d845d22fa..6850196a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -101,6 +101,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetEvent import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile @@ -242,6 +243,7 @@ import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -790,6 +792,10 @@ private fun RenderNoteRow( RenderLongFormContent(baseNote, accountViewModel, nav) } + is CodeSnippetEvent -> { + RenderCodeSnippetEvent(baseNote) + } + is WikiNoteEvent -> { RenderWikiContent(baseNote, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CodeSnippet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CodeSnippet.kt new file mode 100644 index 000000000..8d30c3812 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CodeSnippet.kt @@ -0,0 +1,240 @@ +/* + * 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.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent + +@Composable +fun RenderCodeSnippetEvent( + baseNote: Note, +) { + val noteEvent = baseNote.event as? CodeSnippetEvent ?: return + + val name = + remember(noteEvent) { + noteEvent.snippetName() + ?: noteEvent.language()?.let { "$it snippet" } + ?: "Code Snippet" + } + val language = remember(noteEvent) { noteEvent.language() ?: noteEvent.extension() } + val description = remember(noteEvent) { noteEvent.snippetDescription() } + val codePreview = remember(noteEvent) { noteEvent.content.lines().take(5).joinToString("\n") } + + Column(MaterialTheme.colorScheme.replyModifier) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + language?.let { + Spacer(modifier = StdHorzSpacer) + Box( + modifier = + Modifier + .clip(QuoteBorder) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + description?.ifBlank { null }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdVertSpacer) + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(10.dp) + .clip(QuoteBorder) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f)) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), QuoteBorder) + .padding(10.dp), + ) { + Text( + text = codePreview, + style = + TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + ), + maxLines = 5, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +fun RenderCodeSnippetHeaderForThread( + noteEvent: CodeSnippetEvent, +) { + val name = + remember(noteEvent) { + noteEvent.snippetName() + ?: noteEvent.language()?.let { "$it snippet" } + ?: "Code Snippet" + } + val language = remember(noteEvent) { noteEvent.language() ?: noteEvent.extension() } + val description = remember(noteEvent) { noteEvent.snippetDescription() } + val license = remember(noteEvent) { noteEvent.license() } + val runtime = remember(noteEvent) { noteEvent.runtime() } + val deps = remember(noteEvent) { noteEvent.deps() } + + Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = name, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + language?.let { + Spacer(modifier = StdHorzSpacer) + Box( + modifier = + Modifier + .clip(QuoteBorder) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = it, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + description?.ifBlank { null }?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = StdVertSpacer) + + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(QuoteBorder) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f)) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), QuoteBorder) + .padding(12.dp), + ) { + Text( + text = noteEvent.content, + style = + TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + ), + ) + } + + license?.let { + Spacer(modifier = StdVertSpacer) + Text( + text = "License: $it", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + runtime?.let { + Text( + text = "Runtime: $it", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (deps.isNotEmpty()) { + Text( + text = "Deps: ${deps.joinToString(", ")}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index e3366ed3a..62e5657f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -142,6 +142,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile +import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetHeaderForThread import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent @@ -248,6 +249,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -532,6 +534,7 @@ private fun FullBleedNoteCompose( is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent, baseNote, accountViewModel) is WikiNoteEvent -> RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) is ClassifiedsEvent -> RenderClassifiedsReaderForThread(noteEvent, baseNote, accountViewModel, nav) + is CodeSnippetEvent -> RenderCodeSnippetHeaderForThread(noteEvent) } Row(