diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 2f3e458a..59e18a72 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -16,6 +16,7 @@ CyclomaticComplexMethod:MessagesRemoteMediator.kt$MessagesRemoteMediator$override suspend fun load(loadType: LoadType, state: PagingState<Int, DirectMessage>): MediatorResult CyclomaticComplexMethod:NostrUserText.kt$@Composable fun NostrUserText( displayName: String, internetIdentifier: String?, modifier: Modifier = Modifier, displayNameColor: Color = AppTheme.colorScheme.onSurface, fontSize: TextUnit = TextUnit.Unspecified, style: TextStyle = LocalTextStyle.current, overflow: TextOverflow = TextOverflow.Ellipsis, maxLines: Int = 1, internetIdentifierBadgeSize: Dp = 14.dp, internetIdentifierBadgeAlign: PlaceholderVerticalAlign = PlaceholderVerticalAlign.Center, customBadgeStyle: LegendaryStyle? = null, annotatedStringPrefixBuilder: (AnnotatedString.Builder.() -> Unit)? = null, annotatedStringSuffixBuilder: (AnnotatedString.Builder.() -> Unit)? = null, ) CyclomaticComplexMethod:NoteActionsRow.kt$@Composable fun FeedNoteActionsRow( modifier: Modifier, eventStats: EventStatsUi, isBookmarked: Boolean, highlightedNote: Boolean = false, showBookmark: Boolean = false, showCounts: Boolean = true, onPostAction: ((FeedPostAction) -> Unit)? = null, onPostLongPressAction: ((FeedPostAction) -> Unit)? = null, ) + CyclomaticComplexMethod:NoteContent.kt$@OptIn(ExperimentalFoundationApi::class) @Composable fun NoteContent( modifier: Modifier = Modifier, data: NoteContentUi, expanded: Boolean, noteCallbacks: NoteCallbacks, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, enableTweetsMode: Boolean = false, textSelectable: Boolean = false, referencedEventsHaveBorder: Boolean = false, highlightColor: Color = AppTheme.colorScheme.secondary, contentColor: Color = AppTheme.colorScheme.onSurface, referencedEventsContainerColor: Color = AppTheme.extraColorScheme.surfaceVariantAlt1, onClick: ((offset: Offset) -> Unit)? = null, onUrlClick: ((url: String) -> Unit)? = null, ) CyclomaticComplexMethod:NoteFeedLazyColumn.kt$@ExperimentalMaterial3Api @ExperimentalFoundationApi @Composable fun NoteFeedLazyColumn( modifier: Modifier = Modifier, pagingItems: LazyPagingItems<FeedPostUi>, listState: LazyListState, showPaywall: Boolean, noteCallbacks: NoteCallbacks, onGoToWallet: () -> Unit, showTopZaps: Boolean = false, shouldShowLoadingState: Boolean = true, shouldShowNoContentState: Boolean = true, showReplyTo: Boolean = true, noContentVerticalArrangement: Arrangement.Vertical = Arrangement.Center, noContentPaddingValues: PaddingValues = PaddingValues(all = 0.dp), noContentText: String = stringResource(id = R.string.feed_no_content), contentPadding: PaddingValues = PaddingValues(all = 0.dp), header: @Composable (LazyItemScope.() -> Unit)? = null, stickyHeader: @Composable (LazyItemScope.() -> Unit)? = null, onUiError: ((UiError) -> Unit)? = null, ) CyclomaticComplexMethod:NotePublishHandler.kt$NotePublishHandler$@Throws(NostrPublishException::class) suspend fun publishShortTextNote( userId: String, content: String, attachments: List<NoteAttachment> = emptyList(), rootArticleEventId: String? = null, rootArticleId: String? = null, rootArticleAuthorId: String? = null, rootPostId: String? = null, replyToPostId: String? = null, replyToAuthorId: String? = null, ): Boolean CyclomaticComplexMethod:NotificationEvents.kt$private fun ContentPrimalNotification.parseActionPostId(type: NotificationType): String? @@ -95,8 +96,9 @@ LongParameterList:ArticleDetailsViewModel.kt$ArticleDetailsViewModel$( savedStateHandle: SavedStateHandle, private val activeAccountStore: ActiveAccountStore, private val articleRepository: ArticleRepository, private val feedRepository: FeedRepository, private val profileRepository: ProfileRepository, private val eventRepository: EventRepository, private val zapHandler: ZapHandler, ) LongParameterList:ContentAppearance.kt$ContentAppearance$( val noteBodyFontSize: TextUnit, val noteBodyLineHeight: TextUnit, val noteUsernameSize: TextUnit, val noteAvatarSize: Dp, val articleTextFontSize: TextUnit, val articleTextLineHeight: TextUnit, val tweetFontSize: TextUnit, val tweetLineHeight: TextUnit, ) LongParameterList:MessagesProcessor.kt$MessagesProcessor$( userId: String, messages: List<NostrEvent>, profileMetadata: List<NostrEvent>, mediaResources: List<PrimalEvent>, primalUserNames: PrimalEvent?, primalLegendProfiles: PrimalEvent?, ) - LongParameterList:NostrResources.kt$( eventId: String, 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$( 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$( 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 exploreRepository: ExploreRepository, private val profileRepository: ProfileRepository, private val articleRepository: ArticleRepository, ) 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, ) diff --git a/app/schemas/net.primal.android.db.PrimalDatabase/46.json b/app/schemas/net.primal.android.db.PrimalDatabase/46.json new file mode 100644 index 00000000..85b8ae68 --- /dev/null +++ b/app/schemas/net.primal.android.db.PrimalDatabase/46.json @@ -0,0 +1,1783 @@ +{ + "formatVersion": 1, + "database": { + "version": 46, + "identityHash": "0e3be72662ad114f97ee4b849358fcf2", + "entities": [ + { + "tableName": "PostData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`postId` TEXT NOT NULL, `authorId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `uris` TEXT NOT NULL, `hashtags` TEXT NOT NULL, `sig` TEXT NOT NULL, `raw` TEXT NOT NULL, `authorMetadataId` TEXT, `replyToPostId` TEXT, `replyToAuthorId` TEXT, PRIMARY KEY(`postId`))", + "fields": [ + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uris", + "columnName": "uris", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hashtags", + "columnName": "hashtags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorMetadataId", + "columnName": "authorMetadataId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "replyToPostId", + "columnName": "replyToPostId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "replyToAuthorId", + "columnName": "replyToAuthorId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "postId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ProfileData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ownerId` TEXT NOT NULL, `eventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `raw` TEXT NOT NULL, `handle` TEXT, `displayName` TEXT, `internetIdentifier` TEXT, `lightningAddress` TEXT, `lnUrlDecoded` TEXT, `avatarCdnImage` TEXT, `bannerCdnImage` TEXT, `website` TEXT, `about` TEXT, `aboutUris` TEXT NOT NULL, `aboutHashtags` TEXT NOT NULL, `primalName` TEXT, `primalLegendProfile` TEXT, PRIMARY KEY(`ownerId`))", + "fields": [ + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "handle", + "columnName": "handle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "internetIdentifier", + "columnName": "internetIdentifier", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lightningAddress", + "columnName": "lightningAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lnUrlDecoded", + "columnName": "lnUrlDecoded", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarCdnImage", + "columnName": "avatarCdnImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerCdnImage", + "columnName": "bannerCdnImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "aboutUris", + "columnName": "aboutUris", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aboutHashtags", + "columnName": "aboutHashtags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primalName", + "columnName": "primalName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "primalLegendProfile", + "columnName": "primalLegendProfile", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ownerId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RepostData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostId` TEXT NOT NULL, `authorId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `tags` TEXT NOT NULL, `postId` TEXT NOT NULL, `postAuthorId` TEXT NOT NULL, `sig` TEXT NOT NULL, PRIMARY KEY(`repostId`))", + "fields": [ + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postAuthorId", + "columnName": "postAuthorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostId" + ] + }, + "indices": [ + { + "name": "index_RepostData_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_RepostData_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "EventStats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `likes` INTEGER NOT NULL, `replies` INTEGER NOT NULL, `mentions` INTEGER NOT NULL, `reposts` INTEGER NOT NULL, `zaps` INTEGER NOT NULL, `satsZapped` INTEGER NOT NULL, `score` INTEGER NOT NULL, `score24h` INTEGER NOT NULL, PRIMARY KEY(`eventId`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "likes", + "columnName": "likes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replies", + "columnName": "replies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reposts", + "columnName": "reposts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "zaps", + "columnName": "zaps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "satsZapped", + "columnName": "satsZapped", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score24h", + "columnName": "score24h", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EventZap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `zapSenderId` TEXT NOT NULL, `zapReceiverId` TEXT NOT NULL, `zapRequestAt` INTEGER NOT NULL, `zapReceiptAt` INTEGER NOT NULL, `amountInBtc` REAL NOT NULL, `message` TEXT, `zapSenderDisplayName` TEXT, `zapSenderHandle` TEXT, `zapSenderInternetIdentifier` TEXT, `zapSenderAvatarCdnImage` TEXT, PRIMARY KEY(`zapSenderId`, `eventId`, `zapRequestAt`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zapSenderId", + "columnName": "zapSenderId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zapReceiverId", + "columnName": "zapReceiverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zapRequestAt", + "columnName": "zapRequestAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "zapReceiptAt", + "columnName": "zapReceiptAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountInBtc", + "columnName": "amountInBtc", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "zapSenderDisplayName", + "columnName": "zapSenderDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "zapSenderHandle", + "columnName": "zapSenderHandle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "zapSenderInternetIdentifier", + "columnName": "zapSenderInternetIdentifier", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "zapSenderAvatarCdnImage", + "columnName": "zapSenderAvatarCdnImage", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "zapSenderId", + "eventId", + "zapRequestAt" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EventUserStats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `userId` TEXT NOT NULL, `replied` INTEGER NOT NULL, `liked` INTEGER NOT NULL, `reposted` INTEGER NOT NULL, `zapped` INTEGER NOT NULL, PRIMARY KEY(`eventId`, `userId`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replied", + "columnName": "replied", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reposted", + "columnName": "reposted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "zapped", + "columnName": "zapped", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId", + "userId" + ] + }, + "indices": [ + { + "name": "index_EventUserStats_eventId", + "unique": false, + "columnNames": [ + "eventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventUserStats_eventId` ON `${TABLE_NAME}` (`eventId`)" + }, + { + "name": "index_EventUserStats_userId", + "unique": false, + "columnNames": [ + "userId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_EventUserStats_userId` ON `${TABLE_NAME}` (`userId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "NoteNostrUri", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`noteId` TEXT NOT NULL, `uri` TEXT NOT NULL, `type` TEXT NOT NULL, `refEvent_alt` TEXT, `refHighlight_text` TEXT, `refHighlight_aTag` TEXT, `refHighlight_eventId` TEXT, `refHighlight_authorId` TEXT, `refNote_postId` TEXT, `refNote_createdAt` INTEGER, `refNote_content` TEXT, `refNote_authorId` TEXT, `refNote_authorName` TEXT, `refNote_authorAvatarCdnImage` TEXT, `refNote_authorInternetIdentifier` TEXT, `refNote_authorLightningAddress` TEXT, `refNote_attachments` TEXT, `refNote_nostrUris` TEXT, `refArticle_naddr` TEXT, `refArticle_aTag` TEXT, `refArticle_eventId` TEXT, `refArticle_articleId` TEXT, `refArticle_articleTitle` TEXT, `refArticle_authorId` TEXT, `refArticle_authorName` TEXT, `refArticle_authorAvatarCdnImage` TEXT, `refArticle_createdAt` INTEGER, `refArticle_raw` TEXT, `refArticle_articleImageCdnImage` TEXT, `refArticle_articleReadingTimeInMinutes` INTEGER, `refUser_userId` TEXT, `refUser_handle` TEXT, PRIMARY KEY(`noteId`, `uri`))", + "fields": [ + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referencedEventAlt", + "columnName": "refEvent_alt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedHighlight.text", + "columnName": "refHighlight_text", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedHighlight.aTag", + "columnName": "refHighlight_aTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedHighlight.eventId", + "columnName": "refHighlight_eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedHighlight.authorId", + "columnName": "refHighlight_authorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedNote.postId", + "columnName": "refNote_postId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedNote.createdAt", + "columnName": "refNote_createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "referencedNote.content", + "columnName": "refNote_content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedNote.authorId", + "columnName": "refNote_authorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedNote.authorName", + "columnName": "refNote_authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedNote.authorAvatarCdnImage", + "columnName": "refNote_authorAvatarCdnImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedNote.authorInternetIdentifier", + "columnName": "refNote_authorInternetIdentifier", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedNote.authorLightningAddress", + "columnName": "refNote_authorLightningAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedNote.attachments", + "columnName": "refNote_attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedNote.nostrUris", + "columnName": "refNote_nostrUris", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedArticle.naddr", + "columnName": "refArticle_naddr", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedArticle.aTag", + "columnName": "refArticle_aTag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedArticle.eventId", + "columnName": "refArticle_eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedArticle.articleId", + "columnName": "refArticle_articleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedArticle.articleTitle", + "columnName": "refArticle_articleTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedArticle.authorId", + "columnName": "refArticle_authorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedArticle.authorName", + "columnName": "refArticle_authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedArticle.authorAvatarCdnImage", + "columnName": "refArticle_authorAvatarCdnImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedArticle.createdAt", + "columnName": "refArticle_createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "referencedArticle.raw", + "columnName": "refArticle_raw", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedArticle.articleImageCdnImage", + "columnName": "refArticle_articleImageCdnImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedArticle.articleReadingTimeInMinutes", + "columnName": "refArticle_articleReadingTimeInMinutes", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "referencedUser.userId", + "columnName": "refUser_userId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedUser.handle", + "columnName": "refUser_handle", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "noteId", + "uri" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NoteAttachment", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT, `variants` TEXT, `title` TEXT, `description` TEXT, `thumbnail` TEXT, `authorAvatarUrl` TEXT, PRIMARY KEY(`eventId`, `url`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "variants", + "columnName": "variants", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorAvatarUrl", + "columnName": "authorAvatarUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId", + "url" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`spec` TEXT NOT NULL, `specKind` TEXT NOT NULL, `feedKind` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `enabled` INTEGER NOT NULL, PRIMARY KEY(`spec`))", + "fields": [ + { + "fieldPath": "spec", + "columnName": "spec", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "specKind", + "columnName": "specKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedKind", + "columnName": "feedKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "spec" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeedPostDataCrossRef", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`feedSpec` TEXT NOT NULL, `eventId` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, PRIMARY KEY(`feedSpec`, `eventId`))", + "fields": [ + { + "fieldPath": "feedSpec", + "columnName": "feedSpec", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "feedSpec", + "eventId" + ] + }, + "indices": [ + { + "name": "index_FeedPostDataCrossRef_feedSpec", + "unique": false, + "columnNames": [ + "feedSpec" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeedPostDataCrossRef_feedSpec` ON `${TABLE_NAME}` (`feedSpec`)" + }, + { + "name": "index_FeedPostDataCrossRef_eventId", + "unique": false, + "columnNames": [ + "eventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FeedPostDataCrossRef_eventId` ON `${TABLE_NAME}` (`eventId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "FeedPostRemoteKey", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `directive` TEXT NOT NULL, `sinceId` INTEGER NOT NULL, `untilId` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, PRIMARY KEY(`eventId`, `directive`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directive", + "columnName": "directive", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sinceId", + "columnName": "sinceId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "untilId", + "columnName": "untilId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedAt", + "columnName": "cachedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId", + "directive" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NoteConversationCrossRef", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`noteId` TEXT NOT NULL, `replyNoteId` TEXT NOT NULL, PRIMARY KEY(`noteId`, `replyNoteId`))", + "fields": [ + { + "fieldPath": "noteId", + "columnName": "noteId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyNoteId", + "columnName": "replyNoteId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "noteId", + "replyNoteId" + ] + }, + "indices": [ + { + "name": "index_NoteConversationCrossRef_noteId", + "unique": false, + "columnNames": [ + "noteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NoteConversationCrossRef_noteId` ON `${TABLE_NAME}` (`noteId`)" + }, + { + "name": "index_NoteConversationCrossRef_replyNoteId", + "unique": false, + "columnNames": [ + "replyNoteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NoteConversationCrossRef_replyNoteId` ON `${TABLE_NAME}` (`replyNoteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "ProfileStats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `following` INTEGER, `followers` INTEGER, `notesCount` INTEGER, `readsCount` INTEGER, `mediaCount` INTEGER, `repliesCount` INTEGER, `relaysCount` INTEGER, `totalReceivedZaps` INTEGER, `contentZapCount` INTEGER, `totalReceivedSats` INTEGER, `joinedAt` INTEGER, PRIMARY KEY(`profileId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "following", + "columnName": "following", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "followers", + "columnName": "followers", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notesCount", + "columnName": "notesCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "readsCount", + "columnName": "readsCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mediaCount", + "columnName": "mediaCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "relaysCount", + "columnName": "relaysCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalReceivedZaps", + "columnName": "totalReceivedZaps", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentZapCount", + "columnName": "contentZapCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalReceivedSats", + "columnName": "totalReceivedSats", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "joinedAt", + "columnName": "joinedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TrendingTopic", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`topic` TEXT NOT NULL, `score` REAL NOT NULL, PRIMARY KEY(`topic`))", + "fields": [ + { + "fieldPath": "topic", + "columnName": "topic", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "topic" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NotificationData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notificationId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `type` TEXT NOT NULL, `seenGloballyAt` INTEGER, `actionUserId` TEXT, `actionPostId` TEXT, `satsZapped` INTEGER, PRIMARY KEY(`notificationId`))", + "fields": [ + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "seenGloballyAt", + "columnName": "seenGloballyAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "actionUserId", + "columnName": "actionUserId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "actionPostId", + "columnName": "actionPostId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "satsZapped", + "columnName": "satsZapped", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "notificationId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MutedUserData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `userMetadataEventId` TEXT, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userMetadataEventId", + "columnName": "userMetadataEventId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DirectMessageData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `senderId` TEXT NOT NULL, `receiverId` TEXT NOT NULL, `participantId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `content` TEXT NOT NULL, `uris` TEXT NOT NULL, `hashtags` TEXT NOT NULL, PRIMARY KEY(`messageId`))", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderId", + "columnName": "senderId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverId", + "columnName": "receiverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantId", + "columnName": "participantId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uris", + "columnName": "uris", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hashtags", + "columnName": "hashtags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MessageConversationData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`participantId` TEXT NOT NULL, `lastMessageId` TEXT NOT NULL, `lastMessageAt` INTEGER NOT NULL, `unreadMessagesCount` INTEGER NOT NULL, `relation` TEXT NOT NULL, `participantMetadataId` TEXT, PRIMARY KEY(`participantId`))", + "fields": [ + { + "fieldPath": "participantId", + "columnName": "participantId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageAt", + "columnName": "lastMessageAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMessagesCount", + "columnName": "unreadMessagesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relation", + "columnName": "relation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantMetadataId", + "columnName": "participantMetadataId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "participantId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "WalletTransactionData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `walletLightningAddress` TEXT NOT NULL, `type` TEXT NOT NULL, `state` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `completedAt` INTEGER, `amountInBtc` REAL NOT NULL, `amountInUsd` REAL, `isZap` INTEGER NOT NULL, `isStorePurchase` INTEGER NOT NULL, `userId` TEXT NOT NULL, `userSubWallet` TEXT NOT NULL, `userLightningAddress` TEXT, `otherUserId` TEXT, `otherLightningAddress` TEXT, `note` TEXT, `invoice` TEXT, `totalFeeInBtc` TEXT, `exchangeRate` TEXT, `onChainAddress` TEXT, `onChainTxId` TEXT, `zapNoteId` TEXT, `zapNoteAuthorId` TEXT, `zappedByUserId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletLightningAddress", + "columnName": "walletLightningAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "amountInBtc", + "columnName": "amountInBtc", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "amountInUsd", + "columnName": "amountInUsd", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "isZap", + "columnName": "isZap", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStorePurchase", + "columnName": "isStorePurchase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userSubWallet", + "columnName": "userSubWallet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userLightningAddress", + "columnName": "userLightningAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "otherUserId", + "columnName": "otherUserId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "otherLightningAddress", + "columnName": "otherLightningAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalFeeInBtc", + "columnName": "totalFeeInBtc", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "exchangeRate", + "columnName": "exchangeRate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "onChainAddress", + "columnName": "onChainAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "onChainTxId", + "columnName": "onChainTxId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "zapNoteId", + "columnName": "zapNoteId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "zapNoteAuthorId", + "columnName": "zapNoteAuthorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "zappedByUserId", + "columnName": "zappedByUserId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `kind` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`userId`, `kind`, `url`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "kind", + "url" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EventRelayHints", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `relays` TEXT NOT NULL, PRIMARY KEY(`eventId`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PublicBookmark", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tagValue` TEXT NOT NULL, `tagType` TEXT NOT NULL, `bookmarkType` TEXT NOT NULL, `ownerId` TEXT NOT NULL, PRIMARY KEY(`tagValue`))", + "fields": [ + { + "fieldPath": "tagValue", + "columnName": "tagValue", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tagType", + "columnName": "tagType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookmarkType", + "columnName": "bookmarkType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "tagValue" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ProfileInteraction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `lastInteractionAt` INTEGER NOT NULL, PRIMARY KEY(`profileId`))", + "fields": [ + { + "fieldPath": "profileId", + "columnName": "profileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastInteractionAt", + "columnName": "lastInteractionAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "profileId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ArticleData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`aTag` TEXT NOT NULL, `eventId` TEXT NOT NULL, `articleId` TEXT NOT NULL, `authorId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `content` TEXT NOT NULL, `title` TEXT NOT NULL, `publishedAt` INTEGER NOT NULL, `raw` TEXT NOT NULL, `imageCdnImage` TEXT, `summary` TEXT, `authorMetadataId` TEXT, `wordsCount` INTEGER, `uris` TEXT NOT NULL, `hashtags` TEXT NOT NULL, PRIMARY KEY(`aTag`))", + "fields": [ + { + "fieldPath": "aTag", + "columnName": "aTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "articleId", + "columnName": "articleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publishedAt", + "columnName": "publishedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageCdnImage", + "columnName": "imageCdnImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorMetadataId", + "columnName": "authorMetadataId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "wordsCount", + "columnName": "wordsCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uris", + "columnName": "uris", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hashtags", + "columnName": "hashtags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "aTag" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ArticleCommentCrossRef", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`articleId` TEXT NOT NULL, `articleAuthorId` TEXT NOT NULL, `commentNoteId` TEXT NOT NULL, PRIMARY KEY(`articleId`, `articleAuthorId`, `commentNoteId`))", + "fields": [ + { + "fieldPath": "articleId", + "columnName": "articleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "articleAuthorId", + "columnName": "articleAuthorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "commentNoteId", + "columnName": "commentNoteId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "articleId", + "articleAuthorId", + "commentNoteId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ArticleFeedCrossRef", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`spec` TEXT NOT NULL, `articleId` TEXT NOT NULL, `articleAuthorId` TEXT NOT NULL, PRIMARY KEY(`spec`, `articleId`, `articleAuthorId`))", + "fields": [ + { + "fieldPath": "spec", + "columnName": "spec", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "articleId", + "columnName": "articleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "articleAuthorId", + "columnName": "articleAuthorId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "spec", + "articleId", + "articleAuthorId" + ] + }, + "indices": [ + { + "name": "index_ArticleFeedCrossRef_spec", + "unique": false, + "columnNames": [ + "spec" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ArticleFeedCrossRef_spec` ON `${TABLE_NAME}` (`spec`)" + }, + { + "name": "index_ArticleFeedCrossRef_articleId", + "unique": false, + "columnNames": [ + "articleId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ArticleFeedCrossRef_articleId` ON `${TABLE_NAME}` (`articleId`)" + }, + { + "name": "index_ArticleFeedCrossRef_articleAuthorId", + "unique": false, + "columnNames": [ + "articleAuthorId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ArticleFeedCrossRef_articleAuthorId` ON `${TABLE_NAME}` (`articleAuthorId`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0e3be72662ad114f97ee4b849358fcf2')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/primal/android/attachments/db/NoteNostrUri.kt b/app/src/main/kotlin/net/primal/android/attachments/db/NoteNostrUri.kt index 758c0024..49f091f8 100644 --- a/app/src/main/kotlin/net/primal/android/attachments/db/NoteNostrUri.kt +++ b/app/src/main/kotlin/net/primal/android/attachments/db/NoteNostrUri.kt @@ -1,9 +1,12 @@ package net.primal.android.attachments.db +import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity import kotlinx.serialization.Serializable +import net.primal.android.attachments.domain.NostrUriType import net.primal.android.notes.db.ReferencedArticle +import net.primal.android.notes.db.ReferencedHighlight import net.primal.android.notes.db.ReferencedNote import net.primal.android.notes.db.ReferencedUser @@ -14,6 +17,9 @@ import net.primal.android.notes.db.ReferencedUser data class NoteNostrUri( val noteId: String, val uri: String, + val type: NostrUriType, + @ColumnInfo("refEvent_alt") val referencedEventAlt: String? = null, + @Embedded(prefix = "refHighlight_") val referencedHighlight: ReferencedHighlight? = null, @Embedded(prefix = "refNote_") val referencedNote: ReferencedNote? = null, @Embedded(prefix = "refArticle_") val referencedArticle: ReferencedArticle? = null, @Embedded(prefix = "refUser_") val referencedUser: ReferencedUser? = null, diff --git a/app/src/main/kotlin/net/primal/android/attachments/domain/NostrUriType.kt b/app/src/main/kotlin/net/primal/android/attachments/domain/NostrUriType.kt new file mode 100644 index 00000000..17ba0351 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/attachments/domain/NostrUriType.kt @@ -0,0 +1,9 @@ +package net.primal.android.attachments.domain + +enum class NostrUriType { + Note, + Profile, + Article, + Highlight, + Unsupported, +} diff --git a/app/src/main/kotlin/net/primal/android/core/compose/icons/__PrimalIcons.kt b/app/src/main/kotlin/net/primal/android/core/compose/icons/__PrimalIcons.kt index a38aa531..9ad69116 100644 --- a/app/src/main/kotlin/net/primal/android/core/compose/icons/__PrimalIcons.kt +++ b/app/src/main/kotlin/net/primal/android/core/compose/icons/__PrimalIcons.kt @@ -30,6 +30,7 @@ import net.primal.android.core.compose.icons.primaliconpack.DarkMode import net.primal.android.core.compose.icons.primaliconpack.Delete import net.primal.android.core.compose.icons.primaliconpack.Directory import net.primal.android.core.compose.icons.primaliconpack.Discuss +import net.primal.android.core.compose.icons.primaliconpack.Document import net.primal.android.core.compose.icons.primaliconpack.Download import net.primal.android.core.compose.icons.primaliconpack.Downloads import net.primal.android.core.compose.icons.primaliconpack.DownloadsFilled @@ -283,6 +284,7 @@ val PrimalIcons.PrimalIcons: ____KtList DrawerSettings, DrawerSignOut, OnboardingZapsExplained, + Document, ) return __PrimalIcons!! } diff --git a/app/src/main/kotlin/net/primal/android/core/compose/icons/primaliconpack/Document.kt b/app/src/main/kotlin/net/primal/android/core/compose/icons/primaliconpack/Document.kt new file mode 100644 index 00000000..00539dd2 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/core/compose/icons/primaliconpack/Document.kt @@ -0,0 +1,57 @@ +package net.primal.android.core.compose.icons.primaliconpack + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import net.primal.android.core.compose.icons.PrimalIcons + +val PrimalIcons.Document: ImageVector + get() { + if (_Document != null) { + return _Document!! + } + _Document = ImageVector.Builder( + name = "Document", + defaultWidth = 20.dp, + defaultHeight = 23.dp, + viewportWidth = 20f, + viewportHeight = 23f + ).apply { + path( + stroke = SolidColor(Color(0xFF757575)), + strokeLineWidth = 1.5f + ) { + moveTo(2.75f, 4f) + curveTo(2.75f, 2.757f, 3.757f, 1.75f, 5f, 1.75f) + horizontalLineTo(11.757f) + curveTo(12.354f, 1.75f, 12.926f, 1.987f, 13.348f, 2.409f) + lineTo(13.879f, 1.879f) + lineTo(13.348f, 2.409f) + lineTo(15.47f, 4.53f) + lineTo(17.591f, 6.652f) + curveTo(18.013f, 7.074f, 18.25f, 7.646f, 18.25f, 8.243f) + verticalLineTo(18f) + curveTo(18.25f, 19.243f, 17.243f, 20.25f, 16f, 20.25f) + horizontalLineTo(5f) + curveTo(3.757f, 20.25f, 2.75f, 19.243f, 2.75f, 18f) + verticalLineTo(4f) + close() + } + path( + stroke = SolidColor(Color(0xFF757575)), + strokeLineWidth = 1.5f + ) { + moveTo(11.5f, 2f) + verticalLineTo(7.5f) + curveTo(11.5f, 8.052f, 11.948f, 8.5f, 12.5f, 8.5f) + horizontalLineTo(18f) + } + }.build() + + return _Document!! + } + +@Suppress("ObjectPropertyName") +private var _Document: ImageVector? = null diff --git a/app/src/main/kotlin/net/primal/android/core/serialization/room/JsonTypeConverters.kt b/app/src/main/kotlin/net/primal/android/core/serialization/room/JsonTypeConverters.kt new file mode 100644 index 00000000..10799c74 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/core/serialization/room/JsonTypeConverters.kt @@ -0,0 +1,23 @@ +package net.primal.android.core.serialization.room + +import androidx.room.TypeConverter +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonArray +import net.primal.android.core.serialization.json.NostrJson +import net.primal.android.core.serialization.json.decodeFromStringOrNull + +class JsonTypeConverters { + + @TypeConverter + fun stringToJsonArray(value: String?): JsonArray? { + return NostrJson.decodeFromStringOrNull(value) + } + + @TypeConverter + fun jsonArrayToString(jsonArray: JsonArray?): String? { + return when (jsonArray) { + null -> null + else -> NostrJson.encodeToString(jsonArray) + } + } +} diff --git a/app/src/main/kotlin/net/primal/android/db/PrimalDatabase.kt b/app/src/main/kotlin/net/primal/android/db/PrimalDatabase.kt index 6f46b5ef..4eec9ed5 100644 --- a/app/src/main/kotlin/net/primal/android/db/PrimalDatabase.kt +++ b/app/src/main/kotlin/net/primal/android/db/PrimalDatabase.kt @@ -15,6 +15,7 @@ import net.primal.android.attachments.db.NoteNostrUri import net.primal.android.attachments.db.serialization.AttachmentTypeConverters import net.primal.android.bookmarks.db.PublicBookmark import net.primal.android.bookmarks.db.PublicBookmarkDao +import net.primal.android.core.serialization.room.JsonTypeConverters import net.primal.android.core.serialization.room.ListsTypeConverters import net.primal.android.explore.db.TrendingTopic import net.primal.android.explore.db.TrendingTopicDao @@ -89,11 +90,12 @@ import net.primal.android.wallet.db.WalletTransactionData ArticleCommentCrossRef::class, ArticleFeedCrossRef::class, ], - version = 45, + version = 46, exportSchema = true, ) @TypeConverters( ListsTypeConverters::class, + JsonTypeConverters::class, AttachmentTypeConverters::class, ProfileTypeConverters::class, ) 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 6f13d921..0a8429d6 100644 --- a/app/src/main/kotlin/net/primal/android/editor/NoteEditorViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/editor/NoteEditorViewModel.kt @@ -70,7 +70,7 @@ class NoteEditorViewModel @AssistedInject constructor( ) : ViewModel() { private val replyToNoteId = args.replyToNoteId - private val replyToArticleNaddr = args.replyToArticleNaddr?.let(Nip19TLV::parseAsNaddr) + private val replyToArticleNaddr = args.replyToArticleNaddr?.let(Nip19TLV::parseUriAsNaddrOrNull) private val _state = MutableStateFlow(UiState()) val state = _state.asStateFlow() diff --git a/app/src/main/kotlin/net/primal/android/explore/repository/ExploreRepository.kt b/app/src/main/kotlin/net/primal/android/explore/repository/ExploreRepository.kt index 32088df1..a11f891f 100644 --- a/app/src/main/kotlin/net/primal/android/explore/repository/ExploreRepository.kt +++ b/app/src/main/kotlin/net/primal/android/explore/repository/ExploreRepository.kt @@ -65,6 +65,7 @@ class ExploreRepository @Inject constructor( val notes = response.noteEvents.mapAsPostDataPO(referencedPosts = emptyList()) val nostrUris = notes.flatMapPostsAsNoteNostrUriPO( + eventIdToNostrEvent = emptyMap(), postIdToPostDataMap = emptyMap(), articleIdToArticle = emptyMap(), profileIdToProfileDataMap = profilesMap, diff --git a/app/src/main/kotlin/net/primal/android/messages/api/mediator/MessagesProcessor.kt b/app/src/main/kotlin/net/primal/android/messages/api/mediator/MessagesProcessor.kt index a68da32f..cf198ecd 100644 --- a/app/src/main/kotlin/net/primal/android/messages/api/mediator/MessagesProcessor.kt +++ b/app/src/main/kotlin/net/primal/android/messages/api/mediator/MessagesProcessor.kt @@ -128,6 +128,7 @@ class MessagesProcessor @Inject constructor( database.attachments().upsertAllNostrUris( data = messageDataList.flatMapMessagesAsNostrResourcePO( + eventIdToNostrEvent = emptyMap(), postIdToPostDataMap = referencedNotesMap, articleIdToArticle = emptyMap(), profileIdToProfileDataMap = referencedProfilesMap, diff --git a/app/src/main/kotlin/net/primal/android/nostr/ext/NostrResources.kt b/app/src/main/kotlin/net/primal/android/nostr/ext/NostrResources.kt index c874f759..6a323578 100644 --- a/app/src/main/kotlin/net/primal/android/nostr/ext/NostrResources.kt +++ b/app/src/main/kotlin/net/primal/android/nostr/ext/NostrResources.kt @@ -1,11 +1,13 @@ package net.primal.android.nostr.ext import java.util.regex.Pattern +import kotlinx.serialization.json.JsonArray import net.primal.android.articles.db.ArticleData import net.primal.android.articles.feed.ui.wordsCountToReadingTime import net.primal.android.attachments.db.NoteNostrUri import net.primal.android.attachments.domain.CdnResource import net.primal.android.attachments.domain.LinkPreviewData +import net.primal.android.attachments.domain.NostrUriType import net.primal.android.attachments.ext.flatMapPostsAsNoteAttachmentPO import net.primal.android.core.utils.asEllipsizedNpub import net.primal.android.core.utils.authorNameUiFriendly @@ -14,11 +16,14 @@ import net.primal.android.crypto.bech32ToHexOrThrow import net.primal.android.crypto.bechToBytesOrThrow import net.primal.android.crypto.toHex import net.primal.android.messages.db.DirectMessageData +import net.primal.android.nostr.model.NostrEvent import net.primal.android.nostr.model.NostrEventKind +import net.primal.android.nostr.utils.Naddr import net.primal.android.nostr.utils.Nip19TLV import net.primal.android.nostr.utils.Nip19TLV.toNaddrString import net.primal.android.notes.db.PostData import net.primal.android.notes.db.ReferencedArticle +import net.primal.android.notes.db.ReferencedHighlight import net.primal.android.notes.db.ReferencedNote import net.primal.android.notes.db.ReferencedUser import net.primal.android.profile.db.ProfileData @@ -136,6 +141,19 @@ fun String.extractNoteId(): String? { } } +fun String.extractEventId(): String? { + return extract { bechPrefix: String?, key: String? -> + when (bechPrefix?.lowercase()) { + NEVENT -> { + val tlv = Nip19TLV.parse((bechPrefix + key).bechToBytesOrThrow()) + tlv[Nip19TLV.Type.SPECIAL.id]?.first()?.toHex() + } + + else -> (bechPrefix + key).bechToBytesOrThrow().toHex() + } + } +} + private fun String.extract(parser: (bechPrefix: String?, key: String?) -> String?): String? { val matcher = nostrUriRegexPattern.matcher(this) if (!matcher.find()) return null @@ -174,7 +192,7 @@ fun String.takeAsProfileHexIdOrNull(): String? { fun String.takeAsNaddrOrNull(): String? { return if (isNAddr() || isNAddrUri()) { val result = runCatching { - Nip19TLV.parseAsNaddr(this) + Nip19TLV.parseUriAsNaddrOrNull(this) } if (result.getOrNull() != null) { this @@ -187,6 +205,7 @@ fun String.takeAsNaddrOrNull(): String? { } fun List.flatMapPostsAsNoteNostrUriPO( + eventIdToNostrEvent: Map, postIdToPostDataMap: Map, articleIdToArticle: Map, profileIdToProfileDataMap: Map, @@ -197,6 +216,7 @@ fun List.flatMapPostsAsNoteNostrUriPO( flatMap { postData -> postData.uris.mapAsNoteNostrUriPO( eventId = postData.postId, + eventIdToNostrEvent = eventIdToNostrEvent, postIdToPostDataMap = postIdToPostDataMap, articleIdToArticle = articleIdToArticle, profileIdToProfileDataMap = profileIdToProfileDataMap, @@ -207,6 +227,7 @@ fun List.flatMapPostsAsNoteNostrUriPO( } fun List.flatMapMessagesAsNostrResourcePO( + eventIdToNostrEvent: Map, postIdToPostDataMap: Map, articleIdToArticle: Map, profileIdToProfileDataMap: Map, @@ -216,6 +237,7 @@ fun List.flatMapMessagesAsNostrResourcePO( ) = flatMap { messageData -> messageData.uris.mapAsNoteNostrUriPO( eventId = messageData.messageId, + eventIdToNostrEvent = eventIdToNostrEvent, postIdToPostDataMap = postIdToPostDataMap, articleIdToArticle = articleIdToArticle, profileIdToProfileDataMap = profileIdToProfileDataMap, @@ -227,6 +249,7 @@ fun List.flatMapMessagesAsNostrResourcePO( fun List.mapAsNoteNostrUriPO( eventId: String, + eventIdToNostrEvent: Map, postIdToPostDataMap: Map, articleIdToArticle: Map, profileIdToProfileDataMap: Map, @@ -240,76 +263,150 @@ fun List.mapAsNoteNostrUriPO( val refNote = postIdToPostDataMap[refNoteId] val refPostAuthor = profileIdToProfileDataMap[refNote?.authorId] - val refNaddr = Nip19TLV.parseAsNaddr( - naddr = if (link.startsWith("nostr:")) { - link.removePrefix("nostr:") - } else { - link - }, - ) + val refNaddr = Nip19TLV.parseUriAsNaddrOrNull(link) val refArticle = articleIdToArticle[refNaddr?.identifier] val refArticleAuthor = profileIdToProfileDataMap[refNaddr?.userId] + val referencedNostrEvent: NostrEvent? = eventIdToNostrEvent[link.extractEventId()] + + val refHighlightText = referencedNostrEvent?.content + val refHighlightATag = referencedNostrEvent?.tags?.firstOrNull { it.isATag() } + + val type = if (refUserProfileId != null) { + NostrUriType.Profile + } else if (refNote != null && refPostAuthor != null) { + NostrUriType.Note + } else if (refNaddr?.kind == NostrEventKind.LongFormContent.value && + refArticle != null && refArticleAuthor != null + ) { + NostrUriType.Article + } else if (referencedNostrEvent?.kind == NostrEventKind.Highlight.value && + refHighlightText?.isNotEmpty() == true && refHighlightATag != null + ) { + NostrUriType.Highlight + } else { + NostrUriType.Unsupported + } + NoteNostrUri( noteId = eventId, uri = link, - referencedUser = if (refUserProfileId != null) { - ReferencedUser( - userId = refUserProfileId, - handle = profileIdToProfileDataMap[refUserProfileId]?.usernameUiFriendly() - ?: refUserProfileId.asEllipsizedNpub(), - ) - } else { - null - }, - referencedNote = if (refNote != null && refPostAuthor != null) { - ReferencedNote( - postId = refNote.postId, - createdAt = refNote.createdAt, - content = refNote.content, - authorId = refNote.authorId, - authorName = refPostAuthor.authorNameUiFriendly(), - authorAvatarCdnImage = refPostAuthor.avatarCdnImage, - authorInternetIdentifier = refPostAuthor.internetIdentifier, - authorLightningAddress = refPostAuthor.lightningAddress, - attachments = listOf(refNote).flatMapPostsAsNoteAttachmentPO( - cdnResources = cdnResources, - linkPreviews = linkPreviews, - videoThumbnails = videoThumbnails, - ), - nostrUris = listOf(refNote).flatMapPostsAsNoteNostrUriPO( - postIdToPostDataMap = postIdToPostDataMap, - articleIdToArticle = articleIdToArticle, - profileIdToProfileDataMap = profileIdToProfileDataMap, - cdnResources = cdnResources, - linkPreviews = linkPreviews, - videoThumbnails = videoThumbnails, - ), - ) - } else { - null - }, - referencedArticle = if ( - refNaddr?.kind == NostrEventKind.LongFormContent.value && - refArticle != null && - refArticleAuthor != null - ) { - ReferencedArticle( - naddr = refNaddr.toNaddrString(), - aTag = refArticle.aTag, - eventId = refArticle.eventId, - articleId = refArticle.articleId, - articleTitle = refArticle.title, - authorId = refArticle.authorId, - authorName = refArticleAuthor.authorNameUiFriendly(), - authorAvatarCdnImage = refArticleAuthor.avatarCdnImage, - createdAt = refArticle.createdAt, - raw = refArticle.raw, - articleImageCdnImage = refArticle.imageCdnImage, - articleReadingTimeInMinutes = refArticle.wordsCount.wordsCountToReadingTime(), - ) - } else { - null - }, + type = type, + referencedEventAlt = referencedNostrEvent?.tags?.findFirstAltDescription(), + referencedUser = takeAsReferencedUserOrNull(refUserProfileId, profileIdToProfileDataMap), + referencedNote = takeAsReferencedNoteOrNull( + refNote = refNote, + refPostAuthor = refPostAuthor, + cdnResources = cdnResources, + linkPreviews = linkPreviews, + videoThumbnails = videoThumbnails, + eventIdToNostrEvent = eventIdToNostrEvent, + postIdToPostDataMap = postIdToPostDataMap, + articleIdToArticle = articleIdToArticle, + profileIdToProfileDataMap = profileIdToProfileDataMap, + ), + referencedArticle = takeAsReferencedArticleOrNull(refNaddr, refArticle, refArticleAuthor), + referencedHighlight = takeAsReferencedHighlightOrNull( + uri = link, + highlight = refHighlightText, + aTag = refHighlightATag, + authorId = referencedNostrEvent?.tags?.findFirstProfileId(), + ), + ) +} + +private fun takeAsReferencedNoteOrNull( + refNote: PostData?, + refPostAuthor: ProfileData?, + cdnResources: Map, + linkPreviews: Map, + videoThumbnails: Map, + eventIdToNostrEvent: Map, + postIdToPostDataMap: Map, + articleIdToArticle: Map, + profileIdToProfileDataMap: Map, +) = if (refNote != null && refPostAuthor != null) { + ReferencedNote( + postId = refNote.postId, + createdAt = refNote.createdAt, + content = refNote.content, + authorId = refNote.authorId, + authorName = refPostAuthor.authorNameUiFriendly(), + authorAvatarCdnImage = refPostAuthor.avatarCdnImage, + authorInternetIdentifier = refPostAuthor.internetIdentifier, + authorLightningAddress = refPostAuthor.lightningAddress, + attachments = listOf(refNote).flatMapPostsAsNoteAttachmentPO( + cdnResources = cdnResources, + linkPreviews = linkPreviews, + videoThumbnails = videoThumbnails, + ), + nostrUris = listOf(refNote).flatMapPostsAsNoteNostrUriPO( + eventIdToNostrEvent = eventIdToNostrEvent, + postIdToPostDataMap = postIdToPostDataMap, + articleIdToArticle = articleIdToArticle, + profileIdToProfileDataMap = profileIdToProfileDataMap, + cdnResources = cdnResources, + linkPreviews = linkPreviews, + videoThumbnails = videoThumbnails, + ), + ) +} else { + null +} + +private fun takeAsReferencedUserOrNull( + refUserProfileId: String?, + profileIdToProfileDataMap: Map, +) = if (refUserProfileId != null) { + ReferencedUser( + userId = refUserProfileId, + handle = profileIdToProfileDataMap[refUserProfileId]?.usernameUiFriendly() + ?: refUserProfileId.asEllipsizedNpub(), + ) +} else { + null +} + +private fun takeAsReferencedArticleOrNull( + refNaddr: Naddr?, + refArticle: ArticleData?, + refArticleAuthor: ProfileData?, +) = if ( + refNaddr?.kind == NostrEventKind.LongFormContent.value && + refArticle != null && + refArticleAuthor != null +) { + ReferencedArticle( + naddr = refNaddr.toNaddrString(), + aTag = refArticle.aTag, + eventId = refArticle.eventId, + articleId = refArticle.articleId, + articleTitle = refArticle.title, + authorId = refArticle.authorId, + authorName = refArticleAuthor.authorNameUiFriendly(), + authorAvatarCdnImage = refArticleAuthor.avatarCdnImage, + createdAt = refArticle.createdAt, + raw = refArticle.raw, + articleImageCdnImage = refArticle.imageCdnImage, + articleReadingTimeInMinutes = refArticle.wordsCount.wordsCountToReadingTime(), + ) +} else { + null +} + +private fun takeAsReferencedHighlightOrNull( + uri: String, + highlight: String?, + aTag: JsonArray?, + authorId: String?, +) = if (highlight?.isNotEmpty() == true && aTag != null) { + val nevent = Nip19TLV.parseUriAsNeventOrNull(neventUri = uri) + ReferencedHighlight( + text = highlight, + aTag = aTag, + eventId = nevent?.eventId, + authorId = authorId, ) +} else { + null } diff --git a/app/src/main/kotlin/net/primal/android/nostr/ext/Tags.kt b/app/src/main/kotlin/net/primal/android/nostr/ext/Tags.kt index 22557a13..c7da7b3c 100644 --- a/app/src/main/kotlin/net/primal/android/nostr/ext/Tags.kt +++ b/app/src/main/kotlin/net/primal/android/nostr/ext/Tags.kt @@ -8,30 +8,15 @@ import kotlinx.serialization.json.jsonPrimitive import net.primal.android.core.utils.parseHashtags import net.primal.android.editor.domain.NoteAttachment -fun List.findFirstEventId(): String? { - val postTag = firstOrNull { it.isEventIdTag() } - return postTag?.getTagValueOrNull() -} +fun List.findFirstEventId() = firstOrNull { it.isEventIdTag() }?.getTagValueOrNull() -fun List.findFirstProfileId(): String? { - val postAuthorTag = firstOrNull { it.isPubKeyTag() } - return postAuthorTag?.getTagValueOrNull() -} +fun List.findFirstProfileId() = firstOrNull { it.isPubKeyTag() }?.getTagValueOrNull() -fun List.findFirstZapRequest(): String? { - val zapRequestTag = firstOrNull { it.isDescriptionTag() } - return zapRequestTag?.getTagValueOrNull() -} +fun List.findFirstZapRequest() = firstOrNull { it.isDescriptionTag() }?.getTagValueOrNull() -fun List.findFirstZapAmount(): String? { - val zapRequestTag = firstOrNull { it.isAmountTag() } - return zapRequestTag?.getTagValueOrNull() -} +fun List.findFirstZapAmount() = firstOrNull { it.isAmountTag() }?.getTagValueOrNull() -fun List.findFirstBolt11(): String? { - val zapRequestTag = firstOrNull { it.isBolt11Tag() } - return zapRequestTag?.getTagValueOrNull() -} +fun List.findFirstBolt11() = firstOrNull { it.isBolt11Tag() }?.getTagValueOrNull() fun List.findFirstTitle() = firstOrNull { it.isTitleTag() }?.getTagValueOrNull() @@ -43,6 +28,8 @@ fun List.findFirstPublishedAt() = firstOrNull { it.isPublishedAtTag() fun List.findFirstIdentifier() = firstOrNull { it.isIdentifierTag() }?.getTagValueOrNull() +fun List.findFirstAltDescription() = firstOrNull { it.isAltTag() }?.getTagValueOrNull() + fun JsonArray.isBolt11Tag() = getOrNull(0)?.jsonPrimitive?.content == "bolt11" fun JsonArray.isDescriptionTag() = getOrNull(0)?.jsonPrimitive?.content == "description" @@ -57,12 +44,16 @@ fun JsonArray.isHashtagTag() = getOrNull(0)?.jsonPrimitive?.content == "t" fun JsonArray.isIdentifierTag() = getOrNull(0)?.jsonPrimitive?.content == "d" +fun JsonArray.isATag() = getOrNull(0)?.jsonPrimitive?.content == "a" + fun JsonArray.isTitleTag() = getOrNull(0)?.jsonPrimitive?.content == "title" fun JsonArray.isSummaryTag() = getOrNull(0)?.jsonPrimitive?.content == "summary" fun JsonArray.isImageTag() = getOrNull(0)?.jsonPrimitive?.content == "image" +fun JsonArray.isAltTag() = getOrNull(0)?.jsonPrimitive?.content == "alt" + fun JsonArray.isPublishedAtTag() = getOrNull(0)?.jsonPrimitive?.content == "published_at" fun JsonArray.getTagValueOrNull() = getOrNull(1)?.jsonPrimitive?.content diff --git a/app/src/main/kotlin/net/primal/android/nostr/model/NostrEventKind.kt b/app/src/main/kotlin/net/primal/android/nostr/model/NostrEventKind.kt index e4e7de59..c1e8c168 100644 --- a/app/src/main/kotlin/net/primal/android/nostr/model/NostrEventKind.kt +++ b/app/src/main/kotlin/net/primal/android/nostr/model/NostrEventKind.kt @@ -23,6 +23,7 @@ enum class NostrEventKind(val value: Int) { Reporting(value = 1984), ZapRequest(value = 9734), Zap(value = 9735), + Highlight(value = 9802), MuteList(value = 10_000), PinList(value = 10_001), RelayListMetadata(value = 10_002), diff --git a/app/src/main/kotlin/net/primal/android/nostr/utils/Naddr.kt b/app/src/main/kotlin/net/primal/android/nostr/utils/Naddr.kt index 786a0839..19b164bb 100644 --- a/app/src/main/kotlin/net/primal/android/nostr/utils/Naddr.kt +++ b/app/src/main/kotlin/net/primal/android/nostr/utils/Naddr.kt @@ -1,8 +1,36 @@ package net.primal.android.nostr.utils +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.jsonPrimitive +import net.primal.android.nostr.ext.isATag + data class Naddr( + val kind: Int, + val userId: String, val identifier: String, val relays: List = emptyList(), - val userId: String, - val kind: Int, ) + +@Suppress("MagicNumber") +fun JsonArray.aTagToNaddr(): Naddr? { + return if (this.isATag()) { + val value = getOrNull(1)?.jsonPrimitive?.content + val chunks = value?.split(":") + val kind = chunks?.getOrNull(0)?.toIntOrNull() + if (chunks?.size == 3 && kind != null) { + val userId = chunks[1] + val identifier = chunks[2] + val relay = getOrNull(2)?.jsonPrimitive?.content + Naddr( + kind = kind, + userId = userId, + identifier = identifier, + relays = if (relay?.isNotEmpty() == true) listOf(relay) else emptyList(), + ) + } else { + null + } + } else { + null + } +} diff --git a/app/src/main/kotlin/net/primal/android/nostr/utils/Nevent.kt b/app/src/main/kotlin/net/primal/android/nostr/utils/Nevent.kt new file mode 100644 index 00000000..69c01d49 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/nostr/utils/Nevent.kt @@ -0,0 +1,8 @@ +package net.primal.android.nostr.utils + +data class Nevent( + val kind: Int, + val userId: String, + val eventId: String, + val relays: List = emptyList(), +) 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 a68d9b76..054a5b6d 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 @@ -40,32 +40,74 @@ object Nip19TLV { return result } - fun parseAsNaddr(naddr: String): Naddr? = + private fun String.cleanNostrScheme(): String { + return if (this.startsWith("nostr:")) { + this.removePrefix("nostr:") + } else { + this + } + } + + fun parseUriAsNeventOrNull(neventUri: String): Nevent? = runCatching { - val tlv = parse(naddr) - val identifier = tlv[Type.SPECIAL.id]?.first()?.let { - String(bytes = it, charset = Charsets.US_ASCII) - } - val relays = tlv[Type.RELAY.id]?.first()?.let { - String(bytes = it, charset = Charsets.US_ASCII) - } - val profileId = tlv[Type.AUTHOR.id]?.first()?.toHex() + parseAsNevent(nevent = neventUri.cleanNostrScheme()) + }.getOrNull() - val kind = tlv[Type.KIND.id]?.first()?.let { - toInt32(it) - } - if (identifier != null && profileId != null && kind != null) { - Naddr( - identifier = identifier, - relays = relays?.split(",") ?: emptyList(), - userId = profileId, - kind = kind, - ) - } else { - null - } + private fun parseAsNevent(nevent: String): Nevent? { + val tlv = parse(nevent) + val eventId = tlv[Type.SPECIAL.id]?.first()?.toHex() + + val relays = tlv[Type.RELAY.id]?.first()?.let { + String(bytes = it, charset = Charsets.US_ASCII) + } + val profileId = tlv[Type.AUTHOR.id]?.first()?.toHex() + + val kind = tlv[Type.KIND.id]?.first()?.let { + toInt32(it) + } + return if (eventId != null && profileId != null && kind != null) { + Nevent( + kind = kind, + eventId = eventId, + userId = profileId, + relays = relays?.split(",") ?: emptyList(), + ) + } else { + null + } + } + + fun parseUriAsNaddrOrNull(naddrUri: String) = + runCatching { + parseAsNaddrOrNull(naddr = naddrUri.cleanNostrScheme()) }.getOrNull() + private fun parseAsNaddrOrNull(naddr: String): Naddr? { + val tlv = parse(naddr) + val identifier = tlv[Type.SPECIAL.id]?.first()?.let { + String(bytes = it, charset = Charsets.US_ASCII) + } + val relays = tlv[Type.RELAY.id]?.first()?.let { + String(bytes = it, charset = Charsets.US_ASCII) + } + val profileId = tlv[Type.AUTHOR.id]?.first()?.toHex() + + val kind = tlv[Type.KIND.id]?.first()?.let { + toInt32(it) + } + + return if (identifier != null && profileId != null && kind != null) { + Naddr( + identifier = identifier, + relays = relays?.split(",") ?: emptyList(), + userId = profileId, + kind = kind, + ) + } else { + null + } + } + fun Naddr.toNaddrString(): String { val tlv = mutableListOf() diff --git a/app/src/main/kotlin/net/primal/android/notes/db/ReferencedHighlight.kt b/app/src/main/kotlin/net/primal/android/notes/db/ReferencedHighlight.kt new file mode 100644 index 00000000..46cb3cbc --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/notes/db/ReferencedHighlight.kt @@ -0,0 +1,12 @@ +package net.primal.android.notes.db + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray + +@Serializable +data class ReferencedHighlight( + val text: String, + val eventId: String?, + val authorId: String?, + val aTag: JsonArray, +) diff --git a/app/src/main/kotlin/net/primal/android/notes/feed/model/NoteNostrUriUi.kt b/app/src/main/kotlin/net/primal/android/notes/feed/model/NoteNostrUriUi.kt index 6ea09d4e..8629ae2d 100644 --- a/app/src/main/kotlin/net/primal/android/notes/feed/model/NoteNostrUriUi.kt +++ b/app/src/main/kotlin/net/primal/android/notes/feed/model/NoteNostrUriUi.kt @@ -1,12 +1,17 @@ package net.primal.android.notes.feed.model import net.primal.android.attachments.db.NoteNostrUri +import net.primal.android.attachments.domain.NostrUriType import net.primal.android.notes.db.ReferencedArticle +import net.primal.android.notes.db.ReferencedHighlight import net.primal.android.notes.db.ReferencedNote import net.primal.android.notes.db.ReferencedUser data class NoteNostrUriUi( val uri: String, + val type: NostrUriType, + val referencedEventAlt: String?, + val referencedHighlight: ReferencedHighlight?, val referencedNote: ReferencedNote?, val referencedArticle: ReferencedArticle?, val referencedUser: ReferencedUser?, @@ -15,6 +20,9 @@ data class NoteNostrUriUi( fun NoteNostrUri.asNoteNostrUriUi() = NoteNostrUriUi( uri = this.uri, + type = this.type, + referencedEventAlt = this.referencedEventAlt, + referencedHighlight = this.referencedHighlight, referencedNote = this.referencedNote, referencedArticle = this.referencedArticle, referencedUser = this.referencedUser, diff --git a/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/NoteContent.kt b/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/NoteContent.kt index 506cd5a1..087d2228 100644 --- a/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/NoteContent.kt +++ b/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/NoteContent.kt @@ -2,14 +2,20 @@ package net.primal.android.notes.feed.note.ui import androidx.compose.foundation.ExperimentalFoundationApi 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.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -19,8 +25,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.primal.android.LocalContentDisplaySettings import net.primal.android.R +import net.primal.android.attachments.domain.NostrUriType import net.primal.android.core.compose.PrimalClickableText import net.primal.android.core.compose.attachment.model.isMediaAttachment +import net.primal.android.core.compose.icons.PrimalIcons +import net.primal.android.core.compose.icons.primaliconpack.Document import net.primal.android.core.compose.preview.PrimalPreview import net.primal.android.core.utils.HashtagMatch import net.primal.android.core.utils.HashtagMatcher @@ -41,11 +50,7 @@ private const val NOTE_ANNOTATION_TAG = "note" private const val HASHTAG_ANNOTATION_TAG = "hashtag" private const val NOSTR_ADDRESS_ANNOTATION_TAG = "naddr" -private fun List.filterMentionedNotes() = filter { it.referencedNote != null } - -private fun List.filterMentionedArticles() = filter { it.referencedArticle != null } - -private fun List.filterMentionedUsers() = filter { it.referencedUser != null } +private fun List.filter(type: NostrUriType) = filter { it.type == type } private fun List.filterUnhandledNostrAddressUris() = filter { @@ -201,6 +206,22 @@ fun NoteContent( ) } + val referencedHighlights = data.nostrUris.filter(type = NostrUriType.Highlight) + if (referencedHighlights.isNotEmpty()) { + referencedHighlights + .mapNotNull { it.referencedHighlight } + .forEachIndexed { index, highlight -> + ReferencedHighlight( + highlight = highlight, + onClick = { naddr -> noteCallbacks.onArticleClick?.invoke(naddr) }, + ) + + if (index < referencedHighlights.size - 1) { + Spacer(modifier = Modifier.height(4.dp)) + } + } + } + if (data.invoices.isNotEmpty()) { NoteLightningInvoice( modifier = Modifier.padding(top = if (contentText.isEmpty()) 4.dp else 6.dp), @@ -220,7 +241,7 @@ fun NoteContent( ) } - val referencedPostResources = data.nostrUris.filterMentionedNotes() + val referencedPostResources = data.nostrUris.filter(type = NostrUriType.Note) if (referencedPostResources.isNotEmpty()) { ReferencedNotesColumn( modifier = Modifier.padding(top = 4.dp), @@ -232,7 +253,7 @@ fun NoteContent( ) } - val referencedArticleResources = data.nostrUris.filterMentionedArticles() + val referencedArticleResources = data.nostrUris.filter(type = NostrUriType.Article) if (referencedArticleResources.isNotEmpty()) { ReferencedArticlesColumn( modifier = Modifier.padding(top = 4.dp), @@ -243,6 +264,40 @@ fun NoteContent( hasBorder = referencedEventsHaveBorder, ) } + + val genericEvents = data.nostrUris.filter(type = NostrUriType.Unsupported) + if (genericEvents.isNotEmpty()) { + genericEvents.forEachIndexed { index, nostrUriUi -> + NoteUnknownEvent( + modifier = Modifier.fillMaxWidth(), + icon = nostrUriUi.uri.nostrUriToMissingEventIcon(), + altDescription = nostrUriUi.referencedEventAlt + ?: nostrUriUi.uri.nostrUriToMissingEventAltDescription(), + ) + + if (index < genericEvents.size - 1) { + Spacer(modifier = Modifier.height(4.dp)) + } + } + } + } +} + +@Composable +private fun String.nostrUriToMissingEventAltDescription(): String { + return if (contains("note1")) { + stringResource(R.string.feed_missing_event_alt_description_note) + } else { + stringResource(R.string.feed_missing_event_alt_description_event) + } +} + +@Composable +private fun String.nostrUriToMissingEventIcon(): ImageVector { + return if (contains("note1")) { + Icons.Outlined.ErrorOutline + } else { + PrimalIcons.Document } } @@ -275,8 +330,7 @@ fun renderContentAsAnnotatedString( ): AnnotatedString { val mediaAttachments = data.attachments.filter { it.isMediaAttachment() } val linkAttachments = data.attachments.filterNot { it.isMediaAttachment() } - val mentionedEvents = data.nostrUris.filterMentionedNotes() + data.nostrUris.filterMentionedArticles() - val mentionedUsers = data.nostrUris.filterMentionedUsers() + val mentionedUsers = data.nostrUris.filter(type = NostrUriType.Profile) val unhandledNostrAddressUris = data.nostrUris.filterUnhandledNostrAddressUris() val shouldDeleteLinks = mediaAttachments.isEmpty() && linkAttachments.size == 1 && @@ -288,7 +342,7 @@ fun renderContentAsAnnotatedString( val refinedContent = data.content .cleanNostrUris() .remove(texts = mediaAttachments.map { it.url }) - .remove(texts = if (!shouldKeepNostrNoteUris) mentionedEvents.map { it.uri } else emptyList()) + .remove(texts = if (!shouldKeepNostrNoteUris) data.nostrUris.map { it.uri } else emptyList()) .remove(texts = if (shouldDeleteLinks) linkAttachments.map { it.url } else emptyList()) .remove(texts = data.invoices) .replaceNostrProfileUrisWithHandles(resources = mentionedUsers) @@ -444,12 +498,81 @@ fun PreviewPostContent() { nostrUris = listOf( NoteNostrUriUi( uri = "nostr:referencedUser", + type = NostrUriType.Profile, + referencedEventAlt = null, referencedNote = null, referencedUser = ReferencedUser( userId = "nostr:referencedUser", handle = "alex", ), referencedArticle = null, + referencedHighlight = null, + ), + ), + hashtags = listOf("#nostr"), + ), + expanded = false, + enableTweetsMode = false, + onClick = {}, + onUrlClick = {}, + noteCallbacks = NoteCallbacks(), + ) + } + } +} + +@Preview +@Composable +fun PreviewPostUnknownReferencedEventWithAlt() { + PrimalPreview(primalTheme = PrimalTheme.Sunset) { + Surface(modifier = Modifier.fillMaxWidth()) { + NoteContent( + data = NoteContentUi( + noteId = "", + content = "This is amazing! nostr:nevent124124124214123412", + attachments = emptyList(), + nostrUris = listOf( + NoteNostrUriUi( + uri = "nostr:nevent124124124214123412", + type = NostrUriType.Unsupported, + referencedEventAlt = "This is a music song.", + referencedNote = null, + referencedUser = null, + referencedArticle = null, + referencedHighlight = null, + ), + ), + hashtags = listOf("#nostr"), + ), + expanded = false, + enableTweetsMode = false, + onClick = {}, + onUrlClick = {}, + noteCallbacks = NoteCallbacks(), + ) + } + } +} + +@Preview +@Composable +fun PreviewPostUnknownReferencedEventWithoutAlt() { + PrimalPreview(primalTheme = PrimalTheme.Sunset) { + Surface(modifier = Modifier.fillMaxWidth()) { + NoteContent( + data = NoteContentUi( + noteId = "", + content = "This is amazing! nostr:note111", + attachments = emptyList(), + nostrUris = listOf( + NoteNostrUriUi( + uri = "nostr:note111", + type = NostrUriType.Unsupported, + referencedEventAlt = null, + referencedNote = null, + referencedUser = null, + referencedArticle = null, + referencedHighlight = null, ), ), hashtags = listOf("#nostr"), @@ -485,6 +608,7 @@ fun PreviewPostContentWithReferencedPost() { nostrUris = listOf( NoteNostrUriUi( uri = "nostr:referencedPost", + type = NostrUriType.Note, referencedNote = ReferencedNote( postId = "postId", createdAt = 0, @@ -499,9 +623,12 @@ fun PreviewPostContentWithReferencedPost() { ), referencedUser = null, referencedArticle = null, + referencedEventAlt = null, + referencedHighlight = null, ), NoteNostrUriUi( uri = "nostr:referenced2Post", + type = NostrUriType.Note, referencedNote = ReferencedNote( postId = "postId", createdAt = 0, @@ -516,6 +643,8 @@ fun PreviewPostContentWithReferencedPost() { ), referencedUser = null, referencedArticle = null, + referencedEventAlt = null, + referencedHighlight = null, ), ), hashtags = listOf("#nostr"), @@ -541,7 +670,7 @@ fun PreviewPostContentWithReferencedPost() { @Composable fun PreviewPostContentWithTweet() { PrimalPreview(primalTheme = PrimalTheme.Sunset) { - Surface { + Surface(modifier = Modifier.fillMaxWidth()) { NoteContent( data = NoteContentUi( noteId = "", diff --git a/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/NoteUnknownEvent.kt b/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/NoteUnknownEvent.kt new file mode 100644 index 00000000..a8ebfc0d --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/NoteUnknownEvent.kt @@ -0,0 +1,78 @@ +package net.primal.android.notes.feed.note.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.primal.android.core.compose.preview.PrimalPreview +import net.primal.android.theme.AppTheme + +@Composable +fun NoteUnknownEvent( + modifier: Modifier = Modifier, + altDescription: String, + icon: ImageVector = Icons.Outlined.Description, + onClick: (() -> Unit)? = null, +) { + Row( + modifier = modifier + .background( + shape = AppTheme.shapes.medium, + color = AppTheme.extraColorScheme.surfaceVariantAlt1, + ) + .padding(all = 16.dp) + .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + ) + + Text( + modifier = Modifier.padding(start = 12.dp, top = 2.dp), + text = altDescription, + style = AppTheme.typography.bodyMedium, + maxLines = 7, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Preview +@Composable +private fun PreviewLightNoteInvoice() { + PrimalPreview(primalTheme = net.primal.android.theme.domain.PrimalTheme.Sunrise) { + Surface(modifier = Modifier.fillMaxWidth()) { + NoteUnknownEvent( + altDescription = "This is unknown event.", + ) + } + } +} + +@Preview +@Composable +private fun PreviewDarkNoteInvoice() { + PrimalPreview(primalTheme = net.primal.android.theme.domain.PrimalTheme.Sunset) { + Surface(modifier = Modifier.fillMaxWidth()) { + NoteUnknownEvent( + altDescription = "This is unknown event with some very long alt description " + + "which is going to break into multiple lines for sure.", + ) + } + } +} diff --git a/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/ReferencedHighlight.kt b/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/ReferencedHighlight.kt new file mode 100644 index 00000000..3d693248 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/ReferencedHighlight.kt @@ -0,0 +1,46 @@ +package net.primal.android.notes.feed.note.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import net.primal.android.nostr.utils.Nip19TLV.toNaddrString +import net.primal.android.nostr.utils.aTagToNaddr +import net.primal.android.notes.db.ReferencedHighlight +import net.primal.android.theme.AppTheme + +private val HighlightBackground = Color(0xFF2E3726) + +@Composable +fun ReferencedHighlight(highlight: ReferencedHighlight, onClick: (naddr: String) -> Unit) { + val naddr = highlight.aTag.aTagToNaddr()?.toNaddrString() + Text( + modifier = Modifier + .padding(top = 2.dp) + .clickable( + enabled = naddr != null, + onClick = { naddr?.let(onClick) }, + ), + text = highlight.text, + style = AppTheme.typography.bodyMedium.merge( + background = HighlightBackground, + color = Color.White, + fontSize = 16.sp, + lineBreak = LineBreak.Paragraph, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeight = 2.em, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ), + ) +} diff --git a/app/src/main/kotlin/net/primal/android/notes/repository/FeedResponseProcessor.kt b/app/src/main/kotlin/net/primal/android/notes/repository/FeedResponseProcessor.kt index 1ff9b51c..20399dbb 100644 --- a/app/src/main/kotlin/net/primal/android/notes/repository/FeedResponseProcessor.kt +++ b/app/src/main/kotlin/net/primal/android/notes/repository/FeedResponseProcessor.kt @@ -3,6 +3,8 @@ package net.primal.android.notes.repository import androidx.room.withTransaction import net.primal.android.attachments.ext.flatMapPostsAsNoteAttachmentPO import net.primal.android.core.ext.asMapByKey +import net.primal.android.core.serialization.json.NostrJson +import net.primal.android.core.serialization.json.decodeFromStringOrNull import net.primal.android.db.PrimalDatabase import net.primal.android.nostr.db.eventRelayHintsUpserter import net.primal.android.nostr.ext.flatMapAsEventHintsPO @@ -21,6 +23,7 @@ import net.primal.android.nostr.ext.mapNotNullAsRepostDataPO import net.primal.android.nostr.ext.mapReferencedEventsAsArticleDataPO import net.primal.android.nostr.ext.parseAndMapPrimalLegendProfiles import net.primal.android.nostr.ext.parseAndMapPrimalUserNames +import net.primal.android.nostr.model.NostrEvent import net.primal.android.notes.api.model.FeedResponse import net.primal.android.thread.db.ArticleCommentCrossRef import net.primal.android.thread.db.NoteConversationCrossRef @@ -62,7 +65,10 @@ suspend fun FeedResponse.persistToDatabaseAsTransaction(userId: String, database videoThumbnails = videoThumbnails, ) + val refEvents = referencedEvents.mapNotNull { NostrJson.decodeFromStringOrNull(it.content) } + val noteNostrUris = allPosts.flatMapPostsAsNoteNostrUriPO( + eventIdToNostrEvent = refEvents.associateBy { it.id }, postIdToPostDataMap = allPosts.groupBy { it.postId }.mapValues { it.value.first() }, articleIdToArticle = allArticles.groupBy { it.articleId }.mapValues { it.value.first() }, profileIdToProfileDataMap = profileIdToProfileDataMap, diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsViewModel.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsViewModel.kt index c221197d..cc8c03f7 100644 --- a/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsViewModel.kt @@ -58,7 +58,7 @@ class ArticleDetailsViewModel @Inject constructor( private val zapHandler: ZapHandler, ) : ViewModel() { - private val naddr = Nip19TLV.parseAsNaddr(savedStateHandle.naddrOrThrow) + private val naddr = Nip19TLV.parseUriAsNaddrOrNull(savedStateHandle.naddrOrThrow) private val _state = MutableStateFlow(UiState(naddr = naddr)) val state = _state.asStateFlow() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 750cb1e0..eb40710f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -439,6 +439,9 @@ Expires: %1$s This invoice has expired + Mentioned note not found. + Mentioned event not found. + This is a Primal Premium feed. Buy a Subscription to become a Nostr power user and support our work: Get Primal Premium 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 ba275621..f0ac895b 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 @@ -67,7 +67,26 @@ class Nip19TLVTest { } @Test - fun parseAsNaddr_returnsProperValuesForNaddr1() { + fun parseUriAsNaddrOrNull_returnsProperValuesForNaddr1Uri() { + val naddr = "nostr:naddr1qqw9x6rfwpcxjmn894fks6ts09shyepdg3ty6tthv4unxmf5qy28wumn8ghj7un9d3shjtnyv" + + "9kh2uewd9hsyg86np9a0kajstc8u9h846rmy6320wdepdeydfz8w8cv7kh9sqv02gpsgqqqw4rsgwawdk" + + val expectedIdentifier = "Shipping-Shipyard-DVM-wey3m4" + val expectedRelays = listOf("wss://relay.damus.io") + val expectedProfileId = "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" + val expectedKind = 30023 + + val result = Nip19TLV.parseUriAsNaddrOrNull(naddr) + result.shouldNotBeNull() + + result.identifier shouldBe expectedIdentifier + result.relays shouldBe expectedRelays + result.userId shouldBe expectedProfileId + result.kind shouldBe expectedKind + } + + @Test + fun parseUriAsNaddrOrNull_returnsProperValuesForNaddr1WithoutNostrScheme() { val naddr = "naddr1qqw9x6rfwpcxjmn894fks6ts09shyepdg3ty6tthv4unxmf5qy28wumn8ghj7un9d3shjtnyv" + "9kh2uewd9hsyg86np9a0kajstc8u9h846rmy6320wdepdeydfz8w8cv7kh9sqv02gpsgqqqw4rsgwawdk" @@ -76,7 +95,7 @@ class Nip19TLVTest { val expectedProfileId = "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" val expectedKind = 30023 - val result = Nip19TLV.parseAsNaddr(naddr) + val result = Nip19TLV.parseUriAsNaddrOrNull(naddr) result.shouldNotBeNull() result.identifier shouldBe expectedIdentifier diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 805b7cbe..1cdfbf38 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ detekt = "1.23.6" emoji2-emojipicker = "1.5.0" espresso-core = "3.6.1" flippable = "1.5.4" -guava = "33.0.0-android" +guava = "33.0.0-android" # do not upgrade (added with zap settings, should be removed) hilt = "1.2.0" jetbrains-markdown = "0.7.3" junit = "4.13.2" @@ -36,7 +36,7 @@ ksp = "2.0.21-1.0.25" ktlint_compose_rules = "0.3.17" lifecycle = "2.8.7" material-icons = "1.7.5" -media3-exoplayer = "1.4.1" +media3-exoplayer = "1.5.0" mockk = "1.13.12" okhttp = "4.12.0" paging = "3.3.4"