diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 260951c6a6..e438eb0cab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -99,6 +99,7 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) + matchingFallbacks.add("release") if (project.hasProperty("STORE_FILE")) { signingConfig = signingConfigs.getByName("release") } @@ -107,7 +108,6 @@ android { // } } } - testOptions { unitTests { isReturnDefaultValues = true @@ -225,6 +225,12 @@ dependencies { implementation(libs.openai.client) implementation(libs.ktor.client.okhttp) + // Nostr + implementation(libs.rust.nostr) + + // Markdown + implementation(libs.jetbrains.markdown) + // Only for debug debugImplementation("com.squareup.leakcanary:leakcanary-android:3.0-alpha-1") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 75936a6bf3..d176203ac3 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -37,6 +37,14 @@ # For Jsoup -keep class org.jsoup.** { *; } +# For Nostr +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } +-dontwarn java.awt.Component +-dontwarn java.awt.GraphicsEnvironment +-dontwarn java.awt.HeadlessException +-dontwarn java.awt.Window + # For Kodein -keep, allowobfuscation, allowoptimization class org.kodein.type.TypeReference -keep, allowobfuscation, allowoptimization class org.kodein.type.JVMAbstractTypeToken$Companion$WrappingTest diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt b/app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt index 626c5e6bed..e4ca5a97b2 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/model/FeedParser.kt @@ -10,6 +10,7 @@ import com.nononsenseapps.feeder.model.gofeed.GoFeedAdapter import com.nononsenseapps.feeder.model.gofeed.GoPerson import com.nononsenseapps.feeder.util.Either import com.nononsenseapps.feeder.util.flatMap +import com.nononsenseapps.feeder.util.logDebug import com.nononsenseapps.feeder.util.relativeLinkIntoAbsolute import com.nononsenseapps.feeder.util.relativeLinkIntoAbsoluteOrThrow import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLOrNull @@ -23,16 +24,39 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody +import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor +import org.intellij.markdown.html.HtmlGenerator +import org.intellij.markdown.parser.MarkdownParser import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.kodein.di.DI import org.kodein.di.DIAware import org.kodein.di.instance +import rust.nostr.sdk.Alphabet +import rust.nostr.sdk.Client +import rust.nostr.sdk.Coordinate +import rust.nostr.sdk.Event +import rust.nostr.sdk.Filter +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindEnum +import rust.nostr.sdk.Nip19Profile +import rust.nostr.sdk.Nip21 +import rust.nostr.sdk.Nip21Enum +import rust.nostr.sdk.NostrSdkException +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.SingleLetterTag +import rust.nostr.sdk.TagKind +import rust.nostr.sdk.extractRelayList +import rust.nostr.sdk.getNip05Profile import java.io.IOException import java.lang.NullPointerException import java.net.MalformedURLException import java.net.URL import java.net.URLDecoder +import java.time.Duration +import java.time.Instant +import java.time.ZoneId import java.util.Locale private const val YOUTUBE_CHANNEL_ID_ATTR = "data-channel-external-id" @@ -41,6 +65,14 @@ class FeedParser(override val di: DI) : DIAware { private val client: OkHttpClient by instance() private val goFeedAdapter = GoFeedAdapter() + // Initializing the Nostr Client + private val nostrClient = Client() + + // The default relays to get info from, separated by purpose. + private val defaultFetchRelays = listOf("wss://relay.nostr.band", "wss://relay.damus.io") + private val defaultMetadataRelays = listOf("wss://purplepag.es", "wss://user.kindpag.es") + private val defaultArticleFetchRelays = setOf("wss://nos.lol") + defaultFetchRelays + /** * Parses all relevant information from a main site so duplicate calls aren't needed */ @@ -204,6 +236,142 @@ class FeedParser(override val di: DI) : DIAware { */ private suspend fun curl(url: URL) = client.curl(url) + private suspend fun parseNostrUri(adaptedNostrUri: String): Nip19Profile { + val nostrUri = adaptedNostrUri.removePrefix("https://njump.me/") + if (nostrUri.contains("@")) { // It means it is a Nip05 address + val rawString = nostrUri.removePrefix("nostr:") + val parsedNip5 = getNip05Profile(rawString) + val (pubkey, relays) = parsedNip5.publicKey() to parsedNip5.relays() + return Nip19Profile(pubkey, relays) + } else { + val parsedProfile = Nip21.parse(nostrUri).asEnum() + when (parsedProfile) { + is Nip21Enum.Pubkey -> return Nip19Profile(parsedProfile.publicKey) + is Nip21Enum.Profile -> return Nip19Profile(parsedProfile.profile.publicKey(), parsedProfile.profile.relays()) + else -> throw NostrUriParserException(message = "Could not find the user's info: $nostrUri") + } + } + } + + suspend fun getProfileMetadata(nostrUri: URL): Either { + return Either.catching( + onCatch = { t -> MetaDataParseError(url = nostrUri.toString(), throwable = t) }, + ) { + val possibleNostrProfile = parseNostrUri(nostrUri.toString()) + val publicKey = possibleNostrProfile.publicKey() + val relayList = + possibleNostrProfile.relays() + .takeIf { + it.size < 4 + }.orEmpty() + .ifEmpty { getUserPublishRelays(publicKey) } + logDebug(LOG_TAG, "Relays from Nip19 -> ${relayList.joinToString(separator = ", ")}") + relayList + .ifEmpty { defaultFetchRelays } + .forEach { relayUrl -> + nostrClient.addReadRelay(relayUrl) + } + nostrClient.connect() + val profileInfo = + try { + nostrClient.fetchMetadata( + publicKey = publicKey, + timeout = Duration.ofSeconds(5L), + ) + } catch (e: NostrSdkException) { + // We will use a default relay regardless of whether it is added above, to keep things simple. + nostrClient.addReadRelay(defaultFetchRelays.random()) + nostrClient.connect() + nostrClient.fetchMetadata( + publicKey = publicKey, + timeout = Duration.ofSeconds(5L), + ) + } + logDebug(LOG_TAG, profileInfo.asPrettyJson()) + + // Check if all relays in relaylist can be connected to + AuthorNostrData( + uri = possibleNostrProfile.toNostrUri(), + name = profileInfo.getName().toString(), + publicKey = publicKey, + imageUrl = profileInfo.getPicture().toString(), + relayList = nostrClient.relays().map { relayEntry -> relayEntry.key }, + ) + } + } + + private suspend fun getUserPublishRelays(userPubkey: PublicKey): List { + val userRelaysFilter = + Filter() + .author(userPubkey) + .kind( + Kind.fromEnum(KindEnum.RelayList), + ) + + nostrClient.removeAllRelays() + defaultMetadataRelays.forEach { relayUrl -> + nostrClient.addReadRelay(relayUrl) + } + nostrClient.connect() + val potentialUserRelays = + nostrClient.fetchEventsFrom( + urls = defaultMetadataRelays, + filters = listOf(userRelaysFilter), + timeout = Duration.ofSeconds(5), + ) + val relayList = extractRelayList(potentialUserRelays.toVec().first()) + val relaysToUse = + if (relayList.any { (_, relayType) -> relayType == RelayMetadata.WRITE }) { + relayList.filter { it.value == RelayMetadata.WRITE }.map { entry -> entry.key } + } else if (relayList.size < 7) { + relayList.map { entry -> entry.key } // This represents the relay URL, just as the operation above. + } else { + defaultArticleFetchRelays.map { it } + } + + return relaysToUse + } + + private suspend fun fetchArticlesForAuthor( + author: PublicKey, + relays: List, + ): List { + val articlesByAuthorFilter = + Filter() + .author(author) + .kind(Kind.fromEnum(KindEnum.LongFormTextNote)) + logDebug(LOG_TAG, "Relay List size: ${relays.size}") + + nostrClient.removeAllRelays() + val relaysToUse = + relays.take(3).plus(defaultArticleFetchRelays.random()) + .ifEmpty { defaultFetchRelays } + relaysToUse.forEach { relay -> nostrClient.addReadRelay(relay) } + nostrClient.connect() + logDebug(LOG_TAG, "FETCHING ARTICLES") + val articleEventSet = + nostrClient.fetchEventsFrom( + urls = relaysToUse, + filters = + listOf( + articlesByAuthorFilter, + ), + timeout = Duration.ofSeconds(10L), + ).toVec() + val articleEvents = articleEventSet.distinctBy { it.tags().find(TagKind.Title) } + nostrClient.removeAllRelays() // This is necessary to avoid piling relays to fetch from(on each fetch). + return articleEvents + } + + suspend fun findNostrFeed(authorNostrData: AuthorNostrData): Either { + return Either.catching( + onCatch = { t -> FetchError(url = authorNostrData.uri, throwable = t) }, + ) { + val fetchedArticles = fetchArticlesForAuthor(authorNostrData.publicKey, authorNostrData.relayList) + fetchedArticles.mapToFeed(authorNostrData.uri, authorNostrData.name, authorNostrData.imageUrl) + } + } + suspend fun parseFeedUrl(url: URL): Either { return client.curlAndOnResponse(url) { parseFeedResponse(it) @@ -286,6 +454,93 @@ class FeedParser(override val di: DI) : DIAware { } } +private fun List.mapToFeed( + nostrUri: String, + authorName: String, + imageUrl: String, +): ParsedFeed { + val description = "Nostr: $authorName" + val author = + ParsedAuthor( + name = authorName, + url = "https://njump.me/${first().author().toBech32()}", + avatar = imageUrl, + ) + val articles = + this.sortedByDescending { + it.tags().find(TagKind.PublishedAt)?.content()?.toLong() ?: 0L + }.map { event: Event -> + event.asArticle(authorName, imageUrl) + } + + return ParsedFeed( + title = authorName, + home_page_url = null, + feed_url = nostrUri, + description = description, + user_comment = null, + next_url = null, + icon = imageUrl, + favicon = null, + author = author, + expired = null, + items = articles, + ) +} + +fun Event.asArticle( + authorName: String, + imageUrl: String, +): ParsedArticle { + val articleId = id().toString() + val articleUri = id().toNostrUri() + val articleNostrAddress = + Coordinate( + Kind.fromEnum(KindEnum.LongFormTextNote), + author(), + tags().find( + TagKind.SingleLetter( + SingleLetterTag.lowercase(Alphabet.D), + ), + )?.content().toString(), + ).toBech32() + // Highlighter is a service for reading Nostr articles on the web. + val externalLink = "https://highlighter.com/a/$articleNostrAddress" + val articleAuthor = + ParsedAuthor( + name = authorName, + url = "https://njump.me/${author().toBech32()}", + avatar = imageUrl, + ) + val articleTitle = tags().find(TagKind.Title)?.content() + val publishDate = + Instant.ofEpochSecond( + tags().find(TagKind.PublishedAt)?.content()?.toLong() ?: Instant.EPOCH.epochSecond, + ).atZone(ZoneId.systemDefault()).withFixedOffsetZone() + val articleTags = tags().hashtags() + val articleImage = tags().find(TagKind.Image)?.content() + val articleSummary = tags().find(TagKind.Summary)?.content() + val articleContent = content() + val parsedMarkdown = markDownParser.buildMarkdownTreeFromString(articleContent) + val htmlContent = HtmlGenerator(articleContent, parsedMarkdown, CommonMarkFlavourDescriptor()).generateHtml() + + return ParsedArticle( + id = articleId, + url = articleUri, + external_url = externalLink, + title = articleTitle, + content_html = htmlContent, + content_text = articleContent, + summary = articleSummary, + image = if (articleImage != null) MediaImage(url = articleImage.toString()) else null, + date_published = publishDate.toString(), + date_modified = publishDate.toString(), + author = articleAuthor, + tags = articleTags, + attachments = null, + ) +} + private fun GoFeed.asFeed(url: URL): ParsedFeed = ParsedFeed( title = title, @@ -479,6 +734,22 @@ suspend fun OkHttpClient.curlAndOnResponse( } } +private val markDownParser = MarkdownParser(CommonMarkFlavourDescriptor()) + +class AuthorNostrData( + val uri: String, + val name: String, + val publicKey: PublicKey, + val imageUrl: String, + val relayList: List, +) + +sealed class NostrException(message: String) : Exception(message) + +class NostrUriParserException(override val message: String) : NostrException(message) + +class NostrMetadataException(override val message: String) : NostrException(message) + @Parcelize sealed class FeedParserError : Parcelable { abstract val url: String diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt b/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt index fca8a78dec..d1806ff07f 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/model/RssLocalSync.kt @@ -1,6 +1,8 @@ package com.nononsenseapps.feeder.model +import android.app.Application import android.util.Log +import com.nononsenseapps.feeder.R import com.nononsenseapps.feeder.archmodel.Repository import com.nononsenseapps.feeder.background.runOnceFullTextSync import com.nononsenseapps.feeder.blob.blobFile @@ -13,6 +15,7 @@ import com.nononsenseapps.feeder.sync.SyncRestClient import com.nononsenseapps.feeder.util.Either import com.nononsenseapps.feeder.util.FilePathProvider import com.nononsenseapps.feeder.util.flatMap +import com.nononsenseapps.feeder.util.isAdaptedUrlFromNostrUri import com.nononsenseapps.feeder.util.left import com.nononsenseapps.feeder.util.logDebug import com.nononsenseapps.feeder.util.right @@ -49,6 +52,7 @@ class RssLocalSync(override val di: DI) : DIAware { private val feedParser: FeedParser by instance() private val okHttpClient: OkHttpClient by instance() private val filePathProvider: FilePathProvider by instance() + private val application: Application by instance() suspend fun syncFeeds( feedId: Long = ID_UNSET, @@ -242,32 +246,46 @@ class RssLocalSync(override val di: DI) : DIAware { } logDebug(LOG_TAG, "Fetching ${feedSql.displayTitle}") + // Alternate based on Nostr feed - return Either.catching( - onCatch = { t -> - FetchError(url = url.toString(), throwable = t) - }, - ) { - okHttpClient.getResponse(feedSql.url, forceNetwork = forceNetwork) - }.flatMap { response -> - response.use { - if (response.isSuccessful) { - response.body?.let { responseBody -> - feedParser.parseFeedResponse( - response.request.url.toUrl(), - responseBody, - ) - } ?: NoBody(url = url.toString()).left() - } else { - response.retryAfterSeconds?.let { retryAfterSeconds -> - logDebug(LOG_TAG, "$url, Retry after: $retryAfterSeconds") + return if (url.isAdaptedUrlFromNostrUri()) { + Either.catching( + onCatch = { t -> FetchError(url = url.toString(), throwable = t) }, + ) { + feedParser.getProfileMetadata(url).getOrNull() ?: throw NostrMetadataException("Could not find metadata for $url") + }.flatMap { profile -> + feedParser.findNostrFeed(profile) + .map { + it.copy(description = application.applicationContext.getString(R.string.nostr_feed_description, profile.name)) + } + } + } else { + Either.catching( + onCatch = { t -> + FetchError(url = url.toString(), throwable = t) + }, + ) { + okHttpClient.getResponse(feedSql.url, forceNetwork = forceNetwork) + }.flatMap { response -> + response.use { + if (response.isSuccessful) { + response.body?.let { responseBody -> + feedParser.parseFeedResponse( + response.request.url.toUrl(), + responseBody, + ) + } ?: NoBody(url = url.toString()).left() + } else { + response.retryAfterSeconds?.let { retryAfterSeconds -> + logDebug(LOG_TAG, "$url, Retry after: $retryAfterSeconds") + } + HttpError( + url = url.toString(), + code = response.code, + retryAfterSeconds = response.retryAfterSeconds, + message = response.message, + ).left() } - HttpError( - url = url.toString(), - code = response.code, - retryAfterSeconds = response.retryAfterSeconds, - message = response.message, - ).left() } } }.map { diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/opml/OPMLImporter.kt b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OPMLImporter.kt index 6fb8da8260..b88ee100a0 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/model/opml/OPMLImporter.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OPMLImporter.kt @@ -25,7 +25,6 @@ open class OPMLImporter(override val di: DI) : OPMLParserHandler, DIAware { override suspend fun saveFeed(feed: Feed) { val existing = feedDao.loadFeedWithUrl(feed.url) - // Don't want to remove existing feed on OPML imports if (existing != null) { feedDao.updateFeed(feed.copy(id = existing.id)) diff --git a/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlPullParser.kt b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlPullParser.kt index 79194e4357..74c2d653c6 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlPullParser.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/model/opml/OpmlPullParser.kt @@ -6,6 +6,8 @@ import com.nononsenseapps.feeder.db.room.Feed import com.nononsenseapps.feeder.model.OPMLParserHandler import com.nononsenseapps.feeder.util.Either import com.nononsenseapps.feeder.util.flatMap +import com.nononsenseapps.feeder.util.getOrCreateFromUri +import com.nononsenseapps.feeder.util.isNostrUri import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString @@ -247,7 +249,8 @@ class OpmlPullParser(private val opmlToDb: OPMLParserHandler) { ?: "", ) try { - val feedUrl = URL(parser.getAttributeValue(null, ATTR_XMLURL)) + val parsedUrlOrUri = parser.getAttributeValue(null, ATTR_XMLURL) + val feedUrl = URL(parsedUrlOrUri.getOrCreateFromUri()) val feed = Feed( // Ensure not both are empty string: title will get replaced on sync @@ -263,16 +266,20 @@ class OpmlPullParser(private val opmlToDb: OPMLParserHandler) { ?.toBoolean() ?: feed.notify, fullTextByDefault = - ( - parser.getAttributeValue( - OPML_FEEDER_NAMESPACE, - ATTR_FULL_TEXT_BY_DEFAULT, - ) - ?.toBoolean() - // Support Flym's value for this - ?: parser.getAttributeValue(null, ATTR_FLYM_RETRIEVE_FULL_TEXT) + if (parsedUrlOrUri.isNostrUri()) { + false + } else { + ( + parser.getAttributeValue( + OPML_FEEDER_NAMESPACE, + ATTR_FULL_TEXT_BY_DEFAULT, + ) ?.toBoolean() - ) ?: feed.fullTextByDefault, + // Support Flym's value for this + ?: parser.getAttributeValue(null, ATTR_FLYM_RETRIEVE_FULL_TEXT) + ?.toBoolean() + ) ?: feed.fullTextByDefault + }, alternateId = parser.getAttributeValue(OPML_FEEDER_NAMESPACE, ATTR_ALTERNATE_ID) ?.toBoolean() diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/Constants.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/Constants.kt index 6fcaf553de..01564753f1 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/Constants.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/Constants.kt @@ -14,3 +14,5 @@ const val ARG_FEED_FULL_TEXT_BY_DEFAULT = "feed_full_text_by_default" const val ARG_FEED_OPEN_ARTICLES_WITH = "feed_open_articles_with" const val ARG_URL = "url" const val ARG_ONLY_NEW = "only_new" + +const val NOSTR_URI_PREFIX = "nostr:" diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/CreateFeedScreenViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/CreateFeedScreenViewModel.kt index ab78ff67c4..6da0cc2a81 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/CreateFeedScreenViewModel.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/CreateFeedScreenViewModel.kt @@ -14,6 +14,8 @@ import com.nononsenseapps.feeder.background.runOnceRssSync import com.nononsenseapps.feeder.base.DIAwareViewModel import com.nononsenseapps.feeder.db.room.Feed import com.nononsenseapps.feeder.ui.compose.utils.mutableSavedStateOf +import com.nononsenseapps.feeder.util.getOrCreateFromUri +import com.nononsenseapps.feeder.util.isNostrUri import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLOrNull import kotlinx.coroutines.launch import org.kodein.di.DI @@ -27,13 +29,13 @@ class CreateFeedScreenViewModel( ) : DIAwareViewModel(di), EditFeedScreenState { private val repository: Repository by instance() - // These two are updated as a result of url updating + // These three are updated as a result of url/uri updating override var isNotValidUrl by mutableStateOf(false) override var isOkToSave: Boolean by mutableStateOf(false) override var feedUrl: String by mutableSavedStateOf(state, "") { value -> - isNotValidUrl = !isValidUrl(value) - isOkToSave = isValidUrl(value) + isNotValidUrl = !isValidUrlOrUri(value) + isOkToSave = isValidUrlOrUri(value) } override var feedTitle: String by mutableSavedStateOf(state, "") override var feedTag: String by mutableSavedStateOf(state, "") @@ -81,11 +83,11 @@ class CreateFeedScreenViewModel( val feedId = repository.saveFeed( Feed( - url = URL(feedUrl), + url = URL(feedUrl.getOrCreateFromUri()), title = feedTitle, customTitle = feedTitle, tag = feedTag, - fullTextByDefault = fullTextByDefault, + fullTextByDefault = if (feedUrl.isNostrUri()) false else fullTextByDefault, notify = notify, skipDuplicates = skipDuplicates, openArticlesWith = articleOpener, diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/EditFeedScreen.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/EditFeedScreen.kt index fc5be85c08..c47e87653c 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/EditFeedScreen.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/EditFeedScreen.kt @@ -84,6 +84,7 @@ import com.nononsenseapps.feeder.ui.compose.utils.LocalWindowSizeMetrics import com.nononsenseapps.feeder.ui.compose.utils.ScreenType import com.nononsenseapps.feeder.ui.compose.utils.getScreenType import com.nononsenseapps.feeder.ui.compose.utils.rememberApiPermissionState +import com.nononsenseapps.feeder.util.isNostrUri @Composable fun CreateFeedScreen( @@ -489,6 +490,7 @@ fun ColumnScope.RightContent( SwitchSetting( title = stringResource(id = R.string.fetch_full_articles_by_default), checked = viewState.fullTextByDefault, + enabled = !viewState.feedUrl.isNostrUri(), icon = null, modifier = Modifier diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/EditFeedScreenViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/EditFeedScreenViewModel.kt index 93cb42055c..a94e79f1d2 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/EditFeedScreenViewModel.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/editfeed/EditFeedScreenViewModel.kt @@ -14,6 +14,8 @@ import com.nononsenseapps.feeder.background.runOnceRssSync import com.nononsenseapps.feeder.base.DIAwareViewModel import com.nononsenseapps.feeder.db.room.Feed import com.nononsenseapps.feeder.ui.compose.utils.mutableSavedStateOf +import com.nononsenseapps.feeder.util.getOrCreateFromUri +import com.nononsenseapps.feeder.util.isNostrUri import kotlinx.coroutines.launch import org.kodein.di.DI import org.kodein.di.instance @@ -35,8 +37,8 @@ class EditFeedScreenViewModel( override var isOkToSave: Boolean by mutableStateOf(false) override var feedUrl: String by mutableSavedStateOf(state, "") { value -> - isNotValidUrl = !isValidUrl(value) - isOkToSave = isValidUrl(value) + isNotValidUrl = !isValidUrlOrUri(value) + isOkToSave = isValidUrlOrUri(value) } override var feedTitle: String by mutableSavedStateOf(state, "") override var feedTag: String by mutableSavedStateOf(state, "") @@ -123,11 +125,11 @@ class EditFeedScreenViewModel( val updatedFeed = feed.copy( - url = URL(feedUrl), + url = URL(feedUrl.getOrCreateFromUri()), title = feedTitle, customTitle = feedTitle, tag = feedTag, - fullTextByDefault = fullTextByDefault, + fullTextByDefault = if (feedUrl.isNostrUri()) false else fullTextByDefault, notify = notify, skipDuplicates = skipDuplicates, openArticlesWith = articleOpener, @@ -153,10 +155,14 @@ class EditFeedScreenViewModel( } } -internal fun isValidUrl(value: String): Boolean { +internal fun isValidUrlOrUri(value: String): Boolean { return try { - URL(value) - true + if (value.isNostrUri()) { + true + } else { + URL(value) + true + } } catch (e: Exception) { false } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/searchfeed/SearchFeedScreen.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/searchfeed/SearchFeedScreen.kt index 55bed55289..c762764a49 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/searchfeed/SearchFeedScreen.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/searchfeed/SearchFeedScreen.kt @@ -88,6 +88,8 @@ import com.nononsenseapps.feeder.ui.compose.utils.ScreenType import com.nononsenseapps.feeder.ui.compose.utils.StableHolder import com.nononsenseapps.feeder.ui.compose.utils.getScreenType import com.nononsenseapps.feeder.ui.compose.utils.stableListHolderOf +import com.nononsenseapps.feeder.util.getOrCreateFromUri +import com.nononsenseapps.feeder.util.isNostrUri import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLNoThrows import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch @@ -227,11 +229,13 @@ fun SearchFeedView( // If screen is opened from intent with pre-filled URL, trigger search directly LaunchedEffect(Unit) { if (results.item.isEmpty() && errors.item.isEmpty() && feedUrl.isNotBlank() && - isValidUrl( - feedUrl, - ) + (isValidUrl(feedUrl)) ) { - onSearchCallback(sloppyLinkToStrictURLNoThrows(feedUrl)) + onSearchCallback( + sloppyLinkToStrictURLNoThrows( + feedUrl.getOrCreateFromUri(), + ), + ) } } @@ -335,6 +339,12 @@ fun ColumnScope.leftContent( isValidUrl(feedUrl) } } + val isNostrUri by remember(feedUrl) { + derivedStateOf { + feedUrl.isNostrUri() + } + } + TextField( value = feedUrl, onValueChange = onUrlChange, @@ -352,8 +362,10 @@ fun ColumnScope.leftContent( keyboardActions = KeyboardActions( onSearch = { - if (isValidUrl) { - onSearch(sloppyLinkToStrictURLNoThrows(feedUrl)) + if (isValidUrl || isNostrUri) { + onSearch( + sloppyLinkToStrictURLNoThrows(feedUrl.getOrCreateFromUri()), + ) keyboardController?.hide() } }, @@ -364,7 +376,9 @@ fun ColumnScope.leftContent( .width(dimens.maxContentWidth) .interceptKey(Key.Enter) { if (isValidUrl(feedUrl)) { - onSearch(sloppyLinkToStrictURLNoThrows(feedUrl)) + onSearch( + sloppyLinkToStrictURLNoThrows(feedUrl.getOrCreateFromUri()), + ) keyboardController?.hide() } } @@ -379,7 +393,7 @@ fun ColumnScope.leftContent( onClick = { if (isValidUrl) { try { - onSearch(sloppyLinkToStrictURLNoThrows(feedUrl)) + onSearch(sloppyLinkToStrictURLNoThrows(feedUrl.getOrCreateFromUri())) clearFocus() } catch (e: Exception) { Log.e(LOG_TAG, "Can't search", e) @@ -668,12 +682,16 @@ private fun isValidUrl(url: String): Boolean { return false } return try { - try { - URL(url) - true - } catch (_: MalformedURLException) { - URL("http://$url") + if (url.isNostrUri()) { true + } else { + try { + URL(url) + true + } catch (_: MalformedURLException) { + URL("http://$url") + true + } } } catch (e: Exception) { false diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/searchfeed/SearchFeedViewModel.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/searchfeed/SearchFeedViewModel.kt index 4265934e15..0a8bf8a62d 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/searchfeed/SearchFeedViewModel.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/searchfeed/SearchFeedViewModel.kt @@ -3,16 +3,20 @@ package com.nononsenseapps.feeder.ui.compose.searchfeed import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.nononsenseapps.feeder.FeederApplication +import com.nononsenseapps.feeder.R import com.nononsenseapps.feeder.archmodel.Repository import com.nononsenseapps.feeder.base.DIAwareViewModel import com.nononsenseapps.feeder.model.FeedParser import com.nononsenseapps.feeder.model.FeedParserError +import com.nononsenseapps.feeder.model.FetchError import com.nononsenseapps.feeder.model.HttpError import com.nononsenseapps.feeder.model.NoAlternateFeeds import com.nononsenseapps.feeder.model.NotInitializedYet import com.nononsenseapps.feeder.model.SiteMetaData import com.nononsenseapps.feeder.util.Either import com.nononsenseapps.feeder.util.flatMap +import com.nononsenseapps.feeder.util.isAdaptedUrlFromNostrUri import com.nononsenseapps.feeder.util.sloppyLinkToStrictURLOrNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -27,6 +31,7 @@ import java.time.Instant class SearchFeedViewModel(di: DI) : DIAwareViewModel(di) { private val feedParser: FeedParser by instance() private val repository: Repository by instance() + private val application = getApplication() private var siteMetaData: Either by mutableStateOf( Either.Left( @@ -35,55 +40,83 @@ class SearchFeedViewModel(di: DI) : DIAwareViewModel(di) { ) fun searchForFeeds(initialUrl: URL): Flow> { - return flow { - siteMetaData = feedParser.getSiteMetaData(initialUrl) - // Flow collection makes this block concurrent with map below - val initialSiteMetaData = siteMetaData - - initialSiteMetaData - .onRight { metaData -> - metaData.alternateFeedLinks.forEach { - emit(Either.Right(it.link)) + return if (initialUrl.isAdaptedUrlFromNostrUri()) { + flow { + val nostrProfile = feedParser.getProfileMetadata(initialUrl) + nostrProfile + .onRight { + emit(Either.Right(it)) } - if (metaData.alternateFeedLinks.isEmpty()) { - emit(Either.Left(NoAlternateFeeds(initialUrl.toString()))) + .onLeft { + emit(Either.Left(it)) } - } - .onLeft { - if (it is HttpError) { - handleHttpError(it) + }.map { profileResult -> + profileResult.flatMap { metadata -> + + Either.catching( + onCatch = { t -> FetchError(url = metadata.uri, throwable = t) }, + ) { + SearchResult( + title = metadata.name, + url = metadata.uri, + description = application.applicationContext.getString(R.string.nostr_feed_description, metadata.name), + feedImage = metadata.imageUrl, + ) } - emit(Either.Right(initialUrl)) } - } - .map { urlResult -> - urlResult.flatMap { url -> - feedParser.parseFeedUrl(url) - .onRight { feed -> - if (siteMetaData.isLeft()) { - feed.home_page_url?.let { pageLink -> - sloppyLinkToStrictURLOrNull(pageLink)?.let { pageUrl -> - siteMetaData = feedParser.getSiteMetaData(pageUrl) + } + .flowOn(Dispatchers.Default) + } else { + flow { + siteMetaData = feedParser.getSiteMetaData(initialUrl) + // Flow collection makes this block concurrent with map below + val initialSiteMetaData = siteMetaData + + initialSiteMetaData + .onRight { metaData -> + metaData.alternateFeedLinks.forEach { + emit(Either.Right(it.link)) + } + if (metaData.alternateFeedLinks.isEmpty()) { + emit(Either.Left(NoAlternateFeeds(initialUrl.toString()))) + } + } + .onLeft { + if (it is HttpError) { + handleHttpError(it) + } + emit(Either.Right(initialUrl)) + } + } + .map { urlResult -> + urlResult.flatMap { url -> + feedParser.parseFeedUrl(url) + .onRight { feed -> + if (siteMetaData.isLeft()) { + feed.home_page_url?.let { pageLink -> + sloppyLinkToStrictURLOrNull(pageLink)?.let { pageUrl -> + siteMetaData = feedParser.getSiteMetaData(pageUrl) + } } } } - } - .map { feed -> - SearchResult( - title = feed.title ?: "", - url = feed.feed_url ?: url.toString(), - description = feed.description ?: "", - feedImage = siteMetaData.getOrNull()?.feedImage ?: "", - ) - } - .onLeft { - if (it is HttpError) { - handleHttpError(it) + .map { feed -> + SearchResult( + title = feed.title ?: "", + url = feed.feed_url ?: url.toString(), + description = feed.description ?: "", + feedImage = siteMetaData.getOrNull()?.feedImage ?: "", + ) } - } + .onLeft { + if (it is HttpError) { + handleHttpError(it) + } + } + } } - } - .flowOn(Dispatchers.Default) + .flowOn(Dispatchers.Default) + } } private suspend fun handleHttpError(httpError: HttpError) { diff --git a/app/src/main/java/com/nononsenseapps/feeder/util/LinkUtils.kt b/app/src/main/java/com/nononsenseapps/feeder/util/LinkUtils.kt index 7d6d7de498..3f8586ed9a 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/util/LinkUtils.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/util/LinkUtils.kt @@ -1,5 +1,6 @@ package com.nononsenseapps.feeder.util +import com.nononsenseapps.feeder.ui.NOSTR_URI_PREFIX import java.net.MalformedURLException import java.net.URI import java.net.URISyntaxException @@ -98,3 +99,9 @@ fun relativeLinkIntoAbsoluteOrThrow( } catch (_: MalformedURLException) { URL(base, link) } + +fun String.isNostrUri(): Boolean = startsWith(NOSTR_URI_PREFIX) && length > NOSTR_URI_PREFIX.length + +fun URL.isAdaptedUrlFromNostrUri(): Boolean = this.toString().startsWith("https://njump.me") + +fun String.getOrCreateFromUri(): String = if (isNostrUri()) "https://njump.me/$this" else this diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 750af35821..43f3e7967b 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -229,4 +229,4 @@ دقيقتان كلمتان كلمة واحدة - \ No newline at end of file + diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 66bc566f5a..64bdbf5aa7 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -261,4 +261,4 @@ API ключ Покажи съобщение Покажи емисии след маркиране на всичко като прочетено - \ No newline at end of file + diff --git a/app/src/main/res/values-bs-rBA/strings.xml b/app/src/main/res/values-bs-rBA/strings.xml index a1d56e7fc8..23e8915a41 100644 --- a/app/src/main/res/values-bs-rBA/strings.xml +++ b/app/src/main/res/values-bs-rBA/strings.xml @@ -248,4 +248,4 @@ %1$s minuta %1$s minuta Prikaži procijenjeno vrijeme čitanja - \ No newline at end of file + diff --git a/app/src/main/res/values-ca-rES/strings.xml b/app/src/main/res/values-ca-rES/strings.xml index 4a94670a99..b1cf9020a1 100644 --- a/app/src/main/res/values-ca-rES/strings.xml +++ b/app/src/main/res/values-ca-rES/strings.xml @@ -226,4 +226,4 @@ Error al descarregar El contingut no és HTML Exporta els articles desats - \ No newline at end of file + diff --git a/app/src/main/res/values-cs-rCZ/strings.xml b/app/src/main/res/values-cs-rCZ/strings.xml index b59272837d..956649713f 100644 --- a/app/src/main/res/values-cs-rCZ/strings.xml +++ b/app/src/main/res/values-cs-rCZ/strings.xml @@ -259,4 +259,4 @@ Id nasazení Azure Seznam dostupných modelů Shrnutí - \ No newline at end of file + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index a785bd421e..c29f0daf9c 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -259,4 +259,4 @@ Der blev ikke fundet nogen modeller Azure implementerings-id Liste over tilgængelige modeller - \ No newline at end of file + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4f8880b6b7..69205b6a81 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -261,4 +261,4 @@ Azure-API-Version Nachricht anzeigen Feeds anzeigen, nachdem alle als gelesen markiert wurden - \ No newline at end of file + diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 983d4ac002..f7b6186646 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -261,4 +261,4 @@ Λίστα διαθέσιμων μοντέλων Εμφάνιση μηνύματος Εμφάνιση ροών μετά την επισήμανση όλων ως αναγνωσμένα - \ No newline at end of file + diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index f359a1642a..faa1faac07 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -162,4 +162,4 @@ Generu ekstrajn unikajn identigilojn Ne valida URL Malsukcesis importi OPML - \ No newline at end of file + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index fa772ee89a..1fe16bcb92 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -261,4 +261,4 @@ Identificación de implementación de Azure Mostrar mensaje Mostrar feeds después de marcar todo como leído - \ No newline at end of file + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 826228f635..fd355748a0 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -130,4 +130,4 @@ بستن کشوی ناوبری باز کردن کشوی ناوبری کاغذ الکترونیک - \ No newline at end of file + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 0e83f4070a..c40b820cad 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -260,4 +260,4 @@ Malleja ei löytynyt Azure-käyttöönottotunnus Luettelo saatavilla olevista malleista - \ No newline at end of file + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5b67db9e7a..e56840be1f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -261,4 +261,4 @@ Liste des modèles disponibles Montrer le message Montrer les flux après avoir marqué tout lu - \ No newline at end of file + diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index cb6780aa3c..b0c68a0355 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -250,4 +250,4 @@ Tarxeta compacta Toca para reproducir o audio Mostrar número de artigos non lidos no título - \ No newline at end of file + diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 5d813fffca..818a10fbe6 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -250,4 +250,4 @@ कॉम्पैक्ट कार्ड ऑडियो चलाने के लिए छुएं शीर्षक में अपठित आलेखों की संख्या दिखाएं - \ No newline at end of file + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 7556d292f1..3ae1171944 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -261,4 +261,4 @@ Nem lehet betölteni a modelleket. Üzenet megjelenítése A hírfolyam megjelenítése miután az összes meg lett jelölve olvasottként - \ No newline at end of file + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index f2a2bdf075..a01d759b4e 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -260,4 +260,4 @@ Model tidak ditemukan Daftar model yang tersedia ID Model - \ No newline at end of file + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 15500362f4..2eac10f2ab 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -261,4 +261,4 @@ Id dell\'implementazione Azure Elenco dei modelli disponibili Mostra i feed dopo averli contrassegnati come letti - \ No newline at end of file + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 0ef02c8cf8..55e47aed02 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -246,4 +246,4 @@ 保存した記事をエクスポート 保存した記事のエクスポートに失敗しました コンパクトなカード - \ No newline at end of file + diff --git a/app/src/main/res/values-ku/strings.xml b/app/src/main/res/values-ku/strings.xml index 124b516091..19ce724a42 100644 --- a/app/src/main/res/values-ku/strings.xml +++ b/app/src/main/res/values-ku/strings.xml @@ -242,4 +242,4 @@ %1$s etîketan Naverok ne HTML e %1$s etîketan - \ No newline at end of file + diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 06e726ca6e..715bea1356 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -248,4 +248,4 @@ %1$s minutė Praleisti atsikartojančius straipsnius Rodyti likusį skaitymo laiką - \ No newline at end of file + diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index bd5e106f2f..4a4f892542 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -155,4 +155,4 @@ ഇതിന്റെ അടിസ്ഥാനത്തിൽ പുതിയത് ആൻഡ്രോയിഡ് %1$s-ലും അതിനുമുകളിലുള്ളവയിലും മാത്രമേ ലഭ്യമാകൂ ഇലക്ട്രോണിക് പേപ്പർ - \ No newline at end of file + diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index b5d0eb62dc..239dc241f2 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -183,4 +183,4 @@ Ny Informasjonkanalsikon Artikkelbilde - \ No newline at end of file + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 24fddf6c9c..4a0214e507 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -250,4 +250,4 @@ Tik om audio af te spelen Compacte Kaart Toon het aantal ongelezen artikelen in de titel - \ No newline at end of file + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index fa0c0df8fc..fb87242317 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -261,4 +261,4 @@ Lista dostępnych modeli Pokaż wiadomość Pokazuj kanały po oznaczeniu wszystkiego jako przeczytane - \ No newline at end of file + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7f6775e43e..3caf192dcf 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -250,4 +250,4 @@ Cartão compacto Toque para reproduzir áudio Mostrar contagem de artigos não lidos no título - \ No newline at end of file + diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index dbed557bae..f0c3a9c785 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -209,4 +209,4 @@ A ligação não é um documento de HTML O feed não tem ligação ao artigo Papel eletrónico - \ No newline at end of file + diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 11909f3ff4..e9f7dd8652 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -249,4 +249,4 @@ Atingeți pentru a reda audio Articolele cu linkuri sau titluri identice cu articole existente sunt ignorate Card compact - \ No newline at end of file + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7492182e8c..e5f46ca3de 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -250,4 +250,4 @@ Компактный карточный Коснитесь для воспроизведения аудио Показывать число непрочитанных статей в заголовке - \ No newline at end of file + diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml index 3a195e0e84..cdcf6b1c30 100644 --- a/app/src/main/res/values-sq/strings.xml +++ b/app/src/main/res/values-sq/strings.xml @@ -114,4 +114,4 @@ Bojë elektronike Njoftimet shfaqen vetëm për artikuj të palexuar në feed-et e konfiguruara URL nuk mund të ngarkohet - \ No newline at end of file + diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 1ea0afe96d..bf03435bb9 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -261,4 +261,4 @@ Листа доступних модела Прикажи поруку Када се све обележи као прочитано, прикажи изворе - \ No newline at end of file + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index c84880ad08..22f3399bbf 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -250,4 +250,4 @@ Kompakt kort Tryck för att spela upp ljud Visa antalet olästa artiklar i rubrik - \ No newline at end of file + diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 1edc19b906..b604828796 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -261,4 +261,4 @@ OpenAI ஒருங்கிணைப்பு பநிஇ விசை மாதிரி ஐடி - \ No newline at end of file + diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index 802df642d0..8175f9228c 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -63,4 +63,4 @@ చదవనట్టు గుర్తుపెట్టు ఫీడ్ యూఆర్ఎల్ ఫీడ్ జోడించండి - \ No newline at end of file + diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 8d090d65e2..5f671a4910 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -255,4 +255,4 @@ โหลดโมเดลไม่ได้ ไม่พบโมเดลดังกล่าว รายการโมเดลที่ใช้งานได้ - \ No newline at end of file + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 5422787e63..a7abe80468 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -250,4 +250,4 @@ Sıkı Kart Sesi oynatmak için dokun Başlıkta okunmayan makale sayısını göster - \ No newline at end of file + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 85de57260b..2441e58953 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -250,4 +250,4 @@ Компактна картка Торкніться, щоб відтворити аудіо Показувати кількість непрочитаних статей у заголовку - \ No newline at end of file + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index f9aa098d92..3c7f4cfbdf 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -249,4 +249,4 @@ Xuất các bài viết đã lưu Bỏ qua bài viết trùng lặp Chạm để phát âm thanh - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c6142bac10..0047d968c2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -261,4 +261,4 @@ 可用模型列表 显示消息 标记所有为已读后显示订阅源 - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 7e9673b699..18975f02fc 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -261,4 +261,4 @@ 無法載入模型。 沒有找到模型 可用模型清單 - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b26da93dc..1b2d0114ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ Tag Feed URL Title + Nostr articles by %1$s %1$s, %2$s %1$s diff --git a/settings.gradle.kts b/settings.gradle.kts index bae582224e..aa5cb3de93 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -208,6 +208,14 @@ dependencyResolutionManagement { library("tagsoup", "org.ccil.cowan.tagsoup", "tagsoup").versionRef("tagsoup") // RSS library("gofeed-android", "com.nononsenseapps.gofeed", "gofeed-android").versionRef("gofeed") + // Nostr + library( + "rust-nostr", + "org.rust-nostr", + "nostr-sdk" + ).version("0.37.0") + //Markdown + library("jetbrains-markdown", "org.jetbrains", "markdown").version("0.7.3") // For better fetching library("okhttp", "com.squareup.okhttp3", "okhttp").withoutVersion()