diff --git a/app/schemas/net.primal.android.db.PrimalDatabase/50.json b/app/schemas/net.primal.android.db.PrimalDatabase/50.json new file mode 100644 index 000000000..ab710c225 --- /dev/null +++ b/app/schemas/net.primal.android.db.PrimalDatabase/50.json @@ -0,0 +1,1863 @@ +{ + "formatVersion": 1, + "database": { + "version": 50, + "identityHash": "d3760b2268d561e1a4951fc2ce14e17b", + "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, `primalPremiumInfo` 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": "primalPremiumInfo", + "columnName": "primalPremiumInfo", + "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, `zapSenderPrimalLegendProfile` 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 + }, + { + "fieldPath": "zapSenderPrimalLegendProfile", + "columnName": "zapSenderPrimalLegendProfile", + "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_eventId` TEXT, `refHighlight_authorId` TEXT, `refHighlight_aTag` 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_authorLegendProfile` 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_authorLegendProfile` 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.eventId", + "columnName": "refHighlight_eventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedHighlight.authorId", + "columnName": "refHighlight_authorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedHighlight.aTag", + "columnName": "refHighlight_aTag", + "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.authorLegendProfile", + "columnName": "refNote_authorLegendProfile", + "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.authorLegendProfile", + "columnName": "refArticle_authorLegendProfile", + "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": [] + }, + { + "tableName": "HighlightData", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`highlightId` TEXT NOT NULL, `authorId` TEXT NOT NULL, `content` TEXT NOT NULL, `context` TEXT, `alt` TEXT, `referencedEventId` TEXT, `referencedEventAuthorId` TEXT, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`highlightId`))", + "fields": [ + { + "fieldPath": "highlightId", + "columnName": "highlightId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "alt", + "columnName": "alt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedEventId", + "columnName": "referencedEventId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "referencedEventAuthorId", + "columnName": "referencedEventAuthorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "highlightId" + ] + }, + "indices": [], + "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, 'd3760b2268d561e1a4951fc2ce14e17b')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/net/primal/android/articles/db/Article.kt b/app/src/main/kotlin/net/primal/android/articles/db/Article.kt index 23f52bea5..ca8a30d52 100644 --- a/app/src/main/kotlin/net/primal/android/articles/db/Article.kt +++ b/app/src/main/kotlin/net/primal/android/articles/db/Article.kt @@ -3,6 +3,7 @@ package net.primal.android.articles.db import androidx.room.Embedded import androidx.room.Relation import net.primal.android.bookmarks.db.PublicBookmark +import net.primal.android.highlights.db.HighlightData import net.primal.android.profile.db.ProfileData import net.primal.android.stats.db.EventStats import net.primal.android.stats.db.EventUserStats @@ -26,4 +27,7 @@ data class Article( @Relation(entityColumn = "tagValue", parentColumn = "aTag") val bookmark: PublicBookmark? = null, + + @Relation(entityColumn = "referencedEventId", parentColumn = "aTag") + val highlights: List = emptyList(), ) 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 a3b301fd8..4a7f76082 100644 --- a/app/src/main/kotlin/net/primal/android/db/PrimalDatabase.kt +++ b/app/src/main/kotlin/net/primal/android/db/PrimalDatabase.kt @@ -21,6 +21,8 @@ import net.primal.android.explore.db.TrendingTopic import net.primal.android.explore.db.TrendingTopicDao import net.primal.android.feeds.db.Feed import net.primal.android.feeds.db.FeedDao +import net.primal.android.highlights.db.HighlightDao +import net.primal.android.highlights.db.HighlightData import net.primal.android.messages.db.DirectMessageDao import net.primal.android.messages.db.DirectMessageData import net.primal.android.messages.db.MessageConversationDao @@ -89,8 +91,9 @@ import net.primal.android.wallet.db.WalletTransactionData ArticleData::class, ArticleCommentCrossRef::class, ArticleFeedCrossRef::class, + HighlightData::class, ], - version = 49, + version = 50, exportSchema = true, ) @TypeConverters( @@ -150,4 +153,6 @@ abstract class PrimalDatabase : RoomDatabase() { abstract fun articles(): ArticleDao abstract fun articleFeedsConnections(): ArticleFeedCrossRefDao + + abstract fun highlights(): HighlightDao } diff --git a/app/src/main/kotlin/net/primal/android/highlights/db/HighlightDao.kt b/app/src/main/kotlin/net/primal/android/highlights/db/HighlightDao.kt new file mode 100644 index 000000000..db9a4ef54 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/highlights/db/HighlightDao.kt @@ -0,0 +1,10 @@ +package net.primal.android.highlights.db + +import androidx.room.Dao +import androidx.room.Upsert + +@Dao +interface HighlightDao { + @Upsert + fun upsertAll(data: List) +} diff --git a/app/src/main/kotlin/net/primal/android/highlights/db/HighlightData.kt b/app/src/main/kotlin/net/primal/android/highlights/db/HighlightData.kt new file mode 100644 index 000000000..b41621675 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/highlights/db/HighlightData.kt @@ -0,0 +1,17 @@ +package net.primal.android.highlights.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class HighlightData( + @PrimaryKey + val highlightId: String, + val authorId: String, + val content: String, + val context: String?, + val alt: String?, + val referencedEventId: String?, + val referencedEventAuthorId: String?, + val createdAt: Long, +) diff --git a/app/src/main/kotlin/net/primal/android/highlights/model/HighlightUi.kt b/app/src/main/kotlin/net/primal/android/highlights/model/HighlightUi.kt new file mode 100644 index 000000000..c9cd1c608 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/highlights/model/HighlightUi.kt @@ -0,0 +1,26 @@ +package net.primal.android.highlights.model + +import net.primal.android.highlights.db.HighlightData + +data class HighlightUi( + val highlightId: String, + val authorId: String, + val content: String, + val context: String?, + val alt: String?, + val highlightEventId: String?, + val highlightEventAuthorId: String?, + val createdAt: Long, +) + +fun HighlightData.asHighlightUi() = + HighlightUi( + highlightId = highlightId, + authorId = authorId, + content = content, + context = context, + alt = alt, + highlightEventId = referencedEventId, + highlightEventAuthorId = referencedEventAuthorId, + createdAt = createdAt, + ) diff --git a/app/src/main/kotlin/net/primal/android/nostr/ext/HighlightEvents.kt b/app/src/main/kotlin/net/primal/android/nostr/ext/HighlightEvents.kt new file mode 100644 index 000000000..181943a97 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/nostr/ext/HighlightEvents.kt @@ -0,0 +1,23 @@ +package net.primal.android.nostr.ext + +import net.primal.android.highlights.db.HighlightData +import net.primal.android.nostr.model.NostrEvent +import net.primal.android.nostr.model.NostrEventKind +import net.primal.android.nostr.model.primal.PrimalEvent + +fun List.mapReferencedEventsAsHighlightDataPO() = + this.mapNotNull { it.takeContentOrNull() } + .filter { it.kind == NostrEventKind.Highlight.value } + .map { it.asHighlightData() } + +fun NostrEvent.asHighlightData() = + HighlightData( + highlightId = this.id, + authorId = this.pubKey, + content = this.content, + alt = this.tags.findFirstAltDescription(), + context = this.tags.findFirstContextTag(), + referencedEventId = this.tags.findFirstReplaceableEventId(), + referencedEventAuthorId = this.tags.findFirstProfileId()?.extractProfileId(), + createdAt = this.createdAt, + ) 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 c7da7b3c6..5f307c4e3 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 @@ -30,6 +30,12 @@ fun List.findFirstIdentifier() = firstOrNull { it.isIdentifierTag() } fun List.findFirstAltDescription() = firstOrNull { it.isAltTag() }?.getTagValueOrNull() +fun List.findFirstReplaceableEventId() = firstOrNull { it.isATag() }?.getTagValueOrNull() + +fun List.findFirstContextTag() = firstOrNull { it.isContextTag() }?.getTagValueOrNull() + +fun JsonArray.isContextTag() = getOrNull(0)?.jsonPrimitive?.content == "context" + fun JsonArray.isBolt11Tag() = getOrNull(0)?.jsonPrimitive?.content == "bolt11" fun JsonArray.isDescriptionTag() = getOrNull(0)?.jsonPrimitive?.content == "description" 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 5dc31319a..6c7fe1a09 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 @@ -21,6 +21,7 @@ import net.primal.android.nostr.ext.mapNotNullAsEventUserStatsPO import net.primal.android.nostr.ext.mapNotNullAsPostDataPO import net.primal.android.nostr.ext.mapNotNullAsRepostDataPO import net.primal.android.nostr.ext.mapReferencedEventsAsArticleDataPO +import net.primal.android.nostr.ext.mapReferencedEventsAsHighlightDataPO import net.primal.android.nostr.ext.parseAndMapPrimalLegendProfiles import net.primal.android.nostr.ext.parseAndMapPrimalPremiumInfo import net.primal.android.nostr.ext.parseAndMapPrimalUserNames @@ -43,6 +44,7 @@ suspend fun FeedResponse.persistToDatabaseAsTransaction(userId: String, database val articles = this.articles.mapNotNullAsArticleDataPO(cdnResources = cdnResources) val referencedArticles = this.referencedEvents.mapReferencedEventsAsArticleDataPO(cdnResources = cdnResources) + val referencedHighlights = this.referencedEvents.mapReferencedEventsAsHighlightDataPO() val allArticles = articles + referencedArticles val primalUserNames = this.primalUserNames.parseAndMapPrimalUserNames() @@ -95,6 +97,7 @@ suspend fun FeedResponse.persistToDatabaseAsTransaction(userId: String, database database.eventStats().upsertAll(data = postStats) database.eventUserStats().upsertAll(data = userPostStats) database.articles().upsertAll(list = allArticles) + database.highlights().upsertAll(data = referencedHighlights) val eventHintsDao = database.eventHints() val hintsMap = eventHints.associateBy { it.eventId } diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsScreen.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsScreen.kt index a276669c7..377fbf8cf 100644 --- a/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsScreen.kt +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsScreen.kt @@ -524,6 +524,7 @@ private fun ArticleContentWithComments( .fillMaxWidth() .padding(all = 16.dp), markdown = part.markdown, + highlights = state.article?.highlights ?: emptyList(), onProfileClick = noteCallbacks.onProfileClick, onNoteClick = noteCallbacks.onNoteClick, onArticleClick = noteCallbacks.onArticleClick, diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/ArticleDetailsUi.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/ArticleDetailsUi.kt index c9ce7bf51..845881eeb 100644 --- a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/ArticleDetailsUi.kt +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/ArticleDetailsUi.kt @@ -5,6 +5,8 @@ import net.primal.android.articles.db.Article import net.primal.android.attachments.domain.CdnImage import net.primal.android.core.utils.asEllipsizedNpub import net.primal.android.core.utils.authorNameUiFriendly +import net.primal.android.highlights.model.HighlightUi +import net.primal.android.highlights.model.asHighlightUi import net.primal.android.notes.feed.model.EventStatsUi import net.primal.android.premium.legend.LegendaryCustomization import net.primal.android.premium.legend.asLegendaryCustomization @@ -28,6 +30,7 @@ data class ArticleDetailsUi( val isBookmarked: Boolean = false, val eventStatsUi: EventStatsUi = EventStatsUi(), val authorLegendaryCustomization: LegendaryCustomization? = null, + val highlights: List = emptyList(), ) fun Article.mapAsArticleDetailsUi(): ArticleDetailsUi { @@ -50,5 +53,6 @@ fun Article.mapAsArticleDetailsUi(): ArticleDetailsUi { isBookmarked = this.bookmark != null, eventStatsUi = EventStatsUi.from(eventStats = this.eventStats, userStats = this.userEventStats), authorLegendaryCustomization = this.author?.primalPremiumInfo?.legendProfile?.asLegendaryCustomization(), + highlights = this.highlights.map { it.asHighlightUi() }, ) } diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/CustomBlockNodeComposer.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/CustomBlockNodeComposer.kt new file mode 100644 index 000000000..cc3d62bdb --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/CustomBlockNodeComposer.kt @@ -0,0 +1,25 @@ +package net.primal.android.thread.articles.details.ui.rendering + +import androidx.compose.runtime.Composable +import com.halilibo.richtext.markdown.AstBlockNodeComposer +import com.halilibo.richtext.markdown.node.AstBlockNodeType +import com.halilibo.richtext.markdown.node.AstNode +import com.halilibo.richtext.markdown.node.AstParagraph +import com.halilibo.richtext.ui.RichTextScope +import net.primal.android.highlights.model.HighlightUi +import net.primal.android.thread.articles.details.ui.richtext.MarkdownRichText + +fun customBlockNodeComposer(@Suppress("UnusedParameter") highlights: List) = + object : AstBlockNodeComposer { + override fun predicate(astBlockNodeType: AstBlockNodeType): Boolean = + when (astBlockNodeType) { + AstParagraph -> true + else -> false + } + + @Composable + override fun RichTextScope.Compose(astNode: AstNode, visitChildren: @Composable (AstNode) -> Unit) { + require(astNode.type == AstParagraph) + MarkdownRichText(astNode = astNode) + } + } diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/MarkdownRenderer.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/MarkdownRenderer.kt index d6f89d502..e40fabbc5 100644 --- a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/MarkdownRenderer.kt +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/MarkdownRenderer.kt @@ -4,10 +4,11 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser -import com.halilibo.richtext.commonmark.MarkdownParseOptions import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.ui.material3.RichText +import net.primal.android.highlights.model.HighlightUi import net.primal.android.theme.AppTheme import net.primal.android.thread.articles.details.ui.handleArticleLinkClick @@ -15,6 +16,7 @@ import net.primal.android.thread.articles.details.ui.handleArticleLinkClick fun MarkdownRenderer( markdown: String, modifier: Modifier = Modifier, + highlights: List = emptyList(), onProfileClick: ((profileId: String) -> Unit)? = null, onNoteClick: ((noteId: String) -> Unit)? = null, onArticleClick: ((naddr: String) -> Unit)? = null, @@ -26,14 +28,12 @@ fun MarkdownRenderer( codeBlockContent = AppTheme.colorScheme.onSurface, outlineColor = AppTheme.colorScheme.outline, ) - val parser = remember(markdown) { CommonmarkAstNodeParser(MarkdownParseOptions.Default) } + val parser = remember(markdown) { CommonmarkAstNodeParser(CommonMarkdownParseOptions.Default) } val astNode = remember(parser) { parser.parse(markdown) } SelectionContainer { PrimalMarkdownStylesProvider { - RichText( - modifier = modifier, - style = richTextStyle, + PrimalMarkdownUriHandlerProvider( linkClickHandler = { url -> url.handleArticleLinkClick( onProfileClick = onProfileClick, @@ -43,7 +43,15 @@ fun MarkdownRenderer( ) }, ) { - BasicMarkdown(astNode = astNode) + RichText( + modifier = modifier, + style = richTextStyle, + ) { + BasicMarkdown( + astNode = astNode, + astBlockNodeComposer = customBlockNodeComposer(highlights = highlights), + ) + } } } } diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/MarkdownStyles.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/MarkdownStyles.kt index ced4a21d3..9f364d977 100644 --- a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/MarkdownStyles.kt +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/MarkdownStyles.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -106,8 +107,10 @@ fun buildPrimalRichTextStyle( color = codeBlockContent, background = codeBlockBackground, ), - linkStyle = primalMarkdownBodyTextStyle.toSpanStyle().copy( - color = highlightColor, + linkStyle = TextLinkStyles( + style = primalMarkdownBodyTextStyle.toSpanStyle().copy( + color = highlightColor, + ), ), ), paragraphSpacing = 20.sp, diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/PrimalMarkdownUriHandlerProvider.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/PrimalMarkdownUriHandlerProvider.kt new file mode 100644 index 000000000..710748d19 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/rendering/PrimalMarkdownUriHandlerProvider.kt @@ -0,0 +1,17 @@ +package net.primal.android.thread.articles.details.ui.rendering + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler + +@Composable +fun PrimalMarkdownUriHandlerProvider(linkClickHandler: (uri: String) -> Unit, content: @Composable () -> Unit) { + val uriHandler = remember { + object : UriHandler { + override fun openUri(uri: String) = linkClickHandler(uri) + } + } + CompositionLocalProvider(LocalUriHandler provides uriHandler, content) +} diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/richtext/MarkdownRichText.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/richtext/MarkdownRichText.kt new file mode 100644 index 000000000..c65acf02c --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/richtext/MarkdownRichText.kt @@ -0,0 +1,190 @@ +@file:Suppress("detekt:all") +package net.primal.android.thread.articles.details.ui.richtext + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.halilibo.richtext.markdown.node.AstBlockQuote +import com.halilibo.richtext.markdown.node.AstCode +import com.halilibo.richtext.markdown.node.AstEmphasis +import com.halilibo.richtext.markdown.node.AstFencedCodeBlock +import com.halilibo.richtext.markdown.node.AstHardLineBreak +import com.halilibo.richtext.markdown.node.AstHeading +import com.halilibo.richtext.markdown.node.AstImage +import com.halilibo.richtext.markdown.node.AstIndentedCodeBlock +import com.halilibo.richtext.markdown.node.AstLink +import com.halilibo.richtext.markdown.node.AstLinkReferenceDefinition +import com.halilibo.richtext.markdown.node.AstListItem +import com.halilibo.richtext.markdown.node.AstNode +import com.halilibo.richtext.markdown.node.AstParagraph +import com.halilibo.richtext.markdown.node.AstSoftLineBreak +import com.halilibo.richtext.markdown.node.AstStrikethrough +import com.halilibo.richtext.markdown.node.AstStrongEmphasis +import com.halilibo.richtext.markdown.node.AstText +import com.halilibo.richtext.ui.BlockQuote +import com.halilibo.richtext.ui.FormattedList +import com.halilibo.richtext.ui.RichTextScope +import com.halilibo.richtext.ui.string.InlineContent +import com.halilibo.richtext.ui.string.RichTextString +import com.halilibo.richtext.ui.string.Text +import com.halilibo.richtext.ui.string.withFormat + +/** + * + * NOTE: this component is copied from: https://github.com/halilozercan/compose-richtext + * and later modified for our specific use case + * + * Only render the text content that exists below [astNode]. All the content blocks + * like [AstBlockQuote] or [AstFencedCodeBlock] are ignored. This composable is + * suited for [AstHeading] and [AstParagraph] since they are strictly text blocks. + * + * Some notes about commonmark and in general Markdown parsing. + * + * - Paragraph and Heading are the only RichTextString containers in base implementation. + * - RichTextString is build by traversing the children of Heading or Paragraph. + * - RichTextString can include; + * - Emphasis + * - StrongEmphasis + * - Image + * - Link + * - Code + * - Code blocks should not have any children. Their whole content must reside in + * [AstIndentedCodeBlock.literal] or [AstFencedCodeBlock.literal]. + * - Blocks like [BlockQuote], [FormattedList], [AstListItem] must have an [AstParagraph] + * as a child to include any further RichText. + * - CustomNode and CustomBlock can have their own scope, no idea about that. + * + * @param astNode Root node to accept as Text Content container. + */ +@Composable +internal fun RichTextScope.MarkdownRichText(astNode: AstNode, modifier: Modifier = Modifier) { + // Assume that only RichText nodes reside below this level. + val richText = remember(astNode) { + computeRichTextString(astNode) + } + + Text(text = richText, modifier = modifier) +} + +private fun computeRichTextString(astNode: AstNode): RichTextString { + val richTextStringBuilder = RichTextString.Builder() + + // Modified pre-order traversal with pushFormat, popFormat support. + var iteratorStack = listOf( + AstNodeTraversalEntry( + astNode = astNode, + isVisited = false, + formatIndex = null, + ), + ) + + while (iteratorStack.isNotEmpty()) { + val (currentNode, isVisited, formatIndex) = iteratorStack.first().copy() + iteratorStack = iteratorStack.drop(1) + + if (!isVisited) { + val newFormatIndex = when (val currentNodeType = currentNode.type) { + is AstCode -> { + richTextStringBuilder.withFormat(RichTextString.Format.Code) { + append(currentNodeType.literal) + } + null + } + + is AstEmphasis -> richTextStringBuilder.pushFormat(RichTextString.Format.Italic) + is AstStrikethrough -> richTextStringBuilder.pushFormat( + RichTextString.Format.Strikethrough, + ) + + is AstImage -> { + richTextStringBuilder.appendInlineContent( + content = InlineContent( + initialSize = { + IntSize(128.dp.roundToPx(), 128.dp.roundToPx()) + }, + ) { + RemoteImage( + url = currentNodeType.destination, + contentDescription = currentNodeType.title, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Inside, + ) + }, + ) + null + } + + is AstLink -> { + richTextStringBuilder.pushFormat( + RichTextString.Format.Link( + destination = currentNodeType.destination, + ), + ) + } + + is AstSoftLineBreak -> { + richTextStringBuilder.append(" ") + null + } + + is AstHardLineBreak -> { + richTextStringBuilder.append("\n") + null + } + + is AstStrongEmphasis -> richTextStringBuilder.pushFormat(RichTextString.Format.Bold) + is AstText -> { + richTextStringBuilder.append(currentNodeType.literal) + null + } + + is AstLinkReferenceDefinition -> richTextStringBuilder.pushFormat( + RichTextString.Format.Link(destination = currentNodeType.destination), + ) + + else -> null + } + + iteratorStack = iteratorStack.addFirst( + AstNodeTraversalEntry( + astNode = currentNode, + isVisited = true, + formatIndex = newFormatIndex, + ), + ) + + // Do not visit children of terminals such as Text, Image, etc. + if (!currentNode.isRichTextTerminal()) { + currentNode.childrenSequence(reverse = true).forEach { + iteratorStack = iteratorStack.addFirst( + AstNodeTraversalEntry( + astNode = it, + isVisited = false, + formatIndex = null, + ), + ) + } + } + } + + if (formatIndex != null) { + richTextStringBuilder.pop(formatIndex) + } + } + + return richTextStringBuilder.toRichTextString() +} + +private data class AstNodeTraversalEntry( + val astNode: AstNode, + val isVisited: Boolean, + val formatIndex: Int?, +) + +private inline fun List.addFirst(item: T): List { + return listOf(item) + this +} diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/richtext/RemoteImage.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/richtext/RemoteImage.kt new file mode 100644 index 000000000..0085d1860 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/richtext/RemoteImage.kt @@ -0,0 +1,86 @@ +@file:Suppress("detekt:all") +package net.primal.android.thread.articles.details.ui.richtext + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.isSpecified +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import coil.size.Size + +/** + * NOTE: this file was copied from: https://github.com/halilozercan/compose-richtext + */ + +private val DEFAULT_IMAGE_SIZE = 64.dp + +/** + * Implementation of RemoteImage by using Coil library for Android. + */ +@Composable +fun RemoteImage( + url: String, + contentDescription: String?, + modifier: Modifier, + contentScale: ContentScale, +) { + val painter = rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current) + .data(data = url) + .size(Size.ORIGINAL) + .crossfade(true) + .build(), + ) + + val density = LocalDensity.current + + BoxWithConstraints(modifier, contentAlignment = Alignment.Center) { + val sizeModifier by remember(density, painter) { + derivedStateOf { + val painterIntrinsicSize = painter.state.painter?.intrinsicSize + if (painterIntrinsicSize != null && + painterIntrinsicSize.isSpecified && + painterIntrinsicSize.width != Float.POSITIVE_INFINITY && + painterIntrinsicSize.height != Float.POSITIVE_INFINITY + ) { + val width = painterIntrinsicSize.width + val height = painterIntrinsicSize.height + val scale = if (width > constraints.maxWidth) { + constraints.maxWidth.toFloat() / width + } else { + 1f + } + + with(density) { + Modifier.size( + (width * scale).toDp(), + (height * scale).toDp(), + ) + } + } else { + // if size is not defined at all, Coil fails to render the image + // here, we give a default size for images until they are loaded. + Modifier.size(DEFAULT_IMAGE_SIZE) + } + } + } + + Image( + painter = painter, + contentDescription = contentDescription, + modifier = sizeModifier, + contentScale = contentScale, + ) + } +} diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/richtext/TraverseUtils.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/richtext/TraverseUtils.kt new file mode 100644 index 000000000..ade49e1ec --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ui/richtext/TraverseUtils.kt @@ -0,0 +1,48 @@ +package net.primal.android.thread.articles.details.ui.richtext + +import com.halilibo.richtext.markdown.node.AstCode +import com.halilibo.richtext.markdown.node.AstHardLineBreak +import com.halilibo.richtext.markdown.node.AstImage +import com.halilibo.richtext.markdown.node.AstNode +import com.halilibo.richtext.markdown.node.AstNodeType +import com.halilibo.richtext.markdown.node.AstSoftLineBreak +import com.halilibo.richtext.markdown.node.AstText + +/** + * NOTE: this file was copied from: https://github.com/halilozercan/compose-richtext + */ + +internal fun AstNode.childrenSequence(reverse: Boolean = false): Sequence { + return if (!reverse) { + generateSequence(this.links.firstChild) { it.links.next } + } else { + generateSequence(this.links.lastChild) { it.links.previous } + } +} + +/** + * Markdown rendering is susceptible to have assumptions. Hence, some rendering rules + * may force restrictions on children. So, valid children nodes should be selected + * before traversing. This function returns a LinkedList of children which conforms to + * [filter] function. + * + * @param filter A lambda to select valid children. + */ +fun AstNode.filterChildren(reverse: Boolean = false, filter: (AstNode) -> Boolean): Sequence { + return childrenSequence(reverse).filter(filter) +} + +internal inline fun AstNode.filterChildrenType(): Sequence { + return filterChildren { it.type is T } +} + +/** + * These ASTNode types should never have any children. If any exists, ignore them. + */ +internal fun AstNode.isRichTextTerminal(): Boolean { + return type is AstText || + type is AstCode || + type is AstImage || + type is AstSoftLineBreak || + type is AstHardLineBreak +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8baf10fbf..906a8d2b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ camera = "1.4.0" compose-bom = "2024.11.00" compose-lottie = "6.4.0" compose-navigation = "2.8.4" -compose-richtext = "1.0.0-alpha01" +compose-richtext = "1.0.0-alpha02" constraintlayout = "2.2.0" constraintlayout-compose = "1.1.0" coil = "2.7.0"