diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index ffaaf9222..d87b35d61 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -95,7 +95,7 @@ LongParameterList:NostrResources.kt$( eventId: String, eventIdToNostrEvent: Map<String, NostrEvent>, postIdToPostDataMap: Map<String, PostData>, articleIdToArticle: Map<String, ArticleData>, profileIdToProfileDataMap: Map<String, ProfileData>, cdnResources: Map<String, CdnResource>, linkPreviews: Map<String, LinkPreviewData>, videoThumbnails: Map<String, String>, ) LongParameterList:NostrResources.kt$( eventIdToNostrEvent: Map<String, NostrEvent>, postIdToPostDataMap: Map<String, PostData>, articleIdToArticle: Map<String, ArticleData>, profileIdToProfileDataMap: Map<String, ProfileData>, cdnResources: Map<String, CdnResource>, linkPreviews: Map<String, LinkPreviewData>, videoThumbnails: Map<String, String>, ) LongParameterList:NostrResources.kt$( refNote: PostData?, refPostAuthor: ProfileData?, cdnResources: Map<String, CdnResource>, linkPreviews: Map<String, LinkPreviewData>, videoThumbnails: Map<String, String>, eventIdToNostrEvent: Map<String, NostrEvent>, postIdToPostDataMap: Map<String, PostData>, articleIdToArticle: Map<String, ArticleData>, profileIdToProfileDataMap: Map<String, ProfileData>, ) - LongParameterList:NoteEditorViewModel.kt$NoteEditorViewModel$( @Assisted private val args: NoteEditorArgs, private val dispatcherProvider: CoroutineDispatcherProvider, private val fileAnalyser: FileAnalyser, private val activeAccountStore: ActiveAccountStore, private val feedRepository: FeedRepository, private val notePublishHandler: NotePublishHandler, private val attachmentRepository: AttachmentsRepository, private val highlightRepository: HighlightRepository, private val exploreRepository: ExploreRepository, private val profileRepository: ProfileRepository, private val articleRepository: ArticleRepository, ) + LongParameterList:NoteEditorViewModel.kt$NoteEditorViewModel$( @Assisted private val args: NoteEditorArgs, private val fileAnalyser: FileAnalyser, private val activeAccountStore: ActiveAccountStore, private val feedRepository: FeedRepository, private val notePublishHandler: NotePublishHandler, private val attachmentRepository: AttachmentsRepository, private val highlightRepository: HighlightRepository, private val exploreRepository: ExploreRepository, private val profileRepository: ProfileRepository, private val articleRepository: ArticleRepository, private val relayRepository: RelayRepository, private val relayHintsRepository: RelayHintsRepository, ) LongParameterList:ProfileDetailsViewModel.kt$ProfileDetailsViewModel$( savedStateHandle: SavedStateHandle, private val dispatcherProvider: CoroutineDispatcherProvider, private val activeAccountStore: ActiveAccountStore, private val feedsRepository: FeedsRepository, private val profileRepository: ProfileRepository, private val mutedUserRepository: MutedUserRepository, private val zapHandler: ZapHandler, ) LongParameterList:SubscriptionsManager.kt$SubscriptionsManager$( dispatcherProvider: CoroutineDispatcherProvider, private val activeAccountStore: ActiveAccountStore, private val userRepository: UserRepository, private val nostrNotary: NostrNotary, private val appConfigProvider: AppConfigProvider, @PrimalCacheApiClient private val cacheApiClient: PrimalApiClient, @PrimalWalletApiClient private val walletApiClient: PrimalApiClient, ) LongParameterList:TransactionDetailsViewModel.kt$TransactionDetailsViewModel$( savedStateHandle: SavedStateHandle, private val dispatcherProvider: CoroutineDispatcherProvider, private val activeAccountStore: ActiveAccountStore, private val walletRepository: WalletRepository, private val feedRepository: FeedRepository, private val articleRepository: ArticleRepository, private val exchangeRateHandler: ExchangeRateHandler, ) @@ -132,6 +132,8 @@ TooManyFunctions:ProfileRepository.kt$ProfileRepository TooManyFunctions:Tags.kt$net.primal.android.nostr.ext.Tags.kt TooManyFunctions:UserRepository.kt$UserRepository + TooManyFunctions:UsersApi.kt$UsersApi + TooManyFunctions:UsersApiImpl.kt$UsersApiImpl : UsersApi TooManyFunctions:WalletApi.kt$WalletApi TooManyFunctions:WalletApiImpl.kt$WalletApiImpl : WalletApi TooManyFunctions:WalletRepository.kt$WalletRepository diff --git a/app/src/main/kotlin/net/primal/android/attachments/domain/NoteAttachmentType.kt b/app/src/main/kotlin/net/primal/android/attachments/domain/NoteAttachmentType.kt index ed3882525..71eae5893 100644 --- a/app/src/main/kotlin/net/primal/android/attachments/domain/NoteAttachmentType.kt +++ b/app/src/main/kotlin/net/primal/android/attachments/domain/NoteAttachmentType.kt @@ -9,5 +9,6 @@ enum class NoteAttachmentType { Rumble, Spotify, Tidal, + GitHub, Other, } diff --git a/app/src/main/kotlin/net/primal/android/attachments/ext/NoteAttachmentParsers.kt b/app/src/main/kotlin/net/primal/android/attachments/ext/NoteAttachmentParsers.kt index 666086249..4d8d9a435 100644 --- a/app/src/main/kotlin/net/primal/android/attachments/ext/NoteAttachmentParsers.kt +++ b/app/src/main/kotlin/net/primal/android/attachments/ext/NoteAttachmentParsers.kt @@ -57,22 +57,35 @@ fun List.flatMapMessagesAsNoteAttachmentPO() = } private fun detectNoteAttachmentType(url: String, mimeType: String?): NoteAttachmentType { - return when { - mimeType?.startsWith("image") == true -> NoteAttachmentType.Image - mimeType?.startsWith("video") == true -> NoteAttachmentType.Video - mimeType?.startsWith("audio") == true -> NoteAttachmentType.Audio - mimeType?.endsWith("pdf") == true -> NoteAttachmentType.Pdf - else -> { - when { - url.contains(".youtube.com") -> NoteAttachmentType.YouTube - url.contains("/youtube.com") -> NoteAttachmentType.YouTube - url.contains("/youtu.be") -> NoteAttachmentType.YouTube - url.contains(".rumble.com") -> NoteAttachmentType.Rumble - url.contains("/rumble.com") -> NoteAttachmentType.Rumble - url.contains("/open.spotify.com/") -> NoteAttachmentType.Spotify - url.contains("/listen.tidal.com/") -> NoteAttachmentType.Tidal - else -> NoteAttachmentType.Other - } + mimeType?.let { + val mimeTypeAttachment = detectMimeTypeAttachment(mimeType) + if (mimeTypeAttachment != NoteAttachmentType.Other) { + return mimeTypeAttachment } } + + return detectUrlAttachmentType(url) +} + +private fun detectMimeTypeAttachment(mimeType: String): NoteAttachmentType { + return when { + mimeType.startsWith("image") -> NoteAttachmentType.Image + mimeType.startsWith("video") -> NoteAttachmentType.Video + mimeType.startsWith("audio") -> NoteAttachmentType.Audio + mimeType.endsWith("pdf") -> NoteAttachmentType.Pdf + else -> NoteAttachmentType.Other + } +} + +private fun detectUrlAttachmentType(url: String): NoteAttachmentType { + return when { + url.contains(".youtube.com") -> NoteAttachmentType.YouTube + url.contains("/youtube.com") -> NoteAttachmentType.YouTube + url.contains("/youtu.be") -> NoteAttachmentType.YouTube + url.contains(".rumble.com") || url.contains("/rumble.com") -> NoteAttachmentType.Rumble + url.contains("/open.spotify.com/") -> NoteAttachmentType.Spotify + url.contains("/listen.tidal.com/") -> NoteAttachmentType.Tidal + url.contains("/github.com/") -> NoteAttachmentType.GitHub + else -> NoteAttachmentType.Other + } } diff --git a/app/src/main/kotlin/net/primal/android/attachments/repository/AttachmentsRepository.kt b/app/src/main/kotlin/net/primal/android/attachments/repository/AttachmentsRepository.kt index d5bd22e3d..29c37ebc1 100644 --- a/app/src/main/kotlin/net/primal/android/attachments/repository/AttachmentsRepository.kt +++ b/app/src/main/kotlin/net/primal/android/attachments/repository/AttachmentsRepository.kt @@ -1,9 +1,10 @@ package net.primal.android.attachments.repository -import java.util.* import javax.inject.Inject +import kotlinx.coroutines.withContext import net.primal.android.attachments.db.NoteAttachment import net.primal.android.attachments.domain.NoteAttachmentType +import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.db.PrimalDatabase import net.primal.android.networking.primal.upload.PrimalFileUploader import net.primal.android.networking.primal.upload.UnsuccessfulFileUpload @@ -14,6 +15,7 @@ class AttachmentsRepository @Inject constructor( private val activeAccountStore: ActiveAccountStore, private val fileUploader: PrimalFileUploader, private val database: PrimalDatabase, + private val dispatchers: CoroutineDispatcherProvider, ) { fun loadAttachments(noteId: String, types: List): List { @@ -25,15 +27,16 @@ class AttachmentsRepository @Inject constructor( attachment: net.primal.android.editor.domain.NoteAttachment, uploadId: String, onProgress: ((uploadedBytes: Int, totalBytes: Int) -> Unit)? = null, - ): UploadResult { - val userId = activeAccountStore.activeUserId() - return fileUploader.uploadFile( - uri = attachment.localUri, - userId = userId, - uploadId = uploadId, - onProgress = onProgress, - ) - } + ): UploadResult = + withContext(dispatchers.io()) { + val userId = activeAccountStore.activeUserId() + fileUploader.uploadFile( + uri = attachment.localUri, + userId = userId, + uploadId = uploadId, + onProgress = onProgress, + ) + } suspend fun cancelNoteAttachmentUpload(uploadId: String) { val userId = activeAccountStore.activeUserId() diff --git a/app/src/main/kotlin/net/primal/android/core/utils/UriUtils.kt b/app/src/main/kotlin/net/primal/android/core/utils/UriUtils.kt index 1b1dfa648..a14a36364 100644 --- a/app/src/main/kotlin/net/primal/android/core/utils/UriUtils.kt +++ b/app/src/main/kotlin/net/primal/android/core/utils/UriUtils.kt @@ -15,7 +15,7 @@ fun String.parseUris(includeNostrUris: Boolean = true): List { .filterInvalidTLDs() .map { it.originalUrl } val customUrls = this.detectUrls() - val mergedUrls = mergeUrls(emptyList(), customUrls) + val mergedUrls = mergeUrls(libUrls, customUrls) return if (includeNostrUris) { val nostr = this.parseNostrUris() diff --git a/app/src/main/kotlin/net/primal/android/editor/NoteEditorViewModel.kt b/app/src/main/kotlin/net/primal/android/editor/NoteEditorViewModel.kt index 427e6560e..2c7d373c5 100644 --- a/app/src/main/kotlin/net/primal/android/editor/NoteEditorViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/editor/NoteEditorViewModel.kt @@ -26,16 +26,12 @@ import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import net.primal.android.articles.ArticleRepository import net.primal.android.articles.feed.ui.generateNaddr import net.primal.android.articles.feed.ui.mapAsFeedArticleUi import net.primal.android.attachments.repository.AttachmentsRepository import net.primal.android.core.compose.profile.model.mapAsUserProfileUi -import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.core.files.FileAnalyser -import net.primal.android.crypto.hexToNoteHrp -import net.primal.android.crypto.hexToNpubHrp import net.primal.android.editor.NoteEditorContract.SideEffect import net.primal.android.editor.NoteEditorContract.UiEvent import net.primal.android.editor.NoteEditorContract.UiState @@ -53,20 +49,26 @@ import net.primal.android.networking.relays.errors.MissingRelaysException import net.primal.android.networking.relays.errors.NostrPublishException import net.primal.android.networking.sockets.errors.WssException import net.primal.android.nostr.model.NostrEventKind +import net.primal.android.nostr.repository.RelayHintsRepository +import net.primal.android.nostr.utils.MAX_RELAY_HINTS import net.primal.android.nostr.utils.Naddr import net.primal.android.nostr.utils.Nevent import net.primal.android.nostr.utils.Nip19TLV +import net.primal.android.nostr.utils.Nip19TLV.toNeventString +import net.primal.android.nostr.utils.Nip19TLV.toNprofileString +import net.primal.android.nostr.utils.Nprofile +import net.primal.android.notes.feed.model.FeedPostUi import net.primal.android.notes.feed.model.asFeedPostUi import net.primal.android.notes.repository.FeedRepository import net.primal.android.premium.legend.domain.asLegendaryCustomization import net.primal.android.profile.repository.ProfileRepository import net.primal.android.user.accounts.active.ActiveAccountStore import net.primal.android.user.accounts.active.ActiveUserAccountState +import net.primal.android.user.repository.RelayRepository import timber.log.Timber class NoteEditorViewModel @AssistedInject constructor( @Assisted private val args: NoteEditorArgs, - private val dispatcherProvider: CoroutineDispatcherProvider, private val fileAnalyser: FileAnalyser, private val activeAccountStore: ActiveAccountStore, private val feedRepository: FeedRepository, @@ -76,6 +78,8 @@ class NoteEditorViewModel @AssistedInject constructor( private val exploreRepository: ExploreRepository, private val profileRepository: ProfileRepository, private val articleRepository: ArticleRepository, + private val relayRepository: RelayRepository, + private val relayHintsRepository: RelayHintsRepository, ) : ViewModel() { private val referencedNoteId = args.referencedNoteId @@ -232,9 +236,7 @@ class NoteEditorViewModel @AssistedInject constructor( private fun fetchNoteThreadFromNetwork(replyToNoteId: String) = viewModelScope.launch { try { - withContext(dispatcherProvider.io()) { - feedRepository.fetchReplies(noteId = replyToNoteId) - } + feedRepository.fetchReplies(noteId = replyToNoteId) } catch (error: WssException) { Timber.w(error) } @@ -243,12 +245,10 @@ class NoteEditorViewModel @AssistedInject constructor( private fun fetchArticleDetailsFromNetwork(replyToArticleNaddr: Naddr) = viewModelScope.launch { try { - withContext(dispatcherProvider.io()) { - articleRepository.fetchArticleAndComments( - articleId = replyToArticleNaddr.identifier, - articleAuthorId = replyToArticleNaddr.userId, - ) - } + articleRepository.fetchArticleAndComments( + articleId = replyToArticleNaddr.identifier, + articleAuthorId = replyToArticleNaddr.userId, + ) } catch (error: WssException) { Timber.w(error) } @@ -274,20 +274,8 @@ class NoteEditorViewModel @AssistedInject constructor( userId = activeAccountStore.activeUserId(), content = noteContent, attachments = _state.value.attachments, - rootNoteNevent = rootPost?.let { - Nevent( - kind = NostrEventKind.ShortTextNote.value, - userId = rootPost.authorId, - eventId = rootPost.postId, - ) - }, - replyToNoteNevent = replyToPost?.let { - Nevent( - kind = NostrEventKind.ShortTextNote.value, - userId = replyToPost.authorId, - eventId = replyToPost.postId, - ) - }, + rootNoteNevent = rootPost?.asNevent(), + replyToNoteNevent = replyToPost?.asNevent(), rootArticleNaddr = referencedArticleNaddr ?: _state.value.referencedArticle?.generateNaddr(), rootHighlightNevent = referencedHighlightNevent @@ -329,12 +317,26 @@ class NoteEditorViewModel @AssistedInject constructor( fetchNoteReplies() } - private fun String.replaceUserMentionsWithUserIds(users: List): String { + private suspend fun String.replaceUserMentionsWithUserIds(users: List): String { var content = this + val userRelaysMap = try { + relayRepository + .fetchAndUpdateUserRelays(userIds = users.map { it.userId }) + .associateBy { it.pubkey } + } catch (error: WssException) { + Timber.w(error) + emptyMap() + } + users.forEach { user -> + val nprofile = Nprofile( + pubkey = user.userId, + relays = userRelaysMap[user.userId]?.relays + ?.filter { it.write }?.map { it.url }?.take(MAX_RELAY_HINTS) ?: emptyList(), + ) content = content.replace( oldValue = user.displayUsername, - newValue = "nostr:${user.userId.hexToNpubHrp()}", + newValue = "nostr:${nprofile.toNprofileString()}", ) } return content @@ -380,19 +382,17 @@ class NoteEditorViewModel @AssistedInject constructor( updatedAttachment = updatedAttachment.copy(uploadError = null) updateNoteAttachmentState(attachment = updatedAttachment) - val uploadResult = withContext(dispatcherProvider.io()) { - attachmentRepository.uploadNoteAttachment( - attachment = attachment, - uploadId = uploadId, - onProgress = { uploadedBytes, totalBytes -> - updatedAttachment = updatedAttachment.copy( - originalUploadedInBytes = uploadedBytes, - originalSizeInBytes = totalBytes, - ) - updateNoteAttachmentState(attachment = updatedAttachment) - }, - ) - } + val uploadResult = attachmentRepository.uploadNoteAttachment( + attachment = attachment, + uploadId = uploadId, + onProgress = { uploadedBytes, totalBytes -> + updatedAttachment = updatedAttachment.copy( + originalUploadedInBytes = uploadedBytes, + originalSizeInBytes = totalBytes, + ) + updateNoteAttachmentState(attachment = updatedAttachment) + }, + ) updatedAttachment = updatedAttachment.copy( remoteUrl = uploadResult.remoteUrl, @@ -503,7 +503,7 @@ class NoteEditorViewModel @AssistedInject constructor( private fun fetchPopularUsers() = viewModelScope.launch { try { - val popularUsers = withContext(dispatcherProvider.io()) { exploreRepository.fetchPopularUsers() } + val popularUsers = exploreRepository.fetchPopularUsers() setState { copy(popularUsers = popularUsers.map { it.mapAsUserProfileUi() }) } } catch (error: WssException) { Timber.w(error) @@ -514,9 +514,7 @@ class NoteEditorViewModel @AssistedInject constructor( viewModelScope.launch { if (query.isNotEmpty()) { try { - val result = withContext(dispatcherProvider.io()) { - exploreRepository.searchUsers(query = query, limit = 10) - } + val result = exploreRepository.searchUsers(query = query, limit = 10) setState { copy(users = result.map { it.mapAsUserProfileUi() }) } } catch (error: WssException) { Timber.w(error) @@ -545,16 +543,31 @@ class NoteEditorViewModel @AssistedInject constructor( private fun markProfileInteraction(profileId: String) { viewModelScope.launch { - withContext(dispatcherProvider.io()) { - profileRepository.markAsInteracted(profileId = profileId) - } + profileRepository.markAsInteracted(profileId = profileId) } } - private fun String.concatenateReferencedEvents() = - this + listOfNotNull( - args.referencedNoteId?.hexToNoteHrp(), + private suspend fun FeedPostUi.asNevent(): Nevent { + val relayHints = runCatching { relayHintsRepository.findRelaysByIds(listOf(this.postId)) }.getOrNull() + + return Nevent( + kind = NostrEventKind.ShortTextNote.value, + userId = this.authorId, + eventId = this.postId, + relays = relayHints?.firstOrNull { it.eventId == this.postId }?.relays?.take(MAX_RELAY_HINTS) + ?: emptyList(), + ) + } + + private suspend fun String.concatenateReferencedEvents(): String { + val referencedNoteNevent = referencedNoteId?.let { refNote -> + state.value.conversation.first { it.postId == refNote } + }?.asNevent() + + return this + listOfNotNull( + referencedNoteNevent?.toNeventString(), args.referencedHighlightNevent, args.referencedArticleNaddr, ).joinToString(separator = " \n\n", prefix = " \n\n") { "nostr:$it" } + } } diff --git a/app/src/main/kotlin/net/primal/android/explore/search/SearchViewModel.kt b/app/src/main/kotlin/net/primal/android/explore/search/SearchViewModel.kt index 92cffdfa5..480e51617 100644 --- a/app/src/main/kotlin/net/primal/android/explore/search/SearchViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/explore/search/SearchViewModel.kt @@ -14,9 +14,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import net.primal.android.core.compose.profile.model.mapAsUserProfileUi -import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.explore.repository.ExploreRepository import net.primal.android.explore.search.SearchContract.UiEvent import net.primal.android.explore.search.SearchContract.UiState @@ -26,7 +24,6 @@ import timber.log.Timber @HiltViewModel class SearchViewModel @Inject constructor( - private val dispatcherProvider: CoroutineDispatcherProvider, private val exploreRepository: ExploreRepository, private val profileRepository: ProfileRepository, ) : ViewModel() { @@ -70,7 +67,7 @@ class SearchViewModel @Inject constructor( viewModelScope.launch { setState { copy(searching = true) } try { - val result = withContext(dispatcherProvider.io()) { exploreRepository.searchUsers(query = query) } + val result = exploreRepository.searchUsers(query = query) setState { copy(searchResults = result.map { it.mapAsUserProfileUi() }) } } catch (error: WssException) { Timber.w(error) @@ -92,7 +89,7 @@ class SearchViewModel @Inject constructor( private fun fetchRecommendedUsers() = viewModelScope.launch { try { - val popularUsers = withContext(dispatcherProvider.io()) { exploreRepository.fetchPopularUsers() } + val popularUsers = exploreRepository.fetchPopularUsers() setState { copy(popularUsers = popularUsers.map { it.mapAsUserProfileUi() }) } } catch (error: WssException) { Timber.w(error) @@ -101,9 +98,7 @@ class SearchViewModel @Inject constructor( private fun markProfileInteraction(profileId: String) { viewModelScope.launch { - withContext(dispatcherProvider.io()) { - profileRepository.markAsInteracted(profileId = profileId) - } + profileRepository.markAsInteracted(profileId = profileId) } } } diff --git a/app/src/main/kotlin/net/primal/android/networking/primal/PrimalVerb.kt b/app/src/main/kotlin/net/primal/android/networking/primal/PrimalVerb.kt index 0d1612a81..7cb68dcbb 100644 --- a/app/src/main/kotlin/net/primal/android/networking/primal/PrimalVerb.kt +++ b/app/src/main/kotlin/net/primal/android/networking/primal/PrimalVerb.kt @@ -6,7 +6,7 @@ enum class PrimalVerb(val identifier: String) { USER_PROFILE("user_profile"), USER_PROFILE_FOLLOWED_BY("user_profile_followed_by"), USER_FOLLOWERS("user_followers"), - USER_RELAYS("get_user_relays"), + USER_RELAYS_2("get_user_relays_2"), RECOMMENDED_USERS("get_recommended_users"), GET_APP_SETTINGS("get_app_settings"), GET_DEFAULT_APP_SETTINGS("get_default_app_settings"), diff --git a/app/src/main/kotlin/net/primal/android/nostr/repository/RelayHintsRepository.kt b/app/src/main/kotlin/net/primal/android/nostr/repository/RelayHintsRepository.kt new file mode 100644 index 000000000..4f607fc4c --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/nostr/repository/RelayHintsRepository.kt @@ -0,0 +1,16 @@ +package net.primal.android.nostr.repository + +import javax.inject.Inject +import kotlinx.coroutines.withContext +import net.primal.android.core.coroutines.CoroutineDispatcherProvider +import net.primal.android.db.PrimalDatabase + +class RelayHintsRepository @Inject constructor( + private val database: PrimalDatabase, + private val dispatchers: CoroutineDispatcherProvider, +) { + suspend fun findRelaysByIds(eventIds: List) = + withContext(dispatchers.io()) { + database.eventHints().findById(eventIds = eventIds) + } +} diff --git a/app/src/main/kotlin/net/primal/android/nostr/utils/Constants.kt b/app/src/main/kotlin/net/primal/android/nostr/utils/Constants.kt new file mode 100644 index 000000000..611cbf977 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/nostr/utils/Constants.kt @@ -0,0 +1,3 @@ +package net.primal.android.nostr.utils + +const val MAX_RELAY_HINTS = 2 diff --git a/app/src/main/kotlin/net/primal/android/nostr/utils/Nip19TLV.kt b/app/src/main/kotlin/net/primal/android/nostr/utils/Nip19TLV.kt index e9367449d..71732ddfb 100644 --- a/app/src/main/kotlin/net/primal/android/nostr/utils/Nip19TLV.kt +++ b/app/src/main/kotlin/net/primal/android/nostr/utils/Nip19TLV.kt @@ -42,6 +42,27 @@ object Nip19TLV { private fun String.cleanNostrScheme(): String = this.removePrefix("nostr:") + fun parseUriAsNprofileOrNull(nprofileUri: String): Nprofile? = + runCatching { + parseAsNprofile(nprofile = nprofileUri.cleanNostrScheme()) + }.getOrNull() + + private fun parseAsNprofile(nprofile: String): Nprofile? { + val tlv = parse(nprofile) + val pubkey = tlv[Type.SPECIAL.id]?.first()?.toHex() + + val relays = tlv[Type.RELAY.id]?.map { + String(bytes = it, charset = Charsets.US_ASCII) + } ?: emptyList() + + return pubkey?.let { + Nprofile( + pubkey = pubkey, + relays = relays, + ) + } + } + fun parseUriAsNeventOrNull(neventUri: String): Nevent? = runCatching { parseAsNevent(nevent = neventUri.cleanNostrScheme()) @@ -51,9 +72,10 @@ object Nip19TLV { val tlv = parse(nevent) val eventId = tlv[Type.SPECIAL.id]?.first()?.toHex() - val relays = tlv[Type.RELAY.id]?.first()?.let { + val relays = tlv[Type.RELAY.id]?.map { String(bytes = it, charset = Charsets.US_ASCII) - } + } ?: emptyList() + val profileId = tlv[Type.AUTHOR.id]?.first()?.toHex() val kind = tlv[Type.KIND.id]?.first()?.let { @@ -64,7 +86,7 @@ object Nip19TLV { kind = kind, eventId = eventId, userId = profileId, - relays = relays?.split(",") ?: emptyList(), + relays = relays, ) } else { null @@ -81,9 +103,10 @@ object Nip19TLV { val identifier = tlv[Type.SPECIAL.id]?.first()?.let { String(bytes = it, charset = Charsets.US_ASCII) } - val relays = tlv[Type.RELAY.id]?.first()?.let { + val relays = tlv[Type.RELAY.id]?.map { String(bytes = it, charset = Charsets.US_ASCII) - } + } ?: emptyList() + val profileId = tlv[Type.AUTHOR.id]?.first()?.toHex() val kind = tlv[Type.KIND.id]?.first()?.let { @@ -93,7 +116,7 @@ object Nip19TLV { return if (identifier != null && profileId != null && kind != null) { Naddr( identifier = identifier, - relays = relays?.split(",") ?: emptyList(), + relays = relays, userId = profileId, kind = kind, ) @@ -102,6 +125,24 @@ object Nip19TLV { } } + fun Nprofile.toNprofileString(): String { + val tlv = mutableListOf() + + // Add profile public key + tlv.addAll(this.pubkey.constructNprofileSpecialBytes()) + + // Add RELAY type if not empty + if (this.relays.isNotEmpty()) { + tlv.addAll(this.relays.constructRelayBytes()) + } + + return Bech32.encodeBytes( + hrp = "nprofile", + data = tlv.toByteArray(), + encoding = Bech32.Encoding.Bech32, + ) + } + fun Nevent.toNeventString(): String { val tlv = mutableListOf() @@ -157,6 +198,11 @@ object Nip19TLV { return kindBytes.toTLVBytes(type = Type.KIND) } + private fun String.constructNprofileSpecialBytes(): List { + val authorBytes = this.hexToBytes() + return authorBytes.toTLVBytes(type = Type.SPECIAL) + } + private fun String.constructNeventSpecialBytes(): List { val authorBytes = this.hexToBytes() return authorBytes.toTLVBytes(type = Type.SPECIAL) @@ -173,8 +219,7 @@ object Nip19TLV { } private fun List.constructRelayBytes(): List { - val relaysBytes = this.joinToString(",").toByteArray(Charsets.US_ASCII) - return relaysBytes.toTLVBytes(type = Type.RELAY) + return flatMap { it.toByteArray(Charsets.US_ASCII).toTLVBytes(type = Type.RELAY) } } private fun ByteArray.toTLVBytes(type: Type) = diff --git a/app/src/main/kotlin/net/primal/android/nostr/utils/Nprofile.kt b/app/src/main/kotlin/net/primal/android/nostr/utils/Nprofile.kt new file mode 100644 index 000000000..bbf39b76d --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/nostr/utils/Nprofile.kt @@ -0,0 +1,6 @@ +package net.primal.android.nostr.utils + +data class Nprofile( + val pubkey: String, + val relays: List = emptyList(), +) diff --git a/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/NoteLinkLargePreview.kt b/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/NoteLinkLargePreview.kt new file mode 100644 index 000000000..b9368c233 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/NoteLinkLargePreview.kt @@ -0,0 +1,112 @@ +package net.primal.android.notes.feed.note.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import net.primal.android.core.utils.extractTLD +import net.primal.android.notes.feed.note.ui.attachment.NoteImageLoadingPlaceholder +import net.primal.android.theme.AppTheme + +@Composable +fun NoteLinkLargePreview( + url: String, + title: String?, + description: String?, + thumbnailUrl: String?, + thumbnailImageSize: DpSize, + onClick: (() -> Unit)? = null, +) { + Column( + modifier = Modifier + .padding(top = 4.dp, bottom = 8.dp) + .background( + color = AppTheme.extraColorScheme.surfaceVariantAlt3, + shape = AppTheme.shapes.small, + ) + .border( + width = Dp.Hairline, + color = AppTheme.colorScheme.outline, + shape = AppTheme.shapes.small, + ) + .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }) + .clip(AppTheme.shapes.small), + ) { + if (thumbnailUrl != null) { + SubcomposeAsyncImage( + model = thumbnailUrl, + modifier = Modifier + .clip( + shape = AppTheme.shapes.small.copy( + bottomStart = CornerSize(0.dp), + bottomEnd = CornerSize(0.dp), + ), + ) + .width(thumbnailImageSize.width) + .height(thumbnailImageSize.height), + contentDescription = null, + contentScale = ContentScale.FillHeight, + loading = { NoteImageLoadingPlaceholder() }, + ) + } + + val tld = url.extractTLD() + if (tld != null) { + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 16.dp, vertical = 8.dp), + text = tld, + maxLines = 1, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt3, + style = AppTheme.typography.bodySmall, + ) + } + + if (title != null) { + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 16.dp) + .padding(bottom = if (description != null) 4.dp else 0.dp), + text = title, + maxLines = 2, + color = AppTheme.colorScheme.onSurface, + style = AppTheme.typography.bodyMedium, + ) + } + + if (description != null) { + Text( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 16.dp), + text = description, + maxLines = 4, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt3, + style = AppTheme.typography.bodyMedium, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/attachment/NoteAttachments.kt b/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/attachment/NoteAttachments.kt index cb752078b..63ef5dcb7 100644 --- a/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/attachment/NoteAttachments.kt +++ b/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/attachment/NoteAttachments.kt @@ -6,12 +6,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import net.primal.android.attachments.domain.NoteAttachmentType import net.primal.android.core.compose.attachment.model.NoteAttachmentUi import net.primal.android.core.compose.attachment.model.isMediaAttachment import net.primal.android.notes.feed.note.ui.NoteAudioSpotifyLinkPreview import net.primal.android.notes.feed.note.ui.NoteAudioTidalLinkPreview +import net.primal.android.notes.feed.note.ui.NoteLinkLargePreview import net.primal.android.notes.feed.note.ui.NoteLinkPreview import net.primal.android.notes.feed.note.ui.NoteVideoLinkPreview import net.primal.android.notes.feed.note.ui.NoteYouTubeLinkPreview @@ -122,6 +124,17 @@ private fun NoteLinkAttachment( ) } + NoteAttachmentType.GitHub -> { + NoteLinkLargePreview( + url = attachment.url, + title = attachment.title, + thumbnailUrl = attachment.thumbnailUrl, + onClick = { onUrlClick?.invoke(attachment.url) }, + description = attachment.description, + thumbnailImageSize = DpSize(width = maxWidth, height = maxWidth / 2), + ) + } + else -> if (!attachment.title.isNullOrBlank()) { NoteLinkPreview( modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), diff --git a/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsViewModel.kt b/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsViewModel.kt index 7cb1d137c..8d0612204 100644 --- a/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsViewModel.kt @@ -92,9 +92,7 @@ class ProfileDetailsViewModel @Inject constructor( private fun markProfileInteraction() { if (!isActiveUser) { viewModelScope.launch { - withContext(dispatcherProvider.io()) { - profileRepository.markAsInteracted(profileId = profileId) - } + profileRepository.markAsInteracted(profileId = profileId) } } } diff --git a/app/src/main/kotlin/net/primal/android/profile/repository/ProfileRepository.kt b/app/src/main/kotlin/net/primal/android/profile/repository/ProfileRepository.kt index d5757e018..215756d62 100644 --- a/app/src/main/kotlin/net/primal/android/profile/repository/ProfileRepository.kt +++ b/app/src/main/kotlin/net/primal/android/profile/repository/ProfileRepository.kt @@ -272,14 +272,15 @@ class ProfileRepository @Inject constructor( usersApi.isUserFollowing(userId, targetUserId) } - fun markAsInteracted(profileId: String) { - database.profileInteractions().insertOrUpdate( - ProfileInteraction( - profileId = profileId, - lastInteractionAt = Instant.now().epochSecond, - ), - ) - } + suspend fun markAsInteracted(profileId: String) = + withContext(dispatchers.io()) { + database.profileInteractions().insertOrUpdate( + ProfileInteraction( + profileId = profileId, + lastInteractionAt = Instant.now().epochSecond, + ), + ) + } class FollowListNotFound : Exception() } diff --git a/app/src/main/kotlin/net/primal/android/user/api/UsersApi.kt b/app/src/main/kotlin/net/primal/android/user/api/UsersApi.kt index 6ff21c38c..cc295c6d2 100644 --- a/app/src/main/kotlin/net/primal/android/user/api/UsersApi.kt +++ b/app/src/main/kotlin/net/primal/android/user/api/UsersApi.kt @@ -5,7 +5,7 @@ import net.primal.android.user.api.model.BookmarksResponse import net.primal.android.user.api.model.UserContactsResponse import net.primal.android.user.api.model.UserProfileResponse import net.primal.android.user.api.model.UserProfilesResponse -import net.primal.android.user.api.model.UserRelaysResponse +import net.primal.android.user.api.model.UsersRelaysResponse interface UsersApi { @@ -25,7 +25,7 @@ interface UsersApi { suspend fun getUserFollowing(userId: String): UsersResponse - suspend fun getUserRelays(userId: String): UserRelaysResponse + suspend fun getUserRelays(userIds: List): UsersRelaysResponse suspend fun getDefaultRelays(): List diff --git a/app/src/main/kotlin/net/primal/android/user/api/UsersApiImpl.kt b/app/src/main/kotlin/net/primal/android/user/api/UsersApiImpl.kt index 09d2ba1c1..9644b9d7c 100644 --- a/app/src/main/kotlin/net/primal/android/user/api/UsersApiImpl.kt +++ b/app/src/main/kotlin/net/primal/android/user/api/UsersApiImpl.kt @@ -1,7 +1,6 @@ package net.primal.android.user.api import javax.inject.Inject -import kotlinx.serialization.encodeToString import net.primal.android.core.serialization.json.NostrJson import net.primal.android.explore.api.model.UsersResponse import net.primal.android.networking.di.PrimalCacheApiClient @@ -18,8 +17,9 @@ import net.primal.android.user.api.model.UserProfileFollowedByRequestBody import net.primal.android.user.api.model.UserProfileResponse import net.primal.android.user.api.model.UserProfilesRequestBody import net.primal.android.user.api.model.UserProfilesResponse -import net.primal.android.user.api.model.UserRelaysResponse import net.primal.android.user.api.model.UserRequestBody +import net.primal.android.user.api.model.UsersRelaysResponse +import net.primal.android.user.api.model.UsersRequestBody class UsersApiImpl @Inject constructor( @PrimalCacheApiClient private val primalApiClient: PrimalApiClient, @@ -152,16 +152,16 @@ class UsersApiImpl @Inject constructor( ) } - override suspend fun getUserRelays(userId: String): UserRelaysResponse { + override suspend fun getUserRelays(userIds: List): UsersRelaysResponse { val queryResult = primalApiClient.query( message = PrimalCacheFilter( - primalVerb = PrimalVerb.USER_RELAYS, - optionsJson = NostrJson.encodeToString(UserRequestBody(pubkey = userId)), + primalVerb = PrimalVerb.USER_RELAYS_2, + optionsJson = NostrJson.encodeToString(UsersRequestBody(pubkeys = userIds)), ), ) - return UserRelaysResponse( - cachedRelayListEvent = queryResult.findPrimalEvent(NostrEventKind.PrimalUserRelaysList), + return UsersRelaysResponse( + cachedRelayListEvents = queryResult.filterPrimalEvents(NostrEventKind.PrimalUserRelaysList), ) } diff --git a/app/src/main/kotlin/net/primal/android/user/api/model/UserRelaysResponse.kt b/app/src/main/kotlin/net/primal/android/user/api/model/UsersRelaysResponse.kt similarity index 62% rename from app/src/main/kotlin/net/primal/android/user/api/model/UserRelaysResponse.kt rename to app/src/main/kotlin/net/primal/android/user/api/model/UsersRelaysResponse.kt index c482ac065..c17045b5d 100644 --- a/app/src/main/kotlin/net/primal/android/user/api/model/UserRelaysResponse.kt +++ b/app/src/main/kotlin/net/primal/android/user/api/model/UsersRelaysResponse.kt @@ -4,6 +4,6 @@ import kotlinx.serialization.Serializable import net.primal.android.nostr.model.primal.PrimalEvent @Serializable -data class UserRelaysResponse( - val cachedRelayListEvent: PrimalEvent? = null, +data class UsersRelaysResponse( + val cachedRelayListEvents: List = emptyList(), ) diff --git a/app/src/main/kotlin/net/primal/android/user/api/model/UsersRequestBody.kt b/app/src/main/kotlin/net/primal/android/user/api/model/UsersRequestBody.kt new file mode 100644 index 000000000..3574f7b14 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/user/api/model/UsersRequestBody.kt @@ -0,0 +1,6 @@ +package net.primal.android.user.api.model + +import kotlinx.serialization.Serializable + +@Serializable +data class UsersRequestBody(val pubkeys: List) diff --git a/app/src/main/kotlin/net/primal/android/user/domain/UserRelays.kt b/app/src/main/kotlin/net/primal/android/user/domain/UserRelays.kt new file mode 100644 index 000000000..9d1711789 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/user/domain/UserRelays.kt @@ -0,0 +1,6 @@ +package net.primal.android.user.domain + +data class UserRelays( + val pubkey: String, + val relays: List, +) diff --git a/app/src/main/kotlin/net/primal/android/user/repository/RelayRepository.kt b/app/src/main/kotlin/net/primal/android/user/repository/RelayRepository.kt index 61f641730..ec907fe91 100644 --- a/app/src/main/kotlin/net/primal/android/user/repository/RelayRepository.kt +++ b/app/src/main/kotlin/net/primal/android/user/repository/RelayRepository.kt @@ -3,6 +3,8 @@ package net.primal.android.user.repository import androidx.room.withTransaction import javax.inject.Inject import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.db.PrimalDatabase import net.primal.android.networking.relays.FALLBACK_RELAYS import net.primal.android.networking.relays.errors.NostrPublishException @@ -12,6 +14,7 @@ import net.primal.android.user.accounts.parseNip65Relays import net.primal.android.user.api.UsersApi import net.primal.android.user.domain.Relay as RelayDO import net.primal.android.user.domain.RelayKind +import net.primal.android.user.domain.UserRelays import net.primal.android.user.domain.cleanWebSocketUrl import net.primal.android.user.domain.mapToRelayPO import net.primal.android.user.domain.toRelay @@ -21,6 +24,7 @@ class RelayRepository @Inject constructor( private val primalDatabase: PrimalDatabase, private val usersApi: UsersApi, private val nostrPublisher: NostrPublisher, + private val dispatchers: CoroutineDispatcherProvider, ) { fun observeUserRelays(userId: String) = primalDatabase.relays().observeRelays(userId) @@ -29,20 +33,21 @@ class RelayRepository @Inject constructor( fun findRelays(userId: String, kind: RelayKind) = primalDatabase.relays().findRelays(userId, kind) @Throws(NostrPublishException::class) - suspend fun bootstrapUserRelays(userId: String) { - val relays = try { - usersApi.getDefaultRelays().map { it.toRelay() } - } catch (error: WssException) { - Timber.w(error) - FALLBACK_RELAYS + suspend fun bootstrapUserRelays(userId: String) = + withContext(dispatchers.io()) { + val relays = try { + usersApi.getDefaultRelays().map { it.toRelay() } + } catch (error: WssException) { + Timber.w(error) + FALLBACK_RELAYS + } + replaceUserRelays(userId, relays) + nostrPublisher.publishRelayList(userId, relays) } - replaceUserRelays(userId, relays) - nostrPublisher.publishRelayList(userId, relays) - } private suspend fun fetchUserRelays(userId: String): List? { - val response = usersApi.getUserRelays(userId) - val cachedNip65Event = response.cachedRelayListEvent ?: return null + val response = withContext(dispatchers.io()) { usersApi.getUserRelays(listOf(userId)) } + val cachedNip65Event = response.cachedRelayListEvents.firstOrNull() ?: return null return cachedNip65Event.tags.parseNip65Relays() } @@ -51,17 +56,31 @@ class RelayRepository @Inject constructor( if (relayList != null) replaceUserRelays(userId, relayList) } - private suspend fun replaceUserRelays(userId: String, relays: List) { - primalDatabase.withTransaction { - primalDatabase.relays().deleteAll(userId = userId, kind = RelayKind.UserRelay) - primalDatabase.relays().upsertAll( - relays = relays.map { - it.mapToRelayPO(userId = userId, kind = RelayKind.UserRelay) - }, - ) + private suspend fun fetchUserRelays(userIds: List) = + withContext(dispatchers.io()) { + usersApi.getUserRelays(userIds).cachedRelayListEvents + .filterNot { it.pubKey == null } + .map { UserRelays(pubkey = it.pubKey!!, relays = it.tags.parseNip65Relays()) } + } + + suspend fun fetchAndUpdateUserRelays(userIds: List): List { + return fetchUserRelays(userIds).onEach { + replaceUserRelays(userId = it.pubkey, relays = it.relays) } } + private suspend fun replaceUserRelays(userId: String, relays: List) = + withContext(dispatchers.io()) { + primalDatabase.withTransaction { + primalDatabase.relays().deleteAll(userId = userId, kind = RelayKind.UserRelay) + primalDatabase.relays().upsertAll( + relays = relays.map { + it.mapToRelayPO(userId = userId, kind = RelayKind.UserRelay) + }, + ) + } + } + @Throws(NostrPublishException::class) suspend fun addRelayAndPublishRelayList(userId: String, url: String) { val newRelay = RelayDO(url = url, read = true, write = true) @@ -81,10 +100,11 @@ class RelayRepository @Inject constructor( } } - private suspend fun updateRelayList(userId: String, reducer: List.() -> List) { - val latestRelayList = fetchUserRelays(userId = userId) ?: emptyList() - val newRelayList = latestRelayList.reducer() - nostrPublisher.publishRelayList(userId = userId, relays = newRelayList) - replaceUserRelays(userId = userId, relays = newRelayList) - } + private suspend fun updateRelayList(userId: String, reducer: List.() -> List) = + withContext(dispatchers.io()) { + val latestRelayList = fetchUserRelays(userId = userId) ?: emptyList() + val newRelayList = latestRelayList.reducer() + nostrPublisher.publishRelayList(userId = userId, relays = newRelayList) + replaceUserRelays(userId = userId, relays = newRelayList) + } } diff --git a/app/src/test/kotlin/net/primal/android/core/utils/UriUtilsTest.kt b/app/src/test/kotlin/net/primal/android/core/utils/UriUtilsTest.kt index 6bab6f348..3f2a8fa9b 100644 --- a/app/src/test/kotlin/net/primal/android/core/utils/UriUtilsTest.kt +++ b/app/src/test/kotlin/net/primal/android/core/utils/UriUtilsTest.kt @@ -1,5 +1,6 @@ package net.primal.android.core.utils +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.ints.shouldBeExactly import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe @@ -37,6 +38,63 @@ class UriUtilsTest { expectedUrls.size shouldBeExactly 1 } + @Test + fun `parseUrls should recognize various url formats`() { + val content = """ + Some random links about bitcoin: + https://en.m.wikipedia.org/wiki/Bit_(money) + Check out primal.net for more info! + Visit us at www.primal.net or at https://primal.net + Here's a link to a secure site: https://www.example.com/path/to/resource + And a simple link: example.com + Don't forget the test with brackets: https://example.com/page?(query)=1&sort=desc + """.trimIndent() + + val expectedUrls = content.parseUris() + + expectedUrls shouldBe listOf( + "primal.net", + "www.primal.net", + "https://primal.net", + "https://www.example.com/path/to/resource", + "example.com", + "https://en.m.wikipedia.org/wiki/Bit_(money)", + "https://example.com/page?(query)=1&sort=desc", + ) + expectedUrls.size shouldBeExactly 7 + } + + @Test + fun `parseUrls should recognize urls with port numbers`() { + val content = """ + A link with a port number: + https://www.example.com:443/resource + """.trimIndent() + + val expectedUrls = content.parseUris() + + expectedUrls.shouldNotBeNull() + expectedUrls shouldContainExactly listOf( + "https://www.example.com:443/resource", + ) + } + + @Test + fun `parseUrls should return empty for invalid urls`() { + val content = """ + Some random links: + thisisnotalink + http:// + www. + example@com + """.trimIndent() + + val expectedUrls = content.parseUris() + + expectedUrls.shouldNotBeNull() + expectedUrls.size shouldBe 0 + } + @Test fun `parseUrls should not return urls with brackets`() { val hugeContent = javaClass.getResource("/core/release_notes.txt")?.readText() diff --git a/app/src/test/kotlin/net/primal/android/nostr/utils/Nip19TLVTest.kt b/app/src/test/kotlin/net/primal/android/nostr/utils/Nip19TLVTest.kt index 5094dc3d3..4b1c8af7e 100644 --- a/app/src/test/kotlin/net/primal/android/nostr/utils/Nip19TLVTest.kt +++ b/app/src/test/kotlin/net/primal/android/nostr/utils/Nip19TLVTest.kt @@ -7,6 +7,7 @@ import net.primal.android.crypto.toHex import net.primal.android.crypto.toNpub import net.primal.android.nostr.utils.Nip19TLV.toNaddrString import net.primal.android.nostr.utils.Nip19TLV.toNeventString +import net.primal.android.nostr.utils.Nip19TLV.toNprofileString import org.junit.Test class Nip19TLVTest { @@ -138,8 +139,8 @@ class Nip19TLVTest { @Test fun toNaddrString_createsProperNaddr_forGivenNaddrStructureWithMultipleRelays() { val expectedNaddr = "naddr1qqw9x6rfwpcxjmn894fks6ts09shyepdg3ty6tthv4unxmf5q" + - "y4hwumn8ghj7un9d3shjtnyv9kh2uewd9hjcamnwvaz7tmjv4kxz7fwwpexjmtpdshxuet5" + - "qgs04xzt6ldm9qhs0ctw0t58kf4z57umjzmjg6jywu0seadwtqqc75srqsqqqa28zkfejp" + "y28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9nhwden5te0wfjkccte9ec8y6tdv9kzumn9" + + "wspzp75cf0tahv5z7plpdeaws7ex52nmnwgtwfr2g3m37r844evqrr6jqvzqqqr4gux34syq" val naddr = Naddr( identifier = "Shipping-Shipyard-DVM-wey3m4", @@ -173,12 +174,12 @@ class Nip19TLVTest { @Test fun parseUriAsNeventOrNull_returnsProperValuesForNeventSingleUri() { - val nevent = "nostr:nevent1qvzqqqpxfgpzp4sl80zm866yqrha4esknfwp0j4lxfrt29pkrh5nnnj2rgx6dm62qyvhwumn8g" + - "hj7urjv4kkjatd9ec8y6tdv9kzumn9wshszymhwden5te0wp6hyurvv4cxzeewv4ej7qgkwaehxw309aex2mrp0yhx6mmnw" + - "3ezuur4vghsqgz5fxdagtjhp4pgecdvl4vy9fs7p8jhpgeec6qetl7vea5umx6gaswmqppq" + val nevent = "nostr:nevent1qvzqqqpxfgpzp4sl80zm866yqrha4esknfwp0j4lxfrt2" + + "9pkrh5nnnj2rgx6dm62qyv8wumn8ghj7urjv4kkjatd9ec8y6tdv9kzumn9wsqzq4zf" + + "n02zu4cdg2xwrt8atpp2v8sfu4c2xwwxsx2llnx0d8xekj8v6xeest" val expectedEventId = "54499bd42e570d428ce1acfd5842a61e09e570a339c68195ffcccf69cd9b48ec" - val expectedRelays = listOf("wss://premium.primal.net/") + val expectedRelays = listOf("wss://premium.primal.net") val expectedUserId = "d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a" val expectedKind = 9802 @@ -194,9 +195,9 @@ class Nip19TLVTest { @Test fun parseUriAsNeventOrNull_returnsProperValuesForNeventMultipleUris() { - val nevent = "nostr:nevent1qqs9gjvm6sh9wr2z3ns6el2cg2npuz09wz3nn35pjhluenmfekd53mqp" + - "94mhxue69uhhyetvv9ujuerpd46hxtnfduk8wumn8ghj7urjv4kkjatd9ec8y6tdv9kzumn9wspzp4" + - "sl80zm866yqrha4esknfwp0j4lxfrt29pkrh5nnnj2rgx6dm62qvzqqqpxfgvudpun" + val nevent = "nostr:nevent1qvzqqqpxfgpzp4sl80zm866yqrha4esknfwp0j4l" + + "xfrt29pkrh5nnnj2rgx6dm62qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hszx" + + "rhwden5te0wpex2mtfw4kjuurjd9kkzmpwdejhgqpq23yeh4pw2ux59r8p4n74ss4xrcy72u9r88rgr90len8knnvmfrkqe7q9g2" val expectedEventId = "54499bd42e570d428ce1acfd5842a61e09e570a339c68195ffcccf69cd9b48ec" val expectedRelays = listOf("wss://relay.damus.io", "wss://premium.primal.net") @@ -245,9 +246,9 @@ class Nip19TLVTest { @Test fun toNeventString_createsProperNevent_forGivenNeventStructureWithMultipleRelays() { - val expectedNevent = "nevent1qqs9gjvm6sh9wr2z3ns6el2cg2npuz09wz3nn35pjhluenmfekd53mqp94mhxu" + - "e69uhhyetvv9ujuerpd46hxtnfduk8wumn8ghj7urjv4kkjatd9ec8y6tdv9kzumn9wspzp4sl80zm866yqrha" + - "4esknfwp0j4lxfrt29pkrh5nnnj2rgx6dm62qvzqqqpxfgvudpun" + val expectedNevent = "nevent1qqs9gjvm6sh9wr2z3ns6el2cg2npuz09wz3nn35pjhluenm" + + "fekd53mqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3samnwvaz7tmswfjk66t4d5h8qu" + + "nfd4skctnwv46qygxkruautvltgsqwlkhxz6d9c972hueyddg5xcw7jwwwfgdqmfh0fgpsgqqqye9qaww523" val nevent = Nevent( eventId = "54499bd42e570d428ce1acfd5842a61e09e570a339c68195ffcccf69cd9b48ec", @@ -258,4 +259,85 @@ class Nip19TLVTest { nevent.toNeventString() shouldBe expectedNevent } + + @Test + fun parseUriAsNprofileOrNull_returnsProperValuesForNeventNoUris() { + val nprofile = "nostr:nprofile1qqs9gjvm6sh9wr2z3ns6el2cg2npuz09wz3nn35pjhluenmfekd53mqxg4j84" + + val expectedPubkey = "54499bd42e570d428ce1acfd5842a61e09e570a339c68195ffcccf69cd9b48ec" + val expectedRelays = emptyList() + + val result = Nip19TLV.parseUriAsNprofileOrNull(nprofile) + + result.shouldNotBeNull() + result.pubkey shouldBe expectedPubkey + result.relays shouldBe expectedRelays + } + + @Test + fun parseUriAsNprofileOrNull_returnsProperValuesForNeventSingleUri() { + val nprofile = "nostr:nprofile1qyv8wumn8ghj7urjv4kkjatd9ec8y6tdv9" + + "kzumn9wsqzq4zfn02zu4cdg2xwrt8atpp2v8sfu4c2xwwxsx2llnx0d8xekj8v8ee8fv" + + val expectedPubkey = "54499bd42e570d428ce1acfd5842a61e09e570a339c68195ffcccf69cd9b48ec" + val expectedRelays = listOf("wss://premium.primal.net") + + val result = Nip19TLV.parseUriAsNprofileOrNull(nprofile) + + result.shouldNotBeNull() + result.pubkey shouldBe expectedPubkey + result.relays shouldBe expectedRelays + } + + @Test + fun parseUriAsNprofileOrNull_returnsProperValuesForNeventMultipleUris() { + val nprofile = "nostr:nprofile1qyv8wumn8ghj7urjv4kkjatd9ec8y6tdv9kzu" + + "mn9wsq3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7qpq23yeh4pw2ux59r8p4n74ss4xrcy72u9r88rgr90len8knnvmfrkq3s8k4w" + + val expectedPubkey = "54499bd42e570d428ce1acfd5842a61e09e570a339c68195ffcccf69cd9b48ec" + val expectedRelays = listOf("wss://premium.primal.net", "wss://relay.damus.io") + + val result = Nip19TLV.parseUriAsNprofileOrNull(nprofile) + + result.shouldNotBeNull() + result.pubkey shouldBe expectedPubkey + result.relays shouldBe expectedRelays + } + + @Test + fun toNprofileString_createsProperNprofile_forGivenNprofileStructureWithoutRelays() { + val expectedNprofile = "nprofile1qqs9gjvm6sh9wr2z3ns6el2cg2npuz09wz3nn35pjhluenmfekd53mqxg4j84" + + val nprofile = Nprofile( + pubkey = "54499bd42e570d428ce1acfd5842a61e09e570a339c68195ffcccf69cd9b48ec", + ) + + nprofile.toNprofileString() shouldBe expectedNprofile + } + + @Test + fun toNprofileString_createsProperNprofile_forGivenNprofileStructureWithSingleRelay() { + val expectedNprofile = "nprofile1qqs9gjvm6sh9wr2z3ns6el2cg2npuz09wz3n" + + "n35pjhluenmfekd53mqprpmhxue69uhhqun9d45h2mfwwpexjmtpdshxuet5n9zrjx" + + val nprofile = Nprofile( + pubkey = "54499bd42e570d428ce1acfd5842a61e09e570a339c68195ffcccf69cd9b48ec", + relays = listOf("wss://premium.primal.net"), + ) + + nprofile.toNprofileString() shouldBe expectedNprofile + } + + @Test + fun toNprofileString_createsProperNprofile_forGivenNprofileStructureWithMultipleRelays() { + val expectedNprofile = "nprofile1qqs9gjvm6sh9wr2z3ns6el2cg2npuz09wz3nn35p" + + "jhluenmfekd53mqprpmhxue69uhhqun9d45h2mfwwpexjmtpdshxuet5qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsqa5sds" + + val nprofile = Nprofile( + pubkey = "54499bd42e570d428ce1acfd5842a61e09e570a339c68195ffcccf69cd9b48ec", + relays = listOf("wss://premium.primal.net", "wss://relay.damus.io"), + ) + + nprofile.toNprofileString() shouldBe expectedNprofile + } } diff --git a/app/src/test/kotlin/net/primal/android/user/repository/RelayRepositoryTest.kt b/app/src/test/kotlin/net/primal/android/user/repository/RelayRepositoryTest.kt index b65bcfedc..61bc24655 100644 --- a/app/src/test/kotlin/net/primal/android/user/repository/RelayRepositoryTest.kt +++ b/app/src/test/kotlin/net/primal/android/user/repository/RelayRepositoryTest.kt @@ -18,7 +18,7 @@ import net.primal.android.db.PrimalDatabase import net.primal.android.nostr.model.NostrEventKind import net.primal.android.nostr.model.primal.PrimalEvent import net.primal.android.nostr.publish.NostrPublisher -import net.primal.android.user.api.model.UserRelaysResponse +import net.primal.android.user.api.model.UsersRelaysResponse import net.primal.android.user.domain.Relay import net.primal.android.user.domain.RelayKind import net.primal.android.user.domain.cleanWebSocketUrl @@ -78,6 +78,7 @@ class RelayRepositoryTest { coEvery { getDefaultRelays() } returns expectedRelays }, primalDatabase = myDatabase, + dispatchers = coroutinesTestRule.dispatcherProvider, ) repository.bootstrapUserRelays(userId = userId) @@ -95,11 +96,12 @@ class RelayRepositoryTest { val repository = RelayRepository( nostrPublisher = nostrPublisher, usersApi = mockk(relaxed = true) { - coEvery { getUserRelays(userId) } returns UserRelaysResponse( - cachedRelayListEvent = buildPrimalUserRelaysListEvent(relays = relays), + coEvery { getUserRelays(listOf(userId)) } returns UsersRelaysResponse( + cachedRelayListEvents = listOf(buildPrimalUserRelaysListEvent(relays = relays)), ) }, primalDatabase = myDatabase, + dispatchers = coroutinesTestRule.dispatcherProvider, ) repository.removeRelayAndPublishRelayList(userId = userId, url = relays.first()) @@ -118,11 +120,12 @@ class RelayRepositoryTest { val repository = RelayRepository( nostrPublisher = nostrPublisher, usersApi = mockk(relaxed = true) { - coEvery { getUserRelays(userId) } returns UserRelaysResponse( - cachedRelayListEvent = buildPrimalUserRelaysListEvent(relays = relays), + coEvery { getUserRelays(listOf(userId)) } returns UsersRelaysResponse( + cachedRelayListEvents = listOf(buildPrimalUserRelaysListEvent(relays = relays)), ) }, primalDatabase = myDatabase, + dispatchers = coroutinesTestRule.dispatcherProvider, ) repository.removeRelayAndPublishRelayList(userId = userId, url = "wss://nostr1.current.fyi/") @@ -141,11 +144,12 @@ class RelayRepositoryTest { val repository = RelayRepository( nostrPublisher = nostrPublisher, usersApi = mockk(relaxed = true) { - coEvery { getUserRelays(userId) } returns UserRelaysResponse( - cachedRelayListEvent = buildPrimalUserRelaysListEvent(relays = relays), + coEvery { getUserRelays(listOf(userId)) } returns UsersRelaysResponse( + cachedRelayListEvents = listOf(buildPrimalUserRelaysListEvent(relays = relays)), ) }, primalDatabase = myDatabase, + dispatchers = coroutinesTestRule.dispatcherProvider, ) repository.removeRelayAndPublishRelayList(userId = userId, url = relays.first()) @@ -167,11 +171,12 @@ class RelayRepositoryTest { val repository = RelayRepository( nostrPublisher = nostrPublisher, usersApi = mockk(relaxed = true) { - coEvery { getUserRelays(userId) } returns UserRelaysResponse( - cachedRelayListEvent = buildPrimalUserRelaysListEvent(relays = emptyList()), + coEvery { getUserRelays(listOf(userId)) } returns UsersRelaysResponse( + cachedRelayListEvents = listOf(buildPrimalUserRelaysListEvent(relays = emptyList())), ) }, primalDatabase = myDatabase, + dispatchers = coroutinesTestRule.dispatcherProvider, ) myDatabase.relays().upsertAll( @@ -200,9 +205,11 @@ class RelayRepositoryTest { val repository = RelayRepository( nostrPublisher = nostrPublisher, usersApi = mockk(relaxed = true) { - coEvery { getUserRelays(userId) } returns UserRelaysResponse(cachedRelayListEvent = null) + coEvery { getUserRelays(listOf(userId)) } returns + UsersRelaysResponse(cachedRelayListEvents = emptyList()) }, primalDatabase = myDatabase, + dispatchers = coroutinesTestRule.dispatcherProvider, ) val expectedRelays = listOf(