Add specific associations on quoted/repost/inreply to
This commit is contained in:
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Profile> = emptyList()
|
||||
) {
|
||||
@Composable
|
||||
fun RenderNotePreview() {
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String>,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean> get() = PipController.globalMuted
|
||||
// internal inline val activeVideoUrl: MutableStateFlow<String?> 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]+|(?<!\w)(npub1[a-z0-9]{58})(?!\w|\.[a-zA-Z])|(?:https?|wss?)://\S+|(?<!\w)((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+(?:com|net|org|io|dev|app|pro|ai|co|me|info|xyz|cc|tv|to|gg|sh|im|is|it|rs|ly|site|online|store|tech|cloud|social|world|earth|space|lol|wtf|family|life|art|design|blog|news|live|video|media|chat|games|money|finance|agency|studio|build|run|codes|systems|network|zone|pub|blue|limo|fyi|wiki|page|link|click|exchange|markets|fun|club|today)(?:/\S*)?)(?!\w)|(?<!\w)#([a-zA-Z0-9_][a-zA-Z0-9_-]*)|(?<!\w)((?:note1|nevent1|nprofile1|naddr1)[a-z0-9]{10,})(?!\w)""", RegexOption.IGNORE_CASE)
|
||||
|
||||
internal val emojiShortcodeRegex = Regex(""":([a-zA-Z0-9_-]+):""")
|
||||
|
||||
internal val bolt11Regex = Regex("""(?i)(lightning:)?(lnbc|lntb|lnbcrt)[0-9a-z]{50,}""")
|
||||
|
||||
internal val contentTypeCache = LruCache<String, String>(200)
|
||||
|
||||
internal val ogCache = LruCache<String, OgData>(200)
|
||||
}
|
||||
@@ -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<String, String> = emptyMap(),
|
||||
imetaMap: Map<String, MediaMeta> = emptyMap(),
|
||||
trimBlankLines: Boolean = true
|
||||
): List<ContentSegment> {
|
||||
val segments = mutableListOf<ContentSegment>()
|
||||
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<ContentSegment>()
|
||||
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<ContentSegment>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<String> = emptyList()) : ContentSegment
|
||||
data class NostrProfileSegment(val pubkey: String, val relayHints: List<String> = emptyList()) : ContentSegment
|
||||
data class NostrAddressableSegment(val dTag: String, val relays: List<String>, 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>): List<ContentSegment> {
|
||||
val result = mutableListOf<ContentSegment>()
|
||||
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<ContentSegment> {
|
||||
val matches = bolt11Regex.findAll(text).toList()
|
||||
if (matches.isEmpty()) return listOf(ContentSegment.TextSegment(text))
|
||||
val result = mutableListOf<ContentSegment>()
|
||||
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 <head>
|
||||
// val body = response.body?.source()?.let { source ->
|
||||
// source.request(32 * 1024)
|
||||
// source.buffer.snapshot().utf8()
|
||||
// } ?: return@withContext null
|
||||
//
|
||||
// val ogProps = mutableMapOf<String, String>()
|
||||
// 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
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package ac.cord.auxiliary.compose.ui.composable.widgets.content
|
||||
|
||||
internal enum class InvoicePayState {
|
||||
Idle, Paying, Success, Failed
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
onProfileClick: ((String) -> Unit)?,
|
||||
onLiveStreamClick: ((String, String, String?) -> Unit)? = null,
|
||||
segmentRelayHints: List<String> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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<String>) -> Unit = { _, _ -> },
|
||||
)
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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<String>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> = emptyMap(),
|
||||
imetaMap: Map<String, MediaMeta> = 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<String?>(null) }
|
||||
|
||||
if (fullScreenImageUrl != null) {
|
||||
FullScreenImageViewer(
|
||||
imageUrl = fullScreenImageUrl!!,
|
||||
onDismiss = { fullScreenImageUrl = null }
|
||||
)
|
||||
}
|
||||
|
||||
// Group segments into inline runs vs block-level items
|
||||
val groups = mutableListOf<Any>() // Either MutableList<ContentSegment> (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<ContentSegment>).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<ContentSegment>
|
||||
|
||||
// Build profile display names for this run
|
||||
val profilePubkeys = inlineSegments
|
||||
.filterIsInstance<ContentSegment.NostrProfileSegment>()
|
||||
.map { it.pubkey }
|
||||
|
||||
val profileNames = remember(profilePubkeys) {
|
||||
// TODO: Get these from taggedUsers...
|
||||
val names = mutableMapOf<String, String>()
|
||||
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<String, InlineTextContent> =
|
||||
if (inlineSegments.any { it is ContentSegment.CustomEmojiSegment }) {
|
||||
val emojiSize = style.fontSize
|
||||
inlineSegments
|
||||
.filterIsInstance<ContentSegment.CustomEmojiSegment>()
|
||||
.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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user