diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2a0a11662..d80373eaf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -396,10 +396,10 @@ class Account( ) Client.send(data) - LocalCache.consume(data) + LocalCache.consume(data, null) Client.send(signedEvent) - LocalCache.consume(signedEvent) + LocalCache.consume(signedEvent, null) return LocalCache.notes[signedEvent.id] } @@ -418,7 +418,7 @@ class Account( ) Client.send(signedEvent) - LocalCache.consume(signedEvent) + LocalCache.consume(signedEvent, null) return LocalCache.notes[signedEvent.id] } 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 d2e76ffab..a368fa75b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -653,40 +653,72 @@ object LocalCache { refreshObservers(note) } - fun consume(event: FileHeaderEvent) { + fun consume(event: FileHeaderEvent, relay: Relay?) { val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } // Already processed this event. if (note.event != null) return - val author = getOrCreateUser(event.pubKey) - note.loadEvent(event, author, emptyList()) refreshObservers(note) } - fun consume(event: FileStorageHeaderEvent) { + fun consume(event: FileStorageHeaderEvent, relay: Relay?) { val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } // Already processed this event. if (note.event != null) return - val author = getOrCreateUser(event.pubKey) - note.loadEvent(event, author, emptyList()) refreshObservers(note) } - fun consume(event: FileStorageEvent) { + fun consume(event: HighlightEvent, relay: Relay?) { val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } // Already processed this event. if (note.event != null) return + note.loadEvent(event, author, emptyList()) + + // Adds to user profile + author.addNote(note) + + refreshObservers(note) + } + + fun consume(event: FileStorageEvent, relay: Relay?) { + val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + note.loadEvent(event, author, emptyList()) refreshObservers(note) 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 b5789a178..8335a7177 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -76,9 +76,10 @@ abstract class NostrDataSource(val debugName: String) { is ContactListEvent -> LocalCache.consume(event) is DeletionEvent -> LocalCache.consume(event) - is FileHeaderEvent -> LocalCache.consume(event) - is FileStorageEvent -> LocalCache.consume(event) - is FileStorageHeaderEvent -> LocalCache.consume(event) + is FileHeaderEvent -> LocalCache.consume(event, relay) + is FileStorageEvent -> LocalCache.consume(event, relay) + is FileStorageHeaderEvent -> LocalCache.consume(event, relay) + is HighlightEvent -> LocalCache.consume(event, relay) is LnZapEvent -> { event.zapRequest?.let { onEvent(it, subscriptionId, relay) } LocalCache.consume(event) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt index dbcb987e6..ebf3425db 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent @@ -12,7 +13,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") { fun createGlobalFilter() = TypedFilter( types = setOf(FeedType.GLOBAL), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), limit = 200 ) ) 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 b07eab060..5f855bc7c 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.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent @@ -55,7 +56,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), authors = followSet, limit = 400, since = latestEOSEs.users[account.userProfile()]?.relayList @@ -71,7 +72,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind), tags = mapOf( "t" to hashToLoad.map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index 29ba72306..a7262a2f2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -60,7 +60,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind, HighlightEvent.kind), search = mySearchString, limit = 20 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 85746e500..2e3123c31 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -79,7 +79,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, - PollNoteEvent.kind + PollNoteEvent.kind, + HighlightEvent.kind ), tags = mapOf("e" to listOf(it.idHex)), since = it.lastReactionsDownloadTime @@ -117,7 +118,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, PrivateDmEvent.kind, - FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind + FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind, + HighlightEvent.kind ), ids = interestedEvents.toList() ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 19671718a..23e2196e4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -35,7 +35,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind), authors = listOf(it.pubkeyHex), limit = 200 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 33c09bbe5..508f36487 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -41,6 +41,8 @@ open class Event( fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } + fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } + override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1) fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull { @@ -227,6 +229,7 @@ open class Event( FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig) FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig) + HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt new file mode 100644 index 000000000..1d3d1acf6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt @@ -0,0 +1,36 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class HighlightEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { + + fun inUrl() = taggedUrls().firstOrNull() + fun author() = taggedUsers().firstOrNull() + fun quote() = content + + companion object { + const val kind = 9802 + + fun create( + msg: String, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): PollNoteEvent { + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = mutableListOf>() + val id = generateId(pubKey, createdAt, kind, tags, msg) + val sig = Utils.sign(id, privateKey) + return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) + } + } +} 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 25e05d8eb..2cdac42f4 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 @@ -86,7 +86,8 @@ fun RichTextViewer( navController: NavController ) { Column(modifier = modifier) { - if (content.startsWith("# ") || + if (content.startsWith("> ") || + content.startsWith("# ") || content.contains("##") || content.contains("**") || content.contains("__") || 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 d31a45c68..b5f62086e 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.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.RepostEvent @@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() { return collection .asSequence() .filter { it -> - (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) && + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) && // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable it.author?.let { !account.isHidden(it.pubkeyHex) } ?: 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 0f2237fd5..6732bd98f 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 @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.theme.Following import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.math.BigDecimal +import java.net.URL import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @@ -517,6 +518,26 @@ fun NoteComposeInner( ReactionsRow(note, accountViewModel, navController) } + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) + } else if (noteEvent is HighlightEvent) { + DisplayHighlight( + noteEvent.quote(), + noteEvent.author(), + noteEvent.inUrl(), + makeItShort, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + + if (!makeItShort) { + ReactionsRow(note, accountViewModel, navController) + } + Divider( modifier = Modifier.padding(top = 10.dp), thickness = 0.25.dp @@ -574,6 +595,66 @@ fun NoteComposeInner( } } +@Composable +fun DisplayHighlight( + highlight: String, + authorHex: String?, + url: String?, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val quote = highlight.split("\n").map { "> *${it.removeSuffix(" ")}*" }.joinToString("\n") + + if (quote != null) { + TranslatableRichTextViewer( + quote, + canPreview = canPreview && !makeItShort, + Modifier.fillMaxWidth(), + emptyList(), + backgroundColor, + accountViewModel, + navController + ) + } + + FlowRow() { + authorHex?.let { authorHex -> + val userBase = LocalCache.checkGetOrCreateUser(authorHex) + + if (userBase != null) { + val userState by userBase.live().metadata.observeAsState() + val user = userState?.user + + if (user != null) { + CreateClickableText( + user.toBestDisplayName(), + "", + "User/${user.pubkeyHex}", + navController + ) + } + } + } + + url?.let { url -> + val validatedUrl = try { + URL(url) + } catch (e: Exception) { + Log.w("Note Compose", "Invalid URI: $url") + null + } + + validatedUrl?.host?.let { host -> + Text("on ") + ClickableUrl(urlText = host, url = url) + } + } + } +} + @Composable fun DisplayFollowingHashtagsInPost( noteEvent: EventInterface, 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 2b3a6b466..045ce13d8 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 @@ -54,6 +54,7 @@ import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent +import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status @@ -348,33 +349,46 @@ fun NoteMaster( ) ) { Column() { - val eventContent = note.event?.content() - - val canPreview = note.author == account.userProfile() || - (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - - if (eventContent != null) { - TranslatableRichTextViewer( - eventContent, - canPreview, - Modifier.fillMaxWidth(), - note.event?.tags(), - MaterialTheme.colors.background, + if (noteEvent is HighlightEvent) { + DisplayHighlight( + noteEvent.quote(), + noteEvent.author(), + noteEvent.inUrl(), + false, + true, + backgroundColor, accountViewModel, navController ) + } else { + val eventContent = note.event?.content() - DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + val canPreview = note.author == account.userProfile() || + (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) || + !noteForReports.hasAnyReports() - if (noteEvent is PollNoteEvent) { - PollNote( - note, + if (eventContent != null) { + TranslatableRichTextViewer( + eventContent, canPreview, - backgroundColor, + Modifier.fillMaxWidth(), + note.event?.tags(), + MaterialTheme.colors.background, accountViewModel, navController ) + + DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) + + if (noteEvent is PollNoteEvent) { + PollNote( + note, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + } } }