From d883cc32f5c1c22bf00235e0f7c1a63bae3bb437 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 1 Mar 2023 19:18:07 -0500 Subject: [PATCH] Early support for Markdown and Long Form Text --- app/build.gradle | 5 + .../amethyst/model/LocalCache.kt | 59 +++++++- .../amethyst/service/NostrDataSource.kt | 3 + .../amethyst/service/NostrHomeDataSource.kt | 3 +- .../service/model/LongTextNoteEvent.kt | 56 +++++++ .../amethyst/ui/components/RichTextViewer.kt | 139 ++++++++++++------ .../ui/dal/HomeNewThreadFeedFilter.kt | 3 +- .../amethyst/ui/note/NoteCompose.kt | 56 +++++++ .../amethyst/ui/screen/ThreadFeedView.kt | 43 ++++++ 9 files changed, 315 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt diff --git a/app/build.gradle b/app/build.gradle index 7f3db2c08..8389536da 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -130,6 +130,11 @@ dependencies { implementation 'androidx.camera:camera-lifecycle:1.2.1' implementation 'androidx.camera:camera-view:1.2.1' + // Markdown + implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0" + implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0" + implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0" + // For QR Scanning implementation 'com.google.mlkit:vision-common:17.3.0' diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index db37df801..8249c0507 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent @@ -184,7 +185,52 @@ object LocalCache { refreshObservers() } - private fun replyToWithoutCitations(event: TextNoteEvent): List { + fun consume(event: LongTextNoteEvent, relay: Relay?) { + if (antiSpam.isSpam(event)) { + relay?.let { + it.spamCounter++ + } + return + } + + val note = getOrCreateNote(event.id.toHex()) + val author = getOrCreateUser(event.pubKey.toHexKey()) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) } + val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } + + note.loadEvent(event, author, mentions, replyTo) + + //Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content?.take(100)} ${formattedDateTime(event.createdAt)}") + + // Prepares user's profile view. + author.addNote(note) + + // Adds notifications to users. + mentions.forEach { + it.addTaggedPost(note) + } + replyTo.forEach { + it.author?.addTaggedPost(note) + } + + // Counts the replies + replyTo.forEach { + it.addReply(note) + } + + refreshObservers() + } + + private fun findCitations(event: Event): Set { var citations = mutableSetOf() // Removes citations from replies: val matcher = tagSearch.matcher(event.content) @@ -198,6 +244,17 @@ object LocalCache { } } + return citations + } + + private fun replyToWithoutCitations(event: TextNoteEvent): List { + val citations = findCitations(event) + + return event.replyTos.filter { it !in citations } + } + + private fun replyToWithoutCitations(event: LongTextNoteEvent): List { + val citations = findCitations(event) return event.replyTos.filter { it !in citations } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index b127ca061..7d59dc02e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent @@ -90,6 +91,8 @@ abstract class NostrDataSource(val debugName: String) { ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay) ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) + + LongTextNoteEvent.kind -> LocalCache.consume(LongTextNoteEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay) } } } catch (e: Exception) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 3b1174124..48894e5cf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.TypedFilter import kotlinx.coroutines.Dispatchers @@ -47,7 +48,7 @@ object NostrHomeDataSource: NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind), authors = followSet, limit = 400 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt new file mode 100644 index 000000000..72c1d106f --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt @@ -0,0 +1,56 @@ +package com.vitorpamplona.amethyst.service.model + +import java.util.Date +import nostr.postr.Utils +import nostr.postr.events.Event + +class LongTextNoteEvent( + id: ByteArray, + pubKey: ByteArray, + createdAt: Long, + tags: List>, + content: String, + sig: ByteArray +): Event(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient val replyTos: List + @Transient val mentions: List + + @Transient val title: String? + @Transient val image: String? + @Transient val summary: String? + @Transient val publishedAt: Long? + @Transient val topics: List + + init { + replyTos = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + + topics = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } + title = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + image = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + summary = tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + publishedAt = try { + tags.filter { it.firstOrNull() == "published_at" }.mapNotNull { it.getOrNull(1) }.firstOrNull()?.toLong() + } catch (_: Exception) { + null + } + } + + companion object { + const val kind = 30023 + + fun create(msg: String, replyTos: List?, mentions: List?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LongTextNoteEvent { + val pubKey = Utils.pubkeyCreate(privateKey) + val tags = mutableListOf>() + replyTos?.forEach { + tags.add(listOf("e", it)) + } + mentions?.forEach { + tags.add(listOf("p", it)) + } + val id = generateId(pubKey, createdAt, kind, tags, msg) + val sig = Utils.sign(id, privateKey) + return LongTextNoteEvent(id, pubKey, createdAt, tags, msg, sig) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 6aa1edb8f..09620d90f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -2,24 +2,44 @@ package com.vitorpamplona.amethyst.ui.components import android.util.Patterns import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.LocalContentColor import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.contentColorFor import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.text.Paragraph +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.google.accompanist.flowlayout.FlowRow +import com.halilibo.richtext.markdown.Markdown +import com.halilibo.richtext.markdown.MarkdownParseOptions +import com.halilibo.richtext.ui.RichText +import com.halilibo.richtext.ui.RichTextStyle +import com.halilibo.richtext.ui.currentRichTextStyle +import com.halilibo.richtext.ui.material.MaterialRichText +import com.halilibo.richtext.ui.resolveDefaults +import com.halilibo.richtext.ui.string.RichTextString +import com.halilibo.richtext.ui.string.RichTextStringStyle import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.Nip19 @@ -60,61 +80,82 @@ fun RichTextViewer( accountViewModel: AccountViewModel, navController: NavController, ) { + Column(modifier = modifier.animateContentSize()) { - // FlowRow doesn't work well with paragraphs. So we need to split them - content.split('\n').forEach { paragraph -> - FlowRow() { - paragraph.split(' ').forEach { word: String -> + if (content.startsWith("# ") || content.contains("##") || content.contains("```")) { + var richTextStyle by remember { mutableStateOf(RichTextStyle().resolveDefaults()) } - if (canPreview) { - // Explicit URL - val lnInvoice = LnInvoiceUtil.findInvoice(word) - if (lnInvoice != null) { - InvoicePreview(lnInvoice) - } else if (isValidURL(word)) { - val removedParamsFromUrl = word.split("?")[0].toLowerCase() - if (imageExtension.matcher(removedParamsFromUrl).matches()) { - ZoomableImageView(word) - } else if (videoExtension.matcher(removedParamsFromUrl).matches()) { - VideoView(word) + MaterialRichText( + style = RichTextStyle().resolveDefaults().copy( + stringStyle = richTextStyle.stringStyle?.copy( + linkStyle = SpanStyle( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colors.primary + ) + ) + ), + ) { + Markdown( + content = content, + markdownParseOptions = MarkdownParseOptions.Default, + ) + } + } else { + // FlowRow doesn't work well with paragraphs. So we need to split them + content.split('\n').forEach { paragraph -> + FlowRow() { + paragraph.split(' ').forEach { word: String -> + + if (canPreview) { + // Explicit URL + val lnInvoice = LnInvoiceUtil.findInvoice(word) + if (lnInvoice != null) { + InvoicePreview(lnInvoice) + } else if (isValidURL(word)) { + val removedParamsFromUrl = word.split("?")[0].toLowerCase() + if (imageExtension.matcher(removedParamsFromUrl).matches()) { + ZoomableImageView(word) + } else if (videoExtension.matcher(removedParamsFromUrl).matches()) { + VideoView(word) + } else { + UrlPreview(word, word) + } + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + ClickableEmail(word) + } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { + ClickablePhone(word) + } else if (noProtocolUrlValidator.matcher(word).matches()) { + UrlPreview("https://$word", word) + } else if (tagIndex.matcher(word).matches() && tags != null) { + TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController) + } else if (isBechLink(word)) { + BechLink(word, navController) } else { - UrlPreview(word, word) + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) } - } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { - ClickableEmail(word) - } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { - ClickablePhone(word) - } else if (noProtocolUrlValidator.matcher(word).matches()) { - UrlPreview("https://$word", word) - } else if (tagIndex.matcher(word).matches() && tags != null) { - TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController) - } else if (isBechLink(word)) { - BechLink(word, navController) } else { - Text( - text = "$word ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) - } - } else { - if (isValidURL(word)) { - ClickableUrl("$word ", word) - } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { - ClickableEmail(word) - } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { - ClickablePhone(word) - } else if (noProtocolUrlValidator.matcher(word).matches()) { - ClickableUrl(word, "https://$word") - } else if (tagIndex.matcher(word).matches() && tags != null) { - TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController) - } else if (isBechLink(word)) { - BechLink(word, navController) - } else { - Text( - text = "$word ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) + if (isValidURL(word)) { + ClickableUrl("$word ", word) + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + ClickableEmail(word) + } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { + ClickablePhone(word) + } else if (noProtocolUrlValidator.matcher(word).matches()) { + ClickableUrl(word, "https://$word") + } else if (tagIndex.matcher(word).matches() && tags != null) { + TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController) + } else if (isBechLink(word)) { + BechLink(word, navController) + } else { + Text( + text = "$word ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index 34ca1b737..275eb41d4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import nostr.postr.events.TextNoteEvent @@ -14,7 +15,7 @@ object HomeNewThreadFeedFilter: FeedFilter() { return LocalCache.notes.values .filter { - (it.event is TextNoteEvent || it.event is RepostEvent) + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) && it.author in user.follows // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable && it.author?.let { !account.isHidden(it) } ?: true diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 8394155bf..ad043894f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -25,6 +26,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.navigation.NavController @@ -36,6 +38,7 @@ import com.vitorpamplona.amethyst.RoboHashCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent @@ -43,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Following import kotlin.time.ExperimentalTime @@ -337,6 +341,58 @@ fun NoteCompose( modifier = Modifier.padding(top = 40.dp), thickness = 0.25.dp ) + } else if (noteEvent is LongTextNoteEvent) { + Row( + modifier = Modifier + .clip(shape = RoundedCornerShape(15.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) + ) { + Column { + noteEvent.image?.let { + AsyncImage( + model = noteEvent.image, + contentDescription = stringResource( + R.string.preview_card_image_for, + noteEvent.image + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + } + + noteEvent.title?.let { + Text( + text = it, + style = MaterialTheme.typography.body2, + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + noteEvent.summary?.let { + Text( + text = it, + style = MaterialTheme.typography.caption, + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + ReactionsRow(note, accountViewModel) + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) } else { val eventContent = accountViewModel.decrypt(note) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 62cfc7ed4..ea386f65d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.google.accompanist.swiperefresh.SwipeRefresh @@ -51,6 +52,13 @@ import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.delay import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent @Composable fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { @@ -259,6 +267,41 @@ fun NoteMaster(baseNote: Note, } } + if (noteEvent is LongTextNoteEvent) { + Row(modifier = Modifier.padding(horizontal = 12.dp)) { + Column { + noteEvent.image?.let { + AsyncImage( + model = noteEvent.image, + contentDescription = stringResource( + R.string.preview_card_image_for, + noteEvent.image + ), + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + } + + noteEvent.title?.let { + Text( + text = it, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + ) + } + + noteEvent.summary?.let { + Text( + text = it + ) + } + } + } + } + Row(modifier = Modifier.padding(horizontal = 12.dp)) { Column() { val eventContent = note.event?.content