Early support for Markdown and Long Form Text

This commit is contained in:
Vitor Pamplona 2023-03-01 19:18:07 -05:00
parent 64debb79fc
commit d883cc32f5
9 changed files with 315 additions and 52 deletions

View File

@ -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'

View File

@ -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<String> {
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<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(event.content)
@ -198,6 +244,17 @@ object LocalCache {
}
}
return citations
}
private fun replyToWithoutCitations(event: TextNoteEvent): List<String> {
val citations = findCitations(event)
return event.replyTos.filter { it !in citations }
}
private fun replyToWithoutCitations(event: LongTextNoteEvent): List<String> {
val citations = findCitations(event)
return event.replyTos.filter { it !in citations }
}

View File

@ -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) {

View File

@ -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
)

View File

@ -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<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val replyTos: List<String>
@Transient val mentions: List<String>
@Transient val title: String?
@Transient val image: String?
@Transient val summary: String?
@Transient val publishedAt: Long?
@Transient val topics: List<String>
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<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LongTextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = mutableListOf<List<String>>()
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)
}
}
}

View File

@ -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),
)
}
}
}
}

View File

@ -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<Note>() {
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

View File

@ -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)

View File

@ -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