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(