Support for live chats in streaming events.

This commit is contained in:
Vitor Pamplona
2023-06-19 18:34:52 -04:00
parent dd40b4f205
commit 1613e79860
20 changed files with 425 additions and 73 deletions
@@ -103,7 +103,7 @@ class Account(
} }
fun followingChannels(): List<Channel> { fun followingChannels(): List<Channel> {
return followingChannels.map { LocalCache.getOrCreateChannel(it) } return followingChannels.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
} }
fun isWriteable(): Boolean { fun isWriteable(): Boolean {
@@ -597,6 +597,27 @@ class Account(
LocalCache.consume(signedEvent, null) LocalCache.consume(signedEvent, null)
} }
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = LiveActivitiesChatMessageEvent.create(
message = message,
activity = toChannel,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) { fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -3,7 +3,9 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex import fr.acinq.secp256k1.Hex
@@ -11,20 +13,96 @@ import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@Stable @Stable
class Channel(val idHex: String) { class PublicChatChannel(idHex: String) : Channel(idHex) {
var creator: User? = null
var info = ChannelCreateEvent.ChannelData(null, null, null) var info = ChannelCreateEvent.ChannelData(null, null, null)
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
override fun toBestDisplayName(): String {
return info.name ?: super.toBestDisplayName()
}
override fun summary(): String? {
return info.about
}
override fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.contains(prefix, true) }.isNotEmpty()
}
}
@Stable
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
var info: LiveActivitiesEvent? = null
override fun idNote() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
fun address() = address
fun updateChannelInfo(creator: User, channelInfo: LiveActivitiesEvent, updatedAt: Long) {
this.info = channelInfo
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {
return info?.title() ?: super.toBestDisplayName()
}
override fun summary(): String? {
return info?.summary()
}
override fun profilePicture(): String? {
return info?.image()?.ifBlank { null } ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info?.title(), info?.summary())
.filter { it.contains(prefix, true) }.isNotEmpty()
}
}
@Stable
abstract class Channel(val idHex: String) {
var creator: User? = null
var updatedMetadataAt: Long = 0 var updatedMetadataAt: Long = 0
val notes = ConcurrentHashMap<HexKey, Note>() val notes = ConcurrentHashMap<HexKey, Note>()
fun id() = Hex.decode(idHex) open fun id() = Hex.decode(idHex)
fun idNote() = id().toNote() open fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex() open fun idDisplayNote() = idNote().toShortenHex()
fun toBestDisplayName(): String { open fun toBestDisplayName(): String {
return info.name ?: idDisplayNote() return idDisplayNote()
}
open fun summary(): String? {
return null
}
open fun profilePicture(): String? {
return creator?.profilePicture()
}
open fun updateChannelInfo(creator: User, updatedAt: Long) {
this.creator = creator
this.updatedMetadataAt = updatedAt
live.invalidateData()
} }
fun addNote(note: Note) { fun addNote(note: Note) {
@@ -39,23 +117,7 @@ class Channel(val idHex: String) {
notes.remove(noteHex) notes.remove(noteHex)
} }
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) { abstract fun anyNameStartsWith(prefix: String): Boolean
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture
}
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.startsWith(prefix, true) }.isNotEmpty()
}
// Observers line up here. // Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this) val live: ChannelLiveData = ChannelLiveData(this)
@@ -92,12 +154,20 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
override fun onActive() { override fun onActive() {
super.onActive() super.onActive()
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.add(channel.idHex) NostrSingleChannelDataSource.add(channel.idHex)
} else {
NostrSingleChannelDataSource.add(channel.idHex)
}
} }
override fun onInactive() { override fun onInactive() {
super.onInactive() super.onInactive()
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.remove(channel.idHex) NostrSingleChannelDataSource.remove(channel.idHex)
} else {
NostrSingleChannelDataSource.remove(channel.idHex)
}
} }
} }
@@ -4,6 +4,7 @@ import android.util.Log
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.ATag.Companion.isATag
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -97,7 +98,15 @@ object LocalCache {
checkNotInMainThread() checkNotInMainThread()
if (isValidHexNpub(key)) { if (isValidHexNpub(key)) {
return getOrCreateChannel(key) return getOrCreateChannel(key) {
PublicChatChannel(key)
}
}
val aTag = ATag.parse(key, null)
if (aTag != null) {
return getOrCreateChannel(aTag.toTag()) {
LiveActivitiesChannel(aTag)
}
} }
return null return null
} }
@@ -113,11 +122,11 @@ object LocalCache {
} }
@Synchronized @Synchronized
fun getOrCreateChannel(key: String): Channel { fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel {
checkNotInMainThread() checkNotInMainThread()
return channels[key] ?: run { return channels[key] ?: run {
val answer = Channel(key) val answer = channelFactory(key)
channels.put(key, answer) channels.put(key, answer)
answer answer
} }
@@ -333,6 +342,11 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) { if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList()) note.loadEvent(event, author, emptyList())
val channel = getOrCreateChannel(note.idHex) {
LiveActivitiesChannel(note.address)
} as? LiveActivitiesChannel
channel?.updateChannelInfo(author, event, event.createdAt)
refreshObservers(note) refreshObservers(note)
} }
} }
@@ -572,8 +586,11 @@ object LocalCache {
} }
deleteNote.channelHex()?.let { deleteNote.channelHex()?.let {
val channel = checkGetOrCreateChannel(it) channels[it]?.removeNote(deleteNote)
channel?.removeNote(deleteNote) }
(deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let {
channels[it.toTag()]?.removeNote(deleteNote)
} }
if (deleteNote.event is PrivateDmEvent) { if (deleteNote.event is PrivateDmEvent) {
@@ -708,7 +725,9 @@ object LocalCache {
fun consume(event: ChannelCreateEvent) { fun consume(event: ChannelCreateEvent) {
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
val oldChannel = getOrCreateChannel(event.id) val oldChannel = getOrCreateChannel(event.id) {
PublicChatChannel(it)
}
val author = getOrCreateUser(event.pubKey) val author = getOrCreateUser(event.pubKey)
val note = getOrCreateNote(event.id) val note = getOrCreateNote(event.id)
@@ -723,9 +742,11 @@ object LocalCache {
return // older data, does nothing return // older data, does nothing
} }
if (oldChannel.creator == null || oldChannel.creator == author) { if (oldChannel.creator == null || oldChannel.creator == author) {
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
} }
} }
}
fun consume(event: ChannelMetadataEvent) { fun consume(event: ChannelMetadataEvent) {
val channelId = event.channel() val channelId = event.channel()
@@ -734,11 +755,14 @@ object LocalCache {
// new event // new event
val oldChannel = checkGetOrCreateChannel(channelId) ?: return val oldChannel = checkGetOrCreateChannel(channelId) ?: return
val author = getOrCreateUser(event.pubKey) val author = getOrCreateUser(event.pubKey)
if (event.createdAt > oldChannel.updatedMetadataAt) { if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) { if (oldChannel.creator == null || oldChannel.creator == author) {
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
} }
}
} else { } else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
} }
@@ -795,6 +819,49 @@ object LocalCache {
refreshObservers(note) refreshObservers(note)
} }
fun consume(event: LiveActivitiesChatMessageEvent, relay: Relay?) {
val activityId = event.activity() ?: return
val channel = getOrCreateChannel(activityId.toTag()) {
LiveActivitiesChannel(activityId)
}
val note = getOrCreateNote(event.id)
channel.addNote(note)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
return
}
val replyTo = event.tagsWithoutCitations()
.filter { it != event.activity()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, replyTo)
Log.d("CM", "AAA REPLY TO ${event.content} ${event.taggedEvents()}")
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
fun consume(event: ChannelHideMessageEvent) { fun consume(event: ChannelHideMessageEvent) {
} }
@@ -1084,7 +1151,7 @@ object LocalCache {
} }
} }
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}") println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}")
} }
} }
@@ -1172,6 +1239,7 @@ object LocalCache {
is FileStorageHeaderEvent -> consume(event, relay) is FileStorageHeaderEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay) is HighlightEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay) is LiveActivitiesEvent -> consume(event, relay)
is LiveActivitiesChatMessageEvent -> consume(event, relay)
is LnZapEvent -> { is LnZapEvent -> {
event.zapRequest?.let { event.zapRequest?.let {
verifyAndConsume(it, relay) verifyAndConsume(it, relay)
@@ -73,6 +73,8 @@ open class Note(val idHex: String) {
return (event as? ChannelMessageEvent)?.channel() return (event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel() ?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id ?: (event as? ChannelCreateEvent)?.id
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
} }
open fun address(): ATag? = null open fun address(): ATag? = null
@@ -2,7 +2,10 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -25,7 +28,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
fun createMessagesByMeToChannelFilter(): TypedFilter? { fun createMessagesByMeToChannelFilter(): TypedFilter? {
val myAccount = account ?: return null val myAccount = account ?: return null
if (channel != null) { if (channel is PublicChatChannel) {
// Brings on messages by the user from all other relays. // Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only // Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear. // this guarantees that messages from the author do not disappear.
@@ -37,12 +40,24 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
limit = 50 limit = 50
) )
) )
} else if (channel is LiveActivitiesChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50
)
)
} }
return null return null
} }
fun createMessagesToChannelFilter(): TypedFilter? { fun createMessagesToChannelFilter(): TypedFilter? {
if (channel != null) { if (channel is PublicChatChannel) {
return TypedFilter( return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS), types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter( filter = JsonFilter(
@@ -51,6 +66,15 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
limit = 200 limit = 200
) )
) )
} else if (channel is LiveActivitiesChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200
)
)
} }
return null return null
} }
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
@@ -30,8 +32,8 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch val directEventsToLoad = channelsToWatch
.map { LocalCache.getOrCreateChannel(it) } .mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filter { it.notes.isEmpty() } .filter { it.notes.isEmpty() && it is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
@@ -49,13 +51,43 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
) )
} }
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filterIsInstance<LiveActivitiesChannel>()
.filter { it.info == null }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return directEventsToLoad.map {
it.address().let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex.substring(0, 8))
)
)
}
}
}
val singleChannelChannel = requestNewChannel() val singleChannelChannel = requestNewChannel()
override fun updateChannelFilters() { override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter() val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter() val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null } singleChannelChannel.typedFilters = (
(listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())
).ifEmpty { null }
} }
fun add(eventId: String) { fun add(eventId: String) {
@@ -18,7 +18,7 @@ open class BaseTextNoteEvent(
sig: HexKey sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) { ) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers() fun mentions() = taggedUsers()
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } open fun replyTos() = taggedEvents()
private var citedUsersCache: Set<HexKey>? = null private var citedUsersCache: Set<HexKey>? = null
private var citedNotesCache: Set<HexKey>? = null private var citedNotesCache: Set<HexKey>? = null
@@ -252,6 +252,7 @@ open class Event(
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,82 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class LiveActivitiesChatMessageEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
private fun innerActivity() = tags.firstOrNull {
it.size > 3 && it[0] == "a" && it[3] == "root"
} ?: tags.firstOrNull {
it.size > 1 && it[0] == "a"
}
private fun activityHex() = innerActivity()?.let {
it.getOrNull(1)
}
fun activity() = innerActivity()?.let {
if (it.size > 1) {
val aTagValue = it[1]
val relay = it.getOrNull(2)
ATag.parse(aTagValue, relay)
} else {
null
}
}
override fun replyTos() = taggedEvents().minus(activityHex() ?: "")
companion object {
const val kind = 1311
fun create(
message: String,
activity: ATag,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): LiveActivitiesChatMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
listOf("a", activity.toTag(), "", "root")
)
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LiveActivitiesChatMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -352,12 +352,12 @@ private fun RenderChannel(
channelPicture = item.profilePicture(), channelPicture = item.profilePicture(),
channelTitle = { channelTitle = {
Text( Text(
"${item.info.name}", item.toBestDisplayName(),
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
}, },
channelLastTime = null, channelLastTime = null,
channelLastContent = item.info.about, channelLastContent = item.summary(),
false, false,
onClick = onClick onClick = onClick
) )
@@ -25,14 +25,14 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: Channel? = null) { fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: PublicChatChannel? = null) {
val postViewModel: NewChannelViewModel = viewModel() val postViewModel: NewChannelViewModel = viewModel()
postViewModel.load(accountViewModel.account, channel) postViewModel.load(accountViewModel.account, channel)
@@ -4,17 +4,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.PublicChatChannel
class NewChannelViewModel : ViewModel() { class NewChannelViewModel : ViewModel() {
private var account: Account? = null private var account: Account? = null
private var originalChannel: Channel? = null private var originalChannel: PublicChatChannel? = null
val channelName = mutableStateOf(TextFieldValue()) val channelName = mutableStateOf(TextFieldValue())
val channelPicture = mutableStateOf(TextFieldValue()) val channelPicture = mutableStateOf(TextFieldValue())
val channelDescription = mutableStateOf(TextFieldValue()) val channelDescription = mutableStateOf(TextFieldValue())
fun load(account: Account, channel: Channel?) { fun load(account: Account, channel: PublicChatChannel?) {
this.account = account this.account = account
if (channel != null) { if (channel != null) {
originalChannel = channel originalChannel = channel
@@ -16,6 +16,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.AddressableEvent
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -143,7 +144,11 @@ open class NewPostViewModel() : ViewModel() {
if (wantsPoll) { if (wantsPoll) {
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount) account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
} else if (originalNote?.channelHex() != null) { } else if (originalNote?.channelHex() != null) {
if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
} else {
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount) account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
}
} else if (originalNote?.event is PrivateDmEvent) { } else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount) account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
} else { } else {
@@ -108,7 +108,7 @@ private fun ChannelRoomCompose(
} }
val channelName by remember(channelState) { val channelName by remember(channelState) {
derivedStateOf { derivedStateOf {
channel.info.name channel.toBestDisplayName()
} }
} }
@@ -55,8 +55,8 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
@@ -244,7 +244,7 @@ fun ChatroomMessageCompose(
bubbleSize = it bubbleSize = it
} }
) { ) {
if ((innerQuote || note.author != loggedIn) && noteEvent is ChannelMessageEvent) { if ((innerQuote || note.author != loggedIn) && noteEvent !is PrivateDmEvent) {
DrawAuthorInfo( DrawAuthorInfo(
baseNote, baseNote,
alignment, alignment,
@@ -255,6 +255,7 @@ fun ChatroomMessageCompose(
} }
val replyTo = note.replyTo val replyTo = note.replyTo
println("AAA replyTo ${replyTo?.lastOrNull()}")
if (!innerQuote && !replyTo.isNullOrEmpty()) { if (!innerQuote && !replyTo.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
replyTo.lastOrNull()?.let { note -> replyTo.lastOrNull()?.let { note ->
@@ -100,6 +100,7 @@ import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.Participant import com.vitorpamplona.amethyst.service.model.Participant
@@ -738,6 +739,10 @@ fun routeFor(note: Note, loggedIn: User): String? {
note.channelHex()?.let { note.channelHex()?.let {
return "Channel/$it" return "Channel/$it"
} }
} else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) {
note.channelHex()?.let {
return "Channel/$it"
}
} else if (noteEvent is PrivateDmEvent) { } else if (noteEvent is PrivateDmEvent) {
return "Room/${noteEvent.talkingWith(loggedIn.pubkeyHex)}" return "Room/${noteEvent.talkingWith(loggedIn.pubkeyHex)}"
} else { } else {
@@ -332,23 +332,30 @@ fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) {
} }
@Composable @Composable
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) = fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
QuickActionAlertDialog( QuickActionAlertDialog(
title = stringResource(R.string.quick_action_request_deletion_alert_title), title = stringResource(R.string.quick_action_request_deletion_alert_title),
textContent = stringResource(R.string.quick_action_request_deletion_alert_body), textContent = stringResource(R.string.quick_action_request_deletion_alert_body),
buttonIcon = Icons.Default.Delete, buttonIcon = Icons.Default.Delete,
buttonText = stringResource(R.string.quick_action_delete_dialog_btn), buttonText = stringResource(R.string.quick_action_delete_dialog_btn),
onClickDoOnce = { onClickDoOnce = {
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note) accountViewModel.delete(note)
}
onDismiss() onDismiss()
}, },
onClickDontShowAgain = { onClickDontShowAgain = {
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note) accountViewModel.delete(note)
accountViewModel.dontShowDeleteRequestDialog() accountViewModel.dontShowDeleteRequestDialog()
}
onDismiss() onDismiss()
}, },
onDismiss = onDismiss onDismiss = onDismiss
) )
}
@Composable @Composable
private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) = private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
@@ -394,7 +401,9 @@ private fun QuickActionAlertDialog(
}, },
buttons = { buttons = {
Row( Row(
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
TextButton(onClick = onClickDontShowAgain) { TextButton(onClick = onClickDontShowAgain) {
@@ -190,7 +190,7 @@ fun ReplyInformationChannel(
val channel = channelState?.channel ?: return val channel = channelState?.channel ?: return
val channelName = remember(channelState) { val channelName = remember(channelState) {
AnnotatedString("${channel.info.name} ") AnnotatedString("${channel.toBestDisplayName()} ")
} }
FlowRow() { FlowRow() {
@@ -64,8 +64,10 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
@@ -75,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
@@ -232,6 +235,7 @@ fun ChannelScreen(
message = newPostModel.message.text message = newPostModel.message.text
) )
tagger.run() tagger.run()
if (channel is PublicChatChannel) {
accountViewModel.account.sendChannelMessage( accountViewModel.account.sendChannelMessage(
message = tagger.message, message = tagger.message,
toChannel = channel.idHex, toChannel = channel.idHex,
@@ -239,6 +243,15 @@ fun ChannelScreen(
mentions = tagger.mentions, mentions = tagger.mentions,
wantsToMarkAsSensitive = false wantsToMarkAsSensitive = false
) )
} else if (channel is LiveActivitiesChannel) {
accountViewModel.account.sendLiveMessage(
message = tagger.message,
toChannel = channel.address,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
wantsToMarkAsSensitive = false
)
}
newPostModel.message = TextFieldValue("") newPostModel.message = TextFieldValue("")
replyTo.value = null replyTo.value = null
feedViewModel.sendToTop() feedViewModel.sendToTop()
@@ -412,14 +425,14 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav:
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"${channel.info.name}", channel.toBestDisplayName(),
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
"${channel.info.about}", "${channel.summary()}",
color = MaterialTheme.colors.placeholderText, color = MaterialTheme.colors.placeholderText,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -433,10 +446,29 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav:
.height(Size35dp) .height(Size35dp)
.padding(bottom = 3.dp) .padding(bottom = 3.dp)
) { ) {
if (channel is PublicChatChannel) {
ChannelActionOptions(channel, accountViewModel, nav) ChannelActionOptions(channel, accountViewModel, nav)
} }
} }
} }
}
if (channel is LiveActivitiesChannel) {
val streamingUrl by remember(channelState) {
derivedStateOf {
channel.info?.streaming()
}
}
if (streamingUrl != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 5.dp)) {
VideoView(
videoUri = streamingUrl!!,
description = null
)
}
}
}
Divider( Divider(
modifier = Modifier.padding(start = 12.dp, end = 12.dp), modifier = Modifier.padding(start = 12.dp, end = 12.dp),
@@ -447,7 +479,7 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav:
@Composable @Composable
private fun ChannelActionOptions( private fun ChannelActionOptions(
channel: Channel, channel: PublicChatChannel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit nav: (String) -> Unit
) { ) {
@@ -514,7 +546,7 @@ private fun NoteCopyButton(
} }
@Composable @Composable
private fun EditButton(accountViewModel: AccountViewModel, channel: Channel) { private fun EditButton(accountViewModel: AccountViewModel, channel: PublicChatChannel) {
var wantsToPost by remember { var wantsToPost by remember {
mutableStateOf(false) mutableStateOf(false)
} }
@@ -399,12 +399,12 @@ private fun DisplaySearchResults(
channelPicture = item.profilePicture(), channelPicture = item.profilePicture(),
channelTitle = { channelTitle = {
Text( Text(
"${item.info.name}", "${item.toBestDisplayName()}",
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
}, },
channelLastTime = null, channelLastTime = null,
channelLastContent = item.info.about, channelLastContent = item.summary(),
false, false,
onClick = { nav("Channel/${item.idHex}") } onClick = { nav("Channel/${item.idHex}") }
) )