From 1613e7986003e8e6d166fca9e16281fdd826ca66 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 19 Jun 2023 18:34:52 -0400 Subject: [PATCH] Support for live chats in streaming events. --- .../vitorpamplona/amethyst/model/Account.kt | 23 +++- .../vitorpamplona/amethyst/model/Channel.kt | 122 ++++++++++++++---- .../amethyst/model/LocalCache.kt | 86 ++++++++++-- .../com/vitorpamplona/amethyst/model/Note.kt | 2 + .../service/NostrChannelDataSource.kt | 28 +++- .../service/NostrSingleChannelDataSource.kt | 38 +++++- .../service/model/BaseTextNoteEvent.kt | 2 +- .../amethyst/service/model/Event.kt | 1 + .../model/LiveActivitiesChatMessageEvent.kt | 82 ++++++++++++ .../ui/actions/JoinUserOrChannelView.kt | 4 +- .../amethyst/ui/actions/NewChannelView.kt | 4 +- .../ui/actions/NewChannelViewModel.kt | 6 +- .../amethyst/ui/actions/NewPostViewModel.kt | 7 +- .../amethyst/ui/note/ChatroomCompose.kt | 2 +- .../ui/note/ChatroomMessageCompose.kt | 5 +- .../amethyst/ui/note/NoteCompose.kt | 5 + .../amethyst/ui/note/NoteQuickActionMenu.kt | 19 ++- .../amethyst/ui/note/ReplyInformation.kt | 2 +- .../ui/screen/loggedIn/ChannelScreen.kt | 56 ++++++-- .../ui/screen/loggedIn/SearchScreen.kt | 4 +- 20 files changed, 425 insertions(+), 73 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesChatMessageEvent.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3e14391f7..ce29c770f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -103,7 +103,7 @@ class Account( } fun followingChannels(): List { - return followingChannels.map { LocalCache.getOrCreateChannel(it) } + return followingChannels.mapNotNull { LocalCache.checkGetOrCreateChannel(it) } } fun isWriteable(): Boolean { @@ -597,6 +597,27 @@ class Account( LocalCache.consume(signedEvent, null) } + fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List?, mentions: List?, 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?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) { if (!isWriteable()) return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index c988e6cbd..870e1c1dd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -3,7 +3,9 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData 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.LiveActivitiesEvent import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.note.toShortenHex import fr.acinq.secp256k1.Hex @@ -11,20 +13,96 @@ import kotlinx.coroutines.Dispatchers import java.util.concurrent.ConcurrentHashMap @Stable -class Channel(val idHex: String) { - var creator: User? = null +class PublicChatChannel(idHex: String) : Channel(idHex) { 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 val notes = ConcurrentHashMap() - fun id() = Hex.decode(idHex) - fun idNote() = id().toNote() - fun idDisplayNote() = idNote().toShortenHex() + open fun id() = Hex.decode(idHex) + open fun idNote() = id().toNote() + open fun idDisplayNote() = idNote().toShortenHex() - fun toBestDisplayName(): String { - return info.name ?: idDisplayNote() + open fun toBestDisplayName(): String { + 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) { @@ -39,23 +117,7 @@ class Channel(val idHex: String) { notes.remove(noteHex) } - fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) { - 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() - } + abstract fun anyNameStartsWith(prefix: String): Boolean // Observers line up here. val live: ChannelLiveData = ChannelLiveData(this) @@ -92,12 +154,20 @@ class ChannelLiveData(val channel: Channel) : LiveData(ChannelStat override fun onActive() { super.onActive() - NostrSingleChannelDataSource.add(channel.idHex) + if (channel is PublicChatChannel) { + NostrSingleChannelDataSource.add(channel.idHex) + } else { + NostrSingleChannelDataSource.add(channel.idHex) + } } override fun onInactive() { super.onInactive() - NostrSingleChannelDataSource.remove(channel.idHex) + if (channel is PublicChatChannel) { + NostrSingleChannelDataSource.remove(channel.idHex) + } else { + NostrSingleChannelDataSource.remove(channel.idHex) + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 921e79f0b..152428522 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -4,6 +4,7 @@ import android.util.Log import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.service.checkNotInMainThread 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.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledInsert @@ -97,7 +98,15 @@ object LocalCache { checkNotInMainThread() 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 } @@ -113,11 +122,11 @@ object LocalCache { } @Synchronized - fun getOrCreateChannel(key: String): Channel { + fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel { checkNotInMainThread() return channels[key] ?: run { - val answer = Channel(key) + val answer = channelFactory(key) channels.put(key, answer) answer } @@ -333,6 +342,11 @@ object LocalCache { if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, emptyList()) + val channel = getOrCreateChannel(note.idHex) { + LiveActivitiesChannel(note.address) + } as? LiveActivitiesChannel + channel?.updateChannelInfo(author, event, event.createdAt) + refreshObservers(note) } } @@ -572,8 +586,11 @@ object LocalCache { } deleteNote.channelHex()?.let { - val channel = checkGetOrCreateChannel(it) - channel?.removeNote(deleteNote) + channels[it]?.removeNote(deleteNote) + } + + (deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let { + channels[it.toTag()]?.removeNote(deleteNote) } if (deleteNote.event is PrivateDmEvent) { @@ -708,7 +725,9 @@ object LocalCache { fun consume(event: ChannelCreateEvent) { // 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 note = getOrCreateNote(event.id) @@ -723,7 +742,9 @@ object LocalCache { return // older data, does nothing } if (oldChannel.creator == null || oldChannel.creator == author) { - oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + if (oldChannel is PublicChatChannel) { + oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + } } } @@ -734,10 +755,13 @@ object LocalCache { // new event val oldChannel = checkGetOrCreateChannel(channelId) ?: return + val author = getOrCreateUser(event.pubKey) if (event.createdAt > oldChannel.updatedMetadataAt) { if (oldChannel.creator == null || oldChannel.creator == author) { - oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + if (oldChannel is PublicChatChannel) { + oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) + } } } else { // 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) } + 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") 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 HighlightEvent -> consume(event, relay) is LiveActivitiesEvent -> consume(event, relay) + is LiveActivitiesChatMessageEvent -> consume(event, relay) is LnZapEvent -> { event.zapRequest?.let { verifyAndConsume(it, relay) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index d7fb74ee9..7d701f13e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -73,6 +73,8 @@ open class Note(val idHex: String) { return (event as? ChannelMessageEvent)?.channel() ?: (event as? ChannelMetadataEvent)?.channel() ?: (event as? ChannelCreateEvent)?.id + ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() + ?: (event as? LiveActivitiesEvent)?.address()?.toTag() } open fun address(): ATag? = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt index 5bc2e95c2..5994c23a1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt @@ -2,7 +2,10 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account 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.LiveActivitiesChatMessageEvent import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -25,7 +28,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") { fun createMessagesByMeToChannelFilter(): TypedFilter? { val myAccount = account ?: return null - if (channel != null) { + if (channel is PublicChatChannel) { // 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. @@ -37,12 +40,24 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") { 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 } fun createMessagesToChannelFilter(): TypedFilter? { - if (channel != null) { + if (channel is PublicChatChannel) { return TypedFilter( types = setOf(FeedType.PUBLIC_CHATS), filter = JsonFilter( @@ -51,6 +66,15 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") { 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 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt index 2d5946418..0ed66a608 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt @@ -1,6 +1,8 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel 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.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES @@ -30,8 +32,8 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { val directEventsToLoad = channelsToWatch - .map { LocalCache.getOrCreateChannel(it) } - .filter { it.notes.isEmpty() } + .mapNotNull { LocalCache.checkGetOrCreateChannel(it) } + .filter { it.notes.isEmpty() && it is PublicChatChannel } val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() @@ -49,13 +51,43 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { ) } + fun createLoadStreamingIfNotLoadedFilter(): List? { + val directEventsToLoad = channelsToWatch + .mapNotNull { LocalCache.checkGetOrCreateChannel(it) } + .filterIsInstance() + .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() override fun updateChannelFilters() { val reactions = createRepliesAndReactionsFilter() 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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt index 201622544..009ed591a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt @@ -18,7 +18,7 @@ open class BaseTextNoteEvent( sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { fun mentions() = taggedUsers() - open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + open fun replyTos() = taggedEvents() private var citedUsersCache: Set? = null private var citedNotesCache: Set? = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 18f51912e..2ccc74610 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -252,6 +252,7 @@ open class Event( GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.kind -> HighlightEvent(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) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesChatMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesChatMessageEvent.kt new file mode 100644 index 000000000..60a4c0868 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LiveActivitiesChatMessageEvent.kt @@ -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>, + 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? = null, + mentions: List? = 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()) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index 563445952..c69101f16 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -352,12 +352,12 @@ private fun RenderChannel( channelPicture = item.profilePicture(), channelTitle = { Text( - "${item.info.name}", + item.toBestDisplayName(), fontWeight = FontWeight.Bold ) }, channelLastTime = null, - channelLastContent = item.info.about, + channelLastContent = item.summary(), false, onClick = onClick ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt index 336ba3ebe..330714037 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt @@ -25,14 +25,14 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel 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.theme.placeholderText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable -fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: Channel? = null) { +fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: PublicChatChannel? = null) { val postViewModel: NewChannelViewModel = viewModel() postViewModel.load(accountViewModel.account, channel) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt index 96ba1afe3..18aaf9035 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt @@ -4,17 +4,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.PublicChatChannel class NewChannelViewModel : ViewModel() { private var account: Account? = null - private var originalChannel: Channel? = null + private var originalChannel: PublicChatChannel? = null val channelName = mutableStateOf(TextFieldValue()) val channelPicture = mutableStateOf(TextFieldValue()) val channelDescription = mutableStateOf(TextFieldValue()) - fun load(account: Account, channel: Channel?) { + fun load(account: Account, channel: PublicChatChannel?) { this.account = account if (channel != null) { originalChannel = channel diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 3f216b014..c9b2f6df5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -16,6 +16,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.service.FileHeader 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.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent @@ -143,7 +144,11 @@ open class NewPostViewModel() : ViewModel() { if (wantsPoll) { account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount) } else if (originalNote?.channelHex() != null) { - account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount) + 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) + } } else if (originalNote?.event is PrivateDmEvent) { account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount) } else { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt index d73560f0d..a69d60e13 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt @@ -108,7 +108,7 @@ private fun ChannelRoomCompose( } val channelName by remember(channelState) { derivedStateOf { - channel.info.name + channel.toBestDisplayName() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 16c1b7b8f..e6e076279 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -55,8 +55,8 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note 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.PrivateDmEvent import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji @@ -244,7 +244,7 @@ fun ChatroomMessageCompose( bubbleSize = it } ) { - if ((innerQuote || note.author != loggedIn) && noteEvent is ChannelMessageEvent) { + if ((innerQuote || note.author != loggedIn) && noteEvent !is PrivateDmEvent) { DrawAuthorInfo( baseNote, alignment, @@ -255,6 +255,7 @@ fun ChatroomMessageCompose( } val replyTo = note.replyTo + println("AAA replyTo ${replyTo?.lastOrNull()}") if (!innerQuote && !replyTo.isNullOrEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { replyTo.lastOrNull()?.let { note -> diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 6bb0371f9..d63e368c9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -100,6 +100,7 @@ import com.vitorpamplona.amethyst.service.model.FileHeaderEvent import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent import com.vitorpamplona.amethyst.service.model.GenericRepostEvent 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.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.Participant @@ -738,6 +739,10 @@ fun routeFor(note: Note, loggedIn: User): String? { note.channelHex()?.let { return "Channel/$it" } + } else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) { + note.channelHex()?.let { + return "Channel/$it" + } } else if (noteEvent is PrivateDmEvent) { return "Room/${noteEvent.talkingWith(loggedIn.pubkeyHex)}" } else { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index d9e77385c..17f621ce8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -332,23 +332,30 @@ fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) { } @Composable -fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) = +fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) { + val scope = rememberCoroutineScope() + QuickActionAlertDialog( title = stringResource(R.string.quick_action_request_deletion_alert_title), textContent = stringResource(R.string.quick_action_request_deletion_alert_body), buttonIcon = Icons.Default.Delete, buttonText = stringResource(R.string.quick_action_delete_dialog_btn), onClickDoOnce = { - accountViewModel.delete(note) + scope.launch(Dispatchers.IO) { + accountViewModel.delete(note) + } onDismiss() }, onClickDontShowAgain = { - accountViewModel.delete(note) - accountViewModel.dontShowDeleteRequestDialog() + scope.launch(Dispatchers.IO) { + accountViewModel.delete(note) + accountViewModel.dontShowDeleteRequestDialog() + } onDismiss() }, onDismiss = onDismiss ) +} @Composable private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) = @@ -394,7 +401,9 @@ private fun QuickActionAlertDialog( }, buttons = { Row( - modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), + modifier = Modifier + .padding(all = 8.dp) + .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { TextButton(onClick = onClickDontShowAgain) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index de83035b8..4998f0623 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -190,7 +190,7 @@ fun ReplyInformationChannel( val channel = channelState?.channel ?: return val channelName = remember(channelState) { - AnnotatedString("${channel.info.name} ") + AnnotatedString("${channel.toBestDisplayName()} ") } FlowRow() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 5fd79a147..4256c239d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -64,8 +64,10 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.ui.actions.NewChannelView 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.components.ResizeImage 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.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel @@ -232,13 +235,23 @@ fun ChannelScreen( message = newPostModel.message.text ) tagger.run() - accountViewModel.account.sendChannelMessage( - message = tagger.message, - toChannel = channel.idHex, - replyTo = tagger.replyTos, - mentions = tagger.mentions, - wantsToMarkAsSensitive = false - ) + if (channel is PublicChatChannel) { + accountViewModel.account.sendChannelMessage( + message = tagger.message, + toChannel = channel.idHex, + replyTo = tagger.replyTos, + mentions = tagger.mentions, + 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("") replyTo.value = null feedViewModel.sendToTop() @@ -412,14 +425,14 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav: ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - "${channel.info.name}", + channel.toBestDisplayName(), fontWeight = FontWeight.Bold ) } Row(verticalAlignment = Alignment.CenterVertically) { Text( - "${channel.info.about}", + "${channel.summary()}", color = MaterialTheme.colors.placeholderText, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -433,7 +446,26 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav: .height(Size35dp) .padding(bottom = 3.dp) ) { - ChannelActionOptions(channel, accountViewModel, nav) + if (channel is PublicChatChannel) { + 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 + ) } } } @@ -447,7 +479,7 @@ fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, nav: @Composable private fun ChannelActionOptions( - channel: Channel, + channel: PublicChatChannel, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { @@ -514,7 +546,7 @@ private fun NoteCopyButton( } @Composable -private fun EditButton(accountViewModel: AccountViewModel, channel: Channel) { +private fun EditButton(accountViewModel: AccountViewModel, channel: PublicChatChannel) { var wantsToPost by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 03be1d952..37e0b0788 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -399,12 +399,12 @@ private fun DisplaySearchResults( channelPicture = item.profilePicture(), channelTitle = { Text( - "${item.info.name}", + "${item.toBestDisplayName()}", fontWeight = FontWeight.Bold ) }, channelLastTime = null, - channelLastContent = item.info.about, + channelLastContent = item.summary(), false, onClick = { nav("Channel/${item.idHex}") } )