Add specific associations on quoted/repost/inreply to

This commit is contained in:
Kgothatso Ngako
2026-04-28 02:22:29 +02:00
parent 464445e45d
commit c99e3a31c2
30 changed files with 2800 additions and 47 deletions

View File

@@ -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')"
]
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
)
}
}
}
}

View File

@@ -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),

View File

@@ -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(

View File

@@ -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)
)
}
}
}
}
}
}
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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")
}
}
}
}
}

View File

@@ -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
)
}

View File

@@ -0,0 +1,5 @@
package ac.cord.auxiliary.compose.ui.composable.widgets.content
internal enum class InvoicePayState {
Idle, Paying, Success, Failed
}

View File

@@ -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"
)
}
}
}
}
}
}

View File

@@ -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) }
)
}
}

View File

@@ -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)
}
}

View File

@@ -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
)
}
}
}
}
}

View File

@@ -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
)

View File

@@ -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 = { _, _ -> },
)

View File

@@ -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?
)

View File

@@ -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
)
}
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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"
)
}
}
}
}
}
}
}

View File

@@ -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)
)
}
}

View File

@@ -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 = {}
)