Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nostr feeds #844

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8c62ab3
Add Rhodium Nostr library.
KotlinGeekDev Feb 20, 2025
5e96a61
Introduce fetchNostrFeed(). Add code for parsing Nostr URI input.
KotlinGeekDev Feb 20, 2025
adb6941
Implement parser/mapper for Nostr article events.
KotlinGeekDev Feb 20, 2025
ca8fd20
Apply formatting fixes, and adjust some imports.
KotlinGeekDev Feb 20, 2025
4d974ec
Make sure timestamps are correctly converted before posts are saved. …
KotlinGeekDev Feb 20, 2025
acfc569
Merge pull request #1 from msasikanth/main
KotlinGeekDev Feb 21, 2025
80bb361
Bring in upstream updates
KotlinGeekDev Feb 24, 2025
184bd31
Merge upstream changes.
KotlinGeekDev Feb 24, 2025
9bb34c9
Merge remote-tracking branch 'origin/nostr-feeds' into nostr-feeds
KotlinGeekDev Feb 24, 2025
daf4e3c
Merge pull request #3 from msasikanth/main
KotlinGeekDev Feb 26, 2025
0d13606
Update Rhodium.
KotlinGeekDev Feb 26, 2025
ee00cac
Merge remote-tracking branch 'origin/nostr-feeds' into nostr-feeds
KotlinGeekDev Feb 26, 2025
d8028ae
Use a hack to determine timestamp nature(millis or seconds). Make Nos…
KotlinGeekDev Feb 26, 2025
6ba5ecb
Use one or more services when opening a Nostr article on the web.
KotlinGeekDev Feb 26, 2025
a9d5c14
Nostr articles don't need full content mode. Load already present pos…
KotlinGeekDev Feb 26, 2025
1317b9f
Merge branch 'msasikanth:main' into nostr-feeds
KotlinGeekDev Feb 27, 2025
8748c8f
Move the Nostr article URI transform handling to the correct place.
KotlinGeekDev Feb 27, 2025
d5d5941
Add markdown dependency. Add markdown transform for Nostr article con…
KotlinGeekDev Feb 27, 2025
7c001e2
Fix: Save the input nostr Uri as the feed link, to make syncing work.
KotlinGeekDev Feb 27, 2025
6f77f16
Use the correct default relays for the correct use-case. Apply stylin…
KotlinGeekDev Feb 27, 2025
47763e0
Make fetching more reliable.
KotlinGeekDev Feb 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/network/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ kotlin {
implementation(libs.kermit)
implementation(libs.crashkios.bugsnag)
api(libs.korlibs.string)
// Nostr
implementation("io.github.kotlingeekdev:rhodium:1.0-beta-17")
}
commonTest.dependencies { implementation(libs.kotlin.test) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,21 @@
*/
package dev.sasikanth.rss.reader.core.network.fetcher

import co.touchlab.kermit.Logger
import com.fleeksoft.ksoup.Ksoup
import com.fleeksoft.ksoup.parseSource
import dev.sasikanth.rss.reader.core.model.remote.FeedPayload
import dev.sasikanth.rss.reader.core.model.remote.PostPayload
import dev.sasikanth.rss.reader.core.network.parser.FeedParser
import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATOM_MEDIA_TYPE
import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_HREF
import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_TYPE
import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.RSS_MEDIA_TYPE
import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_LINK
import dev.sasikanth.rss.reader.core.network.utils.UrlUtils
import dev.sasikanth.rss.reader.core.network.utils.UrlUtils.isNostrUri
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
Expand All @@ -38,17 +43,35 @@ import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.asSource
import korlibs.io.lang.Charset
import korlibs.io.lang.Charsets
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import me.tatarka.inject.annotations.Inject
import rhodium.crypto.Nip19Parser
import rhodium.crypto.tlv.entity.NAddress
import rhodium.crypto.tlv.entity.NEvent
import rhodium.crypto.tlv.entity.NProfile
import rhodium.crypto.tlv.entity.NPub
import rhodium.net.NostrService
import rhodium.net.NostrUtils
import rhodium.net.UrlUtil
import rhodium.nostr.Event
import rhodium.nostr.NostrFilter
import rhodium.nostr.client.RequestMessage
import rhodium.nostr.relay.Relay

@Inject
class FeedFetcher(private val httpClient: HttpClient, private val feedParser: FeedParser) {

companion object {
private const val MAX_REDIRECTS_ALLOWED = 5
// The default relays to get info from, separated by purpose.
private val DEFAULT_FETCH_RELAYS = listOf("wss://relay.nostr.band", "wss://relay.damus.io")
private val DEFAULT_METADATA_RELAYS = listOf("wss://purplepag.es", "wss://user.kindpag.es")
private val DEFAULT_ARTICLE_FETCH_RELAYS = setOf("wss://nos.lol") + DEFAULT_FETCH_RELAYS
}

suspend fun fetch(url: String): FeedFetchResult {
return fetch(url, redirectCount = 0)
suspend fun fetch(url: String, transformUrl: Boolean = true): FeedFetchResult {
return if (url.isNostrUri()) fetchNostrFeed(url) else fetch(url, redirectCount = 0)
}

private suspend fun fetch(
Expand Down Expand Up @@ -160,4 +183,176 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe

return UrlUtils.safeUrl(host, link)
}

private suspend fun fetchNostrFeed(nostrUri: String): FeedFetchResult {
val rawNostrAddress = nostrUri.removePrefix("nostr:")
val nostrService = NostrService(client = httpClient.config { install(WebSockets) {} })
if (
rawNostrAddress.contains("@") || UrlUtil.isValidUrl(rawNostrAddress)
) { // It is a NIP05 address
val profileInfo = NostrUtils.getProfileInfoFromAddress(nip05 = rawNostrAddress, httpClient)
val profileIdentifier = profileInfo[0]
val potentialRelays = profileInfo.drop(1)

return innerFetchNostrFeed(nostrUri, profileIdentifier, potentialRelays, nostrService)
} else {
val parsedProfile = Nip19Parser.parse(rawNostrAddress)?.entity
return if (parsedProfile == null) {
FeedFetchResult.Error(Exception("Could not parse the input, as it is null"))
} else {
when (parsedProfile) {
is NPub -> {
innerFetchNostrFeed(nostrUri, parsedProfile.hex, DEFAULT_METADATA_RELAYS, nostrService)
}
is NProfile -> {
innerFetchNostrFeed(nostrUri, parsedProfile.hex, parsedProfile.relay, nostrService)
}
else ->
FeedFetchResult.Error(
Exception("Could not find any profile from the input : $parsedProfile")
)
}
}
}
}

private suspend fun innerFetchNostrFeed(
nostrUri: String,
profilePubKey: String,
profileRelays: List<String>,
nostrService: NostrService
): FeedFetchResult {
val authorInfoEvent =
try {
nostrService.getMetadataFor(
profileHex = profilePubKey,
preferredRelays = profileRelays.ifEmpty { DEFAULT_FETCH_RELAYS }
)
} catch (e: Exception) {
Logger.e("NostrFetcher", e)
nostrService.getMetadataFor(
profileHex = profilePubKey,
preferredRelays = DEFAULT_FETCH_RELAYS
)
}

if (authorInfoEvent.content.isBlank()) {
return FeedFetchResult.Error(
Exception("No corresponding author profile found for this Nostr address.")
)
} else {
val authorInfo = authorInfoEvent.userInfo()
Logger.i(
"NostrFetcher",
) {
"UserInfo: $authorInfo"
}

val userPublishRelays =
try {
nostrService
.fetchRelayListFor(
profileHex = profilePubKey,
fetchRelays = profileRelays.ifEmpty { DEFAULT_METADATA_RELAYS }
)
.filter { relay -> relay.writePolicy }
} catch (e: Exception) {
Logger.e("NostrFetcher", e)
nostrService.fetchRelayListFor(
profileHex = profilePubKey,
fetchRelays = DEFAULT_METADATA_RELAYS
)
}

val userArticlesRequest =
RequestMessage.singleFilterRequest(
filter = NostrFilter.newFilter().authors(profilePubKey).kinds(30023).build()
)

val articleEvents =
nostrService.requestWithResult(
userArticlesRequest,
userPublishRelays.ifEmpty { DEFAULT_ARTICLE_FETCH_RELAYS.map { Relay(it) } }
)

return if (articleEvents.isEmpty())
FeedFetchResult.Error(Exception("No articles found for ${authorInfo.name}"))
else {
FeedFetchResult.Success(
FeedPayload(
name = authorInfo.name,
icon = authorInfo.picture ?: "",
description = authorInfo.about ?: "",
homepageLink = authorInfo.website ?: "",
link = nostrUri,
articleEvents.map { mapEventToPost(it) }
)
)
}
}
}

private fun mapEventToPost(event: Event): PostPayload {
val postTitle = event.tags.find { tag -> tag.identifier == "title" }
val image = event.tags.find { it.identifier == "image" }
val summary = event.tags.find { it.identifier == "summary" }
val publishDate = event.tags.find { it.identifier == "published_at" }
val articleIdentifier = event.tags.find { it.identifier == "d" }
val articleLink =
event.tags
.find { it.identifier == "a" }
.run {
if (this != null) {
val tagElements = this.description.split(":")
val address =
NAddress.create(
kind = tagElements[0].toInt(),
pubKeyHex = tagElements[1],
dTag = tagElements[2],
relay = articleIdentifier?.description
)

"nostr:$address"
} else if (articleIdentifier != null) {
val articleAddress =
NAddress.create(
event.eventKind,
event.pubkey,
articleIdentifier.description,
this?.customContent
)

"nostr:$articleAddress"
} else "nostr:${NEvent.create(event.id, event.pubkey, event.eventKind, null)}"
}

val articleContent = event.content
Logger.i("NostrFetcher") {
"Tag date is ${publishDate?.description?.toLong()}, and Event date is ${event.creationDate}"
}

return PostPayload(
title = postTitle?.description ?: "",
link = articleLink,
description = summary?.description ?: "",
rawContent = articleContent,
imageUrl = image?.description,
date = toActualMillis(publishDate?.description?.toLong() ?: event.creationDate),
commentsLink = null,
isDateParsedCorrectly = true
)
}

// Funny hack to determine if a timestamp is in millis or seconds.
private fun toActualMillis(timeStamp: Long): Long {
fun isTimestampInMilliseconds(timestamp: Long): Boolean {
val generatedMillis = Instant.fromEpochMilliseconds(timestamp).toEpochMilliseconds()
println("Converted timestamp : $generatedMillis")
return generatedMillis.toString().length ==
Clock.System.now().toEpochMilliseconds().toString().length
}

return if (isTimestampInMilliseconds(timeStamp)) timeStamp
else Instant.fromEpochSeconds(timeStamp).toEpochMilliseconds()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,6 @@ object UrlUtils {
val pattern = """^[a-zA-Z][a-zA-Z0-9\+\-\.]*:""".toRegex()
return pattern.containsMatchIn(url)
}

fun String.isNostrUri(): Boolean = startsWith("nostr:")
}
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ android_sdk_compile = "35"
android_sdk_target = "35"
android_sdk_min = "26"

markdown = "0.7.3"
sqldelight = "2.0.2"
ktor = "3.1.1"
kotlinx_coroutines = "1.10.1"
Expand Down Expand Up @@ -74,6 +75,7 @@ kotlinx_immutable_collections = { module = "org.jetbrains.kotlinx:kotlinx-collec
kotlinx_atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" }
kotlinx_serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx_serialization_json" }
kotlinx_io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx_io" }
jetbrains_markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" }
sqldelight_driver_android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight_driver_native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
sqldelight_extensions_coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
Expand Down
1 change: 1 addition & 0 deletions shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ kotlin {
implementation(libs.kermit.bugsnag)
implementation(libs.reorderable)
api(libs.filekit)
implementation(libs.jetbrains.markdown)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import dev.sasikanth.rss.reader.addfeed.AddFeedPresenterFactory
import dev.sasikanth.rss.reader.blockedwords.BlockedWordsPresenterFactory
import dev.sasikanth.rss.reader.bookmarks.BookmarksPresenterFactory
import dev.sasikanth.rss.reader.core.model.local.PostWithMetadata
import dev.sasikanth.rss.reader.core.network.utils.UrlUtils.isNostrUri
import dev.sasikanth.rss.reader.data.repository.RssRepository
import dev.sasikanth.rss.reader.data.repository.SettingsRepository
import dev.sasikanth.rss.reader.di.scopes.ActivityScope
Expand Down Expand Up @@ -274,7 +275,18 @@ class AppPresenter(
if (showReaderView) {
navigation.pushNew(Config.Reader(post.id))
} else {
linkHandler.openLink(post.link)
val actualLink =
kotlin.run {
if (post.link.isNostrUri()) {
val nostrRef = post.link.removePrefix("nostr:")
val modifiedLink =
if (nostrRef.startsWith("naddr")) "https://highlighter.com/a/$nostrRef"
else "https://njump.me/$nostrRef"

modifiedLink
} else post.link
}
linkHandler.openLink(actualLink)
rssRepository.updatePostReadStatus(read = true, id = post.id)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.arkivanov.essenty.instancekeeper.getOrCreate
import com.arkivanov.essenty.lifecycle.doOnCreate
import com.arkivanov.essenty.lifecycle.doOnDestroy
import dev.sasikanth.rss.reader.core.network.post.FullArticleFetcher
import dev.sasikanth.rss.reader.core.network.utils.UrlUtils.isNostrUri
import dev.sasikanth.rss.reader.data.repository.RssRepository
import dev.sasikanth.rss.reader.reader.ReaderState.PostMode.Idle
import dev.sasikanth.rss.reader.reader.ReaderState.PostMode.InProgress
Expand All @@ -39,6 +40,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.tatarka.inject.annotations.Assisted
import me.tatarka.inject.annotations.Inject
import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor
import org.intellij.markdown.html.HtmlGenerator
import org.intellij.markdown.parser.MarkdownParser

internal typealias ReaderPresenterFactory =
(
Expand Down Expand Up @@ -168,24 +172,42 @@ class ReaderPresenter(
private suspend fun loadRssContent() {
_state.update { it.copy(postMode = InProgress) }
val post = rssRepository.post(postId)
val postContent = post.rawContent ?: post.description
val postContent =
if (post.link.isNostrUri()) {
transformMarkdownContent(post.rawContent!!)
} else post.rawContent ?: post.description
_state.update { it.copy(content = postContent, postMode = RssContent) }
}

private suspend fun loadSourceArticle() {
val postLink = _state.value.link
if (!postLink.isNullOrBlank()) {
_state.update { it.copy(postMode = InProgress) }
val content = fullArticleFetcher.fetch(postLink)

if (content.isSuccess) {
_state.update { it.copy(content = content.getOrThrow()) }
} else {
if (postLink.isNostrUri()) {
loadRssContent()
} else {
_state.update { it.copy(postMode = InProgress) }
val content = fullArticleFetcher.fetch(postLink)

if (content.isSuccess) {
_state.update { it.copy(content = content.getOrThrow()) }
} else {
loadRssContent()
}
}

_state.update { it.copy(postMode = Source) }
}
}

val markDownParser = MarkdownParser(CommonMarkFlavourDescriptor())

private fun transformMarkdownContent(originalContent: String): String {
val parsedMarkdown = markDownParser.buildMarkdownTreeFromString(originalContent)
val transformedContent =
HtmlGenerator(originalContent, parsedMarkdown, CommonMarkFlavourDescriptor()).generateHtml()

return transformedContent
}
}
}
Loading