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 f7706cbef..cffc7aed0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.Constants +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.relays.Client @@ -13,13 +14,21 @@ import nostr.postr.events.PrivateDmEvent import nostr.postr.events.TextNoteEvent import nostr.postr.toHex -class Account(val loggedIn: Persona) { +val DefaultChannels = setOf( + "25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb" // -> Anigma's Nostr +) + +class Account(val loggedIn: Persona, val followingChannels: MutableSet = DefaultChannels.toMutableSet()) { var seeReplies: Boolean = true fun userProfile(): User { return LocalCache.getOrCreateUser(loggedIn.pubKey) } + fun followingChannels(): List { + return followingChannels.map { LocalCache.getOrCreateChannel(it) } + } + fun isWriteable(): Boolean { return loggedIn.privKey != null } @@ -33,7 +42,7 @@ class Account(val loggedIn: Persona) { } note.event?.let { - val event = ReactionEvent.create(it, loggedIn.privKey!!) + val event = ReactionEvent.createLike(it, loggedIn.privKey!!) Client.send(event) LocalCache.consume(event) } @@ -109,6 +118,18 @@ class Account(val loggedIn: Persona) { } } + fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null) { + if (!isWriteable()) return + + val signedEvent = ChannelMessageEvent.create( + message = message, + channel = toChannel, + privateKey = loggedIn.privKey!! + ) + Client.send(signedEvent) + LocalCache.consume(signedEvent) + } + fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) { if (!isWriteable()) return val user = LocalCache.users[toUser] ?: 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 new file mode 100644 index 000000000..bb8408e29 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -0,0 +1,58 @@ +package com.vitorpamplona.amethyst.model + +import androidx.lifecycle.LiveData +import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource +import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent +import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.ui.note.toShortenHex +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import nostr.postr.events.ContactListEvent + +class Channel(val id: ByteArray) { + val idHex = id.toHexKey() + val idDisplayHex = id.toShortenHex() + + var info = ChannelCreateEvent.ChannelData(null, null, null) + + var updatedMetadataAt: Long = 0; + + val notes = ConcurrentHashMap() + + @Synchronized + fun getOrCreateNote(idHex: String): Note { + return notes[idHex] ?: run { + val answer = Note(idHex) + notes.put(idHex, answer) + answer + } + } + + fun updateChannelInfo(channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) { + info = channelInfo + updatedMetadataAt = updatedAt + + live.refresh() + } + + fun profilePicture(): String { + if (info.picture.isNullOrBlank()) info.picture = null + return info.picture ?: "https://robohash.org/${idHex}.png" + } + + // Observers line up here. + val live: ChannelLiveData = ChannelLiveData(this) + + private fun refreshObservers() { + live.refresh() + } +} + + +class ChannelLiveData(val channel: Channel): LiveData(ChannelState(channel)) { + fun refresh() { + postValue(ChannelState(channel)) + } +} + +class ChannelState(val channel: Channel) 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 ab758399f..94e5976d3 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,11 @@ import android.util.Log import androidx.lifecycle.LiveData import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent +import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import java.io.ByteArrayInputStream @@ -28,6 +33,7 @@ object LocalCache { val users = ConcurrentHashMap() val notes = ConcurrentHashMap() + val channels = ConcurrentHashMap() @Synchronized fun getOrCreateUser(pubkey: ByteArray): User { @@ -48,6 +54,16 @@ object LocalCache { } } + @Synchronized + fun getOrCreateChannel(key: String): Channel { + return channels[key] ?: run { + val answer = Channel(key.toByteArray()) + channels.put(key, answer) + answer + } + } + + fun consume(event: MetadataEvent) { //Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}") @@ -229,6 +245,73 @@ object LocalCache { } } + fun consume(event: ChannelCreateEvent) { + // new event + val oldChannel = getOrCreateChannel(event.id.toHex()) + if (event.createdAt > oldChannel.updatedMetadataAt) { + oldChannel.updateChannelInfo(event.channelInfo, event.createdAt) + } else { + // older data, does nothing + } + } + fun consume(event: ChannelMetadataEvent) { + //Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}") + if (event.channel.isNullOrBlank()) return + + // new event + val oldChannel = getOrCreateChannel(event.channel) + if (event.createdAt > oldChannel.updatedMetadataAt) { + oldChannel.updateChannelInfo(event.channelInfo, event.createdAt) + } else { + //Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") + } + } + + fun consume(event: ChannelMessageEvent) { + if (event.channel.isNullOrBlank()) return + + val channel = getOrCreateChannel(event.channel) + + val note = channel.getOrCreateNote(event.id.toHex()) + + // Already processed this event. + if (note.event != null) return + + val author = getOrCreateUser(event.pubKey) + val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) }) + val replyTo = Collections.synchronizedList(event.replyTos.map { getOrCreateNote(it) }.toMutableList()) + + note.channel = channel + note.loadEvent(event, author, mentions, replyTo) + + //Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}") + + // Adds notifications to users. + mentions.forEach { + it.taggedPosts.add(note) + } + replyTo.forEach { + it.author?.taggedPosts?.add(note) + } + + // Counts the replies + replyTo.forEach { + it.addReply(note) + } + + UrlCachedPreviewer.preloadPreviewsFor(note) + + refreshObservers() + } + + fun consume(event: ChannelHideMessageEvent) { + + } + + fun consume(event: ChannelMuteUserEvent) { + + } + // Observers line up here. val live: LocalCacheLiveData = LocalCacheLiveData(this) 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 fcc7d6a2a..45e48e854 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -32,6 +32,8 @@ class Note(val idHex: String) { val reactions = Collections.synchronizedSet(mutableSetOf()) val boosts = Collections.synchronizedSet(mutableSetOf()) + var channel: Channel? = null + fun loadEvent(event: Event, author: User, mentions: List, replyTo: MutableList) { this.event = event this.author = author diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Constants.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Constants.kt index a4ef69d71..a74bd12ff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Constants.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Constants.kt @@ -6,7 +6,7 @@ object Constants { val defaultRelays = arrayOf( Relay("wss://nostr.bitcoiner.social", read = true, write = true), Relay("wss://relay.nostr.bg", read = true, write = true), - //Relay("wss://brb.io", read = true, write = true), + Relay("wss://brb.io", read = true, write = true), Relay("wss://nostr.v0l.io", read = true, write = true), Relay("wss://nostr.rocks", read = true, write = true), Relay("wss://relay.damus.io", read = true, write = true), @@ -17,8 +17,9 @@ object Constants { Relay("wss://nostr-pub.wellorder.net", read = true, write = true), Relay("wss://nostr.mom", read = true, write = true), Relay("wss://nostr.orangepill.dev", read = true, write = true), - //Relay("wss://nostr-pub.semisol.dev", read = true, write = true), + Relay("wss://nostr-pub.semisol.dev", read = true, write = true), Relay("wss://nostr.onsats.org", read = true, write = true), - Relay("wss://nostr.sandwich.farm", read = true, write = true) + Relay("wss://nostr.sandwich.farm", read = true, write = true), + Relay("wss://relay.nostr.ch", read = true, write = true) ) } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt new file mode 100644 index 000000000..9fc7c22b3 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt @@ -0,0 +1,31 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import nostr.postr.JsonFilter + +object NostrChannelDataSource: NostrDataSource("ChatroomFeed") { + var channel: com.vitorpamplona.amethyst.model.Channel? = null + + fun loadMessagesBetween(channelId: String) { + channel = LocalCache.channels[channelId] + } + + fun createMessagesToChannelFilter() = JsonFilter( + kinds = listOf(ChannelMessageEvent.kind), + tags = mapOf("e" to listOf(channel?.idHex).filterNotNull()), + since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours + ) + + val messagesChannel = requestNewChannel() + + // returns the last Note of each user. + override fun feed(): List { + return channel?.notes?.values?.sortedBy { it.event!!.createdAt } ?: emptyList() + } + + override fun updateChannelFilters() { + messagesChannel.filter = createMessagesToChannelFilter() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index ae9186ecf..42be07919 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -2,6 +2,9 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account 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 nostr.postr.JsonFilter import nostr.postr.events.PrivateDmEvent @@ -18,21 +21,50 @@ object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") { authors = listOf(account.userProfile().pubkeyHex) ) + fun createMyChannelsFilter() = JsonFilter( + kinds = listOf(ChannelCreateEvent.kind), + ids = account.followingChannels.toList() + ) + + fun createMyChannelsInfoFilter() = JsonFilter( + kinds = listOf(ChannelMetadataEvent.kind), + tags = mapOf("e" to account.followingChannels.toList()) + ) + + fun createMessagesToMyChannelsFilter() = JsonFilter( + kinds = listOf(ChannelMessageEvent.kind), + tags = mapOf("e" to account.followingChannels.toList()), + since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours + ) + val incomingChannel = requestNewChannel() val outgoingChannel = requestNewChannel() + val myChannelsChannel = requestNewChannel() + val myChannelsInfoChannel = requestNewChannel() + val myChannelsMessagesChannel = requestNewChannel() + // returns the last Note of each user. override fun feed(): List { val messages = account.userProfile().messages val messagingWith = messages.keys().toList() - return messagingWith.mapNotNull { + val privateMessages = messagingWith.mapNotNull { messages[it]?.sortedBy { it.event?.createdAt }?.last { it.event != null } - }.sortedBy { it.event?.createdAt }.reversed() + } + + val publicChannels = account.followingChannels().map { + it.notes.values.sortedBy { it.event?.createdAt }.last { it.event != null } + } + + return (privateMessages + publicChannels).sortedBy { it.event?.createdAt }.reversed() } override fun updateChannelFilters() { incomingChannel.filter = createMessagesToMeFilter() outgoingChannel.filter = createMessagesFromMeFilter() + myChannelsChannel.filter = createMyChannelsFilter() + myChannelsInfoChannel.filter = createMyChannelsInfoFilter() + myChannelsMessagesChannel.filter = createMessagesToMyChannelsFilter() } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 251bcabe9..7f00c40c3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -1,6 +1,11 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent +import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.relays.Client @@ -33,6 +38,12 @@ abstract class NostrDataSource(val debugName: String) { else -> when (event.kind) { RepostEvent.kind -> LocalCache.consume(RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) + + ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) + ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) + ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) + ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) + ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt new file mode 100644 index 000000000..7001a1174 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt @@ -0,0 +1,44 @@ +package com.vitorpamplona.amethyst.service.model + +import java.util.Date +import nostr.postr.Utils +import nostr.postr.events.Event +import nostr.postr.events.MetadataEvent + +class ChannelCreateEvent ( + id: ByteArray, + pubKey: ByteArray, + createdAt: Long, + tags: List>, + content: String, + sig: ByteArray +): Event(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient val channelInfo: ChannelData + + init { + try { + channelInfo = MetadataEvent.gson.fromJson(content, ChannelData::class.java) + } catch (e: Exception) { + throw Error("can't parse $content", e) + } + } + + companion object { + const val kind = 40 + + fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent { + val content = if (channelInfo != null) + gson.toJson(channelInfo) + else + "" + + val pubKey = Utils.pubkeyCreate(privateKey) + val tags = emptyList>() + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) + } + } + + data class ChannelData(var name: String?, var about: String?, var picture: String?) +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt new file mode 100644 index 000000000..89fdf6eea --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt @@ -0,0 +1,38 @@ +package com.vitorpamplona.amethyst.service.model + +import java.util.Date +import nostr.postr.Utils +import nostr.postr.events.Event +import nostr.postr.toHex + +class ChannelHideMessageEvent ( + id: ByteArray, + pubKey: ByteArray, + createdAt: Long, + tags: List>, + content: String, + sig: ByteArray +): Event(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient val eventsToHide: List + + init { + eventsToHide = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + } + + companion object { + const val kind = 43 + + fun create(reason: String, messagesToHide: List?, mentions: List?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent { + val content = reason + val pubKey = Utils.pubkeyCreate(privateKey) + val tags = + messagesToHide?.map { + listOf("e", it) + } ?: emptyList() + + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt new file mode 100644 index 000000000..e4dc5ab51 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt @@ -0,0 +1,47 @@ +package com.vitorpamplona.amethyst.service.model + +import java.util.Date +import nostr.postr.Utils +import nostr.postr.events.Event +import nostr.postr.toHex + +class ChannelMessageEvent ( + id: ByteArray, + pubKey: ByteArray, + createdAt: Long, + tags: List>, + content: String, + sig: ByteArray +): Event(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient val channel: String? + @Transient val replyTos: List + @Transient val mentions: List + + init { + channel = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) + replyTos = tags.filter { it.firstOrNull() == "e" && (it.size < 3 || (it.size > 3 && it[3] != "root")) }.mapNotNull { it.getOrNull(1) } + mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + } + + companion object { + const val kind = 42 + + fun create(message: String, channel: String, replyTos: List? = null, mentions: List? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent { + val content = message + val pubKey = Utils.pubkeyCreate(privateKey) + val tags = mutableListOf( + listOf("e", channel, "", "root") + ) + replyTos?.forEach { + tags.add(listOf("e", it)) + } + mentions?.forEach { + tags.add(listOf("p", it)) + } + + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt new file mode 100644 index 000000000..433088563 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt @@ -0,0 +1,46 @@ +package com.vitorpamplona.amethyst.service.model + +import java.util.Date +import nostr.postr.ContactMetaData +import nostr.postr.Utils +import nostr.postr.events.Event +import nostr.postr.events.MetadataEvent +import nostr.postr.toHex + +class ChannelMetadataEvent ( + id: ByteArray, + pubKey: ByteArray, + createdAt: Long, + tags: List>, + content: String, + sig: ByteArray +): Event(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient val channel: String? + @Transient val channelInfo: ChannelCreateEvent.ChannelData + + init { + channel = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) + try { + channelInfo = MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java) + } catch (e: Exception) { + throw Error("can't parse $content", e) + } + } + + companion object { + const val kind = 41 + + fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannel: ChannelCreateEvent, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent { + val content = if (newChannelInfo != null) + gson.toJson(newChannelInfo) + else + "" + + val pubKey = Utils.pubkeyCreate(privateKey) + val tags = listOf( listOf("e", originalChannel.id.toHex(), "", "root") ) + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt new file mode 100644 index 000000000..507105a3e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt @@ -0,0 +1,39 @@ +package com.vitorpamplona.amethyst.service.model + +import java.util.Date +import nostr.postr.Utils +import nostr.postr.events.Event +import nostr.postr.events.MetadataEvent +import nostr.postr.toHex + +class ChannelMuteUserEvent ( + id: ByteArray, + pubKey: ByteArray, + createdAt: Long, + tags: List>, + content: String, + sig: ByteArray +): Event(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient val usersToMute: List + + init { + usersToMute = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + } + + companion object { + const val kind = 43 + + fun create(reason: String, usersToMute: List?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent { + val content = reason + val pubKey = Utils.pubkeyCreate(privateKey) + val tags = + usersToMute?.map { + listOf("p", it) + } ?: emptyList() + + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt index 85b26bfaf..556e1e5f9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt @@ -25,8 +25,11 @@ class ReactionEvent ( companion object { const val kind = 7 - fun create(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent { - val content = "+" + fun createLike(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent { + return create("+", originalNote, privateKey, createdAt) + } + + fun create(content: String, originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent { val pubKey = Utils.pubkeyCreate(privateKey) val tags = listOf( listOf("e", originalNote.id.toHex()), listOf("p", originalNote.pubKey.toHex())) val id = generateId(pubKey, createdAt, kind, tags, content) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 625f7160e..cd5db1a2e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -10,6 +10,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.navArgument import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.ChannelScreen import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.HomeScreen @@ -45,6 +46,11 @@ sealed class Route( arguments = listOf(navArgument("id") { type = NavType.StringType } ), buildScreen = { acc, nav -> { ChatroomScreen(it.arguments?.getString("id"), acc, nav) }} ) + + object Channel : Route("Channel/{id}", R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType } ), + buildScreen = { acc, nav -> { ChannelScreen(it.arguments?.getString("id"), acc, nav) }} + ) } val Routes = listOf( @@ -57,7 +63,8 @@ val Routes = listOf( //drawer Route.Profile, Route.Note, - Route.Room + Route.Room, + Route.Channel ) @Composable 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 007db6fb3..3b258004e 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 @@ -16,12 +16,14 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.RichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import nostr.postr.events.TextNoteEvent @Composable fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) { @@ -33,6 +35,61 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr if (note?.event == null) { BlankNote(Modifier) + } else if (note.channel != null) { + val authorState by note.author!!.live.observeAsState() + val author = authorState?.user + + val channelState by note.channel!!.live.observeAsState() + val channel = channelState?.channel + + Column(modifier = + Modifier.clickable( + onClick = { navController.navigate("Channel/${channel?.idHex}") } + ) + ) { + Row( + modifier = Modifier + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp) + ) { + + AsyncImage( + model = channel?.profilePicture(), + contentDescription = "Public Channel Image", + modifier = Modifier + .width(55.dp).height(55.dp) + .clip(shape = CircleShape) + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${channel?.info?.name}", + fontWeight = FontWeight.Bold, + ) + + Text( + timeAgo(note.event?.createdAt), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + + val eventContent = accountViewModel.decrypt(note) + if (eventContent != null) + RichTextViewer("${author?.toBestDisplayName()}: " + eventContent.take(100), note.event?.tags, note, accountViewModel, navController) + else + RichTextViewer("Referenced event not found", note.event?.tags, note, accountViewModel, navController) + } + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) + } + } else { val authorState by note.author!!.live.observeAsState() val author = authorState?.user @@ -96,4 +153,5 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr ) } } + } \ No newline at end of file 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 586429f76..238f14e86 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 @@ -1,10 +1,18 @@ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable 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.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface @@ -12,19 +20,27 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue 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.Shape +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import coil.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.ui.components.RichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel val ChatBubbleShapeMe = RoundedCornerShape(20.dp, 20.dp, 3.dp, 20.dp) val ChatBubbleShapeThem = RoundedCornerShape(20.dp, 20.dp, 20.dp, 3.dp) +@OptIn(ExperimentalFoundationApi::class) @Composable fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) { val noteState by baseNote.live.observeAsState() @@ -33,6 +49,8 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n val accountUserState by accountViewModel.userLiveData.observeAsState() val accountUser = accountUserState?.user + var popupExpanded by remember { mutableStateOf(false) } + if (note?.event == null) { BlankNote(Modifier) } else { @@ -60,7 +78,12 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n .padding( start = 12.dp, end = 12.dp, - top = 10.dp) + top = 5.dp, + bottom = 5.dp + ).combinedClickable( + onClick = { }, + onLongClick = { popupExpanded = true } + ) ) { Row( @@ -73,6 +96,37 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n Column( modifier = Modifier.padding(10.dp), ) { + + if (author != accountUser && note.event is ChannelMessageEvent) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = alignment + ) { + AsyncImage( + model = author?.profilePicture(), + contentDescription = "Profile Image", + modifier = Modifier + .width(25.dp).height(25.dp) + .clip(shape = CircleShape) + .clickable(onClick = { + author?.let { + navController.navigate("User/${it.pubkeyHex}") + } + }) + ) + + Text( + " ${author?.toBestDisplayName()}", + fontWeight = FontWeight.Bold, + modifier = Modifier.clickable(onClick = { + author?.let { + navController.navigate("User/${it.pubkeyHex}") + } + }) + ) + } + } + Row( verticalAlignment = Alignment.CenterVertically ) { @@ -110,6 +164,8 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n } } } + + NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 7dd0db64d..80d4d75c9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.lifecycle.ViewModel import androidx.security.crypto.EncryptedSharedPreferences import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.DefaultChannels import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource @@ -27,41 +28,47 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre init { // pulls account from storage. - loadFromEncryptedStorage()?.let { login(it) } + loadFromEncryptedStorage()?.let { + login(it) + } } fun login(key: String) { val pattern = Pattern.compile(".+@.+\\.[a-z]+") - login( + val account = if (key.startsWith("nsec")) { - Persona(privKey = key.bechToBytes()) + Account(Persona(privKey = key.bechToBytes())) } else if (key.startsWith("npub")) { - Persona(pubKey = key.bechToBytes()) + Account(Persona(pubKey = key.bechToBytes())) } else if (pattern.matcher(key).matches()) { // Evaluate NIP-5 - Persona() + Account(Persona()) } else { - Persona(Hex.decode(key)) + Account(Persona(Hex.decode(key))) } - ) + saveToEncryptedStorage(account) + + login(account) } - fun login(person: Persona) { - val loggedIn = Account(person) + fun newKey() { + val account = Account(Persona()) + saveToEncryptedStorage(account) + login(account) + } - if (person.privKey != null) - _accountContent.update { AccountState.LoggedIn ( loggedIn ) } + fun login(account: Account) { + if (account.loggedIn.privKey != null) + _accountContent.update { AccountState.LoggedIn ( account ) } else - _accountContent.update { AccountState.LoggedInViewOnly ( Account(person) ) } + _accountContent.update { AccountState.LoggedInViewOnly ( account ) } - saveToEncryptedStorage(person) - - NostrAccountDataSource.account = loggedIn - NostrHomeDataSource.account = loggedIn - NostrNotificationDataSource.account = loggedIn - NostrChatroomListDataSource.account = loggedIn + NostrAccountDataSource.account = account + NostrHomeDataSource.account = account + NostrNotificationDataSource.account = account + NostrChatroomListDataSource.account = account NostrAccountDataSource.start() NostrGlobalDataSource.start() @@ -73,10 +80,6 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre NostrChatroomListDataSource.start() } - fun newKey() { - login(Persona()) - } - fun logOff() { _accountContent.update { AccountState.LoggedOff } @@ -90,20 +93,22 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre }.apply() } - fun saveToEncryptedStorage(login: Persona) { + fun saveToEncryptedStorage(account: Account) { encryptedPreferences.edit().apply { - login.privKey?.let { putString("nostr_privkey", it.toHex()) } - login.pubKey.let { putString("nostr_pubkey", it.toHex()) } + account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) } + account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) } + account.followingChannels.let { putStringSet("following_channels", account.followingChannels) } }.apply() } - fun loadFromEncryptedStorage(): Persona? { + fun loadFromEncryptedStorage(): Account? { encryptedPreferences.apply { val privKey = getString("nostr_privkey", null) val pubKey = getString("nostr_pubkey", null) + val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet() if (pubKey != null) { - return Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()) + return Account(Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), followingChannels) } else { return null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index 560b13857..23631b5b1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -21,7 +21,7 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun ChatroomFeedView(userId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { +fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { val feedState by viewModel.feedContent.collectAsStateWithLifecycle() var isRefreshing by remember { mutableStateOf(false) } 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 new file mode 100644 index 000000000..6d780d6f1 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -0,0 +1,145 @@ +package com.vitorpamplona.amethyst.ui.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +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.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +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.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrChannelDataSource +import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource +import com.vitorpamplona.amethyst.ui.actions.PostButton +import com.vitorpamplona.amethyst.ui.note.UserDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navController: NavController) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account + + if (account != null && channelId != null) { + val newPost = remember { mutableStateOf(TextFieldValue("")) } + + NostrChannelDataSource.loadMessagesBetween(channelId) + + val channelState by NostrChannelDataSource.channel!!.live.observeAsState() + val channel = channelState?.channel ?: return + + val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChannelDataSource ) } + + Column(Modifier.fillMaxHeight()) { + channel?.let { + ChannelHeader( + it, + accountViewModel = accountViewModel, + navController = navController + ) + } + + Column( + modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true) + ) { + ChatroomFeedView(feedViewModel, accountViewModel, navController) + } + + //LAST ROW + Row(modifier = Modifier.padding(10.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = newPost.value, + onValueChange = { newPost.value = it }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ), + modifier = Modifier.weight(1f, true).padding(end = 10.dp), + placeholder = { + Text( + text = "reply here.. ", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + ) + + PostButton( + onPost = { + account.sendChannelMeesage(newPost.value.text, channel.idHex) + newPost.value = TextFieldValue("") + }, + newPost.value.text.isNotBlank() + ) + } + } + } +} + + +@Composable +fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, navController: NavController) { + val channelState by baseChannel.live.observeAsState() + val channel = channelState?.channel + + Column(modifier = + Modifier + .padding(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + + AsyncImage( + model = channel?.profilePicture(), + contentDescription = "Profile Image", + modifier = Modifier + .width(35.dp).height(35.dp) + .clip(shape = CircleShape) + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${channel?.info?.name}", + fontWeight = FontWeight.Bold, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "${channel?.info?.about}", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + } + + Divider( + modifier = Modifier.padding(top = 12.dp, start = 12.dp, end = 12.dp), + thickness = 0.25.dp + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 77c7ecb2f..a7d9d842a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -59,7 +59,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr Column( modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true) ) { - ChatroomFeedView(userId, feedViewModel, accountViewModel, navController) + ChatroomFeedView(feedViewModel, accountViewModel, navController) } //LAST ROW