diff --git a/composeApp/schemas/ac.cord.auxiliary.compose.database.AuxDatabase/1.json b/composeApp/schemas/ac.cord.auxiliary.compose.database.AuxDatabase/1.json index 59afe437..9e52d1fb 100644 --- a/composeApp/schemas/ac.cord.auxiliary.compose.database.AuxDatabase/1.json +++ b/composeApp/schemas/ac.cord.auxiliary.compose.database.AuxDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "70c9c412240874729c50b9c7b6cfc96a", + "identityHash": "98aa54dd7fe776faca364638070a3871", "entities": [ { "tableName": "BroadcastNostrEventReceipt", @@ -262,9 +262,265 @@ } ] }, + { + "tableName": "InReplyToPost", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `inReplyToPostId` TEXT NOT NULL, `inReplyToPostTags` TEXT NOT NULL, `inReplyToPostCreatedAt` INTEGER NOT NULL, `inReplyToPostContent` TEXT NOT NULL, `inReplyToPostAuthorPublicKey` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToPostAuthorPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToPostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToPostId", + "columnName": "inReplyToPostId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToPostTags", + "columnName": "inReplyToPostTags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToPostCreatedAt", + "columnName": "inReplyToPostCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToPostContent", + "columnName": "inReplyToPostContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToPostAuthorPublicKey", + "columnName": "inReplyToPostAuthorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_InReplyToPost_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_InReplyToPost_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_InReplyToPost_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_InReplyToPost_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_InReplyToPost_inReplyToPostId", + "unique": false, + "columnNames": [ + "inReplyToPostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_InReplyToPost_inReplyToPostId` ON `${TABLE_NAME}` (`inReplyToPostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToPostAuthorPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToPostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `relayUrl` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`nostrEventId`, `publicKey`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "publicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, { "tableName": "NostrEvent", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `taggedNostrEventId` TEXT, `taggedNostrEventRelayUrl` TEXT, `taggedPublicKey` TEXT, `taggedPublicKeyRelayUrl` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `quotedNostrEventId` TEXT, `rootNostrEventId` TEXT, `replyNostrEventId` TEXT, `taggedNostrEventId` TEXT, `taggedNostrEventRelayUrl` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -302,6 +558,21 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "rootNostrEventId", + "columnName": "rootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "replyNostrEventId", + "columnName": "replyNostrEventId", + "affinity": "TEXT" + }, { "fieldPath": "taggedNostrEventId", "columnName": "taggedNostrEventId", @@ -312,16 +583,6 @@ "columnName": "taggedNostrEventRelayUrl", "affinity": "TEXT" }, - { - "fieldPath": "taggedPublicKey", - "columnName": "taggedPublicKey", - "affinity": "TEXT" - }, - { - "fieldPath": "taggedPublicKeyRelayUrl", - "columnName": "taggedPublicKeyRelayUrl", - "affinity": "TEXT" - }, { "fieldPath": "unsignedNostrEventId", "columnName": "unsignedNostrEventId", @@ -380,6 +641,33 @@ ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_rootNostrEventId", + "unique": false, + "columnNames": [ + "rootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_rootNostrEventId` ON `${TABLE_NAME}` (`rootNostrEventId`)" + }, + { + "name": "index_NostrEvent_replyNostrEventId", + "unique": false, + "columnNames": [ + "replyNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_replyNostrEventId` ON `${TABLE_NAME}` (`replyNostrEventId`)" } ] }, @@ -697,6 +985,180 @@ } ] }, + { + "tableName": "QuotedPost", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `quotedPostId` TEXT NOT NULL, `quotedPostTags` TEXT NOT NULL, `quotedPostCreatedAt` INTEGER NOT NULL, `quotedPostContent` TEXT NOT NULL, `quotedPostAuthorPublicKey` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedPostAuthorPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedPostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedPostId", + "columnName": "quotedPostId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedPostTags", + "columnName": "quotedPostTags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedPostCreatedAt", + "columnName": "quotedPostCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "quotedPostContent", + "columnName": "quotedPostContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedPostAuthorPublicKey", + "columnName": "quotedPostAuthorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_QuotedPost_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_QuotedPost_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_QuotedPost_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_QuotedPost_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_QuotedPost_quotedPostId", + "unique": false, + "columnNames": [ + "quotedPostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_QuotedPost_quotedPostId` ON `${TABLE_NAME}` (`quotedPostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedPostAuthorPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedPostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, { "tableName": "Reaction", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", @@ -1542,7 +2004,7 @@ ], "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, '70c9c412240874729c50b9c7b6cfc96a')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '98aa54dd7fe776faca364638070a3871')" ] } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/AuxDatabase.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/AuxDatabase.kt index a6d04588..f0912b26 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/AuxDatabase.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/AuxDatabase.kt @@ -4,10 +4,13 @@ import ac.cord.auxiliary.compose.database.converters.AuxConverters import ac.cord.auxiliary.compose.database.dao.BroadcastNostrEventReceiptDao import ac.cord.auxiliary.compose.database.dao.BroadcastNostrEventRequestDao import ac.cord.auxiliary.compose.database.dao.ConnectionDao +import ac.cord.auxiliary.compose.database.dao.InReplyToPostDao +import ac.cord.auxiliary.compose.database.dao.MentionDao import ac.cord.auxiliary.compose.database.dao.NostrDao import ac.cord.auxiliary.compose.database.dao.NostrEventDao import ac.cord.auxiliary.compose.database.dao.PostDao import ac.cord.auxiliary.compose.database.dao.ProfileDao +import ac.cord.auxiliary.compose.database.dao.QuotedPostDao import ac.cord.auxiliary.compose.database.dao.ReactionDao import ac.cord.auxiliary.compose.database.dao.RecentSearchDao import ac.cord.auxiliary.compose.database.dao.RelayDao @@ -19,9 +22,12 @@ import ac.cord.auxiliary.compose.database.dao.ZapDao import ac.cord.auxiliary.compose.database.model.BroadcastNostrEventReceipt import ac.cord.auxiliary.compose.database.model.BroadcastNostrEventRequest import ac.cord.auxiliary.compose.database.model.Connection +import ac.cord.auxiliary.compose.database.model.InReplyToPost +import ac.cord.auxiliary.compose.database.model.Mention import ac.cord.auxiliary.compose.database.model.NostrEvent import ac.cord.auxiliary.compose.database.model.Post import ac.cord.auxiliary.compose.database.model.Profile +import ac.cord.auxiliary.compose.database.model.QuotedPost import ac.cord.auxiliary.compose.database.model.Reaction import ac.cord.auxiliary.compose.database.model.RecentSearch import ac.cord.auxiliary.compose.database.model.Relay @@ -44,9 +50,12 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) BroadcastNostrEventReceipt::class, BroadcastNostrEventRequest::class, Connection::class, + InReplyToPost::class, + Mention::class, NostrEvent::class, Post::class, Profile::class, + QuotedPost::class, Reaction::class, RecentSearch::class, Relay::class, @@ -64,11 +73,18 @@ abstract class AuxDatabase: RoomDatabase() { abstract fun broadcastNostrEventRequestDao(): BroadcastNostrEventRequestDao abstract fun connectionDao(): ConnectionDao + abstract fun inReplyToPostDao(): InReplyToPostDao + + abstract fun mentionDao(): MentionDao + abstract fun nostrDao(): NostrDao abstract fun nostrEventDao(): NostrEventDao abstract fun postDao(): PostDao abstract fun profileDao(): ProfileDao + + abstract fun quotedPostDao(): QuotedPostDao + abstract fun reactionDao(): ReactionDao abstract fun recentSearchDao(): RecentSearchDao diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/InReplyToPostDao.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/InReplyToPostDao.kt new file mode 100644 index 00000000..b3357cbc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/InReplyToPostDao.kt @@ -0,0 +1,18 @@ +package ac.cord.auxiliary.compose.database.dao + +import ac.cord.auxiliary.compose.database.model.InReplyToPost +import ac.cord.auxiliary.compose.database.model.Post +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalPost +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Upsert + +@Dao +interface InReplyToPostDao { + @Insert + suspend fun insert(inReplyToPost: InReplyToPost) + + @Upsert + suspend fun upsert(inReplyToPost: InReplyToPost) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/MentionDao.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/MentionDao.kt new file mode 100644 index 00000000..e5bbca1f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/MentionDao.kt @@ -0,0 +1,19 @@ +package ac.cord.auxiliary.compose.database.dao + +import ac.cord.auxiliary.compose.database.model.Mention +import ac.cord.auxiliary.compose.database.model.Post +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalPost +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Upsert + +@Dao +interface MentionDao { + + @Insert + suspend fun insert(mention: Mention) + + @Upsert + suspend fun upsert(mention: Mention) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/QuotedPostDao.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/QuotedPostDao.kt new file mode 100644 index 00000000..73bcff52 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/QuotedPostDao.kt @@ -0,0 +1,18 @@ +package ac.cord.auxiliary.compose.database.dao + +import ac.cord.auxiliary.compose.database.model.Post +import ac.cord.auxiliary.compose.database.model.QuotedPost +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalPost +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Upsert + +@Dao +interface QuotedPostDao { + @Insert + suspend fun insert(quotedPost: QuotedPost) + + @Upsert + suspend fun upsert(quotedPost: QuotedPost) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/InReplyToPost.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/InReplyToPost.kt new file mode 100644 index 00000000..9936cb2d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/InReplyToPost.kt @@ -0,0 +1,120 @@ +package ac.cord.auxiliary.compose.database.model + +import ac.cord.auxiliary.compose.database.model.traits.BroadcastableEntity +import ac.cord.auxiliary.compose.database.model.traits.LocalStoreEntity +import ac.cord.auxiliary.compose.database.model.traits.NostrEventEntity +import ac.cord.auxiliary.compose.database.model.traits.ProfilePublicKeyEntity +import ac.cord.auxiliary.compose.database.model.traits.SoftDeletableEntity +import ac.cord.auxiliary.compose.database.model.traits.TimestampedEntity +import ac.cord.auxiliary.compose.database.model.traits.UserViewableEntity +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import kotlin.time.Clock +import kotlin.time.Instant + +@Entity( + foreignKeys = [ + ForeignKey( + entity = Profile::class, + parentColumns = ["publicKey"], + childColumns = ["profilePublicKey"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = Profile::class, + parentColumns = ["publicKey"], + childColumns = ["inReplyToPostAuthorPublicKey"], + onDelete = ForeignKey.CASCADE, + ), + // This might cause problems... but we gonna make the feed so cool if it works... + ForeignKey( + entity = NostrEvent::class, + parentColumns = ["id"], + childColumns = ["nostrEventId"], + onDelete = ForeignKey.CASCADE, + ), + // Support post replies + ForeignKey( + entity = Post::class, + parentColumns = ["id"], + childColumns = ["inReplyToPostId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index("profilePublicKey"), + Index("nostrEventId"), + Index("inReplyToPostId") + ], +) +data class InReplyToPost( + @PrimaryKey + val id: HexKey, + + val inReplyToPostId: HexKey, + val inReplyToPostTags: TagArray, + val inReplyToPostCreatedAt: Instant, + val inReplyToPostContent: String, + val inReplyToPostAuthorPublicKey: HexKey, + + val content: String, + + override val nostrEventId: HexKey, // Might be a replaceable nostr event? + override val profilePublicKey: HexKey, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = Clock.System.now(), + override val savedAt: Instant = Clock.System.now(), + override val viewedAt: Instant? = null, + override val deletedAt: Instant? = null, + override val broadcastedAt: Instant? = null, +): NostrEventEntity, ProfilePublicKeyEntity, TimestampedEntity, LocalStoreEntity, UserViewableEntity, BroadcastableEntity, SoftDeletableEntity { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as InReplyToPost + + if (id != other.id) return false + if (inReplyToPostId != other.inReplyToPostId) return false + if (!inReplyToPostTags.contentDeepEquals(other.inReplyToPostTags)) return false + if (inReplyToPostCreatedAt != other.inReplyToPostCreatedAt) return false + if (inReplyToPostContent != other.inReplyToPostContent) return false + if (inReplyToPostAuthorPublicKey != other.inReplyToPostAuthorPublicKey) return false + if (content != other.content) return false + if (nostrEventId != other.nostrEventId) return false + if (profilePublicKey != other.profilePublicKey) return false + if (createdAt != other.createdAt) return false + if (updatedAt != other.updatedAt) return false + if (savedAt != other.savedAt) return false + if (viewedAt != other.viewedAt) return false + if (deletedAt != other.deletedAt) return false + if (broadcastedAt != other.broadcastedAt) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + inReplyToPostId.hashCode() + result = 31 * result + inReplyToPostTags.contentDeepHashCode() + result = 31 * result + inReplyToPostCreatedAt.hashCode() + result = 31 * result + inReplyToPostContent.hashCode() + result = 31 * result + inReplyToPostAuthorPublicKey.hashCode() + result = 31 * result + content.hashCode() + result = 31 * result + nostrEventId.hashCode() + result = 31 * result + profilePublicKey.hashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + updatedAt.hashCode() + result = 31 * result + savedAt.hashCode() + result = 31 * result + (viewedAt?.hashCode() ?: 0) + result = 31 * result + (deletedAt?.hashCode() ?: 0) + result = 31 * result + (broadcastedAt?.hashCode() ?: 0) + return result + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/Mention.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/Mention.kt new file mode 100644 index 00000000..161fbb4b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/Mention.kt @@ -0,0 +1,43 @@ +package ac.cord.auxiliary.compose.database.model + +import ac.cord.auxiliary.compose.database.model.traits.BroadcastableEntity +import ac.cord.auxiliary.compose.database.model.traits.LocalStoreEntity +import ac.cord.auxiliary.compose.database.model.traits.NostrEventEntity +import ac.cord.auxiliary.compose.database.model.traits.SoftDeletableEntity +import ac.cord.auxiliary.compose.database.model.traits.TimestampedEntity +import androidx.room.Entity +import androidx.room.ForeignKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.time.Clock +import kotlin.time.Instant + +@Entity( + foreignKeys = [ + ForeignKey( + entity = Profile::class, + parentColumns = ["publicKey"], + childColumns = ["publicKey"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = NostrEvent::class, + parentColumns = ["id"], + childColumns = ["nostrEventId"], + onDelete = ForeignKey.CASCADE, + ), + ], + primaryKeys = ["nostrEventId", "publicKey"] +) +data class Mention( + val publicKey: HexKey, + + val relayUrl: String? = null, + + override val nostrEventId: HexKey, // This is the contactListEvent with this following... + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = Clock.System.now(), + override val savedAt: Instant = Clock.System.now(), + override val deletedAt: Instant? = null, + override val broadcastedAt: Instant? = null, +): NostrEventEntity, TimestampedEntity, LocalStoreEntity, BroadcastableEntity, SoftDeletableEntity diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/NostrEvent.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/NostrEvent.kt index ae78143a..7183c3f1 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/NostrEvent.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/NostrEvent.kt @@ -36,6 +36,9 @@ import kotlin.time.Instant indices = [ Index("kind"), Index("pubKey"), + Index("quotedNostrEventId"), + Index("rootNostrEventId"), + Index("replyNostrEventId"), ] ) data class NostrEvent( @@ -47,12 +50,14 @@ data class NostrEvent( val content: String, val sig: HexKey, + val quotedNostrEventId: HexKey? = null, + + val rootNostrEventId: HexKey? = null, + val replyNostrEventId: HexKey? = null, + val taggedNostrEventId: HexKey? = null, val taggedNostrEventRelayUrl: String? = null, - val taggedPublicKey: HexKey? = null, - val taggedPublicKeyRelayUrl: String? = null, - override val unsignedNostrEventId: Long? = null, override val createdAt: Instant = Clock.System.now(), @@ -293,7 +298,6 @@ data class NostrEvent( fun fromEvent(event: Event, synchronizeNostrEventRequest: SynchronizeNostrEventRequest? = null): NostrEvent? = try { // TODO: Check for unsupported kind val taggedEvent = event.firstTaggedEvent() - val taggedUser = event.firstTaggedUser() return NostrEvent( id = event.id, @@ -307,9 +311,6 @@ data class NostrEvent( taggedNostrEventId = taggedEvent?.eventId, taggedNostrEventRelayUrl = taggedEvent?.relay?.url, - - taggedPublicKey = taggedUser?.pubKey, - taggedPublicKeyRelayUrl = taggedUser?.relayHint?.url ) } catch (e: Throwable) { return null diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/QuotedPost.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/QuotedPost.kt new file mode 100644 index 00000000..8f6221cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/QuotedPost.kt @@ -0,0 +1,188 @@ +package ac.cord.auxiliary.compose.database.model + +import ac.cord.auxiliary.compose.database.model.traits.BroadcastableEntity +import ac.cord.auxiliary.compose.database.model.traits.LocalStoreEntity +import ac.cord.auxiliary.compose.database.model.traits.NostrEventEntity +import ac.cord.auxiliary.compose.database.model.traits.ProfilePublicKeyEntity +import ac.cord.auxiliary.compose.database.model.traits.SoftDeletableEntity +import ac.cord.auxiliary.compose.database.model.traits.TimestampedEntity +import ac.cord.auxiliary.compose.database.model.traits.UserViewableEntity +import ac.cord.auxiliary.compose.extensions.toFormattedTimeAndDateString +import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileAvatar +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import kotlin.time.Clock +import kotlin.time.Instant + +@Entity( + foreignKeys = [ + ForeignKey( + entity = Profile::class, + parentColumns = ["publicKey"], + childColumns = ["profilePublicKey"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = Profile::class, + parentColumns = ["publicKey"], + childColumns = ["quotedPostAuthorPublicKey"], + onDelete = ForeignKey.CASCADE, + ), + // This might cause problems... but we gonna make the feed so cool if it works... + ForeignKey( + entity = NostrEvent::class, + parentColumns = ["id"], + childColumns = ["nostrEventId"], + onDelete = ForeignKey.CASCADE, + ), + // Support post replies + ForeignKey( + entity = Post::class, + parentColumns = ["id"], + childColumns = ["quotedPostId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index("profilePublicKey"), + Index("nostrEventId"), + Index("quotedPostId") + ], +) +data class QuotedPost( + @PrimaryKey + val id: HexKey, + + val quotedPostId: HexKey, + val quotedPostTags: TagArray, + val quotedPostCreatedAt: Instant, + val quotedPostContent: String, + val quotedPostAuthorPublicKey: HexKey, + + val content: String, + + override val nostrEventId: HexKey, // Might be a replaceable nostr event? + override val profilePublicKey: HexKey, + + override val createdAt: Instant = Clock.System.now(), + override val updatedAt: Instant = Clock.System.now(), + override val savedAt: Instant = Clock.System.now(), + override val viewedAt: Instant? = null, + override val deletedAt: Instant? = null, + override val broadcastedAt: Instant? = null, +): NostrEventEntity, ProfilePublicKeyEntity, TimestampedEntity, LocalStoreEntity, UserViewableEntity, BroadcastableEntity, SoftDeletableEntity { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as QuotedPost + + if (id != other.id) return false + if (quotedPostId != other.quotedPostId) return false + if (!quotedPostTags.contentDeepEquals(other.quotedPostTags)) return false + if (quotedPostCreatedAt != other.quotedPostCreatedAt) return false + if (quotedPostContent != other.quotedPostContent) return false + if (quotedPostAuthorPublicKey != other.quotedPostAuthorPublicKey) return false + if (content != other.content) return false + if (nostrEventId != other.nostrEventId) return false + if (profilePublicKey != other.profilePublicKey) return false + if (createdAt != other.createdAt) return false + if (updatedAt != other.updatedAt) return false + if (savedAt != other.savedAt) return false + if (viewedAt != other.viewedAt) return false + if (deletedAt != other.deletedAt) return false + if (broadcastedAt != other.broadcastedAt) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + quotedPostId.hashCode() + result = 31 * result + quotedPostTags.contentDeepHashCode() + result = 31 * result + quotedPostCreatedAt.hashCode() + result = 31 * result + quotedPostContent.hashCode() + result = 31 * result + quotedPostAuthorPublicKey.hashCode() + result = 31 * result + content.hashCode() + result = 31 * result + nostrEventId.hashCode() + result = 31 * result + profilePublicKey.hashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + updatedAt.hashCode() + result = 31 * result + savedAt.hashCode() + result = 31 * result + (viewedAt?.hashCode() ?: 0) + result = 31 * result + (deletedAt?.hashCode() ?: 0) + result = 31 * result + (broadcastedAt?.hashCode() ?: 0) + return result + } + + @Composable + fun RenderNotePreview() { + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + +// TODO ProfileAvatar( +// profile = profile, +// publicKey = pubKey +// ) + +// Column( +// modifier = Modifier.weight(1f) +// ) { +// Text( +// text = profile?.displayName ?: profile?.userName ?: nostrEvent.pubKey, +// style = MaterialTheme.typography.labelLarge +// ) +// +// profile?.nip05?.let { +// Text( +// text = profile.nip05 ?: "", +// style = MaterialTheme.typography.labelMedium +// ) +// } +// } + Text( + text = "Loading Profile information" + ) + } + Text( + modifier = Modifier.fillMaxWidth().padding( + horizontal = 20.dp + ), + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Start, + overflow = TextOverflow.Ellipsis, + text = content + ) + Row( + modifier = Modifier.fillMaxWidth().padding(20.dp), + horizontalArrangement = Arrangement.Start + ) { + Text( + text = createdAt.toFormattedTimeAndDateString(), + style = MaterialTheme.typography.labelSmall + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/intermdiate/LocalNostrEvent.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/intermdiate/LocalNostrEvent.kt index 34a68cdf..bab8768e 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/intermdiate/LocalNostrEvent.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/model/intermdiate/LocalNostrEvent.kt @@ -1,7 +1,10 @@ package ac.cord.auxiliary.compose.database.model.intermdiate +import ac.cord.auxiliary.compose.database.model.InReplyToPost +import ac.cord.auxiliary.compose.database.model.Mention import ac.cord.auxiliary.compose.database.model.NostrEvent import ac.cord.auxiliary.compose.database.model.Profile +import ac.cord.auxiliary.compose.database.model.QuotedPost import ac.cord.auxiliary.compose.database.model.Repost import ac.cord.auxiliary.compose.extensions.toFormattedTimeAndDateString import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileAvatar @@ -10,7 +13,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,6 +22,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.room.Embedded +import androidx.room.Junction import androidx.room.Relation data class LocalNostrEvent( @@ -38,20 +41,30 @@ data class LocalNostrEvent( val repost: Repost? = null, @Relation( - parentColumn = "taggedNostrEventId", - entityColumn = "id" + parentColumn = "id", + entityColumn = "nostrEventId" ) - val taggedNostrEvent: NostrEvent? = null, + val quotedPost: QuotedPost? = null, @Relation( - parentColumn = "taggedPublicKey", - entityColumn = "publicKey" + parentColumn = "id", + entityColumn = "nostrEventId" ) - val taggedAuthor: Profile? = null, + val inReplyToNostrEvent: InReplyToPost? = null, + + @Relation( + parentColumn = "id", + entityColumn = "nostrEventId", + associateBy = Junction( + value = Mention::class, + parentColumn = "publicKey", + entityColumn = "publicKey" + ) + ) + val mentionedProfiles: List = emptyList() ) { @Composable fun RenderNotePreview() { - Column { Row( modifier = Modifier.fillMaxWidth().padding(12.dp), diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/WriteNewNoteScreen.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/WriteNewNoteScreen.kt index 1e843243..86bdc421 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/WriteNewNoteScreen.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/WriteNewNoteScreen.kt @@ -379,7 +379,7 @@ It has survived not only five centuries, but also the leap into electronic types publicKey = "pubKey", displayName = "John Doe", nostrEventId = "nostrEventId" - ) + ), ), localProfile = LocalProfileWithFollowing( nostrEvent = NostrEvent( diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ArticleCard.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ArticleCard.kt new file mode 100644 index 00000000..370332a3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ArticleCard.kt @@ -0,0 +1,165 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Helpers.formatQuotedTimestamp +import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileAvatar +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +private fun ArticleCard( + localNostrEvent: LocalNostrEvent, + dTag: String, + author: String, + relayHints: List, + onArticleClick: ((Int, String, String) -> Unit)?, + onProfileClick: ((String) -> Unit)? +) { + val profile = localNostrEvent.profile + val event = localNostrEvent.nostrEvent + // Sync event... +// LaunchedEffect(author, dTag) { +// if (eventRepo.findAddressableEvent(30023, author, dTag) == null) { +// eventRepo.requestAddressableEvent(30023, author, dTag, relayHints) +// } +// } + + val title = remember(event) { event.tags?.firstOrNull { it.size >= 2 && it[0] == "title" }?.get(1) } + val summary = remember(event) { event.tags?.firstOrNull { it.size >= 2 && it[0] == "summary" }?.get(1) } + val image = remember(event) { event.tags?.firstOrNull { it.size >= 2 && it[0] == "image" }?.get(1) } + val publishedAt = remember(event) { event.tags?.firstOrNull { it.size >= 2 && it[0] == "published_at" }?.get(1)?.toLongOrNull() } + + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .then( + if (onArticleClick != null) Modifier.clickable { onArticleClick(30023, author, dTag) } + else Modifier + ) + ) { + if (event == null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(14.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.width(14.dp).height(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Loading article...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Column { +// TODO if (image != null) { +// val meta = remember(event.id) { parseImetaTags(event.tags)[image] ?: MediaMeta(url = image) } +// val ratio = remember(meta.dimension) { parseAspectRatio(meta.dimension) } +// LoadingAsyncImage( +// model = image, +// contentDescription = title, +// contentScale = ContentScale.Crop, +// modifier = Modifier +// .fillMaxWidth() +// .let { if (ratio != null) it.aspectRatio(ratio) else it.heightIn(max = 180.dp) } +// .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), +// ) +// } + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.padding(end = 8.dp) + ) { + Text( + text = "ARTICLE", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + Text( + text = title ?: "Untitled Article", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + if (!summary.isNullOrBlank()) { + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 6.dp) + ) { + ProfileAvatar(profile = profile, publicKey = localNostrEvent.nostrEvent.pubKey, size = 20.dp) + Spacer(Modifier.width(6.dp)) + val displayName = profile?.humanReadableNameOrPubkey() + ?: "${author.take(8)}...${author.takeLast(4)}" + Text( + text = displayName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = if (onProfileClick != null) { + Modifier.clickable { onProfileClick(author) } + } else Modifier + ) + if (publishedAt != null) { + Text( + text = " \u00b7 ${formatQuotedTimestamp(publishedAt)}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/Constants.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/Constants.kt new file mode 100644 index 00000000..81b5f505 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/Constants.kt @@ -0,0 +1,36 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import androidx.collection.LruCache +import fr.acinq.lightning.payment.Bolt11Invoice +import io.ktor.http.Url +import io.ktor.http.fullPath +import kotlinx.coroutines.flow.MutableStateFlow + +object Constants { + internal const val INLINE_CONTENT_TAG = "androidx.compose.foundation.text.inlineContent" + + internal val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "heic", "heif") + +// internal inline val globalMuted: MutableStateFlow get() = PipController.globalMuted +// internal inline val activeVideoUrl: MutableStateFlow get() = PipController.activeVideoUrl + + internal val videoExtensions = setOf("mp4", "mov", "webm", "m3u8") + internal val audioExtensions = setOf("mp3", "wav", "ogg", "m4a", "flac", "aac") + + internal val imageMimeTypes = setOf("image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/heic", "image/heif") + internal val videoMimeTypes = setOf("video/mp4", "video/quicktime", "video/webm", "application/vnd.apple.mpegurl", "application/x-mpegurl") + internal val audioMimeTypes = setOf("audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4", "audio/flac", "audio/aac", "audio/x-wav") + + // Matches a bare SHA-256 hex hash as the URL path (no extension) + internal val blossomPathRegex = Regex("""^/[0-9a-f]{64}$""", RegexOption.IGNORE_CASE) + + internal val combinedRegex = Regex("""nostr:(note1|nevent1|npub1|nprofile1|naddr1)[a-z0-9]+|(?(200) + + internal val ogCache = LruCache(200) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ContentParser.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ContentParser.kt new file mode 100644 index 00000000..5482a277 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ContentParser.kt @@ -0,0 +1,144 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.audioExtensions +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.imageExtensions +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.videoExtensions +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Helpers.classifyByMime +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Helpers.isBlossomUrl +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Helpers.isStandaloneUrl +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Helpers.splitTextForEmojis +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Helpers.splitTextForInvoices +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.Entity +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NNote +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile + +internal object ContentParser { + + fun parse( + content: String, + emojiMap: Map = emptyMap(), + imetaMap: Map = emptyMap(), + trimBlankLines: Boolean = true + ): List { + val segments = mutableListOf() + var lastEnd = 0 + + for (match in Constants.combinedRegex.findAll(content)) { + if (match.range.first > lastEnd) { + segments.add(ContentSegment.TextSegment(content.substring(lastEnd, match.range.first))) + } + val token = match.value + val nip19ParseResult = Nip19Parser.parseAll(token) + val bareDomainCapture = match.groupValues.getOrNull(3) + val hashtagCapture = match.groupValues.getOrNull(4) + if (!hashtagCapture.isNullOrEmpty() && token.startsWith("#")) { + segments.add(ContentSegment.HashtagSegment(hashtagCapture)) + } else if (!bareDomainCapture.isNullOrEmpty() && !token.startsWith("http")) { + val url = "https://$bareDomainCapture" + val meta = imetaMap[url] + val imetaMime = meta?.mime?.let { classifyByMime(it) } + val ext = url.substringAfterLast('.').substringBefore('?').lowercase() + when { + imetaMime == "image" -> segments.add(ContentSegment.ImageSegment(meta!!)) + imetaMime == "video" -> segments.add(ContentSegment.VideoSegment(meta!!)) + imetaMime == "audio" -> segments.add(ContentSegment.AudioSegment(meta!!)) + ext in imageExtensions -> segments.add(ContentSegment.ImageSegment(meta ?: MediaMeta(url))) + ext in videoExtensions -> segments.add(ContentSegment.VideoSegment(meta ?: MediaMeta(url))) + ext in audioExtensions -> segments.add(ContentSegment.AudioSegment(meta ?: MediaMeta(url))) + isBlossomUrl(url) -> segments.add(ContentSegment.UnknownMediaSegment(meta ?: MediaMeta(url))) + isStandaloneUrl(content, match.range) -> segments.add(ContentSegment.LinkSegment(url)) + else -> segments.add(ContentSegment.InlineLinkSegment(url)) + } + }else if (nip19ParseResult.isNotEmpty()) { + Logger.d("Nip19Result: $nip19ParseResult") + when (val entity = nip19ParseResult.first()) { + is NAddress -> { + segments.add(ContentSegment.NostrAddressableSegment(entity.dTag, entity.relay.map { it.url }, entity.author, entity.kind)) + } + is NProfile -> { + segments.add(ContentSegment.NostrProfileSegment(entity.hex, entity.relay.map { it.url })) + } + is NNote -> { + segments.add(ContentSegment.NostrNoteSegment(entity.hex)) + } + else -> { + segments.add(ContentSegment.TextSegment(token)) + } + } + } else { + val url = token.trimEnd('.', ',', ')', ']', ';', ':', '!', '?') + val isWebSocket = url.startsWith("wss://") || url.startsWith("ws://") + val meta = imetaMap[url] + val imetaMime = meta?.mime?.let { classifyByMime(it) } + val ext = url.substringAfterLast('.').substringBefore('?').lowercase() + when { + imetaMime == "image" -> segments.add(ContentSegment.ImageSegment(meta!!)) + imetaMime == "video" -> segments.add(ContentSegment.VideoSegment(meta!!)) + imetaMime == "audio" -> segments.add(ContentSegment.AudioSegment(meta!!)) + ext in imageExtensions -> segments.add(ContentSegment.ImageSegment(meta ?: MediaMeta(url))) + ext in videoExtensions -> segments.add(ContentSegment.VideoSegment(meta ?: MediaMeta(url))) + ext in audioExtensions -> segments.add(ContentSegment.AudioSegment(meta ?: MediaMeta(url))) + // TODO: Support websockets +// isWebSocket && '\'' in url -> { +// val parsed = Nip29.parseGroupIdentifier(url) +// if (parsed != null) segments.add(ContentSegment.GroupInviteSegment(parsed.first, parsed.second)) +// else segments.add(ContentSegment.InlineLinkSegment(url)) +// } + isWebSocket -> segments.add(ContentSegment.InlineLinkSegment(url)) + isBlossomUrl(url) -> segments.add(ContentSegment.UnknownMediaSegment(meta ?: MediaMeta(url))) + isStandaloneUrl(content, match.range) -> segments.add(ContentSegment.LinkSegment(url)) + else -> segments.add(ContentSegment.InlineLinkSegment(url)) + } + } + lastEnd = match.range.last + 1 + } + + if (lastEnd < content.length) { + segments.add(ContentSegment.TextSegment(content.substring(lastEnd))) + } + + // Second pass: split TextSegments that contain :shortcode: where shortcode is in emojiMap + val afterEmoji = if (emojiMap.isEmpty()) { + segments + } else { + val result = mutableListOf() + for (segment in segments) { + if (segment is ContentSegment.TextSegment) { + result.addAll(splitTextForEmojis(segment.text, emojiMap)) + } else { + result.add(segment) + } + } + result + } + + // Third pass: detect lightning invoices in text segments + val finalResult = mutableListOf() + for (segment in afterEmoji) { + if (segment is ContentSegment.TextSegment) { + finalResult.addAll(splitTextForInvoices(segment.text)) + } else { + finalResult.add(segment) + } + } + + // Final pass: trim trailing blank lines from text segments that precede + // block-level segments (links, images, embeds, etc.) to avoid extra vertical space + if (trimBlankLines) for (i in 0 until finalResult.size - 1) { + val segment = finalResult[i] + val next = finalResult[i + 1] + if (segment is ContentSegment.TextSegment && next !is ContentSegment.TextSegment && next !is ContentSegment.InlineLinkSegment && next !is ContentSegment.CustomEmojiSegment) { + val trimmed = segment.text.trimEnd('\n') + if (trimmed != segment.text) { + finalResult[i] = ContentSegment.TextSegment(if (trimmed.isEmpty()) trimmed else "$trimmed\n") + } + } + } + + return finalResult + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ContentSegment.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ContentSegment.kt new file mode 100644 index 00000000..dcc261a8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ContentSegment.kt @@ -0,0 +1,20 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import fr.acinq.lightning.payment.Bolt11Invoice + +internal sealed interface ContentSegment { + data class TextSegment(val text: String) : ContentSegment + data class ImageSegment(val meta: MediaMeta) : ContentSegment + data class VideoSegment(val meta: MediaMeta) : ContentSegment + data class AudioSegment(val meta: MediaMeta) : ContentSegment + data class UnknownMediaSegment(val meta: MediaMeta) : ContentSegment + data class LinkSegment(val url: String) : ContentSegment + data class InlineLinkSegment(val url: String) : ContentSegment + data class NostrNoteSegment(val eventId: String, val relayHints: List = emptyList()) : ContentSegment + data class NostrProfileSegment(val pubkey: String, val relayHints: List = emptyList()) : ContentSegment + data class NostrAddressableSegment(val dTag: String, val relays: List, val author: String?, val kind: Int?) : ContentSegment + data class CustomEmojiSegment(val shortcode: String, val url: String) : ContentSegment + data class HashtagSegment(val tag: String) : ContentSegment + data class LightningInvoiceSegment(val invoice: String, val decoded: Bolt11Invoice) : ContentSegment + data class GroupInviteSegment(val relayUrl: String, val groupId: String) : ContentSegment +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/FullScreenImageViewer.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/FullScreenImageViewer.kt new file mode 100644 index 00000000..0fc0a1ea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/FullScreenImageViewer.kt @@ -0,0 +1,128 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage +import kotlinx.coroutines.launch + +@Composable +fun FullScreenImageViewer( + imageUrl: String, + onDismiss: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + val transformableState = rememberTransformableState { zoomChange, panChange, _ -> + scale = (scale * zoomChange).coerceIn(0.5f, 5f) + offset = if (scale > 1f) offset + panChange else Offset.Zero + } + + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { if (scale <= 1f) onDismiss() } + ), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = imageUrl, + contentDescription = "Full screen image", + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offset.x, + translationY = offset.y + ) + .transformable(state = transformableState) + ) + + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + ) { + val buttonColors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Black.copy(alpha = 0.5f), + contentColor = Color.White + ) + +// IconButton( +// onClick = { scope.launch { TODO: MediaDownloader.downloadMedia(context, imageUrl) } }, +// colors = buttonColors, +// modifier = Modifier.size(40.dp) +// ) { +// Icon( +// Icons.Default.Download, +// contentDescription = "Download" +// ) +// } + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = { clipboardManager.setText(AnnotatedString(imageUrl)) }, + colors = buttonColors, + modifier = Modifier.size(40.dp) + ) { + Icon(Icons.Default.ContentCopy, contentDescription = "Copy URL") + } + Spacer(Modifier.width(8.dp)) + IconButton( + onClick = onDismiss, + colors = buttonColors, + modifier = Modifier.size(40.dp) + ) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/Helpers.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/Helpers.kt new file mode 100644 index 00000000..91b021d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/Helpers.kt @@ -0,0 +1,179 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import ac.cord.auxiliary.compose.database.model.NostrEvent +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.audioMimeTypes +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.blossomPathRegex +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.bolt11Regex +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.emojiShortcodeRegex +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.imageMimeTypes +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.ogCache +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.videoMimeTypes +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import fr.acinq.lightning.payment.Bolt11Invoice +import io.ktor.http.Url +import io.ktor.http.cio.Request +import io.ktor.http.fullPath +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +import kotlin.time.Clock + +internal object Helpers { + fun isBlossomUrl(url: String): Boolean { + return try { + val uri = Url(url) + val path = uri.fullPath ?: return false + blossomPathRegex.matches(path) + } catch (_: Exception) { + false + } + } + + fun isStandaloneUrl(content: String, matchRange: IntRange): Boolean { + // Look back: only whitespace between previous newline (or start) and match start + val before = content.substring(0, matchRange.first) + val lineStart = before.lastIndexOf('\n') + 1 + val prefix = before.substring(lineStart) + if (prefix.isNotBlank()) return false + + // Look forward: only whitespace between match end and next newline (or end) + val after = content.substring(matchRange.last + 1) + val lineEnd = after.indexOf('\n') + val suffix = if (lineEnd == -1) after else after.substring(0, lineEnd) + if (suffix.isNotBlank()) return false + + return true + } + + + fun classifyByMime(mime: String): String? = when { + imageMimeTypes.any { mime.startsWith(it) } -> "image" + videoMimeTypes.any { mime.startsWith(it) } -> "video" + audioMimeTypes.any { mime.startsWith(it) } -> "audio" + else -> null + } + + fun splitTextForEmojis(text: String, emojiMap: Map): List { + val result = mutableListOf() + var lastEnd = 0 + for (match in emojiShortcodeRegex.findAll(text)) { + val shortcode = match.groupValues[1] + val url = emojiMap[shortcode] ?: continue + if (match.range.first > lastEnd) { + result.add(ContentSegment.TextSegment(text.substring(lastEnd, match.range.first))) + } + result.add(ContentSegment.CustomEmojiSegment(shortcode, url)) + lastEnd = match.range.last + 1 + } + if (lastEnd < text.length) { + result.add(ContentSegment.TextSegment(text.substring(lastEnd))) + } else if (lastEnd == 0 && result.isEmpty()) { + result.add(ContentSegment.TextSegment(text)) + } + return result + } + + fun splitTextForInvoices(text: String): List { + val matches = bolt11Regex.findAll(text).toList() + if (matches.isEmpty()) return listOf(ContentSegment.TextSegment(text)) + val result = mutableListOf() + var lastEnd = 0 + var anyFound = false + for (match in matches) { + val raw = match.value + val invoice = raw.removePrefix("lightning:").removePrefix("LIGHTNING:").lowercase() + val bolt11Invoice = Bolt11Invoice.read(invoice) + if (bolt11Invoice.isFailure) { + continue + } + val decoded = bolt11Invoice.get() + anyFound = true + if (match.range.first > lastEnd) { + result.add(ContentSegment.TextSegment(text.substring(lastEnd, match.range.first))) + } + result.add(ContentSegment.LightningInvoiceSegment(invoice, decoded)) + lastEnd = match.range.last + 1 + } + if (!anyFound) return listOf(ContentSegment.TextSegment(text)) + if (lastEnd < text.length) { + result.add(ContentSegment.TextSegment(text.substring(lastEnd))) + } + return result + } + + fun formatQuotedTimestamp(epoch: Long): String { + val diff = Clock.System.now().toEpochMilliseconds() - epoch * 1000 + if (diff < 0) return "" + val seconds = diff / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + val days = hours / 24 + return when { + seconds < 60 -> "${seconds}s" + minutes < 60 -> "${minutes}m" + hours < 24 -> "${hours}h" + days == 1L -> "yesterday" + else -> "once upon a time" + } + } + + suspend fun fetchOgData(url: String): OgData? = withContext(Dispatchers.IO) { + ogCache.get(url)?.let { return@withContext it } + + null + // TODO: YouTube blocks bot User-Agents; use their oEmbed API instead +// if (youtubeRegex.containsMatchIn(url)) { +// val yt = fetchYoutubeOembed(url) +// if (yt != null) { ogCache.put(url, yt); return@withContext yt } +// } +// try { +// val request = Request.Builder() +// .url(url) +// .header("User-Agent", "Mozilla/5.0 (compatible; Wisp/1.0)") +// .build() +// httpClient.newCall(request).execute().use { response -> +// if (!response.isSuccessful) return@withContext null +// val contentType = response.header("Content-Type") ?: "" +// if (!contentType.contains("text/html", ignoreCase = true)) return@withContext null +// // Read only the first 32KB — OG tags are in +// val body = response.body?.source()?.let { source -> +// source.request(32 * 1024) +// source.buffer.snapshot().utf8() +// } ?: return@withContext null +// +// val ogProps = mutableMapOf() +// for (match in ogTagRegex.findAll(body)) { +// val prop = (match.groupValues[1].ifEmpty { match.groupValues[4] }).lowercase() +// val content = match.groupValues[2].ifEmpty { match.groupValues[3] } +// if (prop.isNotEmpty() && content.isNotEmpty()) { +// ogProps.putIfAbsent(prop, content) +// } +// } +// +// val title = ogProps["title"] +// ?: titleTagRegex.find(body)?.groupValues?.get(1)?.trim() +// val ogData = OgData( +// title = title?.let { unescapeHtml(it) }, +// description = ogProps["description"]?.let { unescapeHtml(it) }, +// image = ogProps["image"], +// siteName = ogProps["site_name"]?.let { unescapeHtml(it) } +// ) +// if (ogData.title != null || ogData.image != null) { +// ogCache.put(url, ogData) +// ogData +// } else null +// } +// } catch (_: Exception) { +// null +// } + } + + fun isGalleryEvent(event: NostrEvent): Boolean = event.kind in arrayOf( + PictureEvent.KIND, + VideoNormalEvent.KIND, + VideoShortEvent.KIND + ) + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/InvoicePayState.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/InvoicePayState.kt new file mode 100644 index 00000000..ad15beba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/InvoicePayState.kt @@ -0,0 +1,5 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +internal enum class InvoicePayState { + Idle, Paying, Success, Failed +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LightningInvoiceCard.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LightningInvoiceCard.kt new file mode 100644 index 00000000..66d8115b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LightningInvoiceCard.kt @@ -0,0 +1,175 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import fr.acinq.lightning.payment.Bolt11Invoice +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +internal fun LightningInvoiceCard( + invoice: String, + decoded: Bolt11Invoice, + onPayInvoice: (suspend (String) -> Boolean)? +) { + val primary = MaterialTheme.colorScheme.primary + val onPrimary = MaterialTheme.colorScheme.onPrimary + val isExpired = decoded.isExpired() + var showConfirm by remember { mutableStateOf(false) } + var payState by remember { mutableStateOf(InvoicePayState.Idle) } + val scope = rememberCoroutineScope() + + if (showConfirm) { + AlertDialog( + onDismissRequest = { showConfirm = false }, + title = { Text("Lightning Invoice") }, + text = { + Column { + Text( + text = "${decoded.amount?.truncateToSatoshi() ?: "Any amount"}" + ) + if (!decoded.description.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = decoded.description!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + confirmButton = { + Button( + onClick = { + showConfirm = false + if (onPayInvoice != null) { + scope.launch { + payState = InvoicePayState.Paying + val success = onPayInvoice(invoice) + payState = if (success) InvoicePayState.Success else InvoicePayState.Failed + delay(3000) + payState = InvoicePayState.Idle + } + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = primary, + contentColor = onPrimary + ) + ) { Text("Pay") } + }, + dismissButton = { + TextButton(onClick = { showConfirm = false }) { + Text("Cancel") + } + } + ) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, primary.copy(alpha = 0.4f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Bolt, + contentDescription = null, + tint = primary, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + text = if (decoded.amount != null) + "${decoded.amount!!.truncateToSatoshi()}" + else "Any Amount", + style = MaterialTheme.typography.titleSmall, + color = primary + ) + if (!decoded.description.isNullOrBlank()) { + Text( + text = decoded.description!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2 + ) + } + if (isExpired) { + Text( + text = "Expired", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + } + if (onPayInvoice != null) { + Spacer(Modifier.width(8.dp)) + when (payState) { + InvoicePayState.Paying -> CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = primary, + strokeWidth = 2.dp + ) + InvoicePayState.Success -> Text( + text = "✓ Paid", + style = MaterialTheme.typography.labelMedium, + color = primary + ) + InvoicePayState.Failed -> Text( + text = "✗ Failed", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error + ) + InvoicePayState.Idle -> Button( + onClick = { if (!isExpired) showConfirm = true }, + enabled = !isExpired, + colors = ButtonDefaults.buttonColors( + containerColor = primary, + contentColor = onPrimary + ) + ) { + Text( + if (isExpired) "Expired" + else "Pay now" + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LinkPreview.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LinkPreview.kt new file mode 100644 index 00000000..cf410cbe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LinkPreview.kt @@ -0,0 +1,124 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.ogCache +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Helpers.fetchOgData +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import io.ktor.http.Url + +@Composable +internal fun LinkPreview(url: String) { + var ogData by remember(url) { mutableStateOf(ogCache.get(url)) } + val uriHandler = LocalUriHandler.current + + LaunchedEffect(url) { + if (ogData == null) { + ogData = fetchOgData(url) + } + } + + val data = ogData + if (data != null) { + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { uriHandler.openUri(url) } + ) { + Column { + data.image?.let { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 180.dp) + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + ) + } + Column(modifier = Modifier.padding(12.dp)) { + data.siteName?.let { site -> + Text( + text = site.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } ?: run { + // Fall back to domain + val host = try { + Url(url).host.removePrefix("www.").uppercase() ?: "" + } catch (_: Exception) { "" } + if (host.isNotEmpty()) { + Text( + text = host, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + data.title?.let { title -> + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + } + data.description?.let { desc -> + Text( + text = desc, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + } + } else { + // Show clickable link text while loading / if OG fetch fails + Text( + text = url, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(vertical = 2.dp) + .clickable { uriHandler.openUri(url) } + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LiveStreamCard.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LiveStreamCard.kt new file mode 100644 index 00000000..74fd7359 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LiveStreamCard.kt @@ -0,0 +1,62 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +private fun LiveStreamCard( + localNostrEvent: LocalNostrEvent, + dTag: String, + author: String, + relayHints: List, + onProfileClick: ((String) -> Unit)?, + onLiveStreamClick: ((String, String, String?) -> Unit)? = null, + segmentRelayHints: List = emptyList() +) { + val event = localNostrEvent.nostrEvent + val profile = localNostrEvent.profile + + // TODO: Request sync for this event +// LaunchedEffect(author, dTag) { +// if (eventRepo.findAddressableEvent(30311, author, dTag) == null) { +// eventRepo.requestAddressableEvent(30311, author, dTag, relayHints) +// } +// } + + val title = remember(event) { event?.tags?.firstOrNull { it.size >= 2 && it[0] == "title" }?.get(1) } + val summary = remember(event) { event?.tags?.firstOrNull { it.size >= 2 && it[0] == "summary" }?.get(1) } + val image = remember(event) { event?.tags?.firstOrNull { it.size >= 2 && it[0] == "image" }?.get(1) } + val status = remember(event) { event?.tags?.firstOrNull { it.size >= 2 && it[0] == "status" }?.get(1) } + val streamUrl = remember(event) { event?.tags?.firstOrNull { it.size >= 2 && it[0] == "streaming" }?.get(1) } + + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .then( + if (onLiveStreamClick != null) Modifier.clickable { + onLiveStreamClick(author, dTag, segmentRelayHints.firstOrNull()) + } + else Modifier + ) + ) { + LiveStreamCardContent(event, streamUrl, image, title, summary, status, profile, author, onProfileClick) + } +} diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LiveStreamCardContent.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LiveStreamCardContent.kt new file mode 100644 index 00000000..c16c10e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LiveStreamCardContent.kt @@ -0,0 +1,161 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import ac.cord.auxiliary.compose.database.model.NostrEvent +import ac.cord.auxiliary.compose.database.model.Profile +import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileAvatar +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +internal fun LiveStreamCardContent( + event: NostrEvent?, + streamUrl: String?, + image: String?, + title: String?, + summary: String?, + status: String?, + profile: Profile?, + author: String, + onProfileClick: ((String) -> Unit)? +) { + if (event == null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(14.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.width(14.dp).height(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Loading stream...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Column { + if (streamUrl != null) { +// TODO: InlineVideoPlayer( +// url = streamUrl, +// modifier = Modifier +// .fillMaxWidth() +// .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) +// ) + } else if (image != null) { + // TODO: Support media... +// val meta = remember(event.id) { parseImetaTags(event.tags)[image] ?: MediaMeta(url = image) } +// val ratio = remember(meta.dimension) { parseAspectRatio(meta.dimension) } +// val blurPainter = remember(meta.blurhash, meta.dimension) { +// val dims = meta.dimension?.split('x') +// val w = dims?.getOrNull(0)?.toIntOrNull()?.coerceAtMost(100) ?: 32 +// val h = dims?.getOrNull(1)?.toIntOrNull()?.coerceAtMost(100) ?: 32 +// BlurHashDecoder.decode(meta.blurhash, w, h)?.asImageBitmap()?.let { BitmapPainter(it) } +// } +// LoadingAsyncImage( +// model = image, +// contentDescription = title, +// contentScale = ContentScale.Crop, +// blurPainter = blurPainter, +// modifier = Modifier +// .fillMaxWidth() +// .let { if (ratio != null) it.aspectRatio(ratio) else it.heightIn(max = 180.dp) } +// .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), +// ) + } + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (status == "live") { + Surface( + shape = RoundedCornerShape(4.dp), + color = Color(0xFFE53935), + modifier = Modifier.padding(end = 8.dp) + ) { + Text( + text = "LIVE", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } else if (status == "ended") { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.padding(end = 8.dp) + ) { + Text( + text = "ENDED", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + Text( + text = title ?: "Live Stream", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + if (!summary.isNullOrBlank()) { + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 6.dp) + ) { + ProfileAvatar(profile = profile, publicKey = event.pubKey, size = 20.dp) + Spacer(Modifier.width(6.dp)) + val displayName = profile?.humanReadableNameOrPubkey() + ?: "${author.take(8)}...${author.takeLast(4)}" + Text( + text = displayName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = if (onProfileClick != null) { + Modifier.clickable { onProfileClick(author) } + } else Modifier + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/MediaMeta.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/MediaMeta.kt new file mode 100644 index 00000000..da041e15 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/MediaMeta.kt @@ -0,0 +1,8 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +data class MediaMeta( + val url: String, + val mime: String? = null, + val dimension: String? = null, + val blurhash: String? = null +) diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/NoteActions.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/NoteActions.kt new file mode 100644 index 00000000..bcd307e7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/NoteActions.kt @@ -0,0 +1,33 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import ac.cord.auxiliary.compose.database.model.NostrEvent + +data class NoteActions( + val onReply: (NostrEvent) -> Unit = {}, + val onReact: (NostrEvent, String) -> Unit = { _, _ -> }, + val onRepost: (NostrEvent) -> Unit = {}, + val onQuote: (NostrEvent) -> Unit = {}, + val onZap: (NostrEvent) -> Unit = {}, + val onProfileClick: (String) -> Unit = {}, + val onNoteClick: (String) -> Unit = {}, + val onAddToList: (String) -> Unit = {}, + val onFollowAuthor: (String) -> Unit = {}, + val onBlockAuthor: (String) -> Unit = {}, + val onPin: (String) -> Unit = {}, + val onDelete: (String, Int) -> Unit = { _, _ -> }, + val isFollowing: (String) -> Boolean = { false }, + val userPubkey: String? = null, +// val nip05Repo: Nip05Repository? = null, + val onHashtagClick: ((String) -> Unit)? = null, + val onRelayClick: ((String) -> Unit)? = null, + val onArticleClick: ((Int, String, String) -> Unit)? = null, + val onPayInvoice: (suspend (String) -> Boolean)? = null, + val onGroupRoom: ((String, String) -> Unit)? = null, + val onLiveStreamClick: ((hostPubkey: String, dTag: String, relayHint: String?) -> Unit)? = null, +// val groupMetadataProvider: ((String, String) -> Nip29.GroupMetadata?)? = null, +// val fetchGroupPreview: (suspend (String, String) -> GroupPreview?)? = null, + val onAddEmojiSet: ((pubkey: String, dTag: String) -> Unit)? = null, + val onRemoveEmojiSet: ((pubkey: String, dTag: String) -> Unit)? = null, + val isEmojiSetAdded: ((pubkey: String, dTag: String) -> Boolean)? = null, + val onPollVote: (String, List) -> Unit = { _, _ -> }, +) diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/OgData.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/OgData.kt new file mode 100644 index 00000000..f0e36cda --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/OgData.kt @@ -0,0 +1,8 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +internal data class OgData( + val title: String?, + val description: String?, + val image: String?, + val siteName: String? +) diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/QuotedAddressableNote.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/QuotedAddressableNote.kt new file mode 100644 index 00000000..9fb0b7a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/QuotedAddressableNote.kt @@ -0,0 +1,80 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + + +@Composable +internal fun QuotedAddressableNote( + localNostrEvent: LocalNostrEvent? = null, + kind: Int, + dTag: String, + author: String, + relayHints: List, + onNoteClick: ((String) -> Unit)?, + onProfileClick: ((String) -> Unit)?, + noteActions: NoteActions?, + style: TextStyle +) { + + // TODO: Sync quoted event... +// LaunchedEffect(kind, author, dTag) { +// if (eventRepo.findAddressableEvent(kind, author, dTag) == null) { +// eventRepo.requestAddressableEvent(kind, author, dTag, relayHints) +// } +// } + + if (localNostrEvent?.quotedPost != null) { + QuotedNote( + quotedPost = localNostrEvent.quotedPost, + onNoteClick = onNoteClick, + noteActions = noteActions + ) + } else { + // Loading state + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(14.dp) + ) { + CircularProgressIndicator( + modifier = Modifier + .width(14.dp) + .height(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Loading note...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/QuotedNote.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/QuotedNote.kt new file mode 100644 index 00000000..f7eb7ab7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/QuotedNote.kt @@ -0,0 +1,116 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import ac.cord.auxiliary.compose.database.model.QuotedPost +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Helpers.isGalleryEvent +import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileAvatar +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun QuotedNote( + quotedPost: QuotedPost? = null, + relayHints: List = emptyList(), + onNoteClick: ((String) -> Unit)? = null, + noteActions: NoteActions? = null +) { + // Observe versions so we recompose when data arrives from relays + + // TODO: Trigger on-demand fetch if the quoted event isn't cached +// LaunchedEffect(eventId) { +// if (eventRepo.getEvent(eventId) == null) { +// eventRepo.requestQuotedEvent(eventId, relayHints) +// } +// } + + val effectiveNoteClick = noteActions?.onNoteClick ?: onNoteClick + + // Fetch poll votes when quoted event is a poll +// LaunchedEffect(event?.id, event?.kind) { +// if (event != null && event.kind == com.wisp.app.nostr.Nip88.KIND_POLL) { +// eventRepo.requestPollVotes(event.id) +// } +// if (event != null && event.kind == com.wisp.app.nostr.Nip69.KIND_ZAP_POLL) { +// eventRepo.requestZapPollVotes(event.id) +// } +// } + + if (quotedPost != null && noteActions != null) { + // Full rendering with all interactive features + + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + quotedPost.RenderNotePreview() + } + } else if (quotedPost != null) { + // Simple fallback rendering (no noteActions available) + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .then( + if (effectiveNoteClick != null) Modifier.clickable { effectiveNoteClick(quotedPost.nostrEventId) } + else Modifier + ) + ) { + quotedPost.RenderNotePreview() + } + } else { + // Loading state + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(14.dp) + ) { + CircularProgressIndicator( + modifier = Modifier + .width(14.dp) + .height(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Loading note...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/RichContent.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/RichContent.kt new file mode 100644 index 00000000..0efa8874 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/RichContent.kt @@ -0,0 +1,380 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent +import ac.cord.auxiliary.compose.ui.composable.widgets.content.Constants.INLINE_CONTENT_TAG +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import coil3.compose.AsyncImage + +@Composable +fun RichContent( + localNostrEvent: LocalNostrEvent, + style: TextStyle = MaterialTheme.typography.bodyLarge, + color: Color = MaterialTheme.colorScheme.onSurface, + linkColor: Color = Color.Unspecified, + emojiMap: Map = emptyMap(), + imetaMap: Map = emptyMap(), + plainLinks: Boolean = false, + onProfileClick: ((String) -> Unit)? = null, + onNoteClick: ((String) -> Unit)? = null, + onHashtagClick: ((String) -> Unit)? = null, + onLiveStreamClick: ((String, String, String?) -> Unit)? = null, + noteActions: NoteActions? = null, + modifier: Modifier = Modifier +) { + val content = localNostrEvent.nostrEvent.content + + val segments = remember(content, emojiMap, imetaMap, plainLinks) { ContentParser.parse(content.trimEnd('\n', '\r'), emojiMap, imetaMap, trimBlankLines = !plainLinks) } +// val profileVer = eventRepo?.profileVersion?.collectAsState()?.value ?: 0 + var fullScreenImageUrl by remember { mutableStateOf(null) } + + if (fullScreenImageUrl != null) { + FullScreenImageViewer( + imageUrl = fullScreenImageUrl!!, + onDismiss = { fullScreenImageUrl = null } + ) + } + + // Group segments into inline runs vs block-level items + val groups = mutableListOf() // Either MutableList (inline run) or ContentSegment (block) + fun isInline(s: ContentSegment) = s is ContentSegment.TextSegment || + s is ContentSegment.HashtagSegment || + s is ContentSegment.NostrProfileSegment || + s is ContentSegment.CustomEmojiSegment || + s is ContentSegment.InlineLinkSegment || + (plainLinks && s is ContentSegment.LinkSegment) + + for (segment in segments) { + if (isInline(segment)) { + val last = groups.lastOrNull() + if (last is MutableList<*>) { + @Suppress("UNCHECKED_CAST") + (last as MutableList).add(segment) + } else { + groups.add(mutableListOf(segment)) + } + } else { + groups.add(segment) + } + } + + val defaultLinkColor = MaterialTheme.colorScheme.primary + val effectiveLinkColor = if (linkColor == Color.Unspecified) defaultLinkColor else linkColor + val linkDecoration = if (linkColor == Color.Unspecified) TextDecoration.None else TextDecoration.Underline + val uriHandler = LocalUriHandler.current + val effectiveHashtagClick = onHashtagClick ?: noteActions?.onHashtagClick + val effectiveRelayClick = noteActions?.onRelayClick + + SelectionContainer { + Column(modifier = modifier) { + for (group in groups) { + if (group is List<*>) { + @Suppress("UNCHECKED_CAST") + val inlineSegments = group as List + + // Build profile display names for this run + val profilePubkeys = inlineSegments + .filterIsInstance() + .map { it.pubkey } + + val profileNames = remember(profilePubkeys) { + // TODO: Get these from taggedUsers... + val names = mutableMapOf() + for (pubkey in profilePubkeys) { + val profile = localNostrEvent.mentionedProfiles.find { taggedAuthor -> taggedAuthor.publicKey == pubkey } + names[pubkey] = profile?.humanReadableNameOrPubkey() + ?: "${pubkey.take(8)}...${pubkey.takeLast(4)}" + } + names + } + // TODO Queue fetches for any missing profiles +// LaunchedEffect(profilePubkeys) { +// for (seg in inlineSegments) { +// if (seg is ContentSegment.NostrProfileSegment) { +// eventRepo?.requestProfileIfMissing(seg.pubkey, seg.relayHints) +// } +// } +// } + + // Check if run is only whitespace/empty text + val hasContent = inlineSegments.any { seg -> + when (seg) { + is ContentSegment.TextSegment -> seg.text.trim().isNotEmpty() + else -> true + } + } + if (!hasContent) continue + + // Build inline content map for any custom emojis in this run + val emojiInlineContent: Map = + if (inlineSegments.any { it is ContentSegment.CustomEmojiSegment }) { + val emojiSize = style.fontSize + inlineSegments + .filterIsInstance() + .distinctBy { it.shortcode } + .associate { seg -> + seg.shortcode to InlineTextContent( + placeholder = Placeholder( + width = emojiSize * 1.5f, + height = emojiSize * 1.3f, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + AsyncImage( + model = seg.url, + contentDescription = seg.shortcode, + modifier = Modifier.fillMaxSize() + ) + } + } + } else emptyMap() + + val annotated = buildAnnotatedString { + for (seg in inlineSegments) { + when (seg) { + is ContentSegment.TextSegment -> { + withStyle(SpanStyle(color = color)) { + append(seg.text) + } + } + is ContentSegment.CustomEmojiSegment -> { + pushStringAnnotation( + tag = INLINE_CONTENT_TAG, + annotation = seg.shortcode + ) + append(":${seg.shortcode}:") + pop() + } + is ContentSegment.HashtagSegment -> { + val tag = seg.tag + withLink( + LinkAnnotation.Clickable("hashtag") { + effectiveHashtagClick?.invoke(tag) + } + ) { + withStyle(SpanStyle(color = effectiveLinkColor, textDecoration = linkDecoration)) { + append("#${seg.tag}") + } + } + } + is ContentSegment.NostrProfileSegment -> { + val pubkey = seg.pubkey + val displayName = profileNames[seg.pubkey] ?: seg.pubkey.take(8) + withLink( + LinkAnnotation.Clickable("profile") { + onProfileClick?.invoke(pubkey) + } + ) { + withStyle(SpanStyle(color = effectiveLinkColor, textDecoration = linkDecoration)) { + append("@${displayName.trimEnd()}") + } + } + } + is ContentSegment.LinkSegment -> { + val linkUrl = seg.url + val displayUrl = linkUrl.removePrefix("https://").removePrefix("http://") + withLink( + LinkAnnotation.Clickable("url") { + uriHandler.openUri(linkUrl) + } + ) { + withStyle(SpanStyle(color = effectiveLinkColor, textDecoration = linkDecoration)) { + append(displayUrl) + } + } + } + is ContentSegment.InlineLinkSegment -> { + val linkUrl = seg.url + val isRelay = linkUrl.startsWith("wss://") || linkUrl.startsWith("ws://") + val displayUrl = linkUrl + .removePrefix("https://") + .removePrefix("http://") + withLink( + LinkAnnotation.Clickable("url") { + if (isRelay) { + effectiveRelayClick?.invoke(linkUrl) + } else { + uriHandler.openUri(linkUrl) + } + } + ) { + withStyle(SpanStyle(color = effectiveLinkColor, textDecoration = linkDecoration)) { + append(displayUrl) + } + } + } + else -> {} + } + } + } + + Text(text = annotated, style = style, inlineContent = emojiInlineContent) + } else { + val segment = group as ContentSegment + when (segment) { +// is ContentSegment.ImageSegment -> { +// ImageWithContextMenu( +// meta = segment.meta, +// onFullScreen = { fullScreenImageUrl = segment.meta.url } +// ) +// } +// is ContentSegment.VideoSegment -> { +// InlineVideoPlayerWithFullscreen( +// meta = segment.meta, +// onFullScreen = { positionMs -> +// FullScreenVideoState.enter(segment.meta.url, positionMs) +// } +// ) +// } +// is ContentSegment.AudioSegment -> { +// InlineAudioPlayer(meta = segment.meta) +// } +// is ContentSegment.UnknownMediaSegment -> { +// UnknownMediaContent( +// meta = segment.meta, +// onFullScreenImage = { fullScreenImageUrl = segment.meta.url }, +// onFullScreenVideo = { positionMs -> +// FullScreenVideoState.enter(segment.meta.url, positionMs) +// } +// ) +// } + is ContentSegment.LinkSegment -> { + LinkPreview(url = segment.url) + } + is ContentSegment.NostrNoteSegment -> { + if (localNostrEvent.quotedPost != null) { + QuotedNote( + quotedPost = localNostrEvent.quotedPost, + relayHints = segment.relayHints, + onNoteClick = onNoteClick, + noteActions = noteActions + ) + } else { + Text( + text = "nostr:${segment.eventId.take(8)}...", + style = style, + color = MaterialTheme.colorScheme.primary + ) + } + } + is ContentSegment.NostrAddressableSegment -> { + val kind = segment.kind + when { +// kind == 1 || kind == 0 -> { +// if (localNostrEvent.quotedPost != null && segment.author != null) { +// QuotedAddressableNote( +// kind = kind, +// dTag = segment.dTag, +// author = segment.author, +// relayHints = segment.relays, +// onNoteClick = onNoteClick, +// onProfileClick = onProfileClick, +// noteActions = noteActions, +// style = style +// ) +// } else { +// Text( +// text = "nostr:${segment.dTag.take(12)}...", +// style = style, +// color = MaterialTheme.colorScheme.primary +// ) +// } +// } +// kind == 30023 -> { +// if (segment.author != null) { +// // TODO: Add support for articles in localNostrEvents... +// ArticleCard( +// dTag = segment.dTag, +// author = segment.author, +// relayHints = segment.relays, +// eventRepo = eventRepo, +// onArticleClick = noteActions?.onArticleClick, +// onProfileClick = onProfileClick +// ) +// } else { +// UnsupportedKindBadge(kind = kind, style = style) +// } +// } +// kind == 30311 -> { +// if (eventRepo != null && segment.author != null) { +// LiveStreamCard( +// dTag = segment.dTag, +// author = segment.author, +// relayHints = segment.relays, +// eventRepo = eventRepo, +// onProfileClick = onProfileClick, +// onLiveStreamClick = onLiveStreamClick ?: noteActions?.onLiveStreamClick, +// segmentRelayHints = segment.relays +// ) +// } else { +// UnsupportedKindBadge(kind = kind, style = style) +// } +// } +// kind == 30030 -> { +// if (eventRepo != null && segment.author != null) { +// EmojiPackCard( +// dTag = segment.dTag, +// author = segment.author, +// relayHints = segment.relays, +// eventRepo = eventRepo, +// onProfileClick = onProfileClick, +// onAddEmojiSet = noteActions?.onAddEmojiSet, +// onRemoveEmojiSet = noteActions?.onRemoveEmojiSet, +// isEmojiSetAdded = noteActions?.isEmojiSetAdded +// ) +// } else { +// UnsupportedKindBadge(kind = kind, style = style) +// } +// } + else -> UnsupportedKindBadge(kind = kind, style = style) + } + } + is ContentSegment.LightningInvoiceSegment -> { + LightningInvoiceCard( + invoice = segment.invoice, + decoded = segment.decoded, + onPayInvoice = noteActions?.onPayInvoice + ) + } +// is ContentSegment.GroupInviteSegment -> { +// GroupInviteCard( +// relayUrl = segment.relayUrl, +// groupId = segment.groupId, +// initialMetadata = noteActions?.groupMetadataProvider?.invoke(segment.relayUrl, segment.groupId), +// onGroupRoom = noteActions?.onGroupRoom, +// onFetchPreview = noteActions?.fetchGroupPreview, +// eventRepo = eventRepo +// ) +// } + else -> { + Text( + text = "Unsupported Content" + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt new file mode 100644 index 00000000..c20550dc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt @@ -0,0 +1,32 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +@Composable +internal fun UnsupportedKindBadge(kind: Int?, style: TextStyle) { + Surface( + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Text( + text = if (kind != null) "Unsupported event kind: $kind" else "Unsupported event kind", + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(14.dp) + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/feed/EventListView.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/feed/EventListView.kt index 894cb234..59a020f7 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/feed/EventListView.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/feed/EventListView.kt @@ -4,6 +4,7 @@ import ac.cord.auxiliary.compose.database.model.NostrEvent import ac.cord.auxiliary.compose.database.model.Profile import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent import ac.cord.auxiliary.compose.extensions.toFormattedTimeAndDateString +import ac.cord.auxiliary.compose.ui.composable.widgets.content.RichContent import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileAvatar import ac.cord.auxiliary.compose.ui.theme.AuxTheme import androidx.compose.foundation.clickable @@ -61,10 +62,9 @@ fun LocalNostrEvent.ListView( style = MaterialTheme.typography.labelSmall) } - if (taggedNostrEvent != null) { + if (repost != null) { TextNoteFeedItem( - nostrEvent = taggedNostrEvent, - profile = taggedAuthor, + localNostrEvent = this@ListView, onNavigateToEvent = onNavigateToEvent ) } else { @@ -96,10 +96,7 @@ fun LocalNostrEvent.ListView( // TODO: Support other kinds... else -> { TextNoteFeedItem( - nostrEvent = nostrEvent, - profile = profile, - taggedAuthor = taggedAuthor, - taggedNostrEvent = taggedNostrEvent, + localNostrEvent = this, onNavigateToEvent = onNavigateToEvent ) } @@ -109,13 +106,11 @@ fun LocalNostrEvent.ListView( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun TextNoteFeedItem( - nostrEvent: NostrEvent, - profile: Profile?, - taggedNostrEvent: NostrEvent? = null, - taggedAuthor: Profile? = null, + localNostrEvent: LocalNostrEvent, onNavigateToEvent: (HexKey) -> Unit ) { - if (nostrEvent.kind == 1 && taggedNostrEvent != null) { + val nostrEvent = localNostrEvent.nostrEvent + if (localNostrEvent.repost != null) { Row( modifier = Modifier.fillMaxWidth().padding( horizontal = 20.dp @@ -123,7 +118,7 @@ private fun TextNoteFeedItem( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { - // TODO: If we don't have the name of the author being replied too we shouldn't show this.. + // TODO: Get a nested repost... localNostrEvent... Text( "In reply to ", style = MaterialTheme.typography.labelSmall @@ -132,11 +127,11 @@ private fun TextNoteFeedItem( Text( modifier = Modifier.clickable( onClick = { - onNavigateToEvent.invoke(taggedAuthor?.nostrEventId ?: taggedNostrEvent.id) + onNavigateToEvent.invoke(localNostrEvent.repost.repostedPostId) } ), style = MaterialTheme.typography.labelLargeEmphasized, - text = taggedAuthor?.displayName ?: taggedAuthor?.userName ?: "loading information..." + text = localNostrEvent.repost.repostedPostAuthorPublicKey ?: "loading information..." ) } } @@ -154,6 +149,7 @@ private fun TextNoteFeedItem( verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally ) { + val profile = localNostrEvent.profile if (profile != null) { Row( modifier = Modifier.fillMaxWidth().clickable( @@ -182,10 +178,9 @@ private fun TextNoteFeedItem( } // TODO: RichContent here... - Text( + RichContent( modifier = Modifier.fillMaxWidth(), - text = nostrEvent.content, - style = MaterialTheme.typography.bodyLarge + localNostrEvent = localNostrEvent, ) LazyRow( @@ -255,6 +250,10 @@ private fun EventListViewPreview() { nostrEventId = "referenced", displayName = "Frank Talk" ), + repost = null, + quotedPost = null, + inReplyToNostrEvent = null, + mentionedProfiles = emptyList() ).ListView( onNavigateToEvent = {} )